diff --git a/.gitignore b/.gitignore index 856009b4a..e6a543dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ libs/licensing/src/lib/license-public-key.generated.ts examples/ag-ui/angular/src/environments/generated-keys.local.ts # Chat example generated API keys (injected from .env at build time) examples/chat/angular/src/environments/generated-keys.local.ts + +# Local service-account keys (GSC, etc). Never commit these. +keys/ diff --git a/apps/website/.gitignore b/apps/website/.gitignore new file mode 100644 index 000000000..bfcc80dd7 --- /dev/null +++ b/apps/website/.gitignore @@ -0,0 +1 @@ +.gsc/ diff --git a/apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx b/apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx index 839d10ebc..9d5114723 100644 --- a/apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx +++ b/apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx @@ -17,7 +17,7 @@ Most AI chat features still ship without streaming — they buffer the full resp - Wire a real LangGraph backend to the UI without writing any transport code. - Cover the three production patterns that matter once the scaffold works: errors, threads, and generative UI. -## Why streaming matters +## Why does streaming matter? A user reads at roughly 200 to 300 words per minute; a modern model produces tokens faster than that. If you stream, the user starts reading before the model has finished. If you buffer, every response feels like a page load with no progress indicator. @@ -160,7 +160,7 @@ The slot pattern is intentional: the chat doesn't set your welcome copy, pick yo Theming is a separate concern. The chat reads from CSS custom properties — `--chat-bg`, `--chat-fg`, `--chat-accent`, and a few dozen more. If you already use a design system, map your tokens onto theirs in a single stylesheet and the chat picks them up. -## What's happening under the hood +## What's happening under the hood? Let's peek at the contract. The adapter exposes a small surface, the chat consumes it, and everything else is implementation detail. diff --git a/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx index 87be3b491..3c920c87e 100644 --- a/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx +++ b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx @@ -57,7 +57,7 @@ Three boxes. Two seams. **The wire.** Server-Sent Events. Plain HTTP, no WebSocket gymnastics, no custom binary framing. Your firewall, load balancer, and reverse proxy already know what to do with it. -**The Angular side.** This is what ThreadPlane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why. +**The Angular side.** This is what Threadplane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why. ## Let's wire it up @@ -152,7 +152,7 @@ No `EventSource`. No reducer. No manual subscribe-and-render plumbing. No store. Spin up your agent backend, point `url` at it, and the chat just works. -## How AG-UI events become signals +## How do AG-UI events become signals? The AG-UI protocol has seventeen event types, grouped into five families: @@ -164,7 +164,7 @@ The AG-UI protocol has seventeen event types, grouped into five families: The families each do specific work. Lifecycle answers "is something happening?" Text messages are the streaming triad familiar from chat UIs. Tool calls are deliberately incremental so you can render the *intent* before the arguments are fully formed. State sync uses RFC 6902 JSON Patch so the wire stays small even when the agent's state is large. -ThreadPlane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract: +Threadplane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract: - `messages()`: `Message[]`, the chat history. `TEXT_MESSAGE_CONTENT` appends a delta to the in-flight assistant message. - `status()`: `'idle' | 'running' | 'error' | 'paused'`. Driven by the `RUN_*` events. @@ -267,7 +267,7 @@ How you scope threads — per project, per task, per user session — is a produ If you want a starting point, `@threadplane/chat` exposes a `` primitive that handles the layout without locking you into a persistence model. -## Swap the backend without changing the UI +## Can you swap the backend without changing the UI? This is the part that pays off the protocol bet. @@ -300,6 +300,6 @@ Each of those is its own post. The point here is just that the protocol-to-signa ## Conclusion -AG-UI standardizes the wire between the agent and the UI: it's small enough to hold in your head, and the event model maps onto Angular signals cleanly. With ThreadPlane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines — a provider, an inject, and a `` — which leaves the interesting work (tool cards, interrupt flows, generative UI, your design system) as the part you spend the day on. +AG-UI standardizes the wire between the agent and the UI: it's small enough to hold in your head, and the event model maps onto Angular signals cleanly. With Threadplane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines — a provider, an inject, and a `` — which leaves the interesting work (tool cards, interrupt flows, generative UI, your design system) as the part you spend the day on. The adapters are MIT; `@threadplane/chat` is source-available with a free non-commercial tier. If you're building this inside an enterprise Angular app (design system, multi-tenant, regulated), [talk to us](/contact?source=blog_ag_ui_pillar&track=enterprise). diff --git a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx index ac467cf75..b7049191f 100644 --- a/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx @@ -30,7 +30,7 @@ Everything below is running code from the cockpit example at `cockpit/langgraph/ - Render the approval dialog in Angular with the `` composition. - Resume, reject, or edit-then-resume — with a distinct path for each. -## When to use an interrupt +## When should you use an interrupt? Most tool calls don't need approval. Reads, searches, and lookups can run unattended. Reach for an interrupt when a tool does something the operator wouldn't want to undo by hand: moves money, sends a customer-facing message, deletes a record, or triggers a deploy. diff --git a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx index d883230e2..6a1d55895 100644 --- a/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx +++ b/apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx @@ -48,7 +48,7 @@ That's the whole client-side delta. The rest of the file — the template bindin `` reads `agent.interrupt()` (a `Signal`), and `submit({ resume })` is part of the runtime-neutral `Agent` contract declared in `@threadplane/chat`. Both adapters populate the signal and forward the resume; the chat surface above doesn't see the wire format. -## When to use an interrupt +## When should you use an interrupt? Most tool calls don't need approval. Reads, searches, and lookups can run unattended. Reach for an interrupt when a tool does something the operator wouldn't want to undo by hand: moves money, sends a customer-facing message, deletes a record, or triggers a deploy. diff --git a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx index 10dc84de2..ed655d653 100644 --- a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx +++ b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx @@ -78,6 +78,8 @@ Let the adapter own accumulation, deduplication, and lifecycle transitions. Let the component read the result. The [Signals guide](/docs/langgraph/concepts/angular-signals) shows the boundary in practice. +![LangGraph stream chunks and AG-UI SSE events both enter a runtime adapter that owns accumulation, deduplication and lifecycle transitions, and the adapter publishes one Agent contract of Angular signals - messages, status, toolCalls, state, error and interrupt - that chat components and an approved component registry read, with user intent travelling back through the same contract.](/blog/diagrams/agent-contract-boundary.svg) + The tradeoff is that normalization can hide useful runtime detail. Keep an explicit event escape hatch for information that isn't durable UI state, but don't publish messages or tool calls through two competing sources. Two sources of truth create timing bugs that are difficult to reproduce and even harder to explain to a user. diff --git a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx index b26b3c289..98b110708 100644 --- a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx +++ b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx @@ -47,6 +47,8 @@ A reading list. The user asks the assistant to save something; the assistant cal Three tools, three different shapes, and the server implements none of them. +![The Angular chat component declares its action, view and ask client tools; the catalog travels to the FastAPI /agent endpoint where bind_client_tools binds it to the model for that run, the graph ends its turn and streams AG-UI events back over SSE, the @threadplane/ag-ui adapter reduces them into Angular signals, and the tool result the browser produces starts the next run.](/blog/diagrams/ag-ui-event-flow.svg) + ## How do we get an AG-UI endpoint running? Install the integration: diff --git a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx index 3006bfa33..60ed68f5f 100644 --- a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx +++ b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx @@ -51,6 +51,8 @@ Two Angular pieces, and they read from different places. That split is the thing to hold onto. The agent knows about one conversation. The thread adapter knows about all of them. +![chat-sidenav renders every conversation from LangGraphThreadsAdapter while chat renders the active one from the @threadplane/langgraph agent; selecting a row sets the ACTIVE_THREAD signal, the agent adapter watches that signal and switches conversations, and onThreadId writes a newly created thread id back into it.](/blog/diagrams/langgraph-threads-and-runs.svg) + ## How do we get a LangGraph server running? Let's do the backend first, because the Angular side has nothing to bind to without it. diff --git a/apps/website/e2e/blog.spec.ts b/apps/website/e2e/blog.spec.ts index 1c187dda9..2b667d485 100644 --- a/apps/website/e2e/blog.spec.ts +++ b/apps/website/e2e/blog.spec.ts @@ -6,7 +6,7 @@ test.describe('Blog landing page', () => { // Brand eyebrow + H1 await expect(page.getByText('Blog', { exact: true }).first()).toBeVisible(); - await expect(page.getByRole('heading', { level: 1, name: /Articles from ThreadPlane/i })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: /Articles from Threadplane/i })).toBeVisible(); // Filter row contains the "All" chip in active state await expect(page.getByText('All', { exact: true })).toBeVisible(); diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index d0805c4b6..fe2181fde 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -89,6 +89,24 @@ test.describe('Docs slug page', () => { expect(id?.length).toBeGreaterThan(0); }); + test('heading permalinks carry no glyph in the text, only a CSS ::before', async ({ page }) => { + await page.goto(route); + const h2 = page.locator('article h2').first(); + await expect(h2).toBeVisible(); + + // The `#` must never be a text node: extracted heading text feeds search + // snippets, the page outline, and anything summarizing the DOM. + expect((await h2.textContent())?.trim()).not.toContain('#'); + + // ...which means the visible affordance hangs entirely on one CSS rule + // (`.docs-prose h2 .heading-anchor::before` in global.css). jsdom cannot + // resolve pseudo-element content, so this is the only place it is guarded. + const anchor = h2.locator('a.heading-anchor'); + await expect(anchor).toHaveCount(1); + const glyph = await anchor.evaluate((el) => getComputedStyle(el, '::before').content); + expect(glyph).toBe('"#"'); + }); + test('breadcrumb renders exactly once', async ({ page }) => { await page.goto('/docs/langgraph/getting-started/introduction'); await expect(page.locator('nav[aria-label="Breadcrumb"]')).toHaveCount(1); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 21490729f..038aa67a4 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -237,7 +237,7 @@ test('docs pages render canonical and social metadata', async ({ page }) => { ); await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( 'content', - 'Streaming - LangGraph Docs - Threadplane', + 'Streaming — LangGraph Docs — Threadplane', ); await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( 'content', @@ -245,7 +245,7 @@ test('docs pages render canonical and social metadata', async ({ page }) => { ); await expect(page.locator('meta[name="twitter:title"]')).toHaveAttribute( 'content', - 'Streaming - LangGraph Docs - Threadplane', + 'Streaming — LangGraph Docs — Threadplane', ); }); diff --git a/apps/website/public/blog/diagrams/ag-ui-event-flow.svg b/apps/website/public/blog/diagrams/ag-ui-event-flow.svg new file mode 100644 index 000000000..7a85a27d1 --- /dev/null +++ b/apps/website/public/blog/diagrams/ag-ui-event-flow.svg @@ -0,0 +1,85 @@ + + AG-UI client-tool round trip between an Angular browser app and the agent server + The Angular chat component declares its action, view and ask client tools; the catalog travels to the FastAPI /agent endpoint, where bind_client_tools binds it to the model per run. The graph ends its turn and streams AG-UI events over SSE; the @threadplane/ag-ui adapter turns them into Angular signals, the browser executes the call, and the tool result starts the next run. + + + + + + + + + AG-UI · CLIENT TOOLS + The browser declares the tools; the server streams the events + + + + + BROWSER + <chat [clientTools]> + declares action() · view() · ask() and ships them as a catalog + + + + the tool catalog, on every run + + + + + SERVER + POST /agent + FastAPI + ag-ui-langgraph + + + + + + + + SERVER + bind_client_tools(llm, [], state) + binds the browser's catalog to the model, per run + + + + + + + + SERVER + the graph ends its turn + no server ToolNode — every tool here is a client tool + + + + AG-UI events over SSE + + + + + BROWSER + @threadplane/ag-ui + reduces the events into Angular signals + + + + + + + + BROWSER + the browser runs it + a handler returns, a component mounts, or the user answers + + + + the tool result starts the next run + diff --git a/apps/website/public/blog/diagrams/agent-contract-boundary.svg b/apps/website/public/blog/diagrams/agent-contract-boundary.svg new file mode 100644 index 000000000..98bb92457 --- /dev/null +++ b/apps/website/public/blog/diagrams/agent-contract-boundary.svg @@ -0,0 +1,69 @@ + + The runtime-neutral Agent contract between agent backends and Angular components + LangGraph stream chunks and AG-UI SSE events both enter a runtime adapter that normalizes, deduplicates and accumulates them. The adapter publishes one Agent contract of Angular signals — messages, status, toolCalls, state, error and interrupt — which chat components and an approved component registry read. User intent travels back through the same contract. + + + + + + + + + AGENTIC UI · THE NEUTRAL BOUNDARY + Runtime events go in, Angular signals come out + + RUNTIME + + LangGraph + stream chunks + + + AG-UI + SSE events + + + + + ADAPTER + + runtime adapter + accumulation · deduplication · lifecycle status · interrupt shape + + + + AGENT CONTRACT + + Agent + messages() + status() + toolCalls() + state() + error() + interrupt() + + + + + <chat> + tool progress · approvals + + + ViewRegistry + approved components only + + + submit · abort · retry · respond to an interrupt + + Components never read a LangGraph chunk or an AG-UI event name. What differs + between runtimes is feature-detected at a deliberate edge, not assumed. + diff --git a/apps/website/public/blog/diagrams/langgraph-threads-and-runs.svg b/apps/website/public/blog/diagrams/langgraph-threads-and-runs.svg new file mode 100644 index 000000000..c6b1af484 --- /dev/null +++ b/apps/website/public/blog/diagrams/langgraph-threads-and-runs.svg @@ -0,0 +1,67 @@ + + How one Angular signal switches the active LangGraph conversation + chat-sidenav lists every thread from LangGraphThreadsAdapter, while chat renders one conversation from the @threadplane/langgraph agent. Selecting a row sets the ACTIVE_THREAD signal, the agent adapter watches that signal and switches conversations, and onThreadId writes a newly created thread id back into it. + + + + + + + + + LANGGRAPH · THREADS AND RUNS + One signal decides which conversation is on screen + + threads() · archivedThreads() + + + <chat-sidenav> + every conversation, + titled by the server + + + ThreadsAdapter + client.threads.* — + list, rename, archive + + + + + ACTIVE_THREAD + signal<string | null>, + mirrored into the URL + + + threadSelected + + + the adapter watches it, + onThreadId writes back + + messages() · status() · toolCalls() + + + <chat> + the active conversation, + streaming + + + provideAgent() + one thread's run stream + and checkpoint + + + + LangGraph owns the durable half — checkpoints and the thread store. + Angular only owns which thread id is active. + diff --git a/apps/website/scripts/gsc/README.md b/apps/website/scripts/gsc/README.md new file mode 100644 index 000000000..b82898740 --- /dev/null +++ b/apps/website/scripts/gsc/README.md @@ -0,0 +1,42 @@ +# Search Console API harness + +## One-time setup + +1. In Google Cloud console, create (or reuse) a project and enable the + **Google Search Console API** (`searchconsole.googleapis.com`). +2. Create a service account. No project-level IAM roles are needed. +3. Create a JSON key for that service account and download it. +4. In Search Console, open the `threadplane.ai` **Domain property** → + Settings → Users and permissions → Add user → paste the service + account's `client_email` → permission **Full** (required: the URL + Inspection API rejects "Restricted" users). +5. Export the key for local use — do NOT commit it: + + export GSC_SERVICE_ACCOUNT_JSON="$(cat ~/secrets/threadplane-gsc.json)" + export GSC_SITE_URL="sc-domain:threadplane.ai" + +## Usage + + npm run gsc:pull # writes apps/website/.gsc/*.json + npm run gsc:report # writes apps/website/.gsc/report.md + +`gsc:report` reads the snapshots `gsc:pull` wrote, so run the pull first. + +Both scripts resolve their `.gsc` directory relative to the current working +directory (`/apps/website/.gsc`), so the raw form must be invoked from the +repo root: + + npx tsx apps/website/scripts/gsc/pull.ts + npx tsx apps/website/scripts/gsc/report.ts + +If the URL Inspection sweep hits quota or transient errors, `pull.ts` also +writes `.gsc/inspection-errors.json` and the report labels its index-health +counts as lower bounds. + +## What this CANNOT do + +The Search Console **Generative AI performance report** (AI Overviews / +AI Mode impressions and clicks) is UI-only as of 2026-08. It is not in +`searchanalytics.query`, not in `searchAppearance`, and not in the +BigQuery bulk export. See `docs/gtm/ai-search-measurement.md` for the +manual export procedure. diff --git a/apps/website/scripts/gsc/analysis.spec.ts b/apps/website/scripts/gsc/analysis.spec.ts new file mode 100644 index 000000000..91cbe7487 --- /dev/null +++ b/apps/website/scripts/gsc/analysis.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import type { InspectionResult } from './api'; +import { + capList, + describeInspectionCoverage, + findCanonicalMismatches, + findStrikingDistance, + findUnindexed, + findWeakCtr, + findZeroImpressionPages, + normalizeUrl, +} from './analysis'; + +/** A row with sane defaults, so each test states only the field it is about. */ +function row(overrides: Partial<{ key: string; clicks: number; impressions: number; ctr: number; position: number }>) { + return { + keys: [overrides.key ?? 'q'], + clicks: overrides.clicks ?? 0, + impressions: overrides.impressions ?? 500, + ctr: overrides.ctr ?? 0, + position: overrides.position ?? 8, + }; +} + +/** An inspection with sane defaults, so each test states only the field it is about. */ +function inspection(overrides: Partial): InspectionResult { + return { + url: 'https://threadplane.ai/a', + verdict: 'PASS', + coverageState: 'Submitted and indexed', + lastCrawlTime: null, + robotsTxtState: 'ALLOWED', + indexingState: 'INDEXING_ALLOWED', + googleCanonical: null, + userCanonical: null, + ...overrides, + }; +} + +const rows = [ + { keys: ['angular langgraph chat'], clicks: 0, impressions: 400, ctr: 0, position: 11.2 }, + { keys: ['threadplane'], clicks: 90, impressions: 100, ctr: 0.9, position: 1.1 }, + { keys: ['obscure long tail'], clicks: 0, impressions: 3, ctr: 0, position: 42 }, +]; + +describe('findStrikingDistance', () => { + it('returns rows ranking 5-20 with meaningful impressions, best opportunity first', () => { + const result = findStrikingDistance(rows, { minImpressions: 50 }); + expect(result.map((r) => r.keys[0])).toEqual(['angular langgraph chat']); + }); + + it('orders surviving rows by descending impressions, not input order', () => { + const result = findStrikingDistance( + [ + row({ key: 'middle', impressions: 200 }), + row({ key: 'smallest', impressions: 60 }), + row({ key: 'biggest', impressions: 900 }), + ], + { minImpressions: 50 }, + ); + expect(result.map((r) => r.keys[0])).toEqual(['biggest', 'middle', 'smallest']); + }); + + it.each([ + ['position exactly 5 is included', { position: 5 }, true], + ['position exactly 20 is included', { position: 20 }, true], + ['position just above page one at 4.9 is excluded', { position: 4.9 }, false], + ['position just past 20 at 20.1 is excluded', { position: 20.1 }, false], + ['impressions exactly at the floor are included', { impressions: 50 }, true], + ['impressions one below the floor are excluded', { impressions: 49 }, false], + ])('%s', (_name, overrides, kept) => { + const result = findStrikingDistance([row(overrides)], { minImpressions: 50 }); + expect(result).toHaveLength(kept ? 1 : 0); + }); +}); + +describe('findWeakCtr', () => { + it('orders surviving rows by descending impressions', () => { + const result = findWeakCtr( + [ + row({ key: 'fewer', impressions: 150, ctr: 0.01, position: 3 }), + row({ key: 'more', impressions: 800, ctr: 0.01, position: 3 }), + ], + { minImpressions: 100, maxCtr: 0.02 }, + ); + expect(result.map((r) => r.keys[0])).toEqual(['more', 'fewer']); + }); + + it.each([ + ['position exactly 10 is included', { position: 10, ctr: 0.01 }, true], + ['position 10.1 is off page one and excluded', { position: 10.1, ctr: 0.01 }, false], + ['impressions exactly at the floor are included', { impressions: 100, ctr: 0.01 }, true], + ['impressions one below the floor are excluded', { impressions: 99, ctr: 0.01 }, false], + ['ctr exactly at the ceiling is EXCLUDED — the bound is strict', { ctr: 0.02 }, false], + ['ctr just under the ceiling is included', { ctr: 0.0199 }, true], + ])('%s', (_name, overrides, kept) => { + const result = findWeakCtr([row({ position: 3, impressions: 500, ...overrides })], { + minImpressions: 100, + maxCtr: 0.02, + }); + expect(result).toHaveLength(kept ? 1 : 0); + }); +}); + +describe('findCanonicalMismatches', () => { + it('flags a page Google canonicalized away from our declared canonical', () => { + const result = findCanonicalMismatches([ + inspection({ + url: 'https://threadplane.ai/pricing', + googleCanonical: 'https://threadplane.ai/plans', + userCanonical: 'https://threadplane.ai/pricing', + }), + ]); + expect(result.map((i) => i.url)).toEqual(['https://threadplane.ai/pricing']); + }); + + it.each([ + ['both canonicals absent', null, null], + ['Google reports one but we declared none — deliberately NOT a mismatch', '/g', null], + ['we declared one but Google reports none', null, '/u'], + ['both present and in agreement', '/same', '/same'], + ])('does not flag when %s', (_name, googleCanonical, userCanonical) => { + expect(findCanonicalMismatches([inspection({ googleCanonical, userCanonical })])).toEqual([]); + }); +}); + +describe('normalizeUrl', () => { + it.each([ + ['protocol differs', 'http://threadplane.ai/blog/foo', 'https://threadplane.ai/blog/foo'], + ['host case differs', 'https://ThreadPlane.AI/blog/foo', 'https://threadplane.ai/blog/foo'], + ['leading www differs', 'https://www.threadplane.ai/blog/foo', 'https://threadplane.ai/blog/foo'], + ['trailing slash differs', 'https://threadplane.ai/blog/foo/', 'https://threadplane.ai/blog/foo'], + ['a fragment is present', 'https://threadplane.ai/blog/foo#intro', 'https://threadplane.ai/blog/foo'], + ['a query string is present', 'https://threadplane.ai/blog/foo?ref=x', 'https://threadplane.ai/blog/foo'], + ])('treats two URLs as the same page when %s', (_name, a, b) => { + expect(normalizeUrl(a)).toBe(normalizeUrl(b)); + }); + + it('keeps genuinely different paths apart', () => { + expect(normalizeUrl('https://threadplane.ai/a')).not.toBe(normalizeUrl('https://threadplane.ai/b')); + }); + + it('falls back to a textual cleanup instead of throwing on unparseable input', () => { + expect(normalizeUrl(' not a url?ref=x ')).toBe('not a url'); + expect(normalizeUrl('')).toBe(''); + }); +}); + +describe('findZeroImpressionPages', () => { + it('lists sitemap URLs that earned no impressions in the window', () => { + const result = findZeroImpressionPages( + ['https://threadplane.ai/a', 'https://threadplane.ai/b'], + [{ keys: ['https://threadplane.ai/a'], clicks: 1, impressions: 10, ctr: 0.1, position: 5 }], + ); + expect(result).toEqual(['https://threadplane.ai/b']); + }); + + it('does not report a page as invisible just because Search Console tagged the URL', () => { + const result = findZeroImpressionPages( + ['https://threadplane.ai/blog/foo'], + [ + { + keys: ['https://www.threadplane.ai/blog/foo/?ref=newsletter#top'], + clicks: 4, + impressions: 90, + ctr: 0.044, + position: 6, + }, + ], + ); + expect(result).toEqual([]); + }); +}); + +describe('findUnindexed', () => { + it('flags inspections whose verdict is not PASS', () => { + const result = findUnindexed([ + { + url: 'https://threadplane.ai/a', + verdict: 'PASS', + coverageState: 'Submitted and indexed', + lastCrawlTime: null, + robotsTxtState: 'ALLOWED', + indexingState: 'INDEXING_ALLOWED', + googleCanonical: null, + userCanonical: null, + }, + { + url: 'https://threadplane.ai/b', + verdict: 'NEUTRAL', + coverageState: 'Discovered - currently not indexed', + lastCrawlTime: null, + robotsTxtState: 'ALLOWED', + indexingState: 'INDEXING_ALLOWED', + googleCanonical: null, + userCanonical: null, + }, + ]); + expect(result.map((r) => r.url)).toEqual(['https://threadplane.ai/b']); + }); +}); + +describe('describeInspectionCoverage', () => { + it('reports a complete sweep when nothing failed', () => { + expect(describeInspectionCoverage({ inspected: 42, failed: 0 })).toBe( + 'Coverage: complete — all 42 sitemap URLs inspected.', + ); + }); + + it('warns that counts are lower bounds when inspections failed', () => { + const text = describeInspectionCoverage({ inspected: 8, failed: 2 }); + expect(text).toContain('PARTIAL'); + expect(text).toContain('2 of 10'); + expect(text).toContain('lower bound'); + }); +}); + +describe('capList', () => { + it('passes a short list through with nothing withheld', () => { + expect(capList(['a', 'b'], 20)).toEqual({ shown: ['a', 'b'], remaining: 0 }); + }); + + it('trims an overflowing list and counts the remainder', () => { + const items = Array.from({ length: 23 }, (_, i) => `url-${i}`); + const result = capList(items, 20); + expect(result.shown).toHaveLength(20); + expect(result.shown[19]).toBe('url-19'); + expect(result.remaining).toBe(3); + }); +}); diff --git a/apps/website/scripts/gsc/analysis.ts b/apps/website/scripts/gsc/analysis.ts new file mode 100644 index 000000000..6c8f8b980 --- /dev/null +++ b/apps/website/scripts/gsc/analysis.ts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +import type { InspectionResult, SearchAnalyticsRow } from './api'; + +/** Queries ranking just off page one — the cheapest ranking wins available. */ +export function findStrikingDistance( + rows: SearchAnalyticsRow[], + options: { minImpressions: number }, +): SearchAnalyticsRow[] { + return rows + .filter( + (row) => + row.position >= 5 && row.position <= 20 && row.impressions >= options.minImpressions, + ) + .sort((a, b) => b.impressions - a.impressions); +} + +/** + * Reduce a URL to the identity we compare on: no protocol, no `www.`, lowercased + * host, no trailing slash, no fragment, and NO QUERY STRING. + * + * Dropping the query string is a judgement call. Search Console's `page` + * dimension reports campaign- and referral-tagged URLs (`/blog/foo?ref=x`) + * that never appear in sitemap `` entries, and treating those as separate + * pages would report a page with real impressions as invisible. The cost is + * that a site where the query string genuinely selects content (`?page=2`, + * `?id=`) would collapse distinct pages together; threadplane.ai has no such + * routes. Total function — unparseable input falls back to a textual cleanup + * rather than throwing, since a single odd row must not kill the report. + */ +export function normalizeUrl(raw: string): string { + const trimmed = raw.trim(); + try { + const url = new URL(trimmed); + return `${url.hostname.toLowerCase().replace(/^www\./, '')}${url.pathname.replace(/\/$/, '')}`; + } catch { + return trimmed + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, '') + .replace(/^www\./, '') + .replace(/[?#].*$/, '') + .replace(/\/$/, ''); + } +} + +/** Sitemap URLs Google never showed for anything in the window. */ +export function findZeroImpressionPages( + sitemapUrls: string[], + pageRows: SearchAnalyticsRow[], +): string[] { + const seen = new Set(pageRows.map((row) => normalizeUrl(row.keys[0]))); + return sitemapUrls.filter((url) => !seen.has(normalizeUrl(url))); +} + +/** Inspections that are not cleanly indexed. */ +export function findUnindexed(inspections: InspectionResult[]): InspectionResult[] { + return inspections.filter((inspection) => inspection.verdict !== 'PASS'); +} + +/** + * Pages Google canonicalized somewhere other than where we asked — duplicate-content smell. + * + * Policy: BOTH canonicals must be present. A page where Google reports a + * canonical but `userCanonical` is null (i.e. we emitted no ``) + * is deliberately NOT flagged here — it is a finding in its own right, but a + * different one, and folding it in would make "mismatch" mean two things. It is + * still visible in the raw `.gsc/inspections.json` snapshot. + */ +export function findCanonicalMismatches(inspections: InspectionResult[]): InspectionResult[] { + return inspections.filter( + (inspection) => + inspection.googleCanonical !== null && + inspection.userCanonical !== null && + inspection.googleCanonical !== inspection.userCanonical, + ); +} + +/** + * Queries with strong impressions that sit on page one yet convert below one + * flat CTR threshold. + * + * Limitation: the threshold does not vary with position, so a 1.9% CTR at + * position 1 (alarming) is reported identically to 1.9% at position 10 + * (unremarkable). Read the Pos column before acting; ranking the output by + * position-relative expected CTR would need a baseline curve we do not have. + */ +export function findWeakCtr( + rows: SearchAnalyticsRow[], + options: { minImpressions: number; maxCtr: number }, +): SearchAnalyticsRow[] { + return rows + .filter( + (row) => + row.impressions >= options.minImpressions && + row.position <= 10 && + row.ctr < options.maxCtr, + ) + .sort((a, b) => b.impressions - a.impressions); +} + +/** + * How complete an inspection sweep was. `pull.ts` records every URL Inspection + * failure in `inspection-errors.json`, so a sweep can cover fewer URLs than the + * sitemap lists — in which case every index-health count is a lower bound and + * the report has to say so rather than imply a clean bill of health. + */ +export function describeInspectionCoverage(counts: { inspected: number; failed: number }): string { + const total = counts.inspected + counts.failed; + if (counts.failed === 0) { + return `Coverage: complete — all ${total} sitemap URLs inspected.`; + } + return ( + `Coverage: PARTIAL — ${counts.failed} of ${total} sitemap URLs could not be inspected. ` + + `Every count below is a lower bound: an uninspected page may also be unindexed or canonicalized elsewhere.` + ); +} + +/** Trim a list for display, reporting how much was withheld. */ +export function capList(items: string[], limit: number): { shown: string[]; remaining: number } { + return { shown: items.slice(0, limit), remaining: Math.max(0, items.length - limit) }; +} diff --git a/apps/website/scripts/gsc/api.ts b/apps/website/scripts/gsc/api.ts new file mode 100644 index 000000000..15555d73a --- /dev/null +++ b/apps/website/scripts/gsc/api.ts @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +import { getAccessToken } from './auth'; + +const BASE = 'https://www.googleapis.com/webmasters/v3'; +const INSPECT_URL = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect'; + +export type Dimension = 'query' | 'page' | 'country' | 'device' | 'date' | 'searchAppearance'; + +export interface SearchAnalyticsRow { + keys: string[]; + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +export function getSiteUrl(): string { + return process.env['GSC_SITE_URL'] ?? 'sc-domain:threadplane.ai'; +} + +async function authedFetch(url: string, init: RequestInit & { token: string }): Promise { + const { token, ...rest } = init; + const response = await fetch(url, { + ...rest, + headers: { ...(rest.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + }); + if (!response.ok) { + throw new Error(`${url} → ${response.status} ${await response.text()}`); + } + return response.json(); +} + +/** + * Search Analytics. NOTE: `type` accepts only web|image|video|news|discover| + * googleNews. There is no AI Overviews / AI Mode type as of 2026-08. + */ +export async function querySearchAnalytics(options: { + startDate: string; + endDate: string; + dimensions: Dimension[]; + rowLimit?: number; + startRow?: number; + type?: 'web' | 'image' | 'video' | 'news' | 'discover' | 'googleNews'; +}): Promise { + const token = await getAccessToken(); + const site = encodeURIComponent(getSiteUrl()); + const rows: SearchAnalyticsRow[] = []; + let startRow = options.startRow ?? 0; + const rowLimit = options.rowLimit ?? 25000; + + for (;;) { + const page = (await authedFetch(`${BASE}/sites/${site}/searchAnalytics/query`, { + token, + method: 'POST', + body: JSON.stringify({ + startDate: options.startDate, + endDate: options.endDate, + dimensions: options.dimensions, + type: options.type ?? 'web', + rowLimit, + startRow, + dataState: 'all', + }), + })) as { rows?: SearchAnalyticsRow[] }; + const batch = page.rows ?? []; + rows.push(...batch); + if (batch.length < rowLimit) break; + startRow += rowLimit; + } + return rows; +} + +export async function listSitemaps(): Promise { + const token = await getAccessToken(); + return authedFetch(`${BASE}/sites/${encodeURIComponent(getSiteUrl())}/sitemaps`, { + token, + method: 'GET', + }); +} + +export interface InspectionResult { + url: string; + verdict: string; + coverageState: string; + lastCrawlTime: string | null; + robotsTxtState: string; + indexingState: string; + googleCanonical: string | null; + userCanonical: string | null; +} + +/** + * A URL Inspection call that failed during a pull. Serialization contract: + * `pull.ts` writes these to `.gsc/inspection-errors.json`, `report.ts` reads + * them back — so the shape lives here, next to InspectionResult, rather than + * being restated at each end. + */ +export interface InspectionFailure { + url: string; + error: string; +} + +export async function inspectUrl(inspectionUrl: string): Promise { + const token = await getAccessToken(); + const raw = (await authedFetch(INSPECT_URL, { + token, + method: 'POST', + body: JSON.stringify({ inspectionUrl, siteUrl: getSiteUrl(), languageCode: 'en-US' }), + })) as { + inspectionResult?: { + indexStatusResult?: Record; + }; + }; + const status = raw.inspectionResult?.indexStatusResult ?? {}; + return { + url: inspectionUrl, + verdict: status['verdict'] ?? 'UNKNOWN', + coverageState: status['coverageState'] ?? 'UNKNOWN', + lastCrawlTime: status['lastCrawlTime'] ?? null, + robotsTxtState: status['robotsTxtState'] ?? 'UNKNOWN', + indexingState: status['indexingState'] ?? 'UNKNOWN', + googleCanonical: status['googleCanonical'] ?? null, + userCanonical: status['userCanonical'] ?? null, + }; +} diff --git a/apps/website/scripts/gsc/auth.ts b/apps/website/scripts/gsc/auth.ts new file mode 100644 index 000000000..d2b880750 --- /dev/null +++ b/apps/website/scripts/gsc/auth.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +import { createSign } from 'node:crypto'; + +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly'; + +/** Refresh this many seconds before the token's actual expiry, not exactly at it. */ +const REFRESH_SKEW_SECONDS = 60; + +interface ServiceAccountKey { + client_email: string; + private_key: string; +} + +interface CachedToken { + accessToken: string; + /** Epoch seconds at which the token stops being usable (server-reported expiry). */ + expiresAtSeconds: number; +} + +function base64url(value: object): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +export function readServiceAccountKey(): ServiceAccountKey { + const raw = process.env['GSC_SERVICE_ACCOUNT_JSON']; + if (!raw) { + throw new Error( + 'GSC_SERVICE_ACCOUNT_JSON is not set. See apps/website/scripts/gsc/README.md.', + ); + } + const parsed = JSON.parse(raw) as Partial; + if (!parsed.client_email || !parsed.private_key) { + throw new Error('GSC_SERVICE_ACCOUNT_JSON is missing client_email or private_key.'); + } + return { client_email: parsed.client_email, private_key: parsed.private_key }; +} + +let cachedToken: CachedToken | null = null; +let inFlightExchange: Promise | null = null; + +/** + * Clears the in-process access-token cache. Exists so tests (and long-lived + * callers that suspect a revoked/expired token) can force a fresh exchange. + */ +export function resetAccessTokenCache(): void { + cachedToken = null; + inFlightExchange = null; +} + +async function exchangeAccessToken(nowSeconds: number): Promise { + const key = readServiceAccountKey(); + const signingInput = [ + base64url({ alg: 'RS256', typ: 'JWT' }), + base64url({ + iss: key.client_email, + scope: SCOPE, + aud: TOKEN_URL, + iat: nowSeconds, + exp: nowSeconds + 3600, + }), + ].join('.'); + const signature = createSign('RSA-SHA256') + .update(signingInput) + .sign(key.private_key, 'base64url'); + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `${signingInput}.${signature}`, + }), + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.status} ${await response.text()}`); + } + const json = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!json.access_token) throw new Error('Token exchange returned no access_token.'); + return { + accessToken: json.access_token, + expiresAtSeconds: nowSeconds + (json.expires_in ?? 3600), + }; +} + +export async function getAccessToken(nowSeconds = Math.floor(Date.now() / 1000)): Promise { + if (cachedToken && cachedToken.expiresAtSeconds - REFRESH_SKEW_SECONDS > nowSeconds) { + return cachedToken.accessToken; + } + + if (!inFlightExchange) { + inFlightExchange = exchangeAccessToken(nowSeconds).finally(() => { + inFlightExchange = null; + }); + } + + const token = await inFlightExchange; + cachedToken = token; + return token.accessToken; +} diff --git a/apps/website/scripts/gsc/pull.ts b/apps/website/scripts/gsc/pull.ts new file mode 100644 index 000000000..2d787e6f2 --- /dev/null +++ b/apps/website/scripts/gsc/pull.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +import fs from 'node:fs'; +import path from 'node:path'; +import { + inspectUrl, + listSitemaps, + querySearchAnalytics, + type InspectionFailure, + type InspectionResult, +} from './api'; + +const OUT_DIR = path.join(process.cwd(), 'apps', 'website', '.gsc'); + +function isoDaysAgo(days: number): string { + const date = new Date(Date.now() - days * 86_400_000); + return date.toISOString().slice(0, 10); +} + +function write(name: string, value: unknown): void { + fs.mkdirSync(OUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUT_DIR, name), JSON.stringify(value, null, 2)); + console.log(`wrote .gsc/${name}`); +} + +async function sitemapUrls(): Promise { + const response = await fetch('https://threadplane.ai/sitemap.xml'); + if (!response.ok) { + throw new Error(`sitemap fetch failed: ${response.status} ${response.statusText}`); + } + const xml = await response.text(); + const urls = [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); + if (urls.length === 0) { + throw new Error('sitemap.xml matched zero entries; treating as a broken fetch, not empty inventory.'); + } + return urls; +} + +async function main(): Promise { + // Search Analytics data lags ~2 days; end 3 days back for a stable window. + const endDate = isoDaysAgo(3); + const startDate = isoDaysAgo(93); + + write('meta.json', { startDate, endDate, pulledAt: new Date().toISOString() }); + write('queries.json', await querySearchAnalytics({ startDate, endDate, dimensions: ['query'] })); + write('pages.json', await querySearchAnalytics({ startDate, endDate, dimensions: ['page'] })); + write( + 'query-page.json', + await querySearchAnalytics({ startDate, endDate, dimensions: ['query', 'page'] }), + ); + write('dates.json', await querySearchAnalytics({ startDate, endDate, dimensions: ['date'] })); + write( + 'discover.json', + await querySearchAnalytics({ startDate, endDate, dimensions: ['page'], type: 'discover' }), + ); + write('sitemaps.json', await listSitemaps()); + + // URL Inspection is quota-limited (2000/day, 600/min). Serialize with a small delay. + // Failures are collected separately rather than dropped or faked, so a partial + // sweep still yields a usable inspections.json plus a visible error trail. + const urls = await sitemapUrls(); + const inspections: InspectionResult[] = []; + const failures: InspectionFailure[] = []; + for (const [index, url] of urls.entries()) { + try { + inspections.push(await inspectUrl(url)); + } catch (error) { + failures.push({ url, error: error instanceof Error ? error.message : String(error) }); + } + const done = index + 1; + if (done % 25 === 0 || done === urls.length) { + console.log(`inspected ${done}/${urls.length} urls`); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + write('inspections.json', inspections); + if (failures.length > 0) { + write('inspection-errors.json', failures); + } + console.log( + `inspected ${inspections.length}/${urls.length} urls, ${failures.length} failed` + + (failures.length > 0 ? ' (see .gsc/inspection-errors.json)' : ''), + ); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/website/scripts/gsc/report.ts b/apps/website/scripts/gsc/report.ts new file mode 100644 index 000000000..34c4565d4 --- /dev/null +++ b/apps/website/scripts/gsc/report.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +import fs from 'node:fs'; +import path from 'node:path'; +import type { InspectionFailure, InspectionResult, SearchAnalyticsRow } from './api'; +import { + capList, + describeInspectionCoverage, + findCanonicalMismatches, + findStrikingDistance, + findUnindexed, + findWeakCtr, + findZeroImpressionPages, +} from './analysis'; +import { read, readOptional } from './snapshots'; + +const DIR = path.join(process.cwd(), 'apps', 'website', '.gsc'); + +/** Longest any bullet list in the report gets before it is trimmed with a count. */ +const LIST_LIMIT = 20; + +function table(rows: SearchAnalyticsRow[], headers: string[], limit = 30): string { + const head = `| ${headers.join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |`; + const body = rows + .slice(0, limit) + .map( + (row) => + `| ${row.keys.join(' | ')} | ${row.clicks} | ${row.impressions} | ${(row.ctr * 100).toFixed(1)}% | ${row.position.toFixed(1)} |`, + ) + .join('\n'); + return `${head}\n${body}`; +} + +/** A `###` heading over a capped bullet list, or `_none_` when there is nothing to say. */ +function bulletSection(heading: string, items: string[]): string[] { + if (items.length === 0) { + return [`### ${heading}`, ``, `_none_`, ``]; + } + const { shown, remaining } = capList(items, LIST_LIMIT); + return [ + `### ${heading}`, + ``, + shown.map((item) => `- ${item}`).join('\n'), + ...(remaining > 0 ? [``, `_…and ${remaining} more._`] : []), + ``, + ]; +} + +function main(): void { + const meta = read<{ startDate: string; endDate: string }>(DIR, 'meta.json'); + const queries = read(DIR, 'queries.json'); + const pages = read(DIR, 'pages.json'); + const inspections = read(DIR, 'inspections.json'); + const failures = readOptional(DIR, 'inspection-errors.json') ?? []; + // Sitemap inventory = URLs we inspected PLUS URLs we failed to inspect, so a + // failed URL still reaches the zero-impression analysis instead of vanishing. + const sitemapUrls = [...inspections.map((i) => i.url), ...failures.map((f) => f.url)]; + + const totals = queries.reduce( + (acc, row) => ({ + clicks: acc.clicks + row.clicks, + impressions: acc.impressions + row.impressions, + }), + { clicks: 0, impressions: 0 }, + ); + + const unindexed = findUnindexed(inspections); + const mismatches = findCanonicalMismatches(inspections); + const orphans = findZeroImpressionPages(sitemapUrls, pages); + + const report = [ + `# threadplane.ai — Search Console report`, + ``, + `Window: ${meta.startDate} → ${meta.endDate}. Total clicks ${totals.clicks}, impressions ${totals.impressions}.`, + ``, + `> Google's AI Overviews / AI Mode impressions are NOT included — that report is UI-only.`, + `> See docs/gtm/ai-search-measurement.md.`, + ``, + `## Index health`, + ``, + describeInspectionCoverage({ inspected: inspections.length, failed: failures.length }), + ``, + `- Sitemap URLs inspected: ${inspections.length}`, + `- Inspections that failed: ${failures.length}`, + `- Not cleanly indexed: ${unindexed.length}`, + `- Google canonical ≠ our canonical: ${mismatches.length}`, + `- Zero-impression pages in window: ${orphans.length}`, + ``, + // Only rendered on a partial sweep; a clean run should not carry an empty section. + ...(failures.length > 0 + ? bulletSection( + 'Failed inspections', + failures.map((failure) => `${failure.url} — ${failure.error}`), + ) + : []), + ...bulletSection( + 'Not indexed', + unindexed.map((i) => `${i.url} — ${i.coverageState}`), + ), + ...bulletSection( + 'Canonical mismatches', + mismatches.map((i) => `${i.url} → Google chose ${i.googleCanonical}`), + ), + ...bulletSection('Zero-impression pages', orphans), + `## Striking distance (position 5–20, ≥50 impressions)`, + ``, + table(findStrikingDistance(queries, { minImpressions: 50 }), [ + 'Query', + 'Clicks', + 'Impr', + 'CTR', + 'Pos', + ]), + ``, + `## Weak CTR on page one (≥100 impressions, CTR < 2%)`, + ``, + `Title/description rewrite candidates. The threshold is flat across positions 1–10 — read the Pos column before acting.`, + ``, + table(findWeakCtr(queries, { minImpressions: 100, maxCtr: 0.02 }), [ + 'Query', + 'Clicks', + 'Impr', + 'CTR', + 'Pos', + ]), + ``, + `## Top pages`, + ``, + table(pages, ['Page', 'Clicks', 'Impr', 'CTR', 'Pos']), + ``, + ].join('\n'); + + fs.writeFileSync(path.join(DIR, 'report.md'), report); + console.log('wrote .gsc/report.md'); +} + +try { + main(); +} catch (error) { + // The likely failures here are "you have not run the pull yet" and "a snapshot + // is corrupt", both of which read better as one line than as a stack trace. + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/apps/website/scripts/gsc/snapshots.spec.ts b/apps/website/scripts/gsc/snapshots.spec.ts new file mode 100644 index 000000000..886a60254 --- /dev/null +++ b/apps/website/scripts/gsc/snapshots.spec.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { read, readOptional } from './snapshots'; + +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsc-snapshots-')); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('read', () => { + it('parses a snapshot file', () => { + fs.writeFileSync(path.join(dir, 'meta.json'), '{"startDate":"2026-01-01"}'); + expect(read<{ startDate: string }>(dir, 'meta.json')).toEqual({ startDate: '2026-01-01' }); + }); + + it('names the missing file and points at the pull instead of throwing ENOENT', () => { + expect(() => read(dir, 'queries.json')).toThrow(/Missing snapshot .*queries\.json/); + expect(() => read(dir, 'queries.json')).toThrow(/npm run gsc:pull/); + }); + + it('reports malformed JSON with the offending file', () => { + fs.writeFileSync(path.join(dir, 'pages.json'), '{ broken'); + expect(() => read(dir, 'pages.json')).toThrow(/Malformed JSON in .*pages\.json/); + }); +}); + +describe('readOptional', () => { + it('returns null when the file is genuinely absent', () => { + expect(readOptional(dir, 'inspection-errors.json')).toBeNull(); + }); + + it('still rethrows on malformed JSON rather than reporting absence', () => { + fs.writeFileSync(path.join(dir, 'inspection-errors.json'), '{ broken'); + expect(() => readOptional(dir, 'inspection-errors.json')).toThrow(/Malformed JSON/); + }); + + it('parses the file when it is present', () => { + fs.writeFileSync(path.join(dir, 'inspection-errors.json'), '[{"url":"u","error":"429"}]'); + expect(readOptional(dir, 'inspection-errors.json')).toEqual([{ url: 'u', error: '429' }]); + }); +}); diff --git a/apps/website/scripts/gsc/snapshots.ts b/apps/website/scripts/gsc/snapshots.ts new file mode 100644 index 000000000..9b181d6da --- /dev/null +++ b/apps/website/scripts/gsc/snapshots.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Read one snapshot file written by `pull.ts`. The overwhelmingly likely error + * is running the report before the pull, so say that in the message instead of + * surfacing a raw ENOENT stack. + */ +export function read(dir: string, name: string): T { + const file = path.join(dir, name); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Missing snapshot ${file}. Run \`npm run gsc:pull\` first.`); + } + throw error; + } + try { + return JSON.parse(raw) as T; + } catch (error) { + throw new Error( + `Malformed JSON in ${file}: ${(error as Error).message}. Re-run \`npm run gsc:pull\`.`, + ); + } +} + +/** + * Like {@link read}, for a file `pull.ts` writes only on a partial sweep. + * Genuine absence is `null`; every other failure — malformed JSON, permissions — + * still throws. The existence check is deliberate: `read` now reports a missing + * file as a friendly Error, so `.code` is no longer available to discriminate on. + */ +export function readOptional(dir: string, name: string): T | null { + if (!fs.existsSync(path.join(dir, name))) { + return null; + } + return read(dir, name); +} diff --git a/apps/website/src/app/about/page.spec.tsx b/apps/website/src/app/about/page.spec.tsx new file mode 100644 index 000000000..ffa3d47ed --- /dev/null +++ b/apps/website/src/app/about/page.spec.tsx @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import AboutPage from './page'; +import { getAuthor } from '../../lib/blog-authors'; + +vi.mock('../../components/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../../components/ui/Eyebrow', () => ({ + Eyebrow: ({ children }: { children: React.ReactNode }) => {children}, +})); + +const author = getAuthor('brian'); + +/** + * This page is an E-E-A-T signal, so its visible prose is exactly the surface a + * fabricated credential would appear on. These assertions tie the rendered + * claims back to the author record rather than restating the layout. + */ +describe('AboutPage', () => { + it('renders the bio verbatim from the author record', () => { + render(); + // Not a substring match: any editorial embellishment around the sourced + // sentence would still be a claim nothing in the repo supports. + expect(screen.getByText(author.bio as string).textContent).toBe(author.bio); + }); + + it('states the name and role the author record gives, and no other', () => { + render(); + expect(screen.getByText(`Threadplane is written and maintained by ${author.name}, ${author.role}.`)) + .toBeTruthy(); + }); + + it('links the GitHub profile the record names', () => { + render(); + expect(screen.getByRole('link', { name: `github.com/${author.github}` }).getAttribute('href')) + .toBe(`https://github.com/${author.github}`); + }); + + it('references no image asset', () => { + // There is no headshot in the repo. A later `` here would either point + // at a missing file or assert a likeness the project does not have. + const { container } = render(); + expect(container.querySelectorAll('img').length).toBe(0); + }); +}); diff --git a/apps/website/src/app/about/page.tsx b/apps/website/src/app/about/page.tsx new file mode 100644 index 000000000..696662841 --- /dev/null +++ b/apps/website/src/app/about/page.tsx @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +import Link from 'next/link'; +import { tokens } from '@threadplane/design-tokens'; +import { Container } from '../../components/ui/Container'; +import { Section } from '../../components/ui/Section'; +import { Eyebrow } from '../../components/ui/Eyebrow'; +import { JsonLd } from '../../components/shared/JsonLd'; +import { aboutPageJsonLd, REPOSITORY_URL } from '../../lib/structured-data'; +import { getAuthor } from '../../lib/blog-authors'; +import { createPageMetadata } from '../../lib/site-metadata'; +import { LONG_SUBHEAD } from '../../lib/positioning'; + +/** + * The single author record the site already publishes (blog bylines read the + * same object), so the Person node and every BlogPosting byline state one name + * and one role rather than two that happen to agree. + */ +const author = getAuthor('brian'); + +export const metadata = createPageMetadata({ + title: 'About — Threadplane', + description: `Who writes Threadplane: ${author.name}, ${author.role}. ${author.bio}`, + pathname: '/about', + type: 'website', +}); + +const bodyStyle = { + fontFamily: tokens.typography.bodyLg.family, + fontSize: tokens.typography.bodyLg.size, + lineHeight: tokens.typography.bodyLg.line, + color: tokens.colors.textSecondary, + margin: 0, + marginBottom: 16, + maxWidth: '60ch', +} as const; + +const headingStyle = { + fontFamily: tokens.typography.h2.family, + fontSize: tokens.typography.h2.size, + color: tokens.colors.textPrimary, + margin: 0, + marginBottom: 12, +} as const; + +const linkStyle = { color: tokens.colors.accent } as const; + +export default function AboutPage() { + return ( + <> + + +
+ +
+ About +

