Skip to content

Commit cd436f9

Browse files
bloveclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 09012bc commit cd436f9

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { readFileSync, readdirSync } from 'node:fs';
2+
import { join, relative } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import { readFrontmatterDescription } from './docs';
5+
import { META_DESCRIPTION_MAX, clampMetaDescription } from './site-metadata';
6+
import { resolveWebsiteDir } from './website-dir';
7+
8+
/**
9+
* Mechanical authoring rules for `content/**` MDX.
10+
*
11+
* Every rule here stands for a defect the accuracy audit found by hand and
12+
* that no build step catches: the page still renders, it just renders wrong.
13+
* Each one reports the offending `path:line`, so a failure names the file to
14+
* open rather than the rule that fired.
15+
*/
16+
const WEBSITE_ROOT = resolveWebsiteDir();
17+
const CONTENT_ROOT = join(WEBSITE_ROOT, 'content');
18+
const DOCS_ROOT = join(CONTENT_ROOT, 'docs');
19+
const CALLOUT_COMPONENT = 'src/components/docs/mdx/Callout.tsx';
20+
const CARD_COMPONENT = 'src/components/docs/mdx/Card.tsx';
21+
22+
interface MdxFile {
23+
/** Relative to `apps/website`, so a failure reads `content/docs/...`. */
24+
readonly relativePath: string;
25+
readonly content: string;
26+
}
27+
28+
function mdxFiles(directory: string): MdxFile[] {
29+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
30+
const path = join(directory, entry.name);
31+
if (entry.isDirectory()) return mdxFiles(path);
32+
if (!entry.isFile() || !entry.name.endsWith('.mdx')) return [];
33+
return [
34+
{
35+
relativePath: relative(WEBSITE_ROOT, path),
36+
content: readFileSync(path, 'utf8'),
37+
},
38+
];
39+
});
40+
}
41+
42+
function lineNumberAt(content: string, index: number): number {
43+
return content.slice(0, index).split('\n').length;
44+
}
45+
46+
/**
47+
* Opening tags for one MDX component. `[^>]` matches newlines, so a tag whose
48+
* props wrap across lines is found the same as a one-liner.
49+
*/
50+
function openingTags(
51+
file: MdxFile,
52+
component: string
53+
): { readonly tag: string; readonly location: string }[] {
54+
const pattern = new RegExp(`<${component}\\b[^>]*>`, 'g');
55+
return [...file.content.matchAll(pattern)].map((match) => ({
56+
tag: match[0],
57+
location: `${file.relativePath}:${lineNumberAt(file.content, match.index)}`,
58+
}));
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Rule 1 — `<Card icon>` prints the prop verbatim.
63+
// ---------------------------------------------------------------------------
64+
65+
/**
66+
* `Card` has no icon lookup and, since the dead prop was deleted, no `icon`
67+
* prop at all. MDX props are not type-checked, so an `icon` an author adds is
68+
* accepted by the compiler and then silently dropped — and while the prop
69+
* existed it rendered its own value as text (`icon="rocket"` printed
70+
* "rocket"). Either way the page never shows what the author meant.
71+
*/
72+
function findCardIconProps(files: readonly MdxFile[]): string[] {
73+
return files.flatMap((file) =>
74+
openingTags(file, 'Card')
75+
.filter(({ tag }) => /\sicon\s*=/.test(tag))
76+
.map(({ location }) => location)
77+
);
78+
}
79+
80+
// ---------------------------------------------------------------------------
81+
// Rule 2 — every docs page describes itself.
82+
// ---------------------------------------------------------------------------
83+
84+
/**
85+
* With no frontmatter `description`, `getDocDescription()` falls back to the
86+
* page's first paragraph and then to the library blurb, so unrelated pages
87+
* ship identical meta descriptions. A description longer than
88+
* {@link META_DESCRIPTION_MAX} is silently clamped mid-sentence instead.
89+
*/
90+
function findDescriptionDefects(files: readonly MdxFile[]): string[] {
91+
return files.flatMap((file) => {
92+
const description = readFrontmatterDescription(file.content)?.trim();
93+
if (!description) return [`${file.relativePath}: no frontmatter description`];
94+
if (clampMetaDescription(description) !== description) {
95+
return [
96+
`${file.relativePath}: description is ${description.length} characters, clamped at ${META_DESCRIPTION_MAX}`,
97+
];
98+
}
99+
return [];
100+
});
101+
}
102+
103+
// ---------------------------------------------------------------------------
104+
// Rule 3 — `<Callout type>` outside the union renders unstyled.
105+
// ---------------------------------------------------------------------------
106+
107+
/**
108+
* The allowed set is read out of the component so the guard cannot drift from
109+
* it. `Callout` indexes `ICON_PATHS[type]` with no fallback, so an unknown
110+
* type (`type="note"` was the one in the wild) renders an empty icon and an
111+
* unstyled band.
112+
*/
113+
function calloutTypesFrom(source: string): string[] {
114+
const union = source.match(/type\s+CalloutType\s*=\s*([^;]+);/)?.[1];
115+
if (!union) {
116+
throw new Error(`CalloutType union not found in ${CALLOUT_COMPONENT}`);
117+
}
118+
return [...union.matchAll(/'([^']+)'/g)].map((match) => match[1]);
119+
}
120+
121+
function findCalloutTypeDefects(
122+
files: readonly MdxFile[],
123+
allowed: readonly string[]
124+
): string[] {
125+
return files.flatMap((file) =>
126+
openingTags(file, 'Callout').flatMap(({ tag, location }) => {
127+
const attribute = tag.match(/\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/);
128+
if (!attribute) return []; // No type at all is fine; the component defaults.
129+
const literal = attribute[1] ?? attribute[2];
130+
if (literal === undefined) return [`${location}: type={${attribute[3]}}`];
131+
return allowed.includes(literal) ? [] : [`${location}: type="${literal}"`];
132+
})
133+
);
134+
}
135+
136+
// ---------------------------------------------------------------------------
137+
// Rule 4 — docs prose uses no contractions.
138+
// ---------------------------------------------------------------------------
139+
140+
/**
141+
* Possessives are not contractions, so the patterns never match a bare `X's`:
142+
* the `'s` pattern is a closed list of pronouns and determiners that cannot
143+
* take a possessive in this prose, and the other patterns end in suffixes no
144+
* possessive uses.
145+
*/
146+
const CONTRACTION_PATTERNS: readonly RegExp[] = [
147+
/\b[A-Za-z]+n[']t\b/g, // does not, cannot, is not
148+
/\b[A-Za-z]+['](?:re|ve|ll|m|d)\b/g, // you are, we have, it will, I am, we would
149+
/\b(?:everything|he|here|how|it|let|nothing|one|she|something|that|there|this|what|when|where|which|who|why)[']s\b/gi,
150+
];
151+
152+
/** `## What's Next` is the site's section convention and stays as written. */
153+
const WHATS_HEADING = /^#{1,6}\s+What[']s\b/;
154+
155+
/** Blank out code so a contraction inside a sample is not prose. Line numbers survive. */
156+
function withoutCode(content: string): string {
157+
const blank = (block: string): string => block.replace(/[^\n]/g, ' ');
158+
return content
159+
.replace(/```[\s\S]*?```/g, blank)
160+
.replace(/`[^`\n]*`/g, blank)
161+
.replace(/\{\/\*[\s\S]*?\*\/\}/g, blank);
162+
}
163+
164+
function findContractions(files: readonly MdxFile[]): string[] {
165+
return files.flatMap((file) =>
166+
withoutCode(file.content)
167+
.split('\n')
168+
.flatMap((line, index) => {
169+
if (WHATS_HEADING.test(line.trim())) return [];
170+
const found = CONTRACTION_PATTERNS.flatMap((pattern) => [
171+
...line.matchAll(pattern),
172+
]).map((match) => match[0]);
173+
if (found.length === 0) return [];
174+
return [`${file.relativePath}:${index + 1}: ${found.join(', ')}`];
175+
})
176+
);
177+
}
178+
179+
// ---------------------------------------------------------------------------
180+
181+
const CONTENT_FILES = mdxFiles(CONTENT_ROOT);
182+
const DOCS_FILES = mdxFiles(DOCS_ROOT);
183+
184+
describe('docs content rules', () => {
185+
it('scans the whole authored MDX tree', () => {
186+
const paths = CONTENT_FILES.map((file) => file.relativePath);
187+
expect(paths.length).toBeGreaterThan(100);
188+
expect(paths).toContain('content/docs/chat/getting-started/introduction.mdx');
189+
expect(paths).toContain(
190+
'content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx'
191+
);
192+
expect(DOCS_FILES.length).toBeGreaterThan(100);
193+
expect(
194+
DOCS_FILES.every((file) => file.relativePath.startsWith('content/docs/'))
195+
).toBe(true);
196+
});
197+
198+
it('passes no icon prop to Card, which does not accept one', () => {
199+
expect(
200+
readFileSync(join(WEBSITE_ROOT, CARD_COMPONENT), 'utf8'),
201+
`${CARD_COMPONENT} must not reintroduce an icon prop without an icon lookup`
202+
).not.toMatch(/\bicon\b/i);
203+
expect(findCardIconProps(CONTENT_FILES)).toEqual([]);
204+
});
205+
206+
it('detects an icon prop wherever it sits in the tag', () => {
207+
const content = [
208+
'<Card title="A" href="/a">body</Card>',
209+
'<Card icon="rocket" title="B" href="/b">body</Card>',
210+
'<Card',
211+
' title="C"',
212+
' icon="star"',
213+
' href="/c"',
214+
'>body</Card>',
215+
'<CardGroup icon="nope">',
216+
].join('\n');
217+
218+
expect(findCardIconProps([{ relativePath: 'p.mdx', content }])).toEqual([
219+
'p.mdx:2',
220+
'p.mdx:3',
221+
]);
222+
});
223+
224+
it('gives every docs page its own frontmatter description', () => {
225+
expect(findDescriptionDefects(DOCS_FILES)).toEqual([]);
226+
});
227+
228+
it('reports a missing description and one long enough to be clamped', () => {
229+
const long = `A${'b'.repeat(META_DESCRIPTION_MAX)} c`;
230+
const files: MdxFile[] = [
231+
{ relativePath: 'ok.mdx', content: '---\ndescription: A short one.\n---\n# T\n' },
232+
{ relativePath: 'none.mdx', content: '---\ntitle: T\n---\n# T\n' },
233+
{ relativePath: 'empty.mdx', content: '---\ndescription: \n---\n# T\n' },
234+
{ relativePath: 'bare.mdx', content: '# T\n' },
235+
{ relativePath: 'long.mdx', content: `---\ndescription: ${long}\n---\n# T\n` },
236+
];
237+
238+
expect(findDescriptionDefects(files).map((entry) => entry.split(':')[0])).toEqual([
239+
'none.mdx',
240+
'empty.mdx',
241+
'bare.mdx',
242+
'long.mdx',
243+
]);
244+
});
245+
246+
it('reads the Callout union out of the component', () => {
247+
const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8');
248+
// Update this list, the docs style rule, and any affected pages together.
249+
expect([...calloutTypesFrom(source)].sort()).toEqual([
250+
'danger',
251+
'info',
252+
'tip',
253+
'warning',
254+
]);
255+
expect(() => calloutTypesFrom('type Other = 1;')).toThrow(/CalloutType union/);
256+
});
257+
258+
it('uses only Callout types the component styles', () => {
259+
const source = readFileSync(join(WEBSITE_ROOT, CALLOUT_COMPONENT), 'utf8');
260+
expect(findCalloutTypeDefects(CONTENT_FILES, calloutTypesFrom(source))).toEqual([]);
261+
});
262+
263+
it('flags an unknown Callout type and leaves a typeless Callout alone', () => {
264+
const content = [
265+
'<Callout>plain</Callout>',
266+
'<Callout type="tip">fine</Callout>',
267+
'<Callout type="note" title="T">wrong</Callout>',
268+
'<Callout type={kind}>wrong</Callout>',
269+
].join('\n');
270+
271+
expect(
272+
findCalloutTypeDefects([{ relativePath: 'p.mdx', content }], [
273+
'tip',
274+
'warning',
275+
'info',
276+
'danger',
277+
])
278+
).toEqual(['p.mdx:3: type="note"', 'p.mdx:4: type={kind}']);
279+
});
280+
281+
it('writes docs prose without contractions', () => {
282+
expect(findContractions(DOCS_FILES)).toEqual([]);
283+
});
284+
285+
it('flags contractions without flagging possessives or the What’s Next heading', () => {
286+
const content = [
287+
"The agent's state and the component's inputs stay intact.", // 1 possessive
288+
"## What's Next", // 2 site convention
289+
"It doesn't stream.", // 3
290+
"You're holding a signal.", // 4
291+
"That's the whole contract.", // 5
292+
"The graph would’ve resumed.", // 6
293+
'Run `it doesn\'t matter` inline.', // 7 code span
294+
'```ts', // 8
295+
"// you're inside a fence", // 9
296+
'```', // 10
297+
"The user's cannot-be-empty note.", // 11 possessive
298+
].join('\n');
299+
300+
expect(findContractions([{ relativePath: 'p.mdx', content }])).toEqual([
301+
"p.mdx:3: doesn't",
302+
"p.mdx:4: You're",
303+
"p.mdx:5: That's",
304+
'p.mdx:6: would’ve',
305+
]);
306+
});
307+
});

0 commit comments

Comments
 (0)