From 1a7949e9a867a86998ea82cfbaf339997c32bddd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 30 Jun 2026 17:53:07 +1000 Subject: [PATCH 1/3] feat(analytics): track code copies and CTA views in PostHog Instrument documentation code blocks with PostHog so we can measure which examples and calls-to-action drive engagement. - A build-time Shiki transformer (source.config.ts) stamps each rendered
 with data-language plus optional example-id/cta/cta-type/filename
  parsed from the code-fence meta string.
- A TrackedCodeBlock client component overrides the `pre` MDX component and:
  - fires `code_copied` (page_path, example_id, language, is_cta, cta_type)
    on every copy-button click via event delegation, reusing Fumadocs' copy
    logic;
  - fires `cta_viewed` once when a CTA block scrolls into view via
    IntersectionObserver, forming a per-page cta_viewed -> code_copied funnel.
    example_id is resolved identically for both events so they join up.
- Annotate the primary install CTAs (drizzle, dynamodb, prisma-next, nextjs
  identity, supabase, stack migration) with cta metadata.
- Document the fence-metadata authoring syntax in CLAUDE.md.
---
 CLAUDE.md                                     |  18 +++
 .../stack/cipherstash/encryption/drizzle.mdx  |   2 +-
 .../stack/cipherstash/encryption/dynamodb.mdx |   2 +-
 .../stack/cipherstash/encryption/identity.mdx |   2 +-
 .../cipherstash/encryption/prisma-next.mdx    |   2 +-
 content/stack/cipherstash/supabase.mdx        |   2 +-
 content/stack/reference/migration.mdx         |   2 +-
 source.config.ts                              |  67 +++++++++-
 src/components/code-block.tsx                 | 115 ++++++++++++++++++
 src/mdx-components.tsx                        |   8 +-
 10 files changed, 210 insertions(+), 10 deletions(-)
 create mode 100644 src/components/code-block.tsx

diff --git a/CLAUDE.md b/CLAUDE.md
index bdb6bb9..82cf334 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,6 +73,24 @@ description: Install the SDK and encrypt your first value in under 5 minutes
 - Use `highlight={1,3-5}` for line highlighting
 - Fumadocs `Steps`/`Step` components are available for step-by-step guides
 
+#### Copy analytics
+
+Every code block's copy button emits a PostHog `code_copied` event (see
+`src/components/code-block.tsx` and the Shiki transformer in `source.config.ts`).
+Blocks marked as CTAs also emit `cta_viewed` once when scrolled into view, giving
+a per-page `cta_viewed` → `code_copied` (is_cta: true) funnel.
+`page_path` and `language` are captured automatically; add optional fence
+metadata to enrich the event:
+
+- `example-id="install-drizzle"` — stable slug for the block. When omitted, a
+  fallback is derived from the filename/language plus the block's position.
+- `cta` — marks the block as a call-to-action (sets `is_cta: true`).
+- `cta-type="install"` — categorizes the CTA (`install`, `quickstart`,
+  `signup`); only meaningful alongside `cta`.
+
+```` ```bash cta cta-type="install" example-id="install-drizzle" ````
+mark install commands and other CTAs so they can be tracked distinctly.
+
 ### Styling
 
 Fumadocs UI theme with custom purple primary color (`hsl(269, 70%, 45%)`). Dark mode uses pure black background. CSS variables prefixed with `--color-fd-*` in `src/app/global.css`.
diff --git a/content/stack/cipherstash/encryption/drizzle.mdx b/content/stack/cipherstash/encryption/drizzle.mdx
index ddf7e66..1ca35f9 100644
--- a/content/stack/cipherstash/encryption/drizzle.mdx
+++ b/content/stack/cipherstash/encryption/drizzle.mdx
@@ -7,7 +7,7 @@ CipherStash provides first-class Drizzle ORM integration through `@cipherstash/s
 
 ## Installation
 
