Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. ui.lukebennett.dev
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368import { 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, findComponentGuideFiles, findGuideNavigationIssues, findGuideSourceIssues, findRepeatedMetadataIssues,} from '../src/lib/component-guide-inventory.js';import { findComponentPropsContractIssues } from '../src/lib/component-props-contract.js';import { findComponentPropsTableTags } from '../src/lib/component-props-table-tags.js';import { findMdxFiles } from '../src/lib/docs-mdx-files.js';import { extractProseForRules, findProseRuleLabels } from '../src/lib/docs-prose-rules.js';import { exampleBlockSources } from '../src/lib/example-block-sources.js';
const MARKDOWN_H2_PATTERN = /^##\s+(.+?)\s*$/;const FENCED_CODE_PATTERN = /```[\s\S]*?```/g;const PROPS_TABLE_TAG_OPEN_PATTERN = /<component-props-table\b/g;
const scriptDir = dirname(fileURLToPath(import.meta.url));const docsAppRoot = resolve(scriptDir, '..');const repoRoot = resolve(docsAppRoot, '../..');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']);
const BEST_PRACTICES = 'Best practices';const ANATOMY = 'Anatomy';const ACCESSIBILITY = 'Accessibility';const API = 'API';const CONTINUE_LEARNING = 'Continue learning';
const FORBIDDEN_COMPONENT_HEADINGS: Readonly<Record<string, string>> = { Primitive: 'link to the primitive inline in prose instead', 'Continue learning': 'Continue learning belongs on authored guides, not component guides', 'Next steps': 'link to the next component inline in prose instead', 'Related components': 'link to other components inline in prose instead',};
export interface DocsCheckPaths { authoredDocsDir: string; componentsDir: string; contentDir: string; internalDocsDir: string; reactPackageDir: string; reactPackageJsonPath: string;}
export const defaultDocsCheckPaths: DocsCheckPaths = { authoredDocsDir, componentsDir, contentDir, internalDocsDir, reactPackageDir, reactPackageJsonPath,};
/** One mechanically checkable docs convention violation. */export function findDocsIssues(paths: DocsCheckPaths = defaultDocsCheckPaths): Array<string> { const issues: Array<string> = []; const componentGuides = findComponentGuideFiles(paths.componentsDir);
issues.push( ...findComponentDocContractIssues({ docsDir: paths.componentsDir }).map( (issue) => `component-doc-contract: ${issue}`, ), ); for (const guide of componentGuides) { issues.push(...findComponentHeadingIssues(guide)); }
for (const file of findAuthoredGuideFiles(paths.authoredDocsDir)) { 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));
return issues;}
export function readBaseline(path: string = baselinePath): Array<string> { if (!existsSync(path)) return []; return readFileSync(path, 'utf8') .split('\n') .flatMap((line) => { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith('#')) return [];
return [trimmed]; });}
export function diffAgainstBaseline( issues: ReadonlyArray<string>, baseline: ReadonlyArray<string>,): { extra: Array<string>; stale: Array<string> } { const issueSet = new Set(issues); const baselineSet = new Set(baseline); return { extra: issues.filter((issue) => !baselineSet.has(issue)), stale: baseline.filter((issue) => !issueSet.has(issue)), };}
function findAuthoredGuideFiles(docsDir: string): Array<{ relativePath: string; source: string }> { if (!existsSync(docsDir)) return []; const resolvedDocsDir = resolve(docsDir); return findMdxFiles(resolvedDocsDir).flatMap((file) => { if (dirname(file) !== resolvedDocsDir) return [];
return [ { relativePath: `docs/${basename(file)}`, source: readFileSync(file, 'utf8'), }, ]; });}
function findComponentHeadingIssues(guide: { group: string; relativePath: string; source: string;}): Array<string> { const issues: Array<string> = []; const headings = markdownH2s(guide.source); const isPrimitive = guide.group === 'primitives'; let seenFeature = false; let seenAccessibility = false; let seenApi = false;
for (const heading of headings) { const replacement = FORBIDDEN_COMPONENT_HEADINGS[heading]; if (replacement !== undefined) { issues.push(`${guide.relativePath}: heading "${heading}" is not allowed (${replacement})`); continue; }
if (heading === ANATOMY && !isPrimitive) { issues.push(`${guide.relativePath}: "${ANATOMY}" is only allowed on primitive guides`); }
if (seenApi) { issues.push(`${guide.relativePath}: "${API}" must be the last heading`); }
if (heading === API) { seenApi = true; continue; }
if (heading === BEST_PRACTICES) { if (seenFeature || seenAccessibility) { issues.push(`${guide.relativePath}: "${BEST_PRACTICES}" must come before feature sections`); } continue; }
if (heading === ANATOMY) { if (seenAccessibility) { issues.push(`${guide.relativePath}: "${ANATOMY}" must come before "${ACCESSIBILITY}"`); } seenFeature = true; continue; }
if (heading === ACCESSIBILITY) { seenAccessibility = true; continue; }
if (seenAccessibility) { issues.push( `${guide.relativePath}: feature section "${heading}" must come before "${ACCESSIBILITY}"`, ); } seenFeature = true; }
if (GROUPS_REQUIRING_ACCESSIBILITY.has(guide.group) && !headings.includes(ACCESSIBILITY)) { issues.push(`${guide.relativePath}: missing required "${ACCESSIBILITY}" heading`); }
issues.push(...findApiSectionIssues(guide));
return issues;}
/** * A component guide always declares `source:`, so it must document its public API: an `## API` * heading holding at least one `<component-props-table>` tag, and no such tag outside that section. */function findApiSectionIssues(guide: { relativePath: string; source: string }): Array<string> { const headings = markdownH2s(guide.source); const tagsInApiSection = findComponentPropsTableTags(guide.source); const tagsAnywhere = [...guide.source.matchAll(PROPS_TABLE_TAG_OPEN_PATTERN)];
if (!headings.includes(API) || tagsInApiSection.length === 0) { return [ `${guide.relativePath}: missing required "${API}" section with a component-props-table`, ]; }
if (tagsAnywhere.length !== tagsInApiSection.length) { return [`${guide.relativePath}: component-props-table must sit under "${API}"`]; }
return [];}
function findContinueLearningIssues(file: { relativePath: string; source: string }): Array<string> { const headings = markdownH2s(file.source); const lastHeading = headings.at(-1); if (lastHeading !== CONTINUE_LEARNING) { return [`${file.relativePath}: last H2 must be "${CONTINUE_LEARNING}" with <Cards>`]; }
const section = lastH2Section(file.source, CONTINUE_LEARNING); if (section === undefined || !section.includes('<Cards>')) { return [`${file.relativePath}: "${CONTINUE_LEARNING}" must contain <Cards>`]; }
return [];}
function findSharedExampleIssues(docsContentDir: string): Array<string> { if (!existsSync(docsContentDir)) return []; const pagesBySrc = new Map<string, Set<string>>();
for (const file of authoredMdxFiles(docsContentDir)) { const relativePath = posixRelative(docsContentDir, file); for (const src of exampleBlockSources(readFileSync(file, 'utf8'))) { const pages = pagesBySrc.get(src) ?? new Set<string>(); pages.add(relativePath); pagesBySrc.set(src, pages); } }
const issues: Array<string> = []; for (const [src, pages] of [...pagesBySrc.entries()].sort(([a], [b]) => a.localeCompare(b))) { if (pages.size < 2) continue; issues.push(`${src}: referenced from more than one page (${[...pages].join(', ')})`); } return issues;}
function findProseIssues(paths: DocsCheckPaths): Array<string> { const issues: Array<string> = [];
for (const file of authoredMdxFiles(paths.contentDir)) { issues.push( ...matchProsePatterns(posixRelative(paths.contentDir, file), readFileSync(file, 'utf8')), ); }
if (!existsSync(paths.internalDocsDir)) return issues;
for (const entry of readdirSync(paths.internalDocsDir, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const file = resolve(paths.internalDocsDir, entry.name); issues.push(...matchProsePatterns(`docs/${entry.name}`, readFileSync(file, 'utf8'))); }
return issues;}
function matchProsePatterns(relativePath: string, source: string): Array<string> { const prose = extractProseForRules(source); return findProseRuleLabels(prose).map((label) => `${relativePath}: ${label}`);}
function authoredMdxFiles(docsContentDir: string): Array<string> { return findMdxFiles(docsContentDir);}
/** H2 titles in document order, ignoring fenced code. */export function markdownH2s(source: string): Array<string> { const headings: Array<string> = []; for (const line of stripFencedCode(source).split('\n')) { const match = MARKDOWN_H2_PATTERN.exec(line); if (match?.[1] !== undefined) headings.push(match[1]); } return headings;}
function lastH2Section(source: string, heading: string): string | undefined { const stripped = stripFencedCode(source); const marker = `\n## ${heading}`; const start = stripped.lastIndexOf(marker); if (start === -1) { return stripped.startsWith(`## ${heading}`) ? stripped : undefined; } return stripped.slice(start);}
function stripFencedCode(source: string): string { return source.replace(FENCED_CODE_PATTERN, '');}
function posixRelative(from: string, to: string): string { return relative(from, to).split('\\').join('/');}
const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) { const issues = findDocsIssues(); const baseline = readBaseline(); const { extra, stale } = diffAgainstBaseline(issues, baseline);
if (extra.length > 0 || stale.length > 0) { if (extra.length > 0) { // oxlint-disable-next-line no-console console.error('Docs convention violations not in the baseline:'); for (const issue of extra) { // oxlint-disable-next-line no-console console.error(` ${issue}`); } } if (stale.length > 0) { // oxlint-disable-next-line no-console console.error('Baseline entries that no longer occur (remove them from the baseline):'); for (const issue of stale) { // oxlint-disable-next-line no-console console.error(` ${issue}`); } } process.exitCode = 1; } else if (issues.length > 0) { // oxlint-disable-next-line no-console console.log(`check:docs: ${issues.length} known violation(s) match the baseline.`); } else { // oxlint-disable-next-line no-console console.log('check:docs: no violations.'); }}