Skip to content

Commit ac1d588

Browse files
bloveclaude
andauthored
fix(website): repair frontmatter handling in docs, then use it for SERP descriptions (#827)
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 `<hr>` followed by a setext `<h2>`, putting a junk "title: … description: …" heading above the real `<h1>` 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 <noreply@anthropic.com>
1 parent e50c31b commit ac1d588

7 files changed

Lines changed: 91 additions & 11 deletions

File tree

apps/website/content/docs/ag-ui/api/inject-agent.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
description: injectAgent() returns the AG-UI agent configured by provideAgent() — Angular Signals for chat state, async methods for submit and tool calls.
3+
---
4+
15
# injectAgent()
26

37
`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.

apps/website/content/docs/langgraph/api/inject-agent.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
description: injectAgent() connects an Angular app to a LangGraph Platform assistant — streaming messages, tool calls, and interrupts as Angular Signals.
3+
---
4+
15
# injectAgent()
26

37
`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`.

apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
---
2+
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.
3+
---
4+
15
# json-render vs A2UI
26

37
`@threadplane/render` and `@threadplane/a2ui` both render structured UI, but they solve different problems.

apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
109109
</div>
110110
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
111111
<MdxRenderer
112-
source={doc.content}
112+
source={doc.body}
113113
library={library as LibraryId}
114114
section={section}
115115
slug={slug}
@@ -141,7 +141,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
141141
<DocsPrevNext library={library as LibraryId} section={section} slug={slug} />
142142
</div>
143143
</div>
144-
<DocsTOC headings={extractHeadings(doc.content)} />
144+
<DocsTOC headings={extractHeadings(doc.body)} />
145145
</div>
146146
</div>
147147
);

apps/website/src/app/docs/choosing-an-adapter/page.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CodeGroup } from '../../../components/docs/mdx/CodeGroup';
1717
import { Pre } from '../../../components/docs/mdx/CodeBlock';
1818
import { mdxHeadingComponents } from '../../../components/docs/mdx/headings';
1919
import { createPageMetadata } from '../../../lib/site-metadata';
20+
import { stripFrontmatter } from '../../../lib/docs';
2021

2122
export const metadata = createPageMetadata({
2223
title: 'Choosing an adapter — Threadplane',
@@ -59,10 +60,6 @@ function resolveContentFile(): string | null {
5960
return null;
6061
}
6162

62-
function stripFrontmatter(source: string): string {
63-
return source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
64-
}
65-
6663
export default function ChoosingAnAdapterPage() {
6764
const filePath = resolveContentFile();
6865
if (!filePath) notFound();

apps/website/src/lib/docs.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
22
import fs from 'fs';
33
import path from 'path';
44
import { fileURLToPath } from 'url';
5-
import { getAllDocSlugs, getDocBySlug, getDocMetadata } from './docs';
5+
import { getAllDocSlugs, getDocBySlug, getDocMetadata, stripFrontmatter } from './docs';
66
import { allDocsPages, docsConfig, findDocsPage, libraryIntroPath, specialDocsPages } from './docs-config';
77
import { getCanonicalUrl, getSitemapRoutes } from './site-metadata';
88

@@ -127,6 +127,46 @@ describe('website docs bindings', () => {
127127
expect(duplicateDescriptions).toHaveLength(0);
128128
});
129129

130+
// Both regressions below shipped together and hid each other: the description
131+
// regex silently ignored the frontmatter, so the only visible symptom was the
132+
// block rendering as Markdown — an <hr> plus a setext <h2> above the real <h1>.
133+
it('prefers a frontmatter description when `description` is the last key', () => {
134+
// Every real frontmatter block in content/docs/ ends on `description:`.
135+
const metadata = getDocMetadata('chat', 'guides', 'custom-catalogs');
136+
137+
expect(metadata?.description).toBe(
138+
'Compose custom component catalogs for generative UI using ViewRegistry composition.',
139+
);
140+
});
141+
142+
it('never leaks frontmatter keys into a derived description', () => {
143+
for (const { library, section, slug } of getAllDocSlugs()) {
144+
const description = getDocMetadata(library, section, slug)?.description ?? '';
145+
expect(description, `/docs/${library}/${section}/${slug}`).not.toMatch(/^title:/);
146+
}
147+
});
148+
149+
it('exposes a render body with no frontmatter for every doc page', () => {
150+
for (const { library, section, slug } of getAllDocSlugs()) {
151+
const doc = getDocBySlug(library, section, slug);
152+
153+
expect(doc?.body.startsWith('---'), `/docs/${library}/${section}/${slug}`).toBe(false);
154+
}
155+
});
156+
157+
it('strips a frontmatter block before the body is handed to MDX', () => {
158+
const source = '---\ntitle: X\ndescription: D.\n---\n\n# Heading\n\nBody.\n';
159+
160+
expect(stripFrontmatter(source)).toBe('# Heading\n\nBody.\n');
161+
});
162+
163+
it('leaves a body that merely starts with a thematic break alone', () => {
164+
// A leading `---` is only frontmatter when a closing fence follows it.
165+
const source = '---\n\n# Heading\n';
166+
167+
expect(stripFrontmatter(source)).toBe(source);
168+
});
169+
130170
it('includes every configured doc page in the sitemap routes', () => {
131171
const sitemapRoutes = getSitemapRoutes();
132172

apps/website/src/lib/docs.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,44 @@ export const DEFAULT_DOCS_DESCRIPTION = 'Threadplane documentation';
1515

1616
export interface ResolvedDoc {
1717
page: DocsPage;
18+
/** Raw file contents, frontmatter included — the description is read from it. */
1819
content: string;
20+
/** `content` with any frontmatter removed. This is what gets rendered. */
21+
body: string;
1922
title: string;
2023
}
2124

2225
export type ResolvedDocMetadata = Metadata;
2326

24-
const FRONTMATTER_DESCRIPTION_PATTERN = /^---\s*\n[\s\S]*?\ndescription:\s*['"]?(?<description>[^'"\n]+)['"]?\s*\n[\s\S]*?\n---/;
27+
/**
28+
* A leading `---` fence and its closing partner. Matched as a whole block, then
29+
* searched for keys — the previous single pattern spliced the two together and
30+
* required a key to FOLLOW `description:`, so the last key in a block never
31+
* matched. Every real block in content/docs/ ends on `description:`.
32+
*/
33+
const FRONTMATTER_BLOCK_PATTERN = /^---\s*\n(?<body>[\s\S]*?)\n---\s*(?:\n|$)/;
34+
35+
const FRONTMATTER_DESCRIPTION_PATTERN = /^description:\s*['"]?(?<description>[^'"\n]+?)['"]?\s*$/m;
36+
37+
/**
38+
* Remove a frontmatter block so the rest can be handed to the MDX pipeline.
39+
*
40+
* `next-mdx-remote` does not strip frontmatter unless asked, and Markdown reads
41+
* an unstripped block as an `<hr>` followed by a setext `<h2>` — a junk heading
42+
* above the page's real `<h1>`, in its table of contents and heading anchors.
43+
*
44+
* A body that merely opens with a thematic break is left alone: a leading `---`
45+
* is only frontmatter when a closing fence follows it.
46+
*/
47+
export function stripFrontmatter(source: string): string {
48+
return source.replace(FRONTMATTER_BLOCK_PATTERN, '');
49+
}
50+
51+
function readFrontmatterDescription(content: string): string | null {
52+
const body = content.match(FRONTMATTER_BLOCK_PATTERN)?.groups?.body;
53+
if (!body) return null;
54+
return body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description ?? null;
55+
}
2556

2657
function normalizeDescription(description: string): string {
2758
return description
@@ -33,8 +64,7 @@ function normalizeDescription(description: string): string {
3364
}
3465

3566
function extractFirstParagraph(content: string): string | null {
36-
const withoutFrontmatter = content.replace(/^---\s*\n[\s\S]*?\n---\s*/, '');
37-
const withoutImports = withoutFrontmatter.replace(/^import\s.+$/gm, '');
67+
const withoutImports = stripFrontmatter(content).replace(/^import\s.+$/gm, '');
3868
const paragraphs = withoutImports.split(/\n{2,}/);
3969

4070
for (const paragraph of paragraphs) {
@@ -56,7 +86,7 @@ function extractFirstParagraph(content: string): string | null {
5686
}
5787

5888
function getDocDescription(content: string, fallback: string): string {
59-
const frontmatterDescription = content.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description;
89+
const frontmatterDescription = readFrontmatterDescription(content);
6090
if (frontmatterDescription) return normalizeDescription(frontmatterDescription);
6191
return extractFirstParagraph(content) ?? fallback;
6292
}
@@ -75,6 +105,7 @@ export function getDocBySlug(library: string, section: string, slug: string): Re
75105
return {
76106
page,
77107
content,
108+
body: stripFrontmatter(content),
78109
title: titleMatch?.[1] ?? page.title,
79110
};
80111
}

0 commit comments

Comments
 (0)