-```bash
+```bash cta cta-type="install" example-id="install-drizzle"
 npm install @cipherstash/stack drizzle-orm
 ```
 
diff --git a/content/stack/cipherstash/encryption/dynamodb.mdx b/content/stack/cipherstash/encryption/dynamodb.mdx
index 3736285..76e2f44 100644
--- a/content/stack/cipherstash/encryption/dynamodb.mdx
+++ b/content/stack/cipherstash/encryption/dynamodb.mdx
@@ -7,7 +7,7 @@ CipherStash provides a DynamoDB integration through `@cipherstash/stack/dynamodb
 
 ## Installation
 
-```bash
+```bash cta cta-type="install" example-id="install-dynamodb"
 npm install @cipherstash/stack @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
 ```
 
diff --git a/content/stack/cipherstash/encryption/identity.mdx b/content/stack/cipherstash/encryption/identity.mdx
index 496aca5..553a272 100644
--- a/content/stack/cipherstash/encryption/identity.mdx
+++ b/content/stack/cipherstash/encryption/identity.mdx
@@ -114,7 +114,7 @@ const lc = new LockContext({
 
 Install the `@cipherstash/nextjs` package for automatic CTS token setup with [Clerk](https://clerk.com/):
 
-```bash
+```bash cta cta-type="install" example-id="install-nextjs-identity"
 npm install @cipherstash/nextjs
 ```
 
diff --git a/content/stack/cipherstash/encryption/prisma-next.mdx b/content/stack/cipherstash/encryption/prisma-next.mdx
index a21195f..0ab08f2 100644
--- a/content/stack/cipherstash/encryption/prisma-next.mdx
+++ b/content/stack/cipherstash/encryption/prisma-next.mdx
@@ -11,7 +11,7 @@ The Prisma Next integration has a meaningfully shorter onboarding path than the
 
 ## Installation
 
-```bash
+```bash cta cta-type="install" example-id="install-prisma-next"
 npm install @cipherstash/stack @cipherstash/prisma-next
 ```
 
diff --git a/content/stack/cipherstash/supabase.mdx b/content/stack/cipherstash/supabase.mdx
index bb3824e..90a5904 100644
--- a/content/stack/cipherstash/supabase.mdx
+++ b/content/stack/cipherstash/supabase.mdx
@@ -65,7 +65,7 @@ CipherStash splits its functionality across two packages: a runtime SDK that you
 
     Install the runtime SDK as a dependency and the CLI as a dev dependency.
 
-    ```bash
+    ```bash cta cta-type="install" example-id="install-supabase"
     npm install @cipherstash/stack
     npm install -D stash
     ```
diff --git a/content/stack/reference/migration.mdx b/content/stack/reference/migration.mdx
index e48eb00..18a04bf 100644
--- a/content/stack/reference/migration.mdx
+++ b/content/stack/reference/migration.mdx
@@ -33,7 +33,7 @@ The `Result` pattern (`data` / `failure`) is unchanged.
 
 1. Install the new package:
 
