|
| 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