diff --git a/CLAUDE.md b/CLAUDE.md index bdb6bb9..c827c47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,28 @@ 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`). Use + `cta="false"` to opt a block out. +- `cta-type="install"` — categorizes the CTA (`install`, `quickstart`, + `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. + ### 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/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/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/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 b8aaf7a..7769cf3 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,83 @@ 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 = {}; + // 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, unquoted] = match; + attributes[name] = double ?? single ?? unquoted ?? 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);
+
+    // 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" && 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" && filename !== "") {
+      node.properties["data-filename"] = filename;
+    }
+    const ctaType = attributes["cta-type"];
+    const hasCtaType = typeof ctaType === "string" && ctaType !== "";
+    // `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;
+    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;
+      }
+    }
+  },
+};
+
 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..b0f9d18
--- /dev/null
+++ b/src/components/code-block.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import {
+  CodeBlock,
+  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, 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`).
+type TrackingProps = {
+  "data-language"?: string;
+  "data-example-id"?: string;
+  "data-filename"?: string;
+  "data-cta"?: string;
+  "data-cta-type"?: string;
+};
+
+/**
+ * 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); + + // 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 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; + if (!figure || typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + posthog.capture("cta_viewed", { + page_path: pagePath, + example_id: resolveExampleId(), + ...(ctaType ? { cta_type: ctaType } : {}), + }); + observer.disconnect(); + }); + observer.observe(figure); + + return () => observer.disconnect(); + }, [isCta, ctaType, pagePath, resolveExampleId]); + + function handleClick(event: MouseEvent) { + // 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 + // aria-label toggles between "Copy Text" and "Copied Text". + 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: pagePath, + example_id: resolveExampleId(), + 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. 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 { + // `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")); + 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,