-```bash
+```bash cta cta-type="install" example-id="install-stack-migration"
 npm install @cipherstash/stack
 npm uninstall @cipherstash/protect
 ```
diff --git a/source.config.ts b/source.config.ts
index b8aaf7a..560753b 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -1,5 +1,7 @@
-import { defineConfig, defineDocs } from "fumadocs-mdx/config";
+import type { ShikiTransformer } from "@shikijs/types";
+import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins";
 import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
+import { defineConfig, defineDocs } from "fumadocs-mdx/config";
 import { z } from "zod";
 
 // You can customise Zod schemas for frontmatter and `meta.json` here
@@ -21,8 +23,69 @@ export const docs = defineDocs({
   },
 });
 
+// Parse the leftover code-fence meta string (what remains after Fumadocs
+// extracts `title`, `tab`, and line-number directives) for the analytics
+// attributes documented for authors: `example-id`, `cta`, and `cta-type`.
+// Attribute names may contain hyphens, e.g. `example-id="drizzle-basic-query"`.
+function parseTrackingAttributes(raw: string): Record {
+  const attributes: Record = {};
+  const pattern = /(?<=^|\s)([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'))?/g;
+  for (const match of raw.matchAll(pattern)) {
+    const [, name, double, single] = match;
+    attributes[name] = double ?? single ?? true;
+  }
+  return attributes;
+}
+
+// Emit code-fence metadata as `data-*` attributes on the rendered `
` so the
+// client-side copy button (see `src/components/code-block.tsx`) can report it to
+// PostHog. This runs for every code block, so `data-language` is always present
+// even when the fence has no meta string (e.g. a plain ```bash block).
+const codeCopyTrackingTransformer: ShikiTransformer = {
+  name: "cipherstash:code-copy-tracking",
+  pre(node) {
+    node.properties["data-language"] = this.options.lang ?? "plaintext";
+
+    const raw =
+      typeof this.options.meta?.__raw === "string"
+        ? this.options.meta.__raw
+        : "";
+    if (!raw) return;
+
+    const attributes = parseTrackingAttributes(raw);
+
+    const exampleId = attributes["example-id"];
+    if (typeof exampleId === "string") {
+      node.properties["data-example-id"] = exampleId;
+    }
+    // Surface the `filename` so the client can derive a readable fallback
+    // `example_id` for blocks that lack an explicit `example-id`.
+    const filename = attributes.filename;
+    if (typeof filename === "string") {
+      node.properties["data-filename"] = filename;
+    }
+    if ("cta" in attributes) {
+      node.properties["data-cta"] = "true";
+    }
+    const ctaType = attributes["cta-type"];
+    if (typeof ctaType === "string") {
+      node.properties["data-cta-type"] = ctaType;
+    }
+  },
+};
+
 export default defineConfig({
   mdxOptions: {
-    // MDX options
+    rehypeCodeOptions: {
+      // Preserve Fumadocs' default Shiki config (themes, parseMetaString) and
+      // its default transformers (notation highlight, diff, focus, word
+      // highlight) — passing `transformers` alone would replace them entirely —
+      // then append our copy-tracking transformer.
+      ...rehypeCodeDefaultOptions,
+      transformers: [
+        ...(rehypeCodeDefaultOptions.transformers ?? []),
+        codeCopyTrackingTransformer,
+      ],
+    },
   },
 });
diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx
new file mode 100644
index 0000000..d1e052b
--- /dev/null
+++ b/src/components/code-block.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import {
+  CodeBlock,
+  type CodeBlockProps,
+  Pre,
+} from "fumadocs-ui/components/codeblock";
+import posthog from "posthog-js";
+import { type MouseEvent, useEffect, useRef } from "react";
+
+// Build-time metadata is attached to the `
` as `data-*` attributes by the
+// `cipherstash:code-copy-tracking` Shiki transformer (see `source.config.ts`).
+type TrackingProps = {
+  "data-language"?: string;
+  "data-example-id"?: string;
+  "data-filename"?: string;
+  "data-cta"?: string;
+  "data-cta-type"?: string;
+};
+
+function slugify(value: string): string {
+  return value
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+|-+$/g, "");
+}
+
+/**
+ * Drop-in replacement for the Fumadocs `pre` MDX component that keeps the
+ * default code block (highlighting, copy button, tabs) untouched but adds
+ * PostHog instrumentation:
+ *
+ * - `code_copied` whenever the copy button is clicked (any code block).
+ * - `cta_viewed` once when a CTA block scrolls into view, forming a per-page
+ *   "saw the CTA -> copied it" funnel against `code_copied` (is_cta: true).
+ *
+ * Rather than reimplement Fumadocs' copy logic, we listen for the copy button
+ * click via event delegation on the `
` wrapper. + */ +export function TrackedCodeBlock(props: CodeBlockProps) { + const attrs = props as CodeBlockProps & TrackingProps; + const language = attrs["data-language"] ?? "plaintext"; + const exampleId = attrs["data-example-id"]; + const isCta = attrs["data-cta"] === "true"; + const ctaType = attrs["data-cta-type"]; + const filename = + attrs["data-filename"] ?? + (typeof props.title === "string" ? props.title : undefined); + + const figureRef = useRef(null); + + // Fire `cta_viewed` once when a CTA block enters the viewport. `example_id` + // is resolved with the same logic as `code_copied` so the two events join up. + useEffect(() => { + if (!isCta) return; + const figure = figureRef.current; + if (!figure || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + posthog.capture("cta_viewed", { + page_path: window.location.pathname, + example_id: exampleId ?? autoExampleId(figure, filename, language), + ...(ctaType ? { cta_type: ctaType } : {}), + }); + observer.disconnect(); // fire once + }); + observer.observe(figure); + + return () => observer.disconnect(); + }, [isCta, exampleId, ctaType, filename, language]); + + function handleClick(event: MouseEvent) { + const button = (event.target as HTMLElement).closest("button"); + if (!button) return; + + // The Fumadocs copy button is the only button inside the code block; its + // aria-label toggles between "Copy Text" and "Copied Text". + const label = button.getAttribute("aria-label") ?? ""; + if (!/^Cop(y|ied)/.test(label)) return; + + posthog.capture("code_copied", { + page_path: window.location.pathname, + example_id: + exampleId ?? + autoExampleId(button.closest("figure"), filename, language), + language, + is_cta: isCta, + ...(isCta && ctaType ? { cta_type: ctaType } : {}), + }); + } + + return ( + +
{props.children}
+
+ ); +} + +// Derive a stable-per-page slug for code blocks that authors haven't tagged +// with an explicit `example-id`. Combines the filename (or language) with the +// block's position on the page so repeated filenames stay distinct. +function autoExampleId( + figure: Element | null, + filename: string | undefined, + language: string, +): string { + const base = filename ? slugify(filename) : language; + let index = 0; + if (figure) { + const figures = Array.from(document.querySelectorAll("figure.shiki")); + index = Math.max(0, figures.indexOf(figure)); + } + return `${base}-${index}`; +} diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index d207a25..ba31fd2 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -1,11 +1,15 @@ -import defaultMdxComponents from "fumadocs-ui/mdx"; import { Callout } from "fumadocs-ui/components/callout"; -import { Steps, Step } from "fumadocs-ui/components/steps"; +import { Step, Steps } from "fumadocs-ui/components/steps"; +import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; +import { TrackedCodeBlock } from "@/components/code-block"; export function getMDXComponents(components?: MDXComponents): MDXComponents { return { ...defaultMdxComponents, + // Override the default `pre` so code copies fire a PostHog `code_copied` + // event; metadata comes from `data-*` attributes set in `source.config.ts`. + pre: TrackedCodeBlock, Callout, Steps, Step, From 22c99a7415dbd5fcb72a1ddd0dc7996d10c99d88 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 30 Jun 2026 19:53:18 +1000 Subject: [PATCH 2/3] fix(analytics): address code review findings - page_path: use usePathname() (basePath-stripped) so code_copied/cta_viewed share a page key with the existing $pageview event. - example_id: resolve once and cache, so cta_viewed and code_copied for a block always report the same id; treat empty example-id="" as absent. - copy outcome: skip code_copied when navigator.clipboard is unavailable (insecure context), where the underlying copy silently fails. - CTA parsing: cta="false"/"" opts out; a lone cta-type now implies a CTA so the category isn't dropped; accept unquoted attribute values. - slug: use github-slugger (now a runtime dependency) for the auto example_id fallback, matching Fumadocs heading anchors; never emit a bare "-0" id. - Dedup the example_id resolution between the view and copy handlers. - Document the cta/cta-type/quoting semantics in CLAUDE.md. --- CLAUDE.md | 8 +++-- bun.lock | 2 +- package.json | 2 +- source.config.ts | 26 ++++++++++----- src/components/code-block.tsx | 63 ++++++++++++++++++++++++----------- 5 files changed, 69 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 82cf334..c827c47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,13 @@ metadata to enrich the event: - `example-id="install-drizzle"` — stable slug for the block. When omitted, a fallback is derived from the filename/language plus the block's position. -- `cta` — marks the block as a call-to-action (sets `is_cta: true`). +- `cta` — marks the block as a call-to-action (sets `is_cta: true`). Use + `cta="false"` to opt a block out. - `cta-type="install"` — categorizes the CTA (`install`, `quickstart`, - `signup`); only meaningful alongside `cta`. + `signup`). Implies `cta`, so a block with a `cta-type` is treated as a CTA + even without the bare `cta` flag. + +Quote attribute values (`example-id="…"`); bare flags (`cta`) need no value. ```` ```bash cta cta-type="install" example-id="install-drizzle" ```` mark install commands and other CTAs so they can be tracked distinctly. diff --git a/bun.lock b/bun.lock index 3d9d14e..8a38c5d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "fumadocs-core": "16.6.0", "fumadocs-mdx": "14.2.7", "fumadocs-ui": "16.6.0", + "github-slugger": "^2.0.0", "lucide-react": "^0.563.0", "next": "16.2.3", "posthog-js": "^1.354.0", @@ -23,7 +24,6 @@ "@types/node": "^25.2.1", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "github-slugger": "^2.0.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "tsx": "^4.0.0", diff --git a/package.json b/package.json index cc66e7c..7cc2dfd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "fumadocs-core": "16.6.0", "fumadocs-mdx": "14.2.7", "fumadocs-ui": "16.6.0", + "github-slugger": "^2.0.0", "lucide-react": "^0.563.0", "next": "16.2.3", "posthog-js": "^1.354.0", @@ -34,7 +35,6 @@ "@types/node": "^25.2.1", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "github-slugger": "^2.0.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "tsx": "^4.0.0", diff --git a/source.config.ts b/source.config.ts index 560753b..4ae6cf7 100644 --- a/source.config.ts +++ b/source.config.ts @@ -29,10 +29,13 @@ export const docs = defineDocs({ // Attribute names may contain hyphens, e.g. `example-id="drizzle-basic-query"`. function parseTrackingAttributes(raw: string): Record { const attributes: Record = {}; - const pattern = /(?<=^|\s)([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'))?/g; + // Accept quoted ("…"/'…') and bare unquoted values, so a forgotten quote + // (`example-id=foo`) still parses instead of being silently dropped. A bare + // key with no value (`cta`) is recorded as `true`. + const pattern = /(?<=^|\s)([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g; for (const match of raw.matchAll(pattern)) { - const [, name, double, single] = match; - attributes[name] = double ?? single ?? true; + const [, name, double, single, unquoted] = match; + attributes[name] = double ?? single ?? unquoted ?? true; } return attributes; } @@ -54,21 +57,28 @@ const codeCopyTrackingTransformer: ShikiTransformer = { const attributes = parseTrackingAttributes(raw); + // Only emit attributes for non-empty string values, so `example-id=""` + // falls back to the client-derived id rather than reporting an empty slug. const exampleId = attributes["example-id"]; - if (typeof exampleId === "string") { + if (typeof exampleId === "string" && exampleId !== "") { node.properties["data-example-id"] = exampleId; } // Surface the `filename` so the client can derive a readable fallback // `example_id` for blocks that lack an explicit `example-id`. const filename = attributes.filename; - if (typeof filename === "string") { + if (typeof filename === "string" && filename !== "") { node.properties["data-filename"] = filename; } - if ("cta" in attributes) { + const ctaType = attributes["cta-type"]; + const hasCtaType = typeof ctaType === "string" && ctaType !== ""; + // A block is a CTA when it carries a bare `cta` flag (or `cta="true"`) or + // any `cta-type`. An explicit `cta="false"`/`cta=""` opts out. Treating a + // lone `cta-type` as a CTA avoids silently dropping the category. + const ctaFlag = attributes.cta; + if (ctaFlag === true || ctaFlag === "true" || hasCtaType) { node.properties["data-cta"] = "true"; } - const ctaType = attributes["cta-type"]; - if (typeof ctaType === "string") { + if (hasCtaType) { node.properties["data-cta-type"] = ctaType; } }, diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx index d1e052b..f9bd32d 100644 --- a/src/components/code-block.tsx +++ b/src/components/code-block.tsx @@ -5,8 +5,10 @@ import { type CodeBlockProps, Pre, } from "fumadocs-ui/components/codeblock"; +import { slug } from "github-slugger"; +import { usePathname } from "next/navigation"; import posthog from "posthog-js"; -import { type MouseEvent, useEffect, useRef } from "react"; +import { type MouseEvent, useCallback, useEffect, useRef } from "react"; // Build-time metadata is attached to the `
` as `data-*` attributes by the
 // `cipherstash:code-copy-tracking` Shiki transformer (see `source.config.ts`).
@@ -18,13 +20,6 @@ type TrackingProps = {
   "data-cta-type"?: string;
 };
 
-function slugify(value: string): string {
-  return value
-    .toLowerCase()
-    .replace(/[^a-z0-9]+/g, "-")
-    .replace(/^-+|-+$/g, "");
-}
-
 /**
  * Drop-in replacement for the Fumadocs `pre` MDX component that keeps the
  * default code block (highlighting, copy button, tabs) untouched but adds
@@ -47,10 +42,31 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
     attrs["data-filename"] ??
     (typeof props.title === "string" ? props.title : undefined);
 
+  // Pathname without the `/docs` basePath, matching the $pageview event in
+  // `src/lib/posthog/provider.tsx` so the events join on a consistent page key.
+  const pagePath = usePathname();
+
   const figureRef = useRef(null);
+  // Resolve the `example_id` once and cache it, so `cta_viewed` and
+  // `code_copied` for the same block always report the same id — the auto
+  // fallback derives a positional index that must not drift between the
+  // scroll-time and click-time events.
+  const cachedExampleId = useRef(null);
+  const resolveExampleId = useCallback(() => {
+    if (exampleId) return exampleId;
+    if (cachedExampleId.current === null) {
+      cachedExampleId.current = autoExampleId(
+        figureRef.current,
+        filename,
+        language,
+      );
+    }
+    return cachedExampleId.current;
+  }, [exampleId, filename, language]);
 
-  // Fire `cta_viewed` once when a CTA block enters the viewport. `example_id`
-  // is resolved with the same logic as `code_copied` so the two events join up.
+  // Fire `cta_viewed` once per mount when a CTA block enters the viewport.
+  // Re-firing on client-side back/forward navigation is intentional: it mirrors
+  // the per-pageview `$pageview` model, so impressions stay comparable.
   useEffect(() => {
     if (!isCta) return;
     const figure = figureRef.current;
@@ -59,16 +75,16 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
     const observer = new IntersectionObserver((entries) => {
       if (!entries.some((entry) => entry.isIntersecting)) return;
       posthog.capture("cta_viewed", {
-        page_path: window.location.pathname,
-        example_id: exampleId ?? autoExampleId(figure, filename, language),
+        page_path: pagePath,
+        example_id: resolveExampleId(),
         ...(ctaType ? { cta_type: ctaType } : {}),
       });
-      observer.disconnect(); // fire once
+      observer.disconnect();
     });
     observer.observe(figure);
 
     return () => observer.disconnect();
-  }, [isCta, exampleId, ctaType, filename, language]);
+  }, [isCta, ctaType, pagePath, resolveExampleId]);
 
   function handleClick(event: MouseEvent) {
     const button = (event.target as HTMLElement).closest("button");
@@ -79,11 +95,14 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
     const label = button.getAttribute("aria-label") ?? "";
     if (!/^Cop(y|ied)/.test(label)) return;
 
+    // Don't count a copy the browser can't actually perform: Fumadocs' copy
+    // uses `navigator.clipboard`, which is absent in insecure (non-HTTPS)
+    // contexts and there silently rejects.
+    if (!navigator.clipboard) return;
+
     posthog.capture("code_copied", {
-      page_path: window.location.pathname,
-      example_id:
-        exampleId ??
-        autoExampleId(button.closest("figure"), filename, language),
+      page_path: pagePath,
+      example_id: resolveExampleId(),
       language,
       is_cta: isCta,
       ...(isCta && ctaType ? { cta_type: ctaType } : {}),
@@ -99,13 +118,17 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
 
 // Derive a stable-per-page slug for code blocks that authors haven't tagged
 // with an explicit `example-id`. Combines the filename (or language) with the
-// block's position on the page so repeated filenames stay distinct.
+// block's position on the page so repeated filenames stay distinct. Uses
+// `github-slugger` — the same slugger Fumadocs uses for heading anchors — so
+// ids line up with on-page anchors.
 function autoExampleId(
   figure: Element | null,
   filename: string | undefined,
   language: string,
 ): string {
-  const base = filename ? slugify(filename) : language;
+  // `slug()` can return "" for filenames that are all punctuation/non-ASCII;
+  // fall back to the language so the id is never bare (e.g. "-0").
+  const base = (filename && slug(filename)) || language;
   let index = 0;
   if (figure) {
     const figures = Array.from(document.querySelectorAll("figure.shiki"));

From 6d2cbbebb0a9ebefddca7889cf9ae3b473e98fb8 Mon Sep 17 00:00:00 2001
From: Dan Draper 
Date: Tue, 30 Jun 2026 20:11:43 +1000
Subject: [PATCH 3/3] fix(analytics): address Copilot review comments

- cta opt-out: an explicit cta="false"/cta="" now wins even when a cta-type is
  present, matching the documented behavior (was always setting data-cta when a
  cta-type existed). Also gate data-cta-type emission on the block being a CTA.
- copy handler: guard event.target with `instanceof Element` before calling
  closest(), removing the unsafe HTMLElement cast (covers SVG-icon targets).
---
 source.config.ts              | 18 +++++++++++-------
 src/components/code-block.tsx |  7 ++++++-
 2 files changed, 17 insertions(+), 8 deletions(-)

diff --git a/source.config.ts b/source.config.ts
index 4ae6cf7..7769cf3 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -71,15 +71,19 @@ const codeCopyTrackingTransformer: ShikiTransformer = {
     }
     const ctaType = attributes["cta-type"];
     const hasCtaType = typeof ctaType === "string" && ctaType !== "";
-    // A block is a CTA when it carries a bare `cta` flag (or `cta="true"`) or
-    // any `cta-type`. An explicit `cta="false"`/`cta=""` opts out. Treating a
-    // lone `cta-type` as a CTA avoids silently dropping the category.
+    // `cta` is a flag: a bare `cta` (or `cta="true"`) opts in. An explicit
+    // `cta="false"`/`cta=""` is an opt-out that wins even when a `cta-type` is
+    // present. A lone `cta-type` (no `cta`) still implies a CTA so the category
+    // isn't silently dropped.
     const ctaFlag = attributes.cta;
-    if (ctaFlag === true || ctaFlag === "true" || hasCtaType) {
+    const ctaOptOut = ctaFlag === "false" || ctaFlag === "";
+    const isCta =
+      !ctaOptOut && (ctaFlag === true || ctaFlag === "true" || hasCtaType);
+    if (isCta) {
       node.properties["data-cta"] = "true";
-    }
-    if (hasCtaType) {
-      node.properties["data-cta-type"] = ctaType;
+      if (hasCtaType) {
+        node.properties["data-cta-type"] = ctaType;
+      }
     }
   },
 };
diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx
index f9bd32d..b0f9d18 100644
--- a/src/components/code-block.tsx
+++ b/src/components/code-block.tsx
@@ -87,7 +87,12 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
   }, [isCta, ctaType, pagePath, resolveExampleId]);
 
   function handleClick(event: MouseEvent) {
-    const button = (event.target as HTMLElement).closest("button");
+    // The click can originate on the copy button's inner /; guard
+    // for a real Element before walking up. `closest` lives on Element, so this
+    // also covers SVG targets without an unsafe HTMLElement cast.
+    const target = event.target;
+    if (!(target instanceof Element)) return;
+    const button = target.closest("button");
     if (!button) return;
 
     // The Fumadocs copy button is the only button inside the code block; its