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, }; }