diff --git a/apps/docs/content/docs/components/feedback/loading-skeleton.mdx b/apps/docs/content/docs/components/feedback/loading-skeleton.mdx index 72a41d50..b61aefb3 100644 --- a/apps/docs/content/docs/components/feedback/loading-skeleton.mdx +++ b/apps/docs/content/docs/components/feedback/loading-skeleton.mdx @@ -5,6 +5,8 @@ source: packages/@luke-ui/react/src/loading-skeleton props: - name: LoadingSkeletonProps path: packages/@luke-ui/react/src/loading-skeleton/loading-skeleton.tsx + - name: LoadingSkeletonProviderProps + path: packages/@luke-ui/react/src/loading-skeleton/loading-skeleton.tsx --- Use `LoadingSkeleton` when content is loading but its layout is known. Wrap the content that will diff --git a/apps/docs/content/docs/components/primitives/checkbox.mdx b/apps/docs/content/docs/components/primitives/checkbox.mdx index e4620f2d..e75719bb 100644 --- a/apps/docs/content/docs/components/primitives/checkbox.mdx +++ b/apps/docs/content/docs/components/primitives/checkbox.mdx @@ -6,16 +6,12 @@ reactAria: https://react-spectrum.adobe.com/react-aria/Checkbox.html props: - name: CheckboxProps path: packages/@luke-ui/react/src/primitives/checkbox/checkbox.tsx - heading: Checkbox - name: CheckboxContentProps path: packages/@luke-ui/react/src/primitives/checkbox/checkbox.tsx - heading: CheckboxContent - name: CheckboxControlProps path: packages/@luke-ui/react/src/primitives/checkbox/checkbox.tsx - heading: CheckboxControl - name: CheckboxIndicatorProps path: packages/@luke-ui/react/src/primitives/checkbox/checkbox.tsx - heading: CheckboxIndicator --- Use the Checkbox primitive when you need a custom label layout or extra content. For normal diff --git a/apps/docs/content/docs/components/primitives/combobox.mdx b/apps/docs/content/docs/components/primitives/combobox.mdx index 423de0fb..50de0edc 100644 --- a/apps/docs/content/docs/components/primitives/combobox.mdx +++ b/apps/docs/content/docs/components/primitives/combobox.mdx @@ -6,37 +6,26 @@ reactAria: https://react-spectrum.adobe.com/react-aria/ComboBox.html props: - name: ComboboxRootProps path: packages/@luke-ui/react/src/primitives/combobox/root.tsx - heading: ComboboxRoot - name: ComboboxInputGroupProps path: packages/@luke-ui/react/src/primitives/combobox/input-group.tsx - heading: ComboboxInputGroup - name: ComboboxInputProps path: packages/@luke-ui/react/src/primitives/combobox/input.tsx - heading: ComboboxInput - name: ComboboxClearButtonProps path: packages/@luke-ui/react/src/primitives/combobox/clear-button.tsx - heading: ComboboxClearButton - name: ComboboxTriggerProps path: packages/@luke-ui/react/src/primitives/combobox/trigger.tsx - heading: ComboboxTrigger - name: ComboboxPopoverProps path: packages/@luke-ui/react/src/primitives/combobox/popover.tsx - heading: ComboboxPopover - name: ComboboxListBoxProps path: packages/@luke-ui/react/src/primitives/combobox/listbox.tsx - heading: ComboboxListBox - name: ComboboxItemProps path: packages/@luke-ui/react/src/primitives/combobox/item.tsx - heading: ComboboxItem - name: ComboboxLoadMoreItemProps path: packages/@luke-ui/react/src/primitives/combobox/item.tsx - heading: ComboboxLoadMoreItem - name: ComboboxSectionProps path: packages/@luke-ui/react/src/primitives/combobox/section.tsx - heading: ComboboxSection - name: ComboboxEmptyStateProps path: packages/@luke-ui/react/src/primitives/combobox/empty-state.tsx - heading: ComboboxEmptyState --- Use these primitives when [`ComboboxField`](/components/forms/combobox-field) does not fit your diff --git a/apps/docs/content/docs/components/primitives/field.mdx b/apps/docs/content/docs/components/primitives/field.mdx index ed684cf5..de491a78 100644 --- a/apps/docs/content/docs/components/primitives/field.mdx +++ b/apps/docs/content/docs/components/primitives/field.mdx @@ -5,16 +5,12 @@ source: packages/@luke-ui/react/src/primitives/field props: - name: FieldProps path: packages/@luke-ui/react/src/primitives/field/field.tsx - heading: Field - name: FieldLabelProps path: packages/@luke-ui/react/src/primitives/field/label.tsx - heading: FieldLabel - name: FieldDescriptionProps path: packages/@luke-ui/react/src/primitives/field/description.tsx - heading: FieldDescription - name: FieldErrorProps path: packages/@luke-ui/react/src/primitives/field/error.tsx - heading: FieldError --- Use the field primitives to build a custom field with Luke UI labels, descriptions, and validation diff --git a/apps/docs/content/docs/components/primitives/input-group.mdx b/apps/docs/content/docs/components/primitives/input-group.mdx index f4f8bad7..75bd6076 100644 --- a/apps/docs/content/docs/components/primitives/input-group.mdx +++ b/apps/docs/content/docs/components/primitives/input-group.mdx @@ -5,16 +5,12 @@ source: packages/@luke-ui/react/src/primitives/input-group props: - name: InputGroupProps path: packages/@luke-ui/react/src/primitives/input-group/input-group.tsx - heading: InputGroup - name: InputGroupInputProps path: packages/@luke-ui/react/src/primitives/input-group/input-group.tsx - heading: InputGroupInput - name: InputGroupPrefixProps path: packages/@luke-ui/react/src/primitives/input-group/input-group.tsx - heading: InputGroupPrefix - name: InputGroupSuffixProps path: packages/@luke-ui/react/src/primitives/input-group/input-group.tsx - heading: InputGroupSuffix --- Use these primitives to build a custom text control. Reach for them when you need the styled input diff --git a/apps/docs/content/docs/components/typography/heading.mdx b/apps/docs/content/docs/components/typography/heading.mdx index be7b4da0..77736f01 100644 --- a/apps/docs/content/docs/components/typography/heading.mdx +++ b/apps/docs/content/docs/components/typography/heading.mdx @@ -5,6 +5,10 @@ source: packages/@luke-ui/react/src/heading props: - name: HeadingProps path: packages/@luke-ui/react/src/heading/heading.tsx + - name: HeadingLevelsProps + path: packages/@luke-ui/react/src/heading/heading-context.tsx + - name: HeadingLevelsRenderProps + path: packages/@luke-ui/react/src/heading/heading-context.tsx --- `Heading` renders a semantic section heading. It reads its level from `HeadingLevels` context. Set diff --git a/apps/docs/content/docs/components/visuals/icon.mdx b/apps/docs/content/docs/components/visuals/icon.mdx index c3430d51..fba79a26 100644 --- a/apps/docs/content/docs/components/visuals/icon.mdx +++ b/apps/docs/content/docs/components/visuals/icon.mdx @@ -5,6 +5,12 @@ source: packages/@luke-ui/react/src/icon props: - name: IconProps path: packages/@luke-ui/react/src/icon/icon.tsx + - name: CreateIconOptions + path: packages/@luke-ui/react/src/icon/icon.tsx + - name: CustomIconProps + path: packages/@luke-ui/react/src/icon/icon.tsx + - name: IconSpritesheetProviderProps + path: packages/@luke-ui/react/src/icon/icon.tsx --- `Icon` renders a symbol from the generated Luke UI spritesheet. It needs an diff --git a/apps/docs/package.json b/apps/docs/package.json index db0610f0..fa2f7311 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -48,6 +48,7 @@ "lz-string": "catalog:", "monaco-editor": "catalog:", "next-themes": "catalog:", + "oxc-parser": "catalog:", "prettier": "catalog:", "react": "catalog:", "react-aria-components": "catalog:", diff --git a/apps/docs/scripts/check-docs.ts b/apps/docs/scripts/check-docs.ts index 94721aa7..7b483317 100644 --- a/apps/docs/scripts/check-docs.ts +++ b/apps/docs/scripts/check-docs.ts @@ -2,6 +2,14 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { basename, dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { findComponentDocContractIssues } from '../src/lib/component-doc-contract.js'; +import { + buildComponentGuideInventory, + findCategoryMetadataIssues, + findGuideNavigationIssues, + findGuideSourceIssues, + findRepeatedMetadataIssues, +} from '../src/lib/component-guide-inventory.js'; +import { findComponentPropsContractIssues } from '../src/lib/component-props-contract.js'; import { readFrontmatter } from '../src/lib/docs-frontmatter.js'; import { findMdxFiles } from '../src/lib/docs-mdx-files.js'; import { exampleBlockSources } from '../src/lib/example-block-sources.js'; @@ -13,6 +21,8 @@ const contentDir = resolve(docsAppRoot, 'content/docs'); const componentsDir = resolve(contentDir, 'components'); const authoredDocsDir = resolve(contentDir, 'docs'); const internalDocsDir = resolve(repoRoot, 'docs'); +const reactPackageDir = resolve(repoRoot, 'packages/@luke-ui/react'); +const reactPackageJsonPath = resolve(reactPackageDir, 'package.json'); const baselinePath = resolve(scriptDir, 'check-docs.baseline.txt'); const GROUPS_REQUIRING_ACCESSIBILITY = new Set(['actions', 'feedback', 'forms']); @@ -54,6 +64,8 @@ export interface DocsCheckPaths { componentsDir: string; contentDir: string; internalDocsDir: string; + reactPackageDir: string; + reactPackageJsonPath: string; } export const defaultDocsCheckPaths: DocsCheckPaths = { @@ -61,6 +73,8 @@ export const defaultDocsCheckPaths: DocsCheckPaths = { componentsDir, contentDir, internalDocsDir, + reactPackageDir, + reactPackageJsonPath, }; /** One mechanically checkable docs convention violation. */ @@ -73,7 +87,6 @@ export function findDocsIssues(paths: DocsCheckPaths = defaultDocsCheckPaths): A (issue) => `component-doc-contract: ${issue}`, ), ); - for (const guide of componentGuides) { issues.push(...findComponentHeadingIssues(guide)); } @@ -82,6 +95,26 @@ export function findDocsIssues(paths: DocsCheckPaths = defaultDocsCheckPaths): A issues.push(...findContinueLearningIssues(file)); } + const inventory = buildComponentGuideInventory({ + componentsDir: paths.componentsDir, + guides: componentGuides, + reactPackageJsonPath: paths.reactPackageJsonPath, + }); + + issues.push( + ...[ + ...findGuideNavigationIssues(inventory), + ...findRepeatedMetadataIssues(inventory), + ...findCategoryMetadataIssues(inventory, paths.componentsDir), + ...findGuideSourceIssues(inventory, paths.reactPackageDir), + ].map((issue) => `component-guide-inventory: ${issue}`), + ); + issues.push( + ...findComponentPropsContractIssues(inventory, paths.reactPackageDir).map( + (issue) => `component-props-contract: ${issue}`, + ), + ); + issues.push(...findSharedExampleIssues(paths.contentDir)); issues.push(...findProseIssues(paths)); diff --git a/apps/docs/scripts/generate-props-pages.ts b/apps/docs/scripts/generate-props-pages.ts index 2f5c6b60..023797d8 100644 --- a/apps/docs/scripts/generate-props-pages.ts +++ b/apps/docs/scripts/generate-props-pages.ts @@ -9,7 +9,8 @@ import { } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { parseFrontmatterBlocks } from '../src/lib/docs-frontmatter.ts'; +import type { ComponentFrontmatter, PropsEntry } from '../src/lib/docs-frontmatter.ts'; +import { parseComponentFrontmatter } from '../src/lib/docs-frontmatter.ts'; import { findMdxFiles } from '../src/lib/docs-mdx-files.ts'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -28,18 +29,6 @@ function renderMetaJson(componentName: string): string { `; } -export interface PropsEntry { - heading?: string; - name: string; - path: string; -} - -export interface ComponentFrontmatter { - /** Raw frontmatter lines for `description`, `reactAria`, `source`, and `title`, in file order. */ - copiedLines: ReadonlyArray; - props: ReadonlyArray; -} - /** * Emits each component's generated Props page and `meta.json` from the `props` frontmatter * declared on its `/.mdx` guide. `remarkAutoTypeTable` resolves types during MDX @@ -95,10 +84,11 @@ export function generatePropsPages(rootDir: string = componentsDir): { export function renderPropsPage(frontmatter: ComponentFrontmatter): string { const frontmatterBlock = frontmatter.copiedLines.join('\n'); + const withTypeHeadings = frontmatter.props.length > 1; const entries = frontmatter.props .map((entry) => { - const heading = entry.heading === undefined ? '' : `### ${entry.heading}\n\n`; - return `${heading}${renderAutoTypeTable(entry)}`; + const table = renderAutoTypeTable(entry); + return withTypeHeadings ? `### ${entry.name}\n\n${table}` : table; }) .join('\n\n'); @@ -121,58 +111,6 @@ function renderAutoTypeTable(entry: PropsEntry): string { return ``; } -export function parseComponentFrontmatter(contents: string): ComponentFrontmatter { - const blocks = parseFrontmatterBlocks(contents); - if (blocks === null) throw new Error('guide is missing frontmatter'); - - const copiedLines: Array = []; - let props: ReadonlyArray = []; - - for (const block of blocks) { - if (block.key === 'props') { - props = parsePropsEntries(block.lines); - continue; - } - if ( - block.key === 'description' || - block.key === 'reactAria' || - block.key === 'source' || - block.key === 'title' - ) { - copiedLines.push(...block.lines); - } - } - - return { copiedLines, props }; -} - -/** Parses a `props:` block's `- name: … / path: … / heading: …` list items, in file order. */ -function parsePropsEntries(lines: ReadonlyArray): ReadonlyArray { - const entries: Array> = []; - - for (const line of lines) { - const itemMatch = line.match(/^\s*-\s*(\w+):\s*(.+)$/); - const fieldMatch = line.match(/^\s+(\w+):\s*(.+)$/); - - if (itemMatch?.[1] !== undefined && itemMatch[2] !== undefined) { - entries.push({ [itemMatch[1]]: itemMatch[2] }); - continue; - } - - if (fieldMatch?.[1] !== undefined && fieldMatch[2] !== undefined) { - const lastEntry = entries[entries.length - 1]; - if (lastEntry === undefined) throw new Error(`props field with no entry: ${line}`); - Object.assign(lastEntry, { [fieldMatch[1]]: fieldMatch[2] }); - } - } - - return entries.map((entry) => { - if (entry.name === undefined) throw new Error('props entry is missing name'); - if (entry.path === undefined) throw new Error('props entry is missing path'); - return { heading: entry.heading, name: entry.name, path: entry.path }; - }); -} - /** * Every component name in a group: the union of authored `*.mdx` guides and existing directories, * so stale generated output is still discovered after a guide is deleted or renamed. diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index 51f58447..fb52c996 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -24,8 +24,6 @@ export const docs = defineDocs({ props: z .array( z.object({ - /** Heading text for entries backed by more than one type, e.g. `CheckboxContent`. */ - heading: z.string().optional(), /** Exported type name to render, e.g. `ButtonProps`. */ name: z.string(), /** Repo-relative path to the file exporting `name`, e.g. `packages/@luke-ui/react/src/button/button.tsx`. */ diff --git a/apps/docs/src/components/page-actions.browser.test.tsx b/apps/docs/src/components/page-actions.browser.test.tsx index 8b7d7029..5c6ae147 100644 --- a/apps/docs/src/components/page-actions.browser.test.tsx +++ b/apps/docs/src/components/page-actions.browser.test.tsx @@ -78,7 +78,9 @@ test('reports the copied state after Copy Markdown succeeds', async () => { storybookUrl: null, }); - await userEvent.click(page.getByRole('button', { name: 'Copy Markdown' })); + await act(async () => { + await userEvent.click(page.getByRole('button', { name: 'Copy Markdown' })); + }); await expect.element(page.getByRole('button', { name: 'Copied' })).toBeVisible(); expect(writeText).toHaveBeenCalledWith('# Example'); diff --git a/apps/docs/src/lib/check-docs.test.ts b/apps/docs/src/lib/check-docs.test.ts index 5a4d2d6d..d04b982b 100644 --- a/apps/docs/src/lib/check-docs.test.ts +++ b/apps/docs/src/lib/check-docs.test.ts @@ -2,6 +2,7 @@ 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, @@ -513,16 +514,191 @@ test('finds no extra docs issues in the docs app beyond the baseline', () => { expect(diffAgainstBaseline(issues, baseline)).toEqual({ extra: [], stale: [] }); }); +const INVENTORY_GUIDE = `--- +title: Button +source: packages/@luke-ui/react/src/button +--- + + + +## Accessibility + +The visible label is the accessible name. +`; + +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'], + }); +} + +test('accepts a guide that the root and category metadata both list', () => { + expect(findDocsIssues(inventoryFixture({}))).toEqual([]); +}); + +test('reports a guide that is absent from the root component metadata', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: [], title: 'Actions' }, + 'meta.json': { pages: ['---Actions---'], root: true, title: 'Components' }, + }, + }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', + ]); +}); + +test('validates category metadata for root entries before the first separator', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: ['button'], title: 'Actions' }, + 'meta.json': { + pages: ['actions/button', '---Actions---'], + root: true, + title: 'Components', + }, + }, + }); + + expect(findDocsIssues(paths)).toEqual([]); +}); + +test('reports stale category metadata when the root category disappears', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: ['button'], title: 'Actions' }, + 'meta.json': { pages: [], root: true, title: 'Components' }, + }, + }); + + expect(findDocsIssues(paths)).toEqual([ + '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 [])', + ]); +}); + +test('reports leftover category metadata when no guide remains in that category', () => { + const paths = inventoryFixture({ + components: {}, + metadata: { + 'actions/meta.json': { pages: ['button'], title: 'Actions' }, + 'meta.json': { pages: [], root: true, title: 'Components' }, + }, + packageExports: [], + sourceDirs: [], + }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', + ]); +}); + +test('reports a root metadata entry that has no guide', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: ['button', 'link'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/link'], + root: true, + title: 'Components', + }, + }, + }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: components/meta.json: entry "actions/link" has no guide (expected actions/link.mdx)', + ]); +}); + +test('reports a repeated root metadata entry', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: ['button', 'button'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/button'], + root: true, + title: 'Components', + }, + }, + }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: components/meta.json: entry "actions/button" is repeated', + ]); +}); + +test('reports category metadata that does not match the root metadata', () => { + const paths = inventoryFixture({ + metadata: { + 'actions/meta.json': { pages: ['link', 'button'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/link'], + root: true, + title: 'Components', + }, + }, + components: { + 'actions/button.mdx': INVENTORY_GUIDE, + 'actions/link.mdx': `--- +title: Link +source: packages/@luke-ui/react/src/link +--- + + + +## Accessibility + +The visible label is the accessible name. +`, + }, + packageExports: ['./button', './link'], + sourceDirs: ['button', 'link'], + }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: actions/meta.json: pages [link, button] do not match the root metadata (expected [button, link])', + ]); +}); + +test('reports a guide source that is not a public package entry point', () => { + const paths = inventoryFixture({ packageExports: ['./link'] }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/button" is not a public package entry point (expected export "./button" in @luke-ui/react)', + ]); +}); + +test('reports a source directory with no index.ts', () => { + const paths = inventoryFixture({ sourceDirs: [] }); + + expect(findDocsIssues(paths)).toEqual([ + 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/button" has no packages/@luke-ui/react/src/button/index.ts', + ]); +}); + function createDocsFixture(input: { authored?: Record; components?: Record; internal?: Record; -}): { - authoredDocsDir: string; - componentsDir: string; - contentDir: string; - internalDocsDir: string; -} { + /** 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` that get an `index.ts`. */ + sourceDirs?: ReadonlyArray; +}): DocsCheckPaths { const directory = mkdtempSync(join(tmpdir(), 'luke-ui-check-docs-')); testDirectories.push(directory); @@ -530,9 +706,11 @@ function createDocsFixture(input: { 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 ?? {}); @@ -540,7 +718,39 @@ function createDocsFixture(input: { writeFileSync(join(internalDocsDir, name), contents); } - return { authoredDocsDir, componentsDir, contentDir, internalDocsDir }; + 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' })); + + for (const subpath of input.sourceDirs ?? guideSources) { + const sourceDir = join(reactPackageDir, 'src', subpath); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'index.ts'), 'export {};\n'); + } + + return { + authoredDocsDir, + componentsDir, + contentDir, + internalDocsDir, + reactPackageDir, + reactPackageJsonPath, + }; } function writeGuides(root: string, files: Record): void { @@ -550,3 +760,35 @@ function writeGuides(root: string, files: Record): void { 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 + .filter((slug) => slug.startsWith(`${group}/`)) + .map((slug) => 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/', ''); +} diff --git a/apps/docs/src/lib/component-guide-inventory.ts b/apps/docs/src/lib/component-guide-inventory.ts new file mode 100644 index 00000000..b2801974 --- /dev/null +++ b/apps/docs/src/lib/component-guide-inventory.ts @@ -0,0 +1,238 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseComponentFrontmatter, readFrontmatter } from './docs-frontmatter.js'; + +const SOURCE_PREFIX = 'packages/@luke-ui/react/src/'; + +/** One authored component guide, keyed by the slug the navigation uses. */ +interface ComponentGuide { + props: ReadonlyArray<{ name: string; path: string }>; + relativePath: string; + slug: string; + source: string | undefined; +} + +/** + * The single scan the navigation and source checks share. `check:docs` builds it once, so no + * check re-reads the guides or the root metadata. + */ +export interface ComponentGuideInventory { + guides: ReadonlyArray; + metadataEntries: ReadonlyArray; + packageExports: ReadonlyArray; +} + +export interface ComponentGuideInventoryInput { + componentsDir: string; + guides: ReadonlyArray<{ group: string; relativePath: string; source: string }>; + reactPackageJsonPath: string; +} + +/** Reads the root metadata and the package manifest once, alongside the already-read guides. */ +export function buildComponentGuideInventory( + input: ComponentGuideInventoryInput, +): ComponentGuideInventory { + const metadataEntries = readRootMetadata(input.componentsDir); + + return { + guides: input.guides.map((guide) => { + const frontmatter = parseComponentFrontmatter(guide.source); + const name = + guide.relativePath + .replace(/\.mdx$/, '') + .split('/') + .at(-1) ?? ''; + return { + relativePath: guide.relativePath, + slug: `${guide.group}/${name}`, + props: frontmatter.props, + source: readFrontmatter(guide.source).source, + }; + }), + metadataEntries, + packageExports: readPackageExports(input.reactPackageJsonPath), + }; +} + +/** A guide missing from the root metadata, or a metadata entry with no guide. */ +export function findGuideNavigationIssues(inventory: ComponentGuideInventory): Array { + const issues: Array = []; + const entrySet = new Set(inventory.metadataEntries); + const slugSet = new Set(inventory.guides.map((guide) => guide.slug)); + + for (const guide of inventory.guides) { + if (entrySet.has(guide.slug)) continue; + issues.push( + `${guide.relativePath}: guide is absent from the root component metadata (expected entry "${guide.slug}")`, + ); + } + + for (const entry of inventory.metadataEntries) { + if (slugSet.has(entry)) continue; + issues.push(`components/meta.json: entry "${entry}" has no guide (expected ${entry}.mdx)`); + } + + return issues; +} + +/** A slug listed more than once in the root metadata. */ +export function findRepeatedMetadataIssues(inventory: ComponentGuideInventory): Array { + const seen = new Set(); + const reported = new Set(); + const issues: Array = []; + + for (const entry of inventory.metadataEntries) { + if (!seen.has(entry)) { + seen.add(entry); + continue; + } + if (reported.has(entry)) continue; + reported.add(entry); + issues.push(`components/meta.json: entry "${entry}" is repeated`); + } + + return issues; +} + +/** + * Each category `meta.json` must list the `` part of its root metadata slice, in the same + * order, so the sidebar and the root navigation agree. + */ +export function findCategoryMetadataIssues( + inventory: ComponentGuideInventory, + componentsDir: string, +): Array { + const issues: Array = []; + const expectedByGroup = new Map>(); + + for (const slug of inventory.metadataEntries) { + const separator = slug.indexOf('/'); + if (separator === -1) continue; + const group = slug.slice(0, separator); + const pages = expectedByGroup.get(group) ?? []; + pages.push(slug.slice(separator + 1)); + expectedByGroup.set(group, pages); + } + + for (const guide of inventory.guides) { + const group = guide.slug.split('/')[0]; + if (group === undefined) continue; + if (!expectedByGroup.has(group)) expectedByGroup.set(group, []); + } + + // Leftover /meta.json files are otherwise invisible once the root + // metadata and the guides no longer mention that category. + for (const group of categoryDirectories(componentsDir)) { + if (!expectedByGroup.has(group)) expectedByGroup.set(group, []); + } + + for (const [group, expected] of expectedByGroup) { + const metaPath = resolve(componentsDir, group, 'meta.json'); + + if (!existsSync(metaPath)) { + issues.push( + `${group}/meta.json: missing category metadata (expected pages ${formatList(expected)})`, + ); + continue; + } + + const pages = readMetaPages(metaPath); + if (!sameOrder(pages, expected)) { + issues.push( + `${group}/meta.json: pages ${formatList(pages)} do not match the root metadata (expected ${formatList(expected)})`, + ); + } + } + + return issues; +} + +function categoryDirectories(componentsDir: string): Array { + if (!existsSync(componentsDir)) return []; + + return readdirSync(componentsDir, { withFileTypes: true }).flatMap((entry) => { + if (!entry.isDirectory()) return []; + if (!existsSync(resolve(componentsDir, entry.name, 'meta.json'))) return []; + return [entry.name]; + }); +} + +/** + * A guide `source` must name a public package entry point and a real source directory with an + * `index.ts`, so a documented component is one a developer can import. + */ +export function findGuideSourceIssues( + inventory: ComponentGuideInventory, + reactPackageDir: string, +): Array { + const issues: Array = []; + const exportSet = new Set(inventory.packageExports); + + for (const guide of inventory.guides) { + const { relativePath, source } = guide; + if (source === undefined) continue; + + if (!source.startsWith(SOURCE_PREFIX)) { + issues.push( + `${relativePath}: source "${source}" is not a public package entry point (expected a path under ${SOURCE_PREFIX})`, + ); + continue; + } + + const subpath = source.slice(SOURCE_PREFIX.length); + if (!exportSet.has(`./${subpath}`)) { + issues.push( + `${relativePath}: source "${source}" is not a public package entry point (expected export "./${subpath}" in @luke-ui/react)`, + ); + continue; + } + + const indexPath = resolve(reactPackageDir, 'src', subpath, 'index.ts'); + if (!existsSync(indexPath)) { + issues.push(`${relativePath}: source "${source}" has no ${source}/index.ts`); + } + } + + return issues; +} + +function readRootMetadata(componentsDir: string): Array { + const metadataEntries: Array = []; + const metaPath = resolve(componentsDir, 'meta.json'); + if (!existsSync(metaPath)) return metadataEntries; + + for (const page of readMetaPages(metaPath)) { + if (page.startsWith('!') || !page.includes('/')) continue; + + metadataEntries.push(page); + } + + return metadataEntries; +} + +function readMetaPages(metaPath: string): Array { + const parsed: unknown = JSON.parse(readFileSync(metaPath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null) return []; + const pages = (parsed as { pages?: unknown }).pages; + if (!Array.isArray(pages)) return []; + return pages.filter((page): page is string => typeof page === 'string'); +} + +function readPackageExports(packageJsonPath: string): Array { + if (!existsSync(packageJsonPath)) return []; + const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null) return []; + const exports = (parsed as { exports?: unknown }).exports; + if (typeof exports !== 'object' || exports === null) return []; + return Object.keys(exports); +} + +function sameOrder(actual: ReadonlyArray, expected: ReadonlyArray): boolean { + return ( + actual.length === expected.length && actual.every((page, index) => page === expected[index]) + ); +} + +function formatList(pages: ReadonlyArray): string { + return `[${pages.join(', ')}]`; +} diff --git a/apps/docs/src/lib/component-props-contract.test.ts b/apps/docs/src/lib/component-props-contract.test.ts new file mode 100644 index 00000000..88367fe5 --- /dev/null +++ b/apps/docs/src/lib/component-props-contract.test.ts @@ -0,0 +1,522 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { buildComponentGuideInventory } from './component-guide-inventory.js'; +import { findComponentPropsContractIssues } from './component-props-contract.js'; + +const testDirectories: Array = []; + +afterEach(() => { + for (const directory of testDirectories) { + rmSync(directory, { force: true, recursive: true }); + } + testDirectories.length = 0; +}); + +test('requires component props from an exported component parameter', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'export interface ButtonProps {}\nexport function Button(props: ButtonProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('requires provider props from an exported provider parameter', () => { + const fixture = createFixture({ + entry: "export { Provider, type ProviderProps } from './provider.js';\n", + files: { + 'provider.ts': + 'export interface ProviderProps {}\nexport function Provider(props: ProviderProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ProviderProps" in props frontmatter', + ]); +}); + +test('requires factory options and the factory return type', () => { + const fixture = createFixture({ + entry: + "export { createThing, type CreateThingOptions, type CreatedThing } from './factory.js';\n", + files: { + 'factory.ts': + "export interface CreateThingOptions {}\nexport type CreatedThing = { value: string };\nexport function createThing(options: CreateThingOptions): CreatedThing { return { value: 'thing' }; }\n", + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "CreateThingOptions" in props frontmatter', + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "CreatedThing" in props frontmatter', + ]); +}); + +test('requires a render callback value', () => { + const fixture = createFixture({ + entry: "export { createRender, type RenderProps } from './render.js';\n", + files: { + 'render.ts': + 'export type RenderProps = { value: string };\nexport function createRender(): (props: RenderProps) => string { return ({ value }) => value; }\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "RenderProps" in props frontmatter', + ]); +}); + +test('requires a named object generic constraint', () => { + const fixture = createFixture({ + entry: "export { createThing, type ThingProps } from './factory.js';\n", + files: { + 'factory.ts': + 'export type ThingProps = { value: string };\nexport function createThing(): T { throw new Error(); }\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ThingProps" in props frontmatter', + ]); +}); + +test('requires contracts from a multipart entry point', () => { + const fixture = createFixture({ + entry: "export { Root, Item, type RootProps, type ItemProps } from './parts.js';\n", + files: { + 'parts.ts': + 'export interface RootProps {}\nexport interface ItemProps {}\nexport function Root(props: RootProps) {}\nexport function Item(props: ItemProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "RootProps" in props frontmatter', + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ItemProps" in props frontmatter', + ]); +}); + +test('requires contracts re-exported through an intermediate local module', () => { + const fixture = createFixture({ + entry: "export { FieldLabel, type FieldLabelProps } from './field.js';\n", + files: { + 'field.ts': + "import type { FieldLabelProps } from './label.js';\nimport { FieldLabel } from './label.js';\nexport type { FieldLabelProps };\nexport { FieldLabel };\n", + 'label.ts': + 'export interface FieldLabelProps {}\nexport function FieldLabel(props: FieldLabelProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "FieldLabelProps" in props frontmatter', + ]); +}); + +test('follows aliased values and types through an intermediate re-export', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './public.js';\n", + files: { + 'public.ts': + "export { InternalButton as Button, type InternalProps as ButtonProps } from './impl.js';\n", + 'impl.ts': + 'export interface InternalProps {}\nexport function InternalButton(props: InternalProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('follows an aliased value through an intermediate re-export', () => { + const fixture = createFixture({ + entry: "export { Button } from './public.js';\n", + files: { + 'public.ts': "export { InternalButton as Button } from './impl.js';\n", + 'impl.ts': 'export declare const InternalButton: () => unknown;\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" has an unsupported exported signature "Button"', + ]); +}); + +test('follows an aliased type through an intermediate re-export', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './public.js';\n", + files: { + 'public.ts': + "import type { InternalProps as ButtonProps } from './impl.js';\nexport { type InternalProps as ButtonProps } from './impl.js';\nexport function Button(props: ButtonProps) {}\n", + 'impl.ts': 'export interface InternalProps {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('follows aliased values and types through multiple local re-exports', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './public.js';\n", + files: { + 'public.ts': + "export { InternalButton as Button, type InternalProps as ButtonProps } from './bridge.js';\n", + 'bridge.ts': + "export { ImplButton as InternalButton, type ImplProps as InternalProps } from './impl.js';\n", + 'impl.ts': 'export interface ImplProps {}\nexport function ImplButton(props: ImplProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('requires a public imported type from another local module', () => { + const fixture = createFixture({ + entry: + "export { Button } from './button.js';\nexport type { ButtonProps } from './types.js';\n", + files: { + 'button.ts': + "import type { ButtonProps } from './types.js';\nexport function Button(props: ButtonProps) {}\n", + 'types.ts': 'export interface ButtonProps {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('uses the public alias for an imported type from another local module', () => { + const fixture = createFixture({ + entry: + "export { Button } from './button.js';\nexport type { InternalProps as ButtonProps } from './types.js';\nexport { Item } from './item.js';\n", + files: { + 'button.ts': + "import type { InternalProps } from './types.js';\nexport function Button(props: InternalProps) {}\n", + 'types.ts': 'export interface InternalProps {}\n', + 'item.ts': + "export type InternalProps = 'small' | 'large';\nexport function Item(size: InternalProps) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('excludes a public imported leaf type from another local module', () => { + const fixture = createFixture({ + entry: "export { Button } from './button.js';\nexport type { ButtonSize } from './types.js';\n", + files: { + 'button.ts': + "import type { ButtonSize } from './types.js';\nexport function Button(size: ButtonSize) {}\n", + 'types.ts': "export type ButtonSize = 'small' | 'large';\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('excludes an imported object type that is not publicly exported', () => { + const fixture = createFixture({ + entry: "export { Button } from './button.js';\n", + files: { + 'button.ts': + "import type { PrivateProps } from './types.js';\nexport function Button(props: PrivateProps) {}\n", + 'types.ts': 'export interface PrivateProps {}\n', + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('excludes a recipe variant type', () => { + const fixture = createFixture({ + entry: "export { buttonRecipe, type ButtonRecipeVariants } from './recipe.css.js';\n", + files: { 'recipe.css.ts': 'export type ButtonRecipeVariants = { size: string };\n' }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('excludes a leaf type', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonSize } from './button.js';\n", + files: { + 'button.ts': + "export type ButtonSize = 'small' | 'large';\nexport function Button(size: ButtonSize) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('excludes a leaf alias of a union', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonSize } from './button.js';\n", + files: { + 'button.ts': + "type Size = 'small' | 'large';\nexport type ButtonSize = Size;\nexport function Button(size: ButtonSize) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('excludes an indirect leaf alias', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonSize } from './button.js';\n", + files: { + 'button.ts': + "type Size = 'small' | 'large';\ntype Alias = Size;\nexport type ButtonSize = Alias;\nexport function Button(size: ButtonSize) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('requires an indirect alias that resolves to an object type', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'type Inner = { value: string };\ntype Alias = Inner;\nexport type ButtonProps = Alias;\nexport function Button(props: ButtonProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('excludes a parameterised leaf alias of a union', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonSize } from './button.js';\n", + files: { + 'button.ts': + "type Choice = T;\nexport type ButtonSize = Choice<'small' | 'large'>;\nexport function Button(size: ButtonSize) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('requires a parameterised alias that resolves to an object type', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'type Wrap = T;\nexport type ButtonProps = Wrap<{ value: string }>;\nexport function Button(props: ButtonProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('excludes a chained parameterised leaf alias', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonSize } from './button.js';\n", + files: { + 'button.ts': + "type Identity = T;\ntype Choice = Identity;\nexport type ButtonSize = Choice<'small' | 'large'>;\nexport function Button(size: ButtonSize) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('requires a chained parameterised alias that resolves to an object type', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'type Identity = T;\ntype Wrap = Identity;\nexport type ButtonProps = Wrap<{ value: string }>;\nexport function Button(props: ButtonProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + ]); +}); + +test('classifies private aliases using the module that declared them', () => { + const fixture = createFixture({ + entry: + "export { Root, type RootProps } from './root.js';\nexport { Item, type ItemProps } from './item.js';\n", + files: { + 'root.ts': + 'type Alias = { value: string };\nexport type RootProps = Alias;\nexport function Root(props: RootProps) {}\n', + 'item.ts': + "type Alias = 'small' | 'large';\nexport type ItemProps = Alias;\nexport function Item(size: ItemProps) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "RootProps" in props frontmatter', + ]); +}); + +test('does not require an object type with the same name as a public leaf type', () => { + const fixture = createFixture({ + entry: + "export { Root, type SharedProps } from './root.js';\nexport { Item } from './item.js';\n", + files: { + 'root.ts': + "export type SharedProps = 'small' | 'large';\nexport function Root(props: SharedProps) {}\n", + 'item.ts': + 'export type SharedProps = { value: string };\nexport function Item(props: SharedProps) {}\n', + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('requires a public object type when a local leaf type has the same name', () => { + const fixture = createFixture({ + entry: + "export { Root, type SharedProps } from './root.js';\nexport { Item } from './item.js';\n", + files: { + 'root.ts': + 'export type SharedProps = { value: string };\nexport function Root(props: SharedProps) {}\n', + 'item.ts': + "export type SharedProps = 'small' | 'large';\nexport function Item(props: SharedProps) {}\n", + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "SharedProps" in props frontmatter', + ]); +}); + +test('inspects only the re-exported callable when local modules share a value name', () => { + const fixture = createFixture({ + entry: "export { Root, type RootProps } from './root.js';\nexport { Item } from './item.js';\n", + files: { + 'root.ts': 'export interface RootProps {}\nexport function Root(props: RootProps) {}\n', + 'item.ts': + 'export declare const Root: (props: RootProps) => unknown;\nexport function Item() {}\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "RootProps" in props frontmatter', + ]); +}); + +test('reports an unsupported relevant exported signature', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'export interface ButtonProps {}\nexport declare const Button: (props: ButtonProps) => unknown;\n', + }, + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" has an unsupported exported signature "Button"', + ]); +}); + +test('does not treat a typed data constant as an unsupported signature', () => { + const fixture = createFixture({ + entry: "export { iconNames, iconViewBoxes } from './icon.js';\n", + files: { + 'icon.ts': + "export const iconNames = ['add'] as const;\nexport const iconViewBoxes: Record = { add: '0 0 24 24' };\n", + }, + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('does not follow public re-exports outside the entry directory', () => { + const fixture = createFixture({ + entry: "export { Icon, iconNames, type IconProps } from './icon.js';\n", + files: { + 'icon.ts': + "import { iconNames } from '../generated/icon-data.js';\nexport { iconNames };\nexport interface IconProps {}\nexport function Icon(props: IconProps) {}\n", + '../generated/icon-data.ts': + 'export declare const iconNames: (props: IconProps) => unknown;\n', + }, + props: ['IconProps'], + }); + + expect(issues(fixture)).toEqual([]); +}); + +test('reports frontmatter props that the entry point does not export', () => { + const fixture = createFixture({ + entry: "export { Button, type ButtonProps } from './button.js';\n", + files: { + 'button.ts': + 'export interface ButtonProps {}\nexport function Button(props: ButtonProps) {}\n', + }, + props: ['MissingProps'], + }); + + expect(issues(fixture)).toEqual([ + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" requires public object contract "ButtonProps" in props frontmatter', + 'actions/button.mdx: entry point "packages/@luke-ui/react/src/button/index.ts" does not export props frontmatter type "MissingProps"', + ]); +}); + +function createFixture(input: { + entry: string; + files: Record; + props?: ReadonlyArray; +}): { inventory: ReturnType; reactPackageDir: string } { + const directory = mkdtempSync(join(tmpdir(), 'luke-ui-component-props-contract-')); + testDirectories.push(directory); + + const componentsDir = join(directory, 'content', 'components'); + const reactPackageDir = join(directory, 'react-package'); + const sourceDir = join(reactPackageDir, 'src', 'button'); + mkdirSync(componentsDir, { recursive: true }); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(componentsDir, 'meta.json'), '{"pages":["---Actions---","actions/button"]}'); + writeFileSync( + join(reactPackageDir, 'package.json'), + '{"exports":{"./button":"./dist/button/index.js"}}', + ); + writeFileSync(join(sourceDir, 'index.ts'), input.entry); + + for (const [path, contents] of Object.entries(input.files)) { + const filePath = join(sourceDir, path); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents); + } + + const props = input.props ?? []; + const guide = `---\ntitle: Button\nsource: packages/@luke-ui/react/src/button\n${renderProps(props)}---\n`; + return { + inventory: buildComponentGuideInventory({ + componentsDir, + guides: [{ group: 'actions', relativePath: 'actions/button.mdx', source: guide }], + reactPackageJsonPath: join(reactPackageDir, 'package.json'), + }), + reactPackageDir, + }; +} + +function renderProps(props: ReadonlyArray): string { + if (props.length === 0) return ''; + return `props:\n${props.map((name) => ` - name: ${name}\n path: packages/@luke-ui/react/src/button/button.ts`).join('\n')}\n`; +} + +function issues(fixture: { + inventory: ReturnType; + reactPackageDir: string; +}): Array { + return findComponentPropsContractIssues(fixture.inventory, fixture.reactPackageDir); +} diff --git a/apps/docs/src/lib/component-props-contract.ts b/apps/docs/src/lib/component-props-contract.ts new file mode 100644 index 00000000..2a2f4bdc --- /dev/null +++ b/apps/docs/src/lib/component-props-contract.ts @@ -0,0 +1,645 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve, sep } from 'node:path'; +import { parseSync } from 'oxc-parser'; +import type { ComponentGuideInventory } from './component-guide-inventory.js'; + +const SOURCE_PREFIX = 'packages/@luke-ui/react/src/'; + +interface AstNode { + readonly type: string; + readonly [key: string]: unknown; +} + +interface ParsedModule { + path: string; + program: AstNode; +} + +interface PublicExports { + types: Map>; + values: Map>; +} + +/** Checks Props frontmatter against object contracts from a public entry point and its local re-export chain. */ +export function findComponentPropsContractIssues( + inventory: ComponentGuideInventory, + reactPackageDir: string, +): Array { + const issues: Array = []; + + for (const guide of inventory.guides) { + if (guide.source === undefined || !guide.source.startsWith(SOURCE_PREFIX)) continue; + + const entryPath = resolve( + reactPackageDir, + 'src', + guide.source.slice(SOURCE_PREFIX.length), + 'index.ts', + ); + if (!existsSync(entryPath)) continue; + + issues.push(...findGuideIssues(guide.relativePath, guide.props, guide.source, entryPath)); + } + + return issues; +} + +function findGuideIssues( + guidePath: string, + frontmatterProps: ReadonlyArray<{ name: string }>, + source: string, + entryPath: string, +): Array { + const entryModule = parseModule(entryPath); + if (entryModule === undefined) return []; + + const modules = localContractModules(entryModule); + const publicTypes = publicTypeNames(modules); + const modulesByPath = new Map(modules.map((module) => [module.module.path, module])); + const required = new Set(); + const unsupported = new Set(); + + for (const { module, values } of modules) { + for (const signature of exportedSignatures(module.program, values)) { + if (signature.unsupported) { + for (const name of values.get(signature.name) ?? []) unsupported.add(name); + continue; + } + for (const type of signature.types) { + for (const name of publicObjectContract(module, type, modulesByPath)) required.add(name); + } + } + } + + const entryPoint = `${source}/index.ts`; + const documented = new Set(frontmatterProps.map((entry) => entry.name)); + const issues: Array = []; + + for (const type of required) { + if (documented.has(type)) continue; + issues.push( + `${guidePath}: entry point "${entryPoint}" requires public object contract "${type}" in props frontmatter`, + ); + } + + for (const type of documented) { + if (publicTypes.has(type)) continue; + issues.push( + `${guidePath}: entry point "${entryPoint}" does not export props frontmatter type "${type}"`, + ); + } + + for (const name of unsupported) { + issues.push( + `${guidePath}: entry point "${entryPoint}" has an unsupported exported signature "${name}"`, + ); + } + + return issues; +} + +// Public names may be imported and re-exported by an intermediate file. Follow those local +// modules so the callable signature that is inspected is the one consumers actually import. +// Stay inside the entry directory so generated data modules are not scanned. +function localContractModules( + entryModule: ParsedModule, +): Array<{ module: ParsedModule } & PublicExports> { + const modules = new Map(); + const entryExports = exportedNames(entryModule.program); + modules.set(entryModule.path, { module: entryModule, ...entryExports }); + const pending = [entryModule.path]; + const entryDir = dirname(entryModule.path); + + while (pending.length > 0) { + const path = pending.pop(); + if (path === undefined) continue; + const current = modules.get(path); + if (current === undefined) continue; + + for (const target of localReexports(current.module, current)) { + if (modules.has(target.path)) { + const existing = modules.get(target.path); + if (existing !== undefined && mergePublicExports(existing, target)) + pending.push(target.path); + continue; + } + + const targetPath = target.path; + if (targetPath.endsWith('.css.ts') || !isInsideDirectory(targetPath, entryDir)) continue; + + const module = parseModule(targetPath); + if (module === undefined) continue; + modules.set(targetPath, { module, types: target.types, values: target.values }); + pending.push(targetPath); + } + } + + return [...modules.values()]; +} + +function localReexports( + module: ParsedModule, + publicExports: PublicExports, +): Array<{ path: string } & PublicExports> { + const importedLocals = new Map(); + + for (const statement of body(module.program)) { + if (statement.type !== 'ImportDeclaration') continue; + const source = literalString(statement.source); + if (source === undefined || !source.startsWith('.')) continue; + + for (const specifier of nodes(statement.specifiers)) { + const local = identifierName(specifier.local); + const imported = identifierName(specifier.imported); + if (local !== undefined && imported !== undefined) { + importedLocals.set(local, { name: imported, source }); + } + } + } + + const reexports = new Map(); + + for (const statement of body(module.program)) { + if (statement.type !== 'ExportNamedDeclaration') continue; + const source = literalString(statement.source); + + if (source !== undefined) { + if (!source.startsWith('.')) continue; + addReexportedNames( + reexports, + resolveLocalModule(module.path, source), + statement, + publicExports, + ); + continue; + } + + for (const specifier of nodes(statement.specifiers)) { + const local = identifierName(specifier.local); + if (local === undefined) continue; + const imported = importedLocals.get(local); + if (imported === undefined) continue; + addReexportedName( + reexports, + resolveLocalModule(module.path, imported.source), + imported.name, + specifier, + publicExports, + statement, + ); + } + } + + return [...reexports.values()]; +} + +function addReexportedNames( + reexports: Map, + path: string, + statement: AstNode, + publicExports: PublicExports, +): void { + for (const specifier of nodes(statement.specifiers)) { + const local = identifierName(specifier.local); + if (local === undefined) continue; + addReexportedName(reexports, path, local, specifier, publicExports, statement); + } +} + +function addReexportedName( + reexports: Map, + path: string, + targetName: string, + specifier: AstNode, + publicExports: PublicExports, + statement: AstNode, +): void { + const local = identifierName(specifier.local); + const exported = identifierName(specifier.exported); + if (local === undefined || exported === undefined) return; + const kind = exportKind(statement, specifier); + const provenance = kind === 'type' ? publicExports.types : publicExports.values; + const names = provenance.get(exported) ?? provenance.get(local); + if (names === undefined) return; + + const reexport = reexports.get(path) ?? { path, types: new Map(), values: new Map() }; + for (const name of names) + addPublicName(reexport[kind === 'type' ? 'types' : 'values'], targetName, name); + reexports.set(path, reexport); +} + +function resolveLocalModule(entryPath: string, specifier: string): string { + const base = resolve(dirname(entryPath), specifier.replace(/\.js$/, '')); + for (const extension of ['.ts', '.tsx', '.css.ts', '.css.tsx']) { + const path = `${base}${extension}`; + if (existsSync(path)) return path; + } + return `${base}.ts`; +} + +function mergePublicExports(target: PublicExports, source: PublicExports): boolean { + const typesChanged = mergePublicNames(target.types, source.types); + const valuesChanged = mergePublicNames(target.values, source.values); + return typesChanged || valuesChanged; +} + +function mergePublicNames( + target: Map>, + source: ReadonlyMap>, +): boolean { + let changed = false; + for (const [local, exported] of source) { + for (const name of exported) { + if (addPublicName(target, local, name)) changed = true; + } + } + return changed; +} + +function addPublicName(names: Map>, local: string, exported: string): boolean { + const existing = names.get(local); + if (existing !== undefined) { + if (existing.has(exported)) return false; + existing.add(exported); + return true; + } + names.set(local, new Set([exported])); + return true; +} + +function exportKind(statement: AstNode, specifier: AstNode): 'type' | 'value' { + return stringValue(statement.exportKind) === 'type' || + stringValue(specifier.exportKind) === 'type' + ? 'type' + : 'value'; +} + +function parseModule(path: string): ParsedModule | undefined { + if (!existsSync(path)) return undefined; + const result = parseSync(path, readFileSync(path, 'utf8')); + if (result.errors.length > 0) return undefined; + return { path, program: result.program as unknown as AstNode }; +} + +function exportedNames(program: AstNode): PublicExports { + const types = new Map>(); + const values = new Map>(); + + for (const statement of body(program)) { + if (statement.type !== 'ExportNamedDeclaration') continue; + + const declaration = astNode(statement.declaration); + if (declaration !== undefined) { + const name = declarationName(declaration); + if (name !== undefined) + addPublicName(declarationKind(declaration) === 'type' ? types : values, name, name); + } + + for (const specifier of nodes(statement.specifiers)) { + const local = identifierName(specifier.local); + const exported = identifierName(specifier.exported); + if (local !== undefined && exported !== undefined) { + addPublicName( + exportKind(statement, specifier) === 'type' ? types : values, + local, + exported, + ); + } + } + } + + return { types, values }; +} + +function publicTypeNames(modules: ReadonlyArray): Set { + const names = new Set(); + for (const { types } of modules) { + for (const exported of types.values()) { + for (const name of exported) names.add(name); + } + } + return names; +} + +function publicObjectContract( + module: ParsedModule, + name: string, + modules: ReadonlyMap, + seen: Set = new Set(), +): ReadonlySet { + const key = `${module.path}:${name}`; + if (seen.has(key)) return new Set(); + seen.add(key); + + const publicNames = modules.get(module.path)?.types.get(name); + const declarations = typeDeclarations(module); + const declaration = declarations.get(name); + if (publicNames !== undefined && declaration !== undefined) { + return isObjectType(declaration, name, declarations) ? publicNames : new Set(); + } + + const imported = importedType(module, name); + if (imported !== undefined) { + const target = modules.get(resolveLocalModule(module.path, imported.source)); + if (target !== undefined) + return publicObjectContract(target.module, imported.name, modules, seen); + } + + const reexported = reexportedType(module, name); + if (reexported !== undefined) { + const target = modules.get(resolveLocalModule(module.path, reexported.source)); + if (target !== undefined) + return publicObjectContract(target.module, reexported.name, modules, seen); + } + + return new Set(); +} + +function importedType( + module: ParsedModule, + name: string, +): { name: string; source: string } | undefined { + for (const statement of body(module.program)) { + if (statement.type !== 'ImportDeclaration') continue; + const source = literalString(statement.source); + if (source === undefined || !source.startsWith('.')) continue; + + for (const specifier of nodes(statement.specifiers)) { + if (identifierName(specifier.local) !== name) continue; + const imported = identifierName(specifier.imported); + if (imported !== undefined) return { name: imported, source }; + } + } +} + +function reexportedType( + module: ParsedModule, + name: string, +): { name: string; source: string } | undefined { + for (const statement of body(module.program)) { + if (statement.type !== 'ExportNamedDeclaration') continue; + const source = literalString(statement.source); + if (source === undefined || !source.startsWith('.')) continue; + + for (const specifier of nodes(statement.specifiers)) { + if ( + exportKind(statement, specifier) !== 'type' || + identifierName(specifier.exported) !== name + ) { + continue; + } + const local = identifierName(specifier.local); + if (local !== undefined) return { name: local, source }; + } + } +} + +function typeDeclarations(module: ParsedModule): Map { + const declarations = new Map(); + + for (const statement of body(module.program)) { + const declaration = + statement.type === 'ExportNamedDeclaration' ? astNode(statement.declaration) : statement; + if (declaration === undefined || declarationKind(declaration) !== 'type') continue; + const name = declarationName(declaration); + if (name !== undefined) declarations.set(name, declaration); + } + + return declarations; +} + +function isObjectType( + declaration: AstNode, + name: string, + declarations: ReadonlyMap, + seen: Set = new Set(), + substitutions: ReadonlyMap = new Map(), +): boolean { + if (name.endsWith('RecipeVariants')) return false; + if (declaration.type === 'TSInterfaceDeclaration') return true; + if (declaration.type !== 'TSTypeAliasDeclaration') return false; + if (seen.has(name)) return false; + seen.add(name); + + return isObjectAnnotation(astNode(declaration.typeAnnotation), declarations, substitutions, seen); +} + +// Follow a local alias, substituting type parameters. Names outside this module stay object-shaped. +function isObjectAnnotation( + annotation: AstNode | undefined, + declarations: ReadonlyMap, + substitutions: ReadonlyMap, + seen: Set, +): boolean { + if (annotation === undefined) return false; + if ( + annotation.type === 'TSTypeLiteral' || + annotation.type === 'TSIntersectionType' || + annotation.type === 'TSMappedType' + ) { + return true; + } + if (annotation.type !== 'TSTypeReference') return false; + + const name = identifierName(annotation.typeName); + if (name === undefined) return true; + + if (!hasTypeArguments(annotation)) { + const substituted = substitutions.get(name); + if (substituted !== undefined) { + return isObjectAnnotation(substituted, declarations, substitutions, seen); + } + } + + const target = declarations.get(name); + if (target === undefined) return true; + return isObjectType( + target, + name, + declarations, + seen, + bindTypeParameters(target, annotation, substitutions), + ); +} + +function bindTypeParameters( + declaration: AstNode, + reference: AstNode, + substitutions: ReadonlyMap, +): Map { + const names = typeParameterNames(declaration); + const args = nodes(astNode(reference.typeArguments)?.params); + const bound = new Map(); + + for (const [index, name] of names.entries()) { + const argument = args[index]; + if (argument === undefined) continue; + bound.set(name, resolveSubstitutions(argument, substitutions)); + } + + return bound; +} + +function typeParameterNames(declaration: AstNode): ReadonlyArray { + return nodes(astNode(declaration.typeParameters)?.params).flatMap((parameter) => { + const name = identifierName(parameter.name); + return name === undefined ? [] : [name]; + }); +} + +function resolveSubstitutions( + annotation: AstNode, + substitutions: ReadonlyMap, +): AstNode { + const seen = new Set(); + let current = annotation; + + while (current.type === 'TSTypeReference' && !hasTypeArguments(current)) { + const name = identifierName(current.typeName); + if (name === undefined || seen.has(name)) break; + seen.add(name); + const next = substitutions.get(name); + if (next === undefined) break; + current = next; + } + + return current; +} + +function hasTypeArguments(node: AstNode): boolean { + const typeArguments = astNode(node.typeArguments); + return typeArguments !== undefined && nodes(typeArguments.params).length > 0; +} + +function exportedSignatures( + program: AstNode, + publicValues: ReadonlyMap>, +): Array<{ + name: string; + types: ReadonlySet; + unsupported: boolean; +}> { + const signatures: Array<{ name: string; types: ReadonlySet; unsupported: boolean }> = []; + + for (const statement of body(program)) { + if (statement.type !== 'ExportNamedDeclaration') continue; + const declaration = astNode(statement.declaration); + if (declaration === undefined) continue; + + if (declaration.type === 'FunctionDeclaration') { + const name = declarationName(declaration); + if (name !== undefined && publicValues.has(name)) { + signatures.push({ name, types: signatureTypeNames(declaration), unsupported: false }); + } + continue; + } + + if (declaration.type !== 'VariableDeclaration') continue; + for (const declarator of nodes(declaration.declarations)) { + const name = identifierName(declarator.id); + if (name === undefined || !publicValues.has(name)) continue; + const initializer = astNode(declarator.init); + if ( + initializer?.type === 'ArrowFunctionExpression' || + initializer?.type === 'FunctionExpression' + ) { + signatures.push({ name, types: signatureTypeNames(initializer), unsupported: false }); + } else if (isFunctionType(typeAnnotation(astNode(declarator.id)))) { + signatures.push({ name, types: new Set(), unsupported: true }); + } + } + } + + return signatures; +} + +function signatureTypeNames(signature: AstNode): Set { + const names = new Set(); + + for (const parameter of nodes(astNode(signature.typeParameters)?.params)) { + addTypeReferences(names, astNode(parameter.constraint)); + } + for (const parameter of nodes(signature.params)) { + addTypeReferences(names, typeAnnotation(parameter)); + } + addTypeReferences(names, typeAnnotation(astNode(signature.returnType))); + + return names; +} + +function isFunctionType(node: AstNode | undefined): boolean { + return node?.type === 'TSFunctionType' || node?.type === 'TSConstructorType'; +} + +function isInsideDirectory(filePath: string, directory: string): boolean { + const dir = resolve(directory); + const file = resolve(filePath); + return file === dir || file.startsWith(`${dir}${sep}`); +} + +function typeAnnotation(node: AstNode | undefined): AstNode | undefined { + if (node === undefined) return undefined; + if (node.type === 'AssignmentPattern') return typeAnnotation(astNode(node.left)); + if (node.type === 'RestElement') return typeAnnotation(astNode(node.argument)); + const annotation = astNode(node.typeAnnotation); + if (annotation?.type === 'TSTypeAnnotation') return astNode(annotation.typeAnnotation); + return annotation; +} + +function addTypeReferences(names: Set, node: AstNode | undefined): void { + if (node === undefined) return; + if (node.type === 'TSTypeReference') { + const name = identifierName(node.typeName); + if (name !== undefined) names.add(name); + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) addTypeReferences(names, astNode(child)); + } else { + addTypeReferences(names, astNode(value)); + } + } +} + +function declarationKind(declaration: AstNode): 'type' | 'value' { + return declaration.type === 'TSInterfaceDeclaration' || + declaration.type === 'TSTypeAliasDeclaration' + ? 'type' + : 'value'; +} + +function declarationName(declaration: AstNode): string | undefined { + if (declaration.type === 'VariableDeclaration') return undefined; + return identifierName(declaration.id); +} + +function body(program: AstNode): ReadonlyArray { + return nodes(program.body); +} + +function nodes(value: unknown): ReadonlyArray { + return Array.isArray(value) + ? value.flatMap((entry) => (astNode(entry) === undefined ? [] : [entry])) + : []; +} + +function astNode(value: unknown): AstNode | undefined { + if (typeof value !== 'object' || value === null || !('type' in value)) return undefined; + const candidate = value as { type?: unknown }; + return typeof candidate.type === 'string' ? (value as AstNode) : undefined; +} + +function identifierName(value: unknown): string | undefined { + const node = astNode(value); + return node?.type === 'Identifier' ? stringValue(node.name) : undefined; +} + +function literalString(value: unknown): string | undefined { + const node = astNode(value); + return node?.type === 'Literal' ? stringValue(node.value) : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/apps/docs/src/lib/docs-frontmatter.ts b/apps/docs/src/lib/docs-frontmatter.ts index bf8736f6..b660c75c 100644 --- a/apps/docs/src/lib/docs-frontmatter.ts +++ b/apps/docs/src/lib/docs-frontmatter.ts @@ -59,3 +59,66 @@ function readFrontmatterValue(contents: string, key: keyof DocsFrontmatter): str .map((line) => line.trim()) .join(' '); } + +export interface PropsEntry { + name: string; + path: string; +} + +export interface ComponentFrontmatter { + /** Raw frontmatter lines for `description`, `reactAria`, `source`, and `title`, in file order. */ + copiedLines: ReadonlyArray; + props: ReadonlyArray; +} + +export function parseComponentFrontmatter(contents: string): ComponentFrontmatter { + const blocks = parseFrontmatterBlocks(contents); + if (blocks === null) throw new Error('guide is missing frontmatter'); + + const copiedLines: Array = []; + let props: ReadonlyArray = []; + + for (const block of blocks) { + if (block.key === 'props') { + props = parsePropsEntries(block.lines); + continue; + } + if ( + block.key === 'description' || + block.key === 'reactAria' || + block.key === 'source' || + block.key === 'title' + ) { + copiedLines.push(...block.lines); + } + } + + return { copiedLines, props }; +} + +/** Parses a `props:` block's `- name: … / path: …` list items, in file order. */ +function parsePropsEntries(lines: ReadonlyArray): ReadonlyArray { + const entries: Array> = []; + + for (const line of lines) { + const itemMatch = line.match(/^\s*-\s*(\w+):\s*(.+)$/); + const fieldMatch = line.match(/^\s+(\w+):\s*(.+)$/); + + if (itemMatch?.[1] !== undefined && itemMatch[2] !== undefined) { + entries.push({ [itemMatch[1]]: itemMatch[2] }); + continue; + } + + if (fieldMatch?.[1] !== undefined && fieldMatch[2] !== undefined) { + const lastEntry = entries[entries.length - 1]; + if (lastEntry === undefined) throw new Error(`props field with no entry: ${line}`); + Object.assign(lastEntry, { [fieldMatch[1]]: fieldMatch[2] }); + } + } + + return entries.map((entry) => { + if (entry.name === undefined) throw new Error('props entry is missing name'); + if (entry.path === undefined) throw new Error('props entry is missing path'); + return { name: entry.name, path: entry.path }; + }); +} diff --git a/apps/docs/src/lib/generate-props-pages.test.ts b/apps/docs/src/lib/generate-props-pages.test.ts index 2dd5f7b5..eecf7e35 100644 --- a/apps/docs/src/lib/generate-props-pages.test.ts +++ b/apps/docs/src/lib/generate-props-pages.test.ts @@ -11,15 +11,12 @@ import { import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { afterEach, expect, test } from 'vite-plus/test'; -import { - generatePropsPages, - parseComponentFrontmatter, - renderPropsPage, -} from '../../scripts/generate-props-pages.js'; +import { generatePropsPages, renderPropsPage } from '../../scripts/generate-props-pages.js'; +import { parseComponentFrontmatter } from './docs-frontmatter.js'; const componentsDir = resolve(import.meta.dirname, '../../content/docs/components'); -test('renders a single-entry Props page with no sub-headings', () => { +test('renders a single-entry Props page without a type heading', () => { const frontmatter = parseComponentFrontmatter(`--- title: Button description: A labelled control for actions in an interface. @@ -49,22 +46,20 @@ source: packages/@luke-ui/react/src/primitives/field props: - name: FieldProps path: packages/@luke-ui/react/src/primitives/field/field.tsx - heading: Field - name: FieldLabelProps path: packages/@luke-ui/react/src/primitives/field/label.tsx - heading: FieldLabel --- `); const page = renderPropsPage(frontmatter); expect(page).toContain('## Props'); - expect(page).toContain('### Field'); - expect(page).toContain('### FieldLabel'); + expect(page).toContain('### FieldProps'); + expect(page).toContain('### FieldLabelProps'); expect(page).toContain( '', ); expect(page).toContain('name="FieldLabelProps"'); - expect(page.indexOf('### Field')).toBeLessThan(page.indexOf('### FieldLabel')); + expect(page.indexOf('### FieldProps')).toBeLessThan(page.indexOf('### FieldLabelProps')); }); test('the generator has written a Props page for every guide that declares props', () => { diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index fb46a46f..71b93508 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -329,6 +329,14 @@ in the "Primitives" section under components. Component pages may link to their related primitive pages when the normal component API does not fit. Keep primitive pages at the end of the Components area. +`apps/docs/content/docs/components/meta.json` is the source for component category and order. List +every component guide there once. Each category `meta.json` must list the same components as its +slice of that file, in the same order. A leftover category file with no remaining guides or root +entries is stale. A guide's `source` must name a public entry point in +`packages/@luke-ui/react/package.json`, and that directory needs an `index.ts`. `check:docs` +enforces all of this, so a guide cannot leave the navigation or point at a path a developer cannot +import. + ## MDX page structure `/.mdx` is the authored component guide. It keeps the `/components//` URL. @@ -370,7 +378,8 @@ Add only the sections that help a developer use the component. The generated gui primary example and no placeholder prose, and `check:docs` fails on a leftover placeholder. Keep cross-reference sections near the end. Put only the `## Props` section and its -`` content on `props.mdx`. +`` content on `props.mdx`. A page with one table has no extra heading. A page with +several tables uses the type name as a heading above each one. ## API reference diff --git a/packages/turbo-generators/src/component-creation-plan.test.ts b/packages/turbo-generators/src/component-creation-plan.test.ts index 691122a3..a86d9c4e 100644 --- a/packages/turbo-generators/src/component-creation-plan.test.ts +++ b/packages/turbo-generators/src/component-creation-plan.test.ts @@ -1,10 +1,8 @@ import { parseSync } from 'oxc-parser'; import { describe, expect, it } from 'vite-plus/test'; import { ZodError } from 'zod'; -import { - parseComponentFrontmatter, - renderPropsPage, -} from '../../../apps/docs/scripts/generate-props-pages.js'; +import { renderPropsPage } from '../../../apps/docs/scripts/generate-props-pages.js'; +import { parseComponentFrontmatter } from '../../../apps/docs/src/lib/docs-frontmatter.js'; import { COMPONENT_DEFAULTS, createComponentPlan, @@ -105,7 +103,6 @@ describe('createComponentPlan', () => { const frontmatter = parseComponentFrontmatter(guide); expect(frontmatter.props).toEqual([ { - heading: undefined, name: 'StatusBadgeProps', path: 'packages/@luke-ui/react/src/status-badge/status-badge.tsx', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723a2c8a..69e2fea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + oxc-parser: + specifier: 'catalog:' + version: 0.145.0 prettier: specifier: 'catalog:' version: 3.9.6 diff --git a/turbo.json b/turbo.json index 9080666d..8cc925dd 100644 --- a/turbo.json +++ b/turbo.json @@ -56,7 +56,13 @@ }, "check:docs": { "cache": true, - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/docs/**"] + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/docs/**", + "$TURBO_ROOT$/packages/@luke-ui/react/package.json", + "$TURBO_ROOT$/packages/@luke-ui/react/src/**/*.ts", + "$TURBO_ROOT$/packages/@luke-ui/react/src/**/*.tsx" + ] }, "check:format": { "dependsOn": ["generate"]