import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, test } from 'vite-plus/test'; import type { DocsCheckPaths } from '../../scripts/check-docs.js'; import { diffAgainstBaseline, findDocsIssues, markdownH2s, readBaseline, } from '../../scripts/check-docs.js'; import { findComponentPropsTableTags } from './component-props-table-tags.js'; const testDirectories: Array = []; afterEach(() => { for (const directory of testDirectories) { rmSync(directory, { force: true, recursive: true }); } testDirectories.length = 0; }); test('accepts a component guide and authored guide that follow the heading contract', () => { const paths = createDocsFixture({ authored: { 'installation.mdx': `--- title: Installation --- Install Luke UI. ## Install the package Run the install command. ## Continue learning Choose a styling approach. `, }, components: { 'actions/button.mdx': `--- title: Button source: packages/@luke-ui/react/src/exports/button.ts --- ## Best practices Use Button for actions. ## Size Medium is the default. ## Accessibility The visible label is the accessible name. ## API `, }, }); expect(findDocsIssues(paths)).toEqual([]); }); test('reports heading vocabulary, order, and required Accessibility issues', () => { const paths = createDocsFixture({ components: { 'actions/button.mdx': `--- title: Button source: packages/@luke-ui/react/src/exports/button.ts --- ## Size Medium is the default. ## Primitive Use the button primitive. ## Best practices Use Button for actions. ## API `, 'visuals/icon.mdx': `--- title: Icon source: packages/@luke-ui/react/src/exports/icon.ts --- ## Anatomy Icons reference the spritesheet. ## API `, }, }); expect(findDocsIssues(paths)).toEqual([ 'actions/button.mdx: heading "Primitive" is not allowed (link to the primitive inline in prose instead)', 'actions/button.mdx: "Best practices" must come before feature sections', 'actions/button.mdx: missing required "Accessibility" heading', 'visuals/icon.mdx: "Anatomy" is only allowed on primitive guides', ]); }); test('rejects a "Related components" heading', () => { const paths = createDocsFixture({ components: { 'actions/button.mdx': `--- title: Button source: packages/@luke-ui/react/src/exports/button.ts --- ## Accessibility The visible label is the accessible name. ## Related components Use Link for navigation. ## API `, }, }); expect(findDocsIssues(paths)).toEqual([ 'actions/button.mdx: heading "Related components" is not allowed (link to other components inline in prose instead)', ]); }); test('reports a guide with no API section, an API section that is not last, and a tag outside it', () => { const paths = createDocsFixture({ components: { 'actions/button.mdx': `--- title: Button source: packages/@luke-ui/react/src/exports/button.ts --- ## Accessibility The visible label is the accessible name. `, 'actions/icon-button.mdx': `--- title: Icon Button source: packages/@luke-ui/react/src/exports/icon-button.ts --- ## API ## Accessibility The visible label is the accessible name. `, 'actions/link.mdx': `--- title: Link source: packages/@luke-ui/react/src/exports/link.ts --- ## Accessibility The visible label is the accessible name. ## API `, }, }); expect(findDocsIssues(paths)).toEqual([ 'actions/button.mdx: missing required "API" section with a component-props-table', 'actions/icon-button.mdx: "API" must be the last heading', 'actions/link.mdx: component-props-table must sit under "API"', ]); }); test('requires Continue learning with Cards on authored guides', () => { const paths = createDocsFixture({ authored: { 'styling.mdx': `--- title: Styling --- ## Choose a styling approach Use component props first. ## Next steps Read the colour guide. `, }, }); expect(findDocsIssues(paths)).toEqual([ 'docs/styling.mdx: last H2 must be "Continue learning" with ', ]); }); test('reports an example referenced from two pages', () => { const paths = createDocsFixture({ authored: { 'layout.mdx': `--- title: Layout --- ## Continue learning See the Box guide. `, }, components: { 'layout/box.mdx': `--- title: Box source: packages/@luke-ui/react/src/exports/box.ts --- ## API `, }, }); expect(findDocsIssues(paths)).toEqual([ 'box/responsive-layout: referenced from more than one page (components/layout/box.mdx, docs/layout.mdx)', ]); }); test('does not report a shared-example issue for a page referencing the same example twice', () => { const paths = createDocsFixture({ components: { 'layout/box.mdx': `--- title: Box source: packages/@luke-ui/react/src/exports/box.ts --- ## API `, }, }); expect(findDocsIssues(paths)).toEqual([]); }); test('reports prose patterns outside code and ignores them inside fences', () => { const paths = createDocsFixture({ authored: { 'color.mdx': `--- title: Colour --- import { TokenExplorer } from './token-explorer'; This is useful; do not keep the semicolon. We document the system for users. Write foo—bar without spaces around the dash. A wrapped dash is fine — on the next line. \`\`\`tsx const users = ['we', 'us']; const note = 'simply; note that'; \`\`\` ## Continue learning Choose a styling approach. `, }, internal: { 'TESTING.md': `# Testing An assertion should fail if an intention we own is not met. \`\`\`ts const users = true; \`\`\` `, }, }); expect(findDocsIssues(paths)).toEqual([ 'docs/color.mdx: prose semicolon', 'docs/color.mdx: unspaced em dash', 'docs/color.mdx: first-person "we"', 'docs/color.mdx: terminology "users"', 'docs/TESTING.md: first-person "we"', ]); }); test('diffAgainstBaseline reports extra and stale entries', () => { expect(diffAgainstBaseline(['keep', 'new'], ['keep', 'fixed'])).toEqual({ extra: ['new'], stale: ['fixed'], }); }); test('markdownH2s ignores headings inside fenced code', () => { expect( markdownH2s(`## Size \`\`\`md ## Primitive \`\`\` ## Accessibility `), ).toEqual(['Size', 'Accessibility']); }); test('finds no extra docs issues in the docs app beyond the baseline', () => { const issues = findDocsIssues(); const baseline = readBaseline(); expect(diffAgainstBaseline(issues, baseline)).toEqual({ extra: [], stale: [] }); }); const INVENTORY_GUIDE = `--- title: Button source: packages/@luke-ui/react/src/exports/button.ts --- ## Accessibility The visible label is the accessible name. ## API `; function inventoryFixture(overrides: { components?: Record; metadata?: Record; packageExports?: ReadonlyArray; sourceDirs?: ReadonlyArray; }): DocsCheckPaths { return createDocsFixture({ components: overrides.components ?? { 'actions/button.mdx': INVENTORY_GUIDE }, metadata: overrides.metadata ?? { 'actions/meta.json': { pages: ['button'], title: 'Actions' }, 'meta.json': { pages: ['---Actions---', 'actions/button'], root: true, title: 'Components' }, }, packageExports: overrides.packageExports ?? ['./button'], sourceDirs: overrides.sourceDirs ?? ['button'], }); } type InventoryFixtureOverrides = Parameters[0]; const LINK_INVENTORY_GUIDE = `--- title: Link source: packages/@luke-ui/react/src/exports/link.ts --- ## Accessibility The visible label is the accessible name. ## API `; const inventoryCases: ReadonlyArray<{ expected: ReadonlyArray; name: string; overrides: InventoryFixtureOverrides; }> = [ { expected: [], name: 'accepts a guide that the root and category metadata both list', overrides: {}, }, { expected: [ 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', ], name: 'reports a guide that is absent from the root component metadata', overrides: { metadata: { 'actions/meta.json': { pages: [], title: 'Actions' }, 'meta.json': { pages: ['---Actions---'], root: true, title: 'Components' }, }, }, }, { expected: [ 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', ], name: 'reports stale category metadata when the root category disappears', overrides: { metadata: { 'actions/meta.json': { pages: ['button'], title: 'Actions' }, 'meta.json': { pages: [], root: true, title: 'Components' }, }, }, }, { expected: [ 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', ], name: 'reports leftover category metadata when no guide remains in that category', overrides: { components: {}, metadata: { 'actions/meta.json': { pages: ['button'], title: 'Actions' }, 'meta.json': { pages: [], root: true, title: 'Components' }, }, packageExports: [], sourceDirs: [], }, }, { expected: [ 'component-guide-inventory: components/meta.json: entry "actions/link" has no guide (expected actions/link.mdx)', ], name: 'reports a root metadata entry that has no guide', overrides: { metadata: { 'actions/meta.json': { pages: ['button', 'link'], title: 'Actions' }, 'meta.json': { pages: ['---Actions---', 'actions/button', 'actions/link'], root: true, title: 'Components', }, }, }, }, { expected: [ 'component-guide-inventory: components/meta.json: entry "actions/button" is repeated', ], name: 'reports a repeated root metadata entry', overrides: { metadata: { 'actions/meta.json': { pages: ['button', 'button'], title: 'Actions' }, 'meta.json': { pages: ['---Actions---', 'actions/button', 'actions/button'], root: true, title: 'Components', }, }, }, }, { expected: [ 'component-guide-inventory: actions/meta.json: pages [link, button] do not match the root metadata (expected [button, link])', ], name: 'reports category metadata that does not match the root metadata', overrides: { components: { 'actions/button.mdx': INVENTORY_GUIDE, 'actions/link.mdx': LINK_INVENTORY_GUIDE, }, metadata: { 'actions/meta.json': { pages: ['link', 'button'], title: 'Actions' }, 'meta.json': { pages: ['---Actions---', 'actions/button', 'actions/link'], root: true, title: 'Components', }, }, packageExports: ['./button', './link'], sourceDirs: ['button', 'link'], }, }, { expected: [ 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" is not a public package entry point (expected export "./button" in @luke-ui/react)', ], name: 'reports a guide source that is not a public package entry point', overrides: { packageExports: ['./link'] }, }, { expected: [ 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" does not exist', ], name: 'reports a missing exports module', overrides: { sourceDirs: [] }, }, ]; for (const inventoryCase of inventoryCases) { test(`${inventoryCase.name}`, () => { expect(findDocsIssues(inventoryFixture(inventoryCase.overrides))).toEqual( inventoryCase.expected, ); }); } function createDocsFixture(input: { authored?: Record; components?: Record; internal?: Record; /** Root and category `meta.json` files, keyed by their path under the components directory. */ metadata?: Record; /** Export keys for the fake `@luke-ui/react` manifest. */ packageExports?: ReadonlyArray; /** Subpaths under the fake package `src/exports` that get a `.ts` module. */ sourceDirs?: ReadonlyArray; }): DocsCheckPaths { const directory = mkdtempSync(join(tmpdir(), 'luke-ui-check-docs-')); testDirectories.push(directory); const contentDir = join(directory, 'content'); const componentsDir = join(contentDir, 'components'); const authoredDocsDir = join(contentDir, 'docs'); const internalDocsDir = join(directory, 'internal-docs'); const reactPackageDir = join(directory, 'react-package'); mkdirSync(componentsDir, { recursive: true }); mkdirSync(authoredDocsDir, { recursive: true }); mkdirSync(internalDocsDir, { recursive: true }); mkdirSync(reactPackageDir, { recursive: true }); writeGuides(componentsDir, input.components ?? {}); writeGuides(authoredDocsDir, input.authored ?? {}); for (const [name, contents] of Object.entries(input.internal ?? {})) { writeFileSync(join(internalDocsDir, name), contents); } const guidePaths = Object.keys(input.components ?? {}); for (const [relativePath, contents] of Object.entries( input.metadata ?? defaultMetadata(guidePaths), )) { const path = join(componentsDir, relativePath); mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, JSON.stringify(contents)); } const guideSources = guidePaths.map((path) => guideSourceSubpath(input.components?.[path] ?? '')); const exports = Object.fromEntries( (input.packageExports ?? guideSources.map((subpath) => `./${subpath}`)).map((key) => [ key, `./dist${key.slice(1)}/index.js`, ]), ); const reactPackageJsonPath = join(reactPackageDir, 'package.json'); writeFileSync(reactPackageJsonPath, JSON.stringify({ exports, name: '@luke-ui/react' })); const propsTypesBySubpath = new Map( guidePaths.map((path) => [ guideSourceSubpath(input.components?.[path] ?? ''), guidePropsTypeNames(input.components?.[path] ?? ''), ]), ); for (const subpath of input.sourceDirs ?? guideSources) { const sourcePath = join(reactPackageDir, 'src', 'exports', `${subpath}.ts`); mkdirSync(join(sourcePath, '..'), { recursive: true }); writeFileSync(sourcePath, renderExportsModule(propsTypesBySubpath.get(subpath) ?? [])); } return { authoredDocsDir, componentsDir, contentDir, internalDocsDir, reactPackageDir, reactPackageJsonPath, }; } function writeGuides(root: string, files: Record): void { for (const [relativePath, contents] of Object.entries(files)) { const path = join(root, relativePath); mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, contents); } } /** Root and category metadata that lists exactly the guides a fixture wrote, in that order. */ function defaultMetadata(guidePaths: ReadonlyArray): Record { const slugs = guidePaths.map((path) => path.replace(/\.mdx$/, '')); const groups = [...new Set(slugs.map((slug) => slug.split('/')[0] ?? ''))]; const metadata: Record = {}; for (const group of groups) { metadata[`${group}/meta.json`] = { pages: slugs.flatMap((slug) => { if (!slug.startsWith(`${group}/`)) return []; return [slug.slice(group.length + 1)]; }), title: group, }; } metadata['meta.json'] = { pages: groups.flatMap((group) => [ `---${group}---`, ...slugs.filter((slug) => slug.startsWith(`${group}/`)), ]), root: true, title: 'Components', }; return metadata; } function guideSourceSubpath(contents: string): string { const source = contents.match(/^source:\s*(.+)$/m)?.[1]?.trim() ?? ''; return source.replace('packages/@luke-ui/react/src/exports/', '').replace(/\.ts$/, ''); } /** Type names a guide's `## API` component-props-table tags document. */ function guidePropsTypeNames(contents: string): Array { return findComponentPropsTableTags(contents).map((entry) => entry.name); } /** A fake `src/exports/.ts` module that exports every documented props type as an empty interface. */ function renderExportsModule(typeNames: ReadonlyArray): string { if (typeNames.length === 0) return 'export {};\n'; return typeNames.map((name) => `export interface ${name} {}\n`).join(''); }