From 843310110e84adce0ab6bcfd398fd6eccd157425 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 07:56:03 -0700 Subject: [PATCH 1/4] fix(website): read a frontmatter description that contains an apostrophe The description value pattern excluded every quote character, so any `description:` holding a possessive never matched. Nine docs pages declared a description and silently shipped the first-paragraph fallback instead. Capture the value whole and strip only a matched pair of surrounding quotes. Co-Authored-By: Claude Opus 5 --- apps/website/src/lib/docs.spec.ts | 27 +++++++++++++++++++++++++++ apps/website/src/lib/docs.ts | 21 ++++++++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/website/src/lib/docs.spec.ts b/apps/website/src/lib/docs.spec.ts index 8d812ae30..216672b21 100644 --- a/apps/website/src/lib/docs.spec.ts +++ b/apps/website/src/lib/docs.spec.ts @@ -10,6 +10,7 @@ import { getAllDocSlugs, getDocBySlug, getDocMetadata, + readFrontmatterDescription, stripFrontmatter, } from './docs'; import { @@ -235,6 +236,32 @@ describe('website docs bindings', () => { ); }); + it('reads a frontmatter description that contains an apostrophe', () => { + // The value pattern excluded every quote character, so a description with + // a possessive never matched and the page silently fell back to its first + // paragraph while declaring a description of its own. + const metadata = getDocMetadata('chat', 'components', 'chat-reasoning'); + + expect(metadata?.description).toBe( + "The ChatReasoningComponent pill that expands to reveal an assistant's reasoning text, its five inputs, and the auto-collapse behavior." + ); + }); + + it('strips only a matched pair of surrounding quotes', () => { + expect(readFrontmatterDescription("---\ndescription: 'Quoted.'\n---\n")).toBe( + 'Quoted.' + ); + expect(readFrontmatterDescription('---\ndescription: "Quoted."\n---\n')).toBe( + 'Quoted.' + ); + expect( + readFrontmatterDescription("---\ndescription: The child's state.\n---\n") + ).toBe("The child's state."); + expect(readFrontmatterDescription('---\ntitle: T\n---\n')).toBeNull(); + expect(readFrontmatterDescription('---\ndescription: \n---\n')).toBeNull(); + expect(readFrontmatterDescription('# No frontmatter\n')).toBeNull(); + }); + it('never leaks frontmatter keys into a derived description', () => { for (const { library, section, slug } of getAllDocSlugs()) { const description = diff --git a/apps/website/src/lib/docs.ts b/apps/website/src/lib/docs.ts index bb25c4227..b3af65d8f 100644 --- a/apps/website/src/lib/docs.ts +++ b/apps/website/src/lib/docs.ts @@ -32,7 +32,16 @@ export type ResolvedDocMetadata = Metadata; */ const FRONTMATTER_BLOCK_PATTERN = /^---\s*\n(?[\s\S]*?)\n---\s*(?:\n|$)/; -const FRONTMATTER_DESCRIPTION_PATTERN = /^description:\s*['"]?(?[^'"\n]+?)['"]?\s*$/m; +/** + * The value is captured whole and unquoted afterwards. Excluding quote + * characters from the capture instead made every description containing a + * possessive unreadable, so those pages fell back to their first paragraph. + */ +const FRONTMATTER_DESCRIPTION_PATTERN = /^description:[^\S\n]*(?\S.*?)[^\S\n]*$/m; + +function unquote(value: string): string { + return value.match(/^(?['"])(?.*)\k$/)?.groups?.inner ?? value; +} /** * Remove a frontmatter block so the rest can be handed to the MDX pipeline. @@ -48,10 +57,16 @@ export function stripFrontmatter(source: string): string { return source.replace(FRONTMATTER_BLOCK_PATTERN, ''); } -function readFrontmatterDescription(content: string): string | null { +/** + * The `description` a page declares for itself, or `null` when it declares + * none. Exported so the content guard can assert on the same parse the page + * metadata uses rather than a second copy of these patterns. + */ +export 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; + const description = body.match(FRONTMATTER_DESCRIPTION_PATTERN)?.groups?.description; + return description ? unquote(description) : null; } function normalizeDescription(description: string): string { From 211c0d18b1db59fdcffdffae64ef919e0db63283 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 07:56:14 -0700 Subject: [PATCH 2/4] refactor(website): delete the Card icon prop, which had no icon lookup `Card` rendered `{icon}` straight into a div, so `icon="rocket"` printed the word "rocket" above the title. Nothing looked the value up. Remove the prop, its `.mdx-card-icon` rule, and the four glyph icons the two interrupt blog posts passed; the cards keep their title and arrow. Co-Authored-By: Claude Opus 5 --- ...26-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx | 4 ++-- .../2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx | 4 ++-- apps/website/src/components/docs/mdx/Card.tsx | 3 --- apps/website/src/styles/docs.css | 4 ---- 4 files changed, 4 insertions(+), 11 deletions(-) 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 d0cf923d8..ff7ba3163 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 @@ -16,10 +16,10 @@ Clone the repo, run `nx serve cockpit-langgraph-interrupts-angular`, and follow
- + The refund agent running in the docs workspace. Walk the approve / edit / cancel flow yourself. - + The exact graph.py and Angular component from this post. 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 094ff645a..a073289c0 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 @@ -16,10 +16,10 @@ Clone the repo, run `nx serve cockpit-ag-ui-interrupts-angular`, and follow alon
- + The refund agent running in the docs workspace. Walk the approve / edit / cancel flow yourself. - + The exact graph.py, server.py, and Angular component from this post. diff --git a/apps/website/src/components/docs/mdx/Card.tsx b/apps/website/src/components/docs/mdx/Card.tsx index fdbf3ab46..23daeb321 100644 --- a/apps/website/src/components/docs/mdx/Card.tsx +++ b/apps/website/src/components/docs/mdx/Card.tsx @@ -15,13 +15,11 @@ export function CardGroup({ cols = 2, children }: { cols?: number; children: Rea export function Card({ title, href, - icon, external = false, children, }: { title: string; href: string; - icon?: string; /** When true, open in a new tab (for off-site links: demos, GitHub, etc.). */ external?: boolean; children: React.ReactNode; @@ -34,7 +32,6 @@ export function Card({
- {icon ?
{icon}
: null}
{title}
diff --git a/apps/website/src/styles/docs.css b/apps/website/src/styles/docs.css index c07e319cb..e1ad7b65f 100644 --- a/apps/website/src/styles/docs.css +++ b/apps/website/src/styles/docs.css @@ -565,10 +565,6 @@ body:has([data-website-workspace-host]) { justify-content: space-between; align-items: flex-start; } -.mdx-card-icon { - font-size: 1.15rem; - margin-bottom: 6px; -} .mdx-card-title { font-family: var(--font-display); font-size: 0.95rem; From f130f0feec63ac766b1c0549562c76393b40b609 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 07:56:14 -0700 Subject: [PATCH 3/4] docs: keep every meta description inside the clamp Nine descriptions ran past META_DESCRIPTION_MAX and were truncated mid-sentence by clampMetaDescription(). Trim each to say the same thing in under 160 characters. Co-Authored-By: Claude Opus 5 --- apps/website/content/docs/ag-ui/guides/testing.mdx | 2 +- apps/website/content/docs/chat/guides/error-handling.mdx | 2 +- apps/website/content/docs/chat/guides/lifecycle.mdx | 2 +- apps/website/content/docs/chat/guides/theming.mdx | 2 +- apps/website/content/docs/chat/guides/writing-an-adapter.mdx | 2 +- apps/website/content/docs/langgraph/concepts/agent-contract.mdx | 2 +- apps/website/content/docs/langgraph/guides/subgraphs.mdx | 2 +- apps/website/content/docs/langgraph/guides/time-travel.mdx | 2 +- .../website/content/docs/render/api/define-angular-registry.mdx | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/website/content/docs/ag-ui/guides/testing.mdx b/apps/website/content/docs/ag-ui/guides/testing.mdx index fffbdf951..264144b34 100644 --- a/apps/website/content/docs/ag-ui/guides/testing.mdx +++ b/apps/website/content/docs/ag-ui/guides/testing.mdx @@ -1,5 +1,5 @@ --- -description: Test AG-UI components with provideFakeAgent() and its event script, the neutral mockAgent(), or a hand-written AbstractAgent, and know which double covers which surface. +description: Test AG-UI components with provideFakeAgent() and its event script, the neutral mockAgent(), or a hand-written AbstractAgent, and which double fits where. --- # Testing diff --git a/apps/website/content/docs/chat/guides/error-handling.mdx b/apps/website/content/docs/chat/guides/error-handling.mdx index 682b51191..0f9cf0820 100644 --- a/apps/website/content/docs/chat/guides/error-handling.mdx +++ b/apps/website/content/docs/chat/guides/error-handling.mdx @@ -1,5 +1,5 @@ --- -description: The structured AgentError on the agent error signal — its failure kinds, the retryable flag, the built-in chat-error UI, and classifying failures in a custom adapter. +description: The structured AgentError on the agent error signal — its failure kinds, the retryable flag, the built-in chat-error UI, and classifying failures. --- # Error Handling diff --git a/apps/website/content/docs/chat/guides/lifecycle.mdx b/apps/website/content/docs/chat/guides/lifecycle.mdx index dd6305d5d..9e7c5fd70 100644 --- a/apps/website/content/docs/chat/guides/lifecycle.mdx +++ b/apps/website/content/docs/chat/guides/lifecycle.mdx @@ -1,5 +1,5 @@ --- -description: The CHAT_LIFECYCLE token exposes per-instance signals for readiness, first submit, submit count and last submit time — and where in the injector tree they are reachable. +description: The CHAT_LIFECYCLE token exposes per-instance signals for readiness, first submit, submit count and last submit time, and where they are reachable. --- # Chat Lifecycle Signals diff --git a/apps/website/content/docs/chat/guides/theming.mdx b/apps/website/content/docs/chat/guides/theming.mdx index 4458578c8..c61eda29a 100644 --- a/apps/website/content/docs/chat/guides/theming.mdx +++ b/apps/website/content/docs/chat/guides/theming.mdx @@ -1,5 +1,5 @@ --- -description: How the theming example swaps --tplane-chat-* custom properties at runtime, where the token defaults come from, and how to override them in your own application. +description: How the theming example swaps --tplane-chat-* custom properties at runtime, where the token defaults come from, and how to override them in your app. --- # Theming diff --git a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx index 06ff35e71..ffab2c707 100644 --- a/apps/website/content/docs/chat/guides/writing-an-adapter.mdx +++ b/apps/website/content/docs/chat/guides/writing-an-adapter.mdx @@ -1,5 +1,5 @@ --- -description: Implement the runtime-neutral Agent contract for a custom backend — the required fields, a working in-process example, the conformance suite, and publishing notes. +description: Implement the runtime-neutral Agent contract for a custom backend — the required fields, a working in-process example, and the conformance suite. --- # Writing an Adapter diff --git a/apps/website/content/docs/langgraph/concepts/agent-contract.mdx b/apps/website/content/docs/langgraph/concepts/agent-contract.mdx index 106645cb8..244c4bd8e 100644 --- a/apps/website/content/docs/langgraph/concepts/agent-contract.mdx +++ b/apps/website/content/docs/langgraph/concepts/agent-contract.mdx @@ -1,5 +1,5 @@ --- -description: The runtime-neutral Agent contract from @threadplane/chat — its state signals, actions, optional capabilities, and how the LangGraph and AG-UI adapters satisfy it +description: The runtime-neutral Agent contract from @threadplane/chat — its state signals, actions, optional capabilities, and how the adapters satisfy it. --- # Agent Contract diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index 80cde3eae..2592dd655 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -1,5 +1,5 @@ --- -description: How the subgraphs example routes a turn into a compiled child graph, keeps the child's state and tokens out of the transcript, and surfaces the child as a stream +description: How the subgraphs example routes a turn into a compiled child graph, keeps the child's state and tokens out of the transcript, and streams the child. --- # Subgraphs diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx index 433c1ccb4..c73b7ccf4 100644 --- a/apps/website/content/docs/langgraph/guides/time-travel.mdx +++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx @@ -1,5 +1,5 @@ --- -description: How the time travel example checkpoints every turn, lists the checkpoints in a timeline sidebar, and selects or forks from one, plus the history signals and the branch tree +description: How the time travel example checkpoints every turn, lists them in a timeline sidebar, and forks from one, plus the history signals and the branch tree. --- # Time Travel diff --git a/apps/website/content/docs/render/api/define-angular-registry.mdx b/apps/website/content/docs/render/api/define-angular-registry.mdx index 071439013..d9d4ccdb5 100644 --- a/apps/website/content/docs/render/api/define-angular-registry.mdx +++ b/apps/website/content/docs/render/api/define-angular-registry.mdx @@ -1,6 +1,6 @@ --- title: defineAngularRegistry() -description: API reference for defineAngularRegistry() -- the entry shapes it accepts, the normalized registry it returns, per-component fallbacks, and schema-gated mounting. +description: API reference for defineAngularRegistry() -- the entry shapes it accepts, the normalized registry it returns, per-component fallbacks, and schema gating. --- # defineAngularRegistry() From 5df08b9be47778ecf0ec798b6b92a796d6f8c670 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 07:56:14 -0700 Subject: [PATCH 4/4] test(website): guard the four mechanical MDX authoring rules The accuracy audit fixed roughly 160 wrong claims by hand; several of the defect classes render fine and return silently. Walk `content/**` and fail with the offending `path:line` when a page: - passes an `icon` prop to `Card`, which does not accept one; - (docs only) declares no frontmatter `description`, or one long enough that `clampMetaDescription()` truncates it; - gives `Callout` a `type` outside the union, read out of Callout.tsx so the guard cannot drift from the component; - (docs only) uses a contraction. The patterns match no possessive, and the `## What's Next` heading is exempt structurally, not by file list. Each detector also has a unit test over synthetic content, so a rule that stops firing fails rather than passing vacuously. Co-Authored-By: Claude Opus 5 --- .../src/lib/docs-content-rules.spec.ts | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 apps/website/src/lib/docs-content-rules.spec.ts diff --git a/apps/website/src/lib/docs-content-rules.spec.ts b/apps/website/src/lib/docs-content-rules.spec.ts new file mode 100644 index 000000000..08c2af158 --- /dev/null +++ b/apps/website/src/lib/docs-content-rules.spec.ts @@ -0,0 +1,307 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readFrontmatterDescription } from './docs'; +import { META_DESCRIPTION_MAX, clampMetaDescription } from './site-metadata'; +import { resolveWebsiteDir } from './website-dir'; + +/** + * Mechanical authoring rules for `content/**` MDX. + * + * Every rule here stands for a defect the accuracy audit found by hand and + * that no build step catches: the page still renders, it just renders wrong. + * Each one reports the offending `path:line`, so a failure names the file to + * open rather than the rule that fired. + */ +const WEBSITE_ROOT = resolveWebsiteDir(); +const CONTENT_ROOT = join(WEBSITE_ROOT, 'content'); +const DOCS_ROOT = join(CONTENT_ROOT, 'docs'); +const CALLOUT_COMPONENT = 'src/components/docs/mdx/Callout.tsx'; +const CARD_COMPONENT = 'src/components/docs/mdx/Card.tsx'; + +interface MdxFile { + /** Relative to `apps/website`, so a failure reads `content/docs/...`. */ + readonly relativePath: string; + readonly content: string; +} + +function mdxFiles(directory: string): MdxFile[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return mdxFiles(path); + if (!entry.isFile() || !entry.name.endsWith('.mdx')) return []; + return [ + { + relativePath: relative(WEBSITE_ROOT, path), + content: readFileSync(path, 'utf8'), + }, + ]; + }); +} + +function lineNumberAt(content: string, index: number): number { + return content.slice(0, index).split('\n').length; +} + +/** + * Opening tags for one MDX component. `[^>]` matches newlines, so a tag whose + * props wrap across lines is found the same as a one-liner. + */ +function openingTags( + file: MdxFile, + component: string +): { readonly tag: string; readonly location: string }[] { + const pattern = new RegExp(`<${component}\\b[^>]*>`, 'g'); + return [...file.content.matchAll(pattern)].map((match) => ({ + tag: match[0], + location: `${file.relativePath}:${lineNumberAt(file.content, match.index)}`, + })); +} + +// --------------------------------------------------------------------------- +// Rule 1 — `` prints the prop verbatim. +// --------------------------------------------------------------------------- + +/** + * `Card` has no icon lookup and, since the dead prop was deleted, no `icon` + * prop at all. MDX props are not type-checked, so an `icon` an author adds is + * accepted by the compiler and then silently dropped — and while the prop + * existed it rendered its own value as text (`icon="rocket"` printed + * "rocket"). Either way the page never shows what the author meant. + */ +function findCardIconProps(files: readonly MdxFile[]): string[] { + return files.flatMap((file) => + openingTags(file, 'Card') + .filter(({ tag }) => /\sicon\s*=/.test(tag)) + .map(({ location }) => location) + ); +} + +// --------------------------------------------------------------------------- +// Rule 2 — every docs page describes itself. +// --------------------------------------------------------------------------- + +/** + * With no frontmatter `description`, `getDocDescription()` falls back to the + * page's first paragraph and then to the library blurb, so unrelated pages + * ship identical meta descriptions. A description longer than + * {@link META_DESCRIPTION_MAX} is silently clamped mid-sentence instead. + */ +function findDescriptionDefects(files: readonly MdxFile[]): string[] { + return files.flatMap((file) => { + const description = readFrontmatterDescription(file.content)?.trim(); + if (!description) return [`${file.relativePath}: no frontmatter description`]; + if (clampMetaDescription(description) !== description) { + return [ + `${file.relativePath}: description is ${description.length} characters, clamped at ${META_DESCRIPTION_MAX}`, + ]; + } + return []; + }); +} + +// --------------------------------------------------------------------------- +// Rule 3 — `` outside the union renders unstyled. +// --------------------------------------------------------------------------- + +/** + * The allowed set is read out of the component so the guard cannot drift from + * it. `Callout` indexes `ICON_PATHS[type]` with no fallback, so an unknown + * type (`type="note"` was the one in the wild) renders an empty icon and an + * unstyled band. + */ +function calloutTypesFrom(source: string): string[] { + const union = source.match(/type\s+CalloutType\s*=\s*([^;]+);/)?.[1]; + if (!union) { + throw new Error(`CalloutType union not found in ${CALLOUT_COMPONENT}`); + } + return [...union.matchAll(/'([^']+)'/g)].map((match) => match[1]); +} + +function findCalloutTypeDefects( + files: readonly MdxFile[], + allowed: readonly string[] +): string[] { + return files.flatMap((file) => + openingTags(file, 'Callout').flatMap(({ tag, location }) => { + const attribute = tag.match(/\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/); + if (!attribute) return []; // No type at all is fine; the component defaults. + const literal = attribute[1] ?? attribute[2]; + if (literal === undefined) return [`${location}: type={${attribute[3]}}`]; + return allowed.includes(literal) ? [] : [`${location}: type="${literal}"`]; + }) + ); +} + +// --------------------------------------------------------------------------- +// Rule 4 — docs prose uses no contractions. +// --------------------------------------------------------------------------- + +/** + * Possessives are not contractions, so the patterns never match a bare `X's`: + * the `'s` pattern is a closed list of pronouns and determiners that cannot + * take a possessive in this prose, and the other patterns end in suffixes no + * possessive uses. + */ +const CONTRACTION_PATTERNS: readonly RegExp[] = [ + /\b[A-Za-z]+n['’]t\b/g, // does not, cannot, is not + /\b[A-Za-z]+['’](?:re|ve|ll|m|d)\b/g, // you are, we have, it will, I am, we would + /\b(?:everything|he|here|how|it|let|nothing|one|she|something|that|there|this|what|when|where|which|who|why)['’]s\b/gi, +]; + +/** `## What's Next` is the site's section convention and stays as written. */ +const WHATS_HEADING = /^#{1,6}\s+What['’]s\b/; + +/** Blank out code so a contraction inside a sample is not prose. Line numbers survive. */ +function withoutCode(content: string): string { + const blank = (block: string): string => block.replace(/[^\n]/g, ' '); + return content + .replace(/```[\s\S]*?```/g, blank) + .replace(/`[^`\n]*`/g, blank) + .replace(/\{\/\*[\s\S]*?\*\/\}/g, blank); +} + +function findContractions(files: readonly MdxFile[]): string[] { + return files.flatMap((file) => + withoutCode(file.content) + .split('\n') + .flatMap((line, index) => { + if (WHATS_HEADING.test(line.trim())) return []; + const found = CONTRACTION_PATTERNS.flatMap((pattern) => [ + ...line.matchAll(pattern), + ]).map((match) => match[0]); + if (found.length === 0) return []; + return [`${file.relativePath}:${index + 1}: ${found.join(', ')}`]; + }) + ); +} + +// --------------------------------------------------------------------------- + +const CONTENT_FILES = mdxFiles(CONTENT_ROOT); +const DOCS_FILES = mdxFiles(DOCS_ROOT); + +describe('docs content rules', () => { + it('scans the whole authored MDX tree', () => { + const paths = CONTENT_FILES.map((file) => file.relativePath); + expect(paths.length).toBeGreaterThan(100); + expect(paths).toContain('content/docs/chat/getting-started/introduction.mdx'); + expect(paths).toContain( + 'content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx' + ); + expect(DOCS_FILES.length).toBeGreaterThan(100); + expect( + DOCS_FILES.every((file) => file.relativePath.startsWith('content/docs/')) + ).toBe(true); + }); + + it('passes no icon prop to Card, which does not accept one', () => { + expect( + readFileSync(join(WEBSITE_ROOT, CARD_COMPONENT), 'utf8'), + `${CARD_COMPONENT} must not reintroduce an icon prop without an icon lookup` + ).not.toMatch(/\bicon\b/i); + expect(findCardIconProps(CONTENT_FILES)).toEqual([]); + }); + + it('detects an icon prop wherever it sits in the tag', () => { + const content = [ + 'body', + 'body', + 'body', + '', + ].join('\n'); + + expect(findCardIconProps([{ relativePath: 'p.mdx', content }])).toEqual([ + 'p.mdx:2', + 'p.mdx:3', + ]); + }); + + it('gives every docs page its own frontmatter description', () => { + expect(findDescriptionDefects(DOCS_FILES)).toEqual([]); + }); + + it('reports a missing description and one long enough to be clamped', () => { + const long = `A${'b'.repeat(META_DESCRIPTION_MAX)} c`; + const files: MdxFile[] = [ + { relativePath: 'ok.mdx', content: '---\ndescription: A short one.\n---\n# T\n' }, + { relativePath: 'none.mdx', content: '---\ntitle: T\n---\n# T\n' }, + { relativePath: 'empty.mdx', content: '---\ndescription: \n---\n# T\n' }, + { relativePath: 'bare.mdx', content: '# T\n' }, + { relativePath: 'long.mdx', content: `---\ndescription: ${long}\n---\n# T\n` }, + ]; + + expect(findDescriptionDefects(files).map((entry) => entry.split(':')[0])).toEqual([ + 'none.mdx', + 'empty.mdx', + 'bare.mdx', + 'long.mdx', + ]); + }); + + it('reads the Callout union out of the component', () => { + const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8'); + // Update this list, the docs style rule, and any affected pages together. + expect([...calloutTypesFrom(source)].sort()).toEqual([ + 'danger', + 'info', + 'tip', + 'warning', + ]); + expect(() => calloutTypesFrom('type Other = 1;')).toThrow(/CalloutType union/); + }); + + it('uses only Callout types the component styles', () => { + const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8'); + expect(findCalloutTypeDefects(CONTENT_FILES, calloutTypesFrom(source))).toEqual([]); + }); + + it('flags an unknown Callout type and leaves a typeless Callout alone', () => { + const content = [ + 'plain', + 'fine', + 'wrong', + 'wrong', + ].join('\n'); + + expect( + findCalloutTypeDefects([{ relativePath: 'p.mdx', content }], [ + 'tip', + 'warning', + 'info', + 'danger', + ]) + ).toEqual(['p.mdx:3: type="note"', 'p.mdx:4: type={kind}']); + }); + + it('writes docs prose without contractions', () => { + expect(findContractions(DOCS_FILES)).toEqual([]); + }); + + it('flags contractions without flagging possessives or the What’s Next heading', () => { + const content = [ + "The agent's state and the component's inputs stay intact.", // 1 possessive + "## What's Next", // 2 site convention + "It doesn't stream.", // 3 + "You're holding a signal.", // 4 + "That's the whole contract.", // 5 + "The graph would’ve resumed.", // 6 + 'Run `it doesn\'t matter` inline.', // 7 code span + '```ts', // 8 + "// you're inside a fence", // 9 + '```', // 10 + "The user's cannot-be-empty note.", // 11 possessive + ].join('\n'); + + expect(findContractions([{ relativePath: 'p.mdx', content }])).toEqual([ + "p.mdx:3: doesn't", + "p.mdx:4: You're", + "p.mdx:5: That's", + 'p.mdx:6: would’ve', + ]); + }); +});