Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. ui.lukebennett.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273import { existsSync, readdirSync, readFileSync } from 'node:fs';import { basename, dirname, relative, resolve } from 'node:path';import { findComponentPropsTableTags } from './component-props-table-tags.js';import { readFrontmatter } from './docs-frontmatter.js';import { findMdxFiles } from './docs-mdx-files.js';
const EXPORTS_PREFIX = 'packages/@luke-ui/react/src/exports/';
const MDX_EXTENSION_PATTERN = /\.mdx$/;
/** 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<ComponentGuide>; metadataEntries: ReadonlyArray<string>; packageExports: ReadonlyArray<string>;}
export interface ComponentGuideInventoryInput { componentsDir: string; guides: ReadonlyArray<{ group: string; relativePath: string; source: string }>; reactPackageJsonPath: string;}
/** Component guides with a public `source` frontmatter entry, in the same shape `check:docs` uses. */export function findComponentGuideFiles(componentsDir: string): Array<{ group: string; relativePath: string; source: string;}> { if (!existsSync(componentsDir)) return [];
const resolvedComponentsDir = resolve(componentsDir); return findMdxFiles(resolvedComponentsDir).flatMap((file) => { if (dirname(dirname(file)) !== resolvedComponentsDir) return [];
const source = readFileSync(file, 'utf8'); if (readFrontmatter(source).source === undefined) return [];
return [ { group: basename(dirname(file)), relativePath: posixRelative(resolvedComponentsDir, file), source, }, ]; });}
/** 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 name = guide.relativePath.replace(MDX_EXTENSION_PATTERN, '').split('/').at(-1) ?? ''; return { props: findComponentPropsTableTags(guide.source), relativePath: guide.relativePath, slug: `${guide.group}/${name}`, 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<string> { const issues: Array<string> = []; 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<string> { const seen = new Set<string>(); const reported = new Set<string>(); const issues: Array<string> = [];
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 `<name>` 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<string> { const issues: Array<string> = []; const expectedByGroup = new Map<string, Array<string>>();
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 <group>/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<string> { 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 its exports module, so a documented * component is one a developer can import. */export function findGuideSourceIssues( inventory: ComponentGuideInventory, reactPackageDir: string,): Array<string> { const issues: Array<string> = []; const exportSet = new Set(inventory.packageExports);
for (const guide of inventory.guides) { const { relativePath, source } = guide; if (source === undefined) continue;
if (!source.startsWith(EXPORTS_PREFIX)) { issues.push( `${relativePath}: source "${source}" is not a public package entry point (expected a path under ${EXPORTS_PREFIX})`, ); continue; }
const exportPath = source.slice(EXPORTS_PREFIX.length); if (!exportPath.endsWith('.ts')) { issues.push(`${relativePath}: source "${source}" must name an exports .ts module`); continue; }
const subpath = exportPath.slice(0, -'.ts'.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 sourcePath = resolve(reactPackageDir, 'src', 'exports', exportPath); if (!existsSync(sourcePath)) { issues.push(`${relativePath}: source "${source}" does not exist`); } }
return issues;}
function readRootMetadata(componentsDir: string): Array<string> { const metadataEntries: Array<string> = []; 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<string> { 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<string> { 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<string>, expected: ReadonlyArray<string>): boolean { return ( actual.length === expected.length && actual.every((page, index) => page === expected[index]) );}
function formatList(pages: ReadonlyArray<string>): string { return `[${pages.join(', ')}]`;}
function posixRelative(from: string, to: string): string { return relative(from, to).split('\\').join('/');}