From c546870bc932d3b94bbeaac63512ac9f5aeda91d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 24 Aug 2026 08:41:09 -0700 Subject: [PATCH] fix(website): repair frontmatter handling in docs, then use it for SERP descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions that hid each other. `FRONTMATTER_DESCRIPTION_PATTERN` spliced the opening and closing fences into one match and required a key to *follow* `description:`, so the last key in a block never matched — and `description:` is the last key in every frontmatter block in content/docs/. Every such page silently fell back to its first paragraph. Because the description was ignored, the only visible symptom was the second bug: the docs route handed raw file contents to `next-mdx-remote`, which does not strip frontmatter unless asked. Markdown then read the block as an `
` followed by a setext `

`, putting a junk "title: … description: …" heading above the real `

` on /docs/chat/guides/custom-catalogs and /docs/render/api/views — in the page, its table of contents, and its heading anchor labels. Live in production since the frontmatter was added. Match the block first and search it for keys; strip it via a shared `stripFrontmatter` (the one route that was already correct, /docs/choosing-an-adapter, had its own private copy — now deleted). `ResolvedDoc` gains an explicit `body`: the description is read from `content`, so stripping in place would have traded one bug for the other. With frontmatter working, set descriptions on the three pages the Search Console data singles out. Derived descriptions truncate at 180 chars and Google cuts at ~155, so /docs/langgraph/api/inject-agent — 101 impressions at position 5.6, the site's top striking-distance page — was serving a snippet that ended mid-word. All three replacements are under 155 and answer the query rather than restating the title. `resolveDocDescription` is shared with the JSON-LD, so the meta tag and the structured data stay in sync. Not touched: `ag ui angular` and `json-render vs a2ui` reads at n=8..25, where hyphen/space twins of one query swing 0%->25% at the same position. That is binomial noise, not a CTR signal. Co-Authored-By: Claude Opus 5 --- .../content/docs/ag-ui/api/inject-agent.mdx | 4 ++ .../docs/langgraph/api/inject-agent.mdx | 4 ++ .../render/concepts/json-render-vs-a2ui.mdx | 4 ++ .../docs/[library]/[section]/[slug]/page.tsx | 4 +- .../src/app/docs/choosing-an-adapter/page.tsx | 5 +-- apps/website/src/lib/docs.spec.ts | 42 ++++++++++++++++++- apps/website/src/lib/docs.ts | 39 +++++++++++++++-- 7 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/website/content/docs/ag-ui/api/inject-agent.mdx b/apps/website/content/docs/ag-ui/api/inject-agent.mdx index fd308672e..8f4547233 100644 --- a/apps/website/content/docs/ag-ui/api/inject-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/inject-agent.mdx @@ -1,3 +1,7 @@ +--- +description: injectAgent() returns the AG-UI agent configured by provideAgent() — Angular Signals for chat state, async methods for submit and tool calls. +--- + # injectAgent() `injectAgent()` retrieves the AG-UI agent from Angular's dependency injection container. Call it in an Angular injection context — typically as a component field initializer. The returned object exposes Angular Signals for reactive UI state and async methods for user actions. diff --git a/apps/website/content/docs/langgraph/api/inject-agent.mdx b/apps/website/content/docs/langgraph/api/inject-agent.mdx index 5ced855d8..6b5dae088 100644 --- a/apps/website/content/docs/langgraph/api/inject-agent.mdx +++ b/apps/website/content/docs/langgraph/api/inject-agent.mdx @@ -1,3 +1,7 @@ +--- +description: injectAgent() connects an Angular app to a LangGraph Platform assistant — streaming messages, tool calls, and interrupts as Angular Signals. +--- + # injectAgent() `injectAgent()` is the LangGraph adapter for Angular. It connects to a LangGraph Platform assistant, consumes the LangGraph SDK event stream, and projects the result into the runtime-neutral `Agent` contract used by `@threadplane/chat`. diff --git a/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx b/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx index f481b5706..23d4f2a4c 100644 --- a/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx +++ b/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx @@ -1,3 +1,7 @@ +--- +description: json-render renders a fixed spec. A2UI is an agent-to-UI protocol for surfaces that update over time and send user actions back. When to pick each. +--- + # json-render vs A2UI `@threadplane/render` and `@threadplane/a2ui` both render structured UI, but they solve different problems. diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index fc591265f..cb887f902 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -109,7 +109,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
- + ); diff --git a/apps/website/src/app/docs/choosing-an-adapter/page.tsx b/apps/website/src/app/docs/choosing-an-adapter/page.tsx index fc9daef25..8b622e605 100644 --- a/apps/website/src/app/docs/choosing-an-adapter/page.tsx +++ b/apps/website/src/app/docs/choosing-an-adapter/page.tsx @@ -17,6 +17,7 @@ import { CodeGroup } from '../../../components/docs/mdx/CodeGroup'; import { Pre } from '../../../components/docs/mdx/CodeBlock'; import { mdxHeadingComponents } from '../../../components/docs/mdx/headings'; import { createPageMetadata } from '../../../lib/site-metadata'; +import { stripFrontmatter } from '../../../lib/docs'; export const metadata = createPageMetadata({ title: 'Choosing an adapter — Threadplane', @@ -59,10 +60,6 @@ function resolveContentFile(): string | null { return null; } -function stripFrontmatter(source: string): string { - return source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ''); -} - export default function ChoosingAnAdapterPage() { const filePath = resolveContentFile(); if (!filePath) notFound(); diff --git a/apps/website/src/lib/docs.spec.ts b/apps/website/src/lib/docs.spec.ts index 279294a14..5b321921e 100644 --- a/apps/website/src/lib/docs.spec.ts +++ b/apps/website/src/lib/docs.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { getAllDocSlugs, getDocBySlug, getDocMetadata } from './docs'; +import { getAllDocSlugs, getDocBySlug, getDocMetadata, stripFrontmatter } from './docs'; import { allDocsPages, docsConfig, findDocsPage, libraryIntroPath, specialDocsPages } from './docs-config'; import { getCanonicalUrl, getSitemapRoutes } from './site-metadata'; @@ -127,6 +127,46 @@ describe('website docs bindings', () => { expect(duplicateDescriptions).toHaveLength(0); }); + // Both regressions below shipped together and hid each other: the description + // regex silently ignored the frontmatter, so the only visible symptom was the + // block rendering as Markdown — an
plus a setext

above the real

. + it('prefers a frontmatter description when `description` is the last key', () => { + // Every real frontmatter block in content/docs/ ends on `description:`. + const metadata = getDocMetadata('chat', 'guides', 'custom-catalogs'); + + expect(metadata?.description).toBe( + 'Compose custom component catalogs for generative UI using ViewRegistry composition.', + ); + }); + + it('never leaks frontmatter keys into a derived description', () => { + for (const { library, section, slug } of getAllDocSlugs()) { + const description = getDocMetadata(library, section, slug)?.description ?? ''; + expect(description, `/docs/${library}/${section}/${slug}`).not.toMatch(/^title:/); + } + }); + + it('exposes a render body with no frontmatter for every doc page', () => { + for (const { library, section, slug } of getAllDocSlugs()) { + const doc = getDocBySlug(library, section, slug); + + expect(doc?.body.startsWith('---'), `/docs/${library}/${section}/${slug}`).toBe(false); + } + }); + + it('strips a frontmatter block before the body is handed to MDX', () => { + const source = '---\ntitle: X\ndescription: D.\n---\n\n# Heading\n\nBody.\n'; + + expect(stripFrontmatter(source)).toBe('# Heading\n\nBody.\n'); + }); + + it('leaves a body that merely starts with a thematic break alone', () => { + // A leading `---` is only frontmatter when a closing fence follows it. + const source = '---\n\n# Heading\n'; + + expect(stripFrontmatter(source)).toBe(source); + }); + it('includes every configured doc page in the sitemap routes', () => { const sitemapRoutes = getSitemapRoutes(); diff --git a/apps/website/src/lib/docs.ts b/apps/website/src/lib/docs.ts index f553b5cce..17057aa25 100644 --- a/apps/website/src/lib/docs.ts +++ b/apps/website/src/lib/docs.ts @@ -15,13 +15,44 @@ export const DEFAULT_DOCS_DESCRIPTION = 'Threadplane documentation'; export interface ResolvedDoc { page: DocsPage; + /** Raw file contents, frontmatter included — the description is read from it. */ content: string; + /** `content` with any frontmatter removed. This is what gets rendered. */ + body: string; title: string; } export type ResolvedDocMetadata = Metadata; -const FRONTMATTER_DESCRIPTION_PATTERN = /^---\s*\n[\s\S]*?\ndescription:\s*['"]?(?[^'"\n]+)['"]?\s*\n[\s\S]*?\n---/; +/** + * A leading `---` fence and its closing partner. Matched as a whole block, then + * searched for keys — the previous single pattern spliced the two together and + * required a key to FOLLOW `description:`, so the last key in a block never + * matched. Every real block in content/docs/ ends on `description:`. + */ +const FRONTMATTER_BLOCK_PATTERN = /^---\s*\n(?[\s\S]*?)\n---\s*(?:\n|$)/; + +const FRONTMATTER_DESCRIPTION_PATTERN = /^description:\s*['"]?(?[^'"\n]+?)['"]?\s*$/m; + +/** + * Remove a frontmatter block so the rest can be handed to the MDX pipeline. + * + * `next-mdx-remote` does not strip frontmatter unless asked, and Markdown reads + * an unstripped block as an `
` followed by a setext `

` — a junk heading + * above the page's real `

`, in its table of contents and heading anchors. + * + * A body that merely opens with a thematic break is left alone: a leading `---` + * is only frontmatter when a closing fence follows it. + */ +export function stripFrontmatter(source: string): string { + return source.replace(FRONTMATTER_BLOCK_PATTERN, ''); +} + +function readFrontmatterDescription(content: string): string | null { + const body = content.match(FRONTMATTER_BLOCK_PATTERN)?.groups?.body; + if (!body) return null; + return body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description ?? null; +} function normalizeDescription(description: string): string { return description @@ -33,8 +64,7 @@ function normalizeDescription(description: string): string { } function extractFirstParagraph(content: string): string | null { - const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*/, ''); - const withoutImports = withoutFrontmatter.replace(/^import\s.+$/gm, ''); + const withoutImports = stripFrontmatter(content).replace(/^import\s.+$/gm, ''); const paragraphs = withoutImports.split(/\n{2,}/); for (const paragraph of paragraphs) { @@ -56,7 +86,7 @@ function extractFirstParagraph(content: string): string | null { } function getDocDescription(content: string, fallback: string): string { - const frontmatterDescription = content.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description; + const frontmatterDescription = readFrontmatterDescription(content); if (frontmatterDescription) return normalizeDescription(frontmatterDescription); return extractFirstParagraph(content) ?? fallback; } @@ -75,6 +105,7 @@ export function getDocBySlug(library: string, section: string, slug: string): Re return { page, content, + body: stripFrontmatter(content), title: titleMatch?.[1] ?? page.title, }; }