Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. ui.lukebennett.dev
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680import { 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<string> = [];
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
<Cards> <Card href="/docs/styling" title="Styling"> Choose a styling approach. </Card></Cards>`, }, components: { 'actions/button.mdx': `---title: Buttonsource: packages/@luke-ui/react/src/exports/button.ts---
<ExampleBlock src="button/basic" title="Button: Basic" />
## Best practices
Use Button for actions.
## Size
Medium is the default.
## Accessibility
The visible label is the accessible name.
## API
<component-props-table path="packages/@luke-ui/react/src/core/button/button.tsx" name="ButtonProps" />`, }, });
expect(findDocsIssues(paths)).toEqual([]);});
test('reports heading vocabulary, order, and required Accessibility issues', () => { const paths = createDocsFixture({ components: { 'actions/button.mdx': `---title: Buttonsource: packages/@luke-ui/react/src/exports/button.ts---
<ExampleBlock src="button/basic" title="Button: Basic" />
## Size
Medium is the default.
## Primitive
Use the button primitive.
## Best practices
Use Button for actions.
## API
<component-props-table path="packages/@luke-ui/react/src/core/button/button.tsx" name="ButtonProps" />`, 'visuals/icon.mdx': `---title: Iconsource: packages/@luke-ui/react/src/exports/icon.ts---
<ExampleBlock src="icon/basic" title="Icon: Basic" />
## Anatomy
Icons reference the spritesheet.
## API
<component-props-table path="packages/@luke-ui/react/src/core/icon/icon.tsx" name="IconProps" />`, }, });
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: Buttonsource: packages/@luke-ui/react/src/exports/button.ts---
<ExampleBlock src="button/basic" title="Button — Basic" />
## Accessibility
The visible label is the accessible name.
## Related components
Use Link for navigation.
## API
<component-props-table path="packages/@luke-ui/react/src/core/button/button.tsx" name="ButtonProps" />`, }, });
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: Buttonsource: packages/@luke-ui/react/src/exports/button.ts---
<ExampleBlock src="button/basic" title="Button: Basic" />
## Accessibility
The visible label is the accessible name.`, 'actions/icon-button.mdx': `---title: Icon Buttonsource: packages/@luke-ui/react/src/exports/icon-button.ts---
<ExampleBlock src="icon-button/basic" title="Icon Button: Basic" />
## API
<component-props-table path="packages/@luke-ui/react/src/core/icon-button/icon-button.tsx" name="IconButtonProps" />
## Accessibility
The visible label is the accessible name.`, 'actions/link.mdx': `---title: Linksource: packages/@luke-ui/react/src/exports/link.ts---
<component-props-table path="packages/@luke-ui/react/src/core/link/link.tsx" name="LinkProps" />
<ExampleBlock src="link/basic" title="Link: Basic" />
## Accessibility
The visible label is the accessible name.
## API
<component-props-table path="packages/@luke-ui/react/src/core/link/link.tsx" name="LinkProps" />`, }, });
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 <Cards>', ]);});
test('reports an example referenced from two pages', () => { const paths = createDocsFixture({ authored: { 'layout.mdx': `---title: Layout---
<ExampleBlock src="box/responsive-layout" title="Layout: Responsive values" />
## Continue learning
<Cards> <Card href="/components/layout/box" title="Box"> See the Box guide. </Card></Cards>`, }, components: { 'layout/box.mdx': `---title: Boxsource: packages/@luke-ui/react/src/exports/box.ts---
<ExampleBlock src="box/basic" title="Box: Basic" />
<ExampleBlock src="box/responsive-layout" title="Box: Responsive layout" />
## API
<component-props-table path="packages/@luke-ui/react/src/core/box/box.tsx" name="BoxProps" />`, }, });
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: Boxsource: packages/@luke-ui/react/src/exports/box.ts---
<ExampleBlock src="box/basic" title="Box: Basic" />
<ExampleBlock src="box/basic" title="Box: Basic again" />
## API
<component-props-table path="packages/@luke-ui/react/src/core/box/box.tsx" name="BoxProps" />`, }, });
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.
\`\`\`tsxconst users = ['we', 'us'];const note = 'simply; note that';\`\`\`
## Continue learning
<Cards> <Card href="/docs/styling" title="Styling"> Choose a styling approach. </Card></Cards>`, }, internal: { 'TESTING.md': `# Testing
An assertion should fail if an intention we own is not met.
\`\`\`tsconst 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: Buttonsource: packages/@luke-ui/react/src/exports/button.ts---
<ExampleBlock src="button/basic" title="Button: Basic" />
## Accessibility
The visible label is the accessible name.
## API
<component-props-table path="packages/@luke-ui/react/src/core/button/button.tsx" name="ButtonProps" />`;
function inventoryFixture(overrides: { components?: Record<string, string>; metadata?: Record<string, unknown>; packageExports?: ReadonlyArray<string>; sourceDirs?: ReadonlyArray<string>;}): 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<typeof inventoryFixture>[0];
const LINK_INVENTORY_GUIDE = `---title: Linksource: packages/@luke-ui/react/src/exports/link.ts---
<ExampleBlock src="link/basic" title="Link: Basic" />
## Accessibility
The visible label is the accessible name.
## API
<component-props-table path="packages/@luke-ui/react/src/core/link/link.tsx" name="LinkProps" />`;
const inventoryCases: ReadonlyArray<{ expected: ReadonlyArray<string>; 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<string, string>; components?: Record<string, string>; internal?: Record<string, string>; /** Root and category `meta.json` files, keyed by their path under the components directory. */ metadata?: Record<string, unknown>; /** Export keys for the fake `@luke-ui/react` manifest. */ packageExports?: ReadonlyArray<string>; /** Subpaths under the fake package `src/exports` that get a `.ts` module. */ sourceDirs?: ReadonlyArray<string>;}): 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<string, string>): 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<string>): Record<string, unknown> { const slugs = guidePaths.map((path) => path.replace(/\.mdx$/, '')); const groups = [...new Set(slugs.map((slug) => slug.split('/')[0] ?? ''))]; const metadata: Record<string, unknown> = {};
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<string> { return findComponentPropsTableTags(contents).map((entry) => entry.name);}
/** A fake `src/exports/<subpath>.ts` module that exports every documented props type as an empty interface. */function renderExportsModule(typeNames: ReadonlyArray<string>): string { if (typeNames.length === 0) return 'export {};\n'; return typeNames.map((name) => `export interface ${name} {}\n`).join('');}