+ Who writes Threadplane +

+

+ Threadplane is written and maintained by {author.name}, {author.role}. +

+

{author.bio}

+

+ + github.com/{author.github} + +

+
+
+
+ +
+ +
+

What Threadplane is

+

{LONG_SUBHEAD}

+

+ The source is public at{' '} + + github.com/cacheplane/angular-agent-framework + + . +

+
+
+
+ +
+ +
+

How it is licensed

+

+ @threadplane/chat is free for noncommercial use under PolyForm + Noncommercial 1.0.0; commercial production use requires a Threadplane Commercial + license. The other libraries are MIT. The{' '} + + licensing docs + {' '} + and the pricing page have the details. +

+

+ Questions about a specific build go to{' '} + + contact + + ; ongoing writing is on the blog. +

+
+
+
+ + ); +} diff --git a/apps/website/src/app/blog/[slug]/opengraph-image.tsx b/apps/website/src/app/blog/[slug]/opengraph-image.tsx index cf9f4a28d..c87daff93 100644 --- a/apps/website/src/app/blog/[slug]/opengraph-image.tsx +++ b/apps/website/src/app/blog/[slug]/opengraph-image.tsx @@ -1,6 +1,7 @@ import { ImageResponse } from 'next/og'; -import { getPostBySlug } from '../../../lib/blog'; +import { getAllPosts, getPostBySlug } from '../../../lib/blog'; import { getAuthor } from '../../../lib/blog-authors'; +import { loadCardFonts } from '../../og-font'; export const runtime = 'nodejs'; export const alt = 'Threadplane blog post'; @@ -11,35 +12,20 @@ interface Params { params: Promise<{ slug: string }>; } -async function loadFont(family: string, weight: number): Promise { - try { - const css = await fetch( - `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`, - { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' } }, - ).then((res) => res.text()); - const match = css.match(/src:\s*url\((https?:\/\/[^)]+)\)/); - if (!match) return null; - const fontRes = await fetch(match[1]); - if (!fontRes.ok) return null; - return await fontRes.arrayBuffer(); - } catch { - return null; - } -} - -async function loadLocalGaramond(): Promise { - try { - const { fileURLToPath } = await import('node:url'); - const { readFile } = await import('node:fs/promises'); - const { dirname, join } = await import('node:path'); - // The TTF lives next to the root opengraph-image.tsx, one level up from blog/[slug] - const here = dirname(fileURLToPath(import.meta.url)); - const buf = await readFile(join(here, '../../EBGaramond-Bold.ttf')); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; - } catch (err) { - console.warn('blog/[slug]/opengraph-image: failed to load local Garamond TTF', err); - return null; - } +/** + * Prerenders one card per published post at build time. Mirrors the + * `generateStaticParams` in this segment's `page.tsx`, so drafts are excluded. + * + * This is load-bearing beyond the obvious caching win. Satori rejects some + * markup at render time (a div with multiple children and no explicit + * `display`, for one) and a request-time route turns that into a production + * 500 on every post — which is exactly how the byline below shipped broken. + * Prerendering promotes that whole class of mistake into a build failure. + * It also keeps the MDX read and the Google Fonts round-trips on the build, + * where `resolveWebsiteDir()` is known to resolve, rather than per request. + */ +export function generateStaticParams() { + return getAllPosts().map((p) => ({ slug: p.slug })); } export default async function og({ params }: Params) { @@ -47,6 +33,7 @@ export default async function og({ params }: Params) { const post = getPostBySlug(slug); if (!post || post.frontmatter.draft) { + // No `fonts` option at all: next/og falls back to its bundled Noto Sans. return new ImageResponse( (
=> f !== null); - + const fonts = await loadCardFonts(); const author = getAuthor(post.frontmatter.author); + return new ImageResponse( (
{post.frontmatter.title}
-
+ {/* + Satori requires an explicit `display` on any div with more than one + child node, and throws otherwise. This byline has three (name, + separator, date), so the `display: flex` is load-bearing — its + absence is what 500ed every post's card. The two divs above have a + single child each and need no `display`. + */} +
{author.name} · {post.frontmatter.date}
diff --git a/apps/website/src/app/blog/[slug]/page.tsx b/apps/website/src/app/blog/[slug]/page.tsx index d666fd023..ce64c7712 100644 --- a/apps/website/src/app/blog/[slug]/page.tsx +++ b/apps/website/src/app/blog/[slug]/page.tsx @@ -9,7 +9,10 @@ import { Eyebrow } from '../../../components/ui/Eyebrow'; import { getAllPosts, getPostBySlug, formatPostDate, readingTimeMin } from '../../../lib/blog'; import { getAuthor } from '../../../lib/blog-authors'; import { extractHeadings } from '../../../lib/extract-headings'; -import { createPageMetadata } from '../../../lib/site-metadata'; +import { createPageMetadata, ogImagePath } from '../../../lib/site-metadata'; +import { getPostLastModified, publishedDate } from '../../../lib/sitemap-dates'; +import { JsonLd } from '../../../components/shared/JsonLd'; +import { blogPostingJsonLd, breadcrumbJsonLd } from '../../../lib/structured-data'; interface Params { params: Promise<{ slug: string }>; @@ -23,13 +26,29 @@ export async function generateMetadata({ params }: Params): Promise { const { slug } = await params; const post = getPostBySlug(slug); if (!post || post.frontmatter.draft) { - return { title: 'Post not found — ThreadPlane' }; + return { title: 'Post not found — Threadplane' }; } + const author = getAuthor(post.frontmatter.author); + const pathname = `/blog/${post.slug}`; + const lastModified = getPostLastModified(post); + // Undefined for an unparseable frontmatter date, which drops the article + // block rather than shipping the bad string as `article:published_time`. + const published = publishedDate(post); + return createPageMetadata({ - title: `${post.frontmatter.title} — ThreadPlane`, + title: `${post.frontmatter.title} — Threadplane`, description: post.frontmatter.description, - pathname: `/blog/${post.slug}`, + pathname, type: 'article', + image: ogImagePath(post.slug), + article: published + ? { + publishedTime: published.toISOString(), + modifiedTime: lastModified?.toISOString(), + authors: [author.name], + tags: post.frontmatter.tags, + } + : undefined, }); } @@ -44,9 +63,34 @@ export default async function BlogPostPage({ params }: Params) { ? post.frontmatter.tags[0].toUpperCase() : 'POST'; const headings = extractHeadings(post.content); + // Same derivation `generateMetadata` uses for `article:modified_time`, and the + // same one the sitemap uses for ``, so all three agree. + const lastModified = getPostLastModified(post); + // Undefined for an unparseable frontmatter date; the BlogPosting is dropped + // rather than published without a `datePublished`, matching the metadata. + const published = publishedDate(post); + + const postData = published + ? blogPostingJsonLd({ + title: post.frontmatter.title, + description: post.frontmatter.description, + slug: post.slug, + datePublished: published.toISOString(), + dateModified: lastModified?.toISOString(), + authorName: author.name, + tags: post.frontmatter.tags, + }) + : null; + + const breadcrumbs = breadcrumbJsonLd([ + { name: 'Blog', pathname: '/blog' }, + { name: post.frontmatter.title, pathname: `/blog/${post.slug}` }, + ]); return (
+ {postData ? : null} +
- Articles from ThreadPlane + Articles from Threadplane

{ const { library, section, slug } = await params; return getDocMetadata(library, section, slug) ?? { - title: 'Docs - Threadplane', - description: 'Threadplane documentation', + title: 'Docs — Threadplane', + description: DEFAULT_DOCS_DESCRIPTION, }; } @@ -52,11 +61,37 @@ export default async function DocsPage({ params }: DocsRouteProps) { const doc = getDocBySlug(library, section, slug); if (!doc) notFound(); + const pathname = `/docs/${library}/${section}/${slug}`; + + const articleData = techArticleJsonLd({ + title: doc.title, + // Exactly the string `generateMetadata` puts in the meta description. + description: resolveDocDescription(doc, library), + pathname, + dateModified: getDocLastModified(pathname)?.toISOString(), + }); + + // Mirrors the visible , which links the library rung through + // the same `libraryIntroPath()` — there is no /docs/ route, so a + // crumb pointing there would 404. + // + // The section rung the visible trail shows between library and page is + // deliberately absent: it is plain text there because no section index route + // exists, and a non-final BreadcrumbList item with no `item` URL is invalid. + // Omitting it is closer to the visible trail than inventing a URL for it. + const breadcrumbs = breadcrumbJsonLd([ + { name: 'Docs', pathname: '/docs' }, + { name: libConfig.title, pathname: libraryIntroPath(library) }, + { name: doc.title, pathname }, + ]); + return (

+ +
{children}
), - h2: ({ id, children, ...rest }: React.HTMLAttributes) => ( -

- {id ? ( - - # - - ) : null} - {children} -

- ), - h3: ({ id, children, ...rest }: React.HTMLAttributes) => ( -

- {id ? ( - - # - - ) : null} - {children} -

- ), + ...mdxHeadingComponents, }; const rehypeOptions = { diff --git a/apps/website/src/app/docs/docs-structured-data.spec.tsx b/apps/website/src/app/docs/docs-structured-data.spec.tsx new file mode 100644 index 000000000..f84bfdbb3 --- /dev/null +++ b/apps/website/src/app/docs/docs-structured-data.spec.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { isValidElement, type ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import DocsPage, { generateMetadata } from './[library]/[section]/[slug]/page'; +import { DocsBreadcrumb } from '../../components/docs/DocsBreadcrumb'; +import { docsConfig, type LibraryId } from '../../lib/docs-config'; +import { getDocBySlug } from '../../lib/docs'; +import { getSitemapRoutes } from '../../lib/site-metadata'; + +interface Slug { + library: string; + section: string; + slug: string; +} + +/** + * A spread of shapes rather than every page: one per description source + * (frontmatter, first-paragraph fallback), an API page, and a page that is + * itself the library's breadcrumb target. + */ +const SAMPLES: Slug[] = [ + { library: 'langgraph', section: 'guides', slug: 'streaming' }, + { library: 'langgraph', section: 'getting-started', slug: 'introduction' }, + { library: 'langgraph', section: 'api', slug: 'inject-agent' }, + { library: 'chat', section: 'getting-started', slug: 'introduction' }, + { library: 'a2ui', section: 'getting-started', slug: 'introduction' }, +]; + +/** Every `data` payload the page hands to a ``, in render order. */ +async function renderedJsonLd({ library, section, slug }: Slug): Promise[]> { + const tree = await DocsPage({ params: Promise.resolve({ library, section, slug }) }); + const found: Record[] = []; + + const walk = (node: ReactNode): void => { + if (Array.isArray(node)) return node.forEach(walk); + if (!isValidElement(node)) return; + const props = node.props as { data?: Record; children?: ReactNode }; + // Matched by name because `JsonLd` is a plain function component; importing + // it for identity would still work, but the name keeps the failure legible. + if (typeof node.type === 'function' && (node.type as { name?: string }).name === 'JsonLd' && props.data) { + found.push(props.data); + } + walk(props.children); + }; + + walk(tree); + return found; +} + +function nodeOfType(nodes: Record[], type: string): Record | undefined { + return nodes.find((node) => node['@type'] === type); +} + +describe('docs page structured data', () => { + // The tautology this replaces compared `resolveDocDescription` against + // `getDocMetadata`, which calls it — both sides were the same function. The + // real risk is the *page* describing itself differently from its own + // `generateMetadata`, so the assertion runs over both actual surfaces. + it('describes itself with the same string its metadata publishes', async () => { + for (const sample of SAMPLES) { + const [article, metadata] = await Promise.all([ + renderedJsonLd(sample).then((nodes) => nodeOfType(nodes, 'TechArticle')), + generateMetadata({ params: Promise.resolve(sample) }), + ]); + + const label = `${sample.library}/${sample.section}/${sample.slug}`; + expect([label, typeof metadata.description]).toEqual([label, 'string']); + expect([label, article?.description]).toEqual([label, metadata.description]); + } + }); + + it('emits exactly a TechArticle and a BreadcrumbList', async () => { + const nodes = await renderedJsonLd(SAMPLES[0]); + expect(nodes.map((node) => node['@type'])).toEqual(['TechArticle', 'BreadcrumbList']); + }); + + // Google expects the breadcrumb markup to correspond to the visible trail, so + // the JSON-LD is checked against what actually renders rather + // than against a second copy of the same string. + it('links the same library URL the visible breadcrumb links', async () => { + for (const sample of SAMPLES) { + const doc = getDocBySlug(sample.library, sample.section, sample.slug); + const nodes = await renderedJsonLd(sample); + const crumbs = nodeOfType(nodes, 'BreadcrumbList')?.itemListElement as + | { name: string; item: string }[] + | undefined; + + const { unmount } = render( + , + ); + const libraryTitle = docsConfig.find((lib) => lib.id === sample.library)?.title ?? ''; + const visibleHref = screen.getByRole('link', { name: libraryTitle }).getAttribute('href'); + unmount(); + + const label = `${sample.library}/${sample.section}/${sample.slug}`; + expect([label, crumbs?.find((crumb) => crumb.name === libraryTitle)?.item]).toEqual([ + label, + `https://threadplane.ai${visibleHref}`, + ]); + } + }); + + // A non-final BreadcrumbList item pointing at a 404 is a defect, and the + // plan's original `/docs/` was exactly that. Rendering the page is + // what gives this teeth: reverting the URL in page.tsx fails here. + it('points every non-final breadcrumb rung at a route that exists', async () => { + const routes = new Set(getSitemapRoutes().map((route) => `https://threadplane.ai${route === '/' ? '/' : route}`)); + + for (const sample of SAMPLES) { + const nodes = await renderedJsonLd(sample); + const crumbs = nodeOfType(nodes, 'BreadcrumbList')?.itemListElement as { item: string }[]; + const missing = crumbs.slice(0, -1).map((crumb) => crumb.item).filter((item) => !routes.has(item)); + expect([`${sample.library}/${sample.section}/${sample.slug}`, missing]).toEqual([ + `${sample.library}/${sample.section}/${sample.slug}`, + [], + ]); + } + }); +}); diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index 7e8041115..7bcf356bd 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -182,6 +182,14 @@ html { box-shadow: 0 4px 16px rgba(0, 32, 72, 0.08); } +/* Architecture diagrams are authored at exactly the width `.docs-prose` + * computes to (70ch = ~706px), so they render 1:1 and their labels stay at + * their authored size. Below that, scaling the whole figure down would take + * 11.5px labels under 5px, so the paragraph scrolls instead — the same + * treatment `.docs-table-scroll` gives a wide table. */ +.docs-prose > p:has(> .docs-diagram) { overflow-x: auto; } +.docs-prose > p > img.docs-diagram { max-width: none; } + .docs-table-scroll { max-width: 100%; overflow-x: auto; margin: 1.5rem 0; } .docs-prose table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 0; } .docs-prose th { text-align: left; padding: 0.5rem 0.75rem; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; color: #555770; border-bottom: 1px solid rgba(0, 64, 144, 0.15); } @@ -290,6 +298,15 @@ html { text-decoration: none; transition: opacity 120ms ease; } +/* + * The `#` glyph is generated content, never a text node: that keeps it out of + * the heading's textContent so extracted headings ("Prerequisites") stay clean + * for search snippets, page outlines, and agents reading the DOM. + */ +.docs-prose h2 .heading-anchor::before, +.docs-prose h3 .heading-anchor::before { + content: '#'; +} .docs-prose h2:hover .heading-anchor, .docs-prose h3:hover .heading-anchor, .docs-prose h2 .heading-anchor:focus-visible, diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index e567dd5c3..dcf4d972c 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -4,6 +4,8 @@ import './global.css'; import { Nav } from '../components/shared/Nav'; import { Footer } from '../components/shared/Footer'; import { AnnouncementToast } from '../components/shared/AnnouncementToast'; +import { JsonLd } from '../components/shared/JsonLd'; +import { rootJsonLd } from '../lib/structured-data'; import { DEFAULT_META_DESCRIPTION, DEFAULT_SOCIAL_IMAGE, @@ -57,6 +59,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + {/* + Site-wide structured data, mounted once here so it is present on every + route. Per-route nodes (BlogPosting, TechArticle) reference the + Organization by `@id`; those references only resolve because this + renders alongside them. `rootJsonLd()` is a single `@graph` for that + reason — do not mount its component builders individually. + */} +