/// /** * The hand-written map of a Storybook, held against the Storybook it describes * (moved here from Peek's own status checks, audit A3; widened by the re-review * after it, R16). * * A repository may keep two maps of its sidebar: the order of its top-level * headings in `.storybook/preview.tsx` — a `const HEADINGS = [...]` list read by * a `storySort` function (Peek), or `storySort: { order: [...] }` (Ship, this * package) — and the Introduction page, which names each heading, in a table row * (`| **Folders** | … |`, Peek) or a bullet (`- **Primitives · Inputs** — …`, * Ship), and in an app states how many parts are worth reusing. Both are written * by hand and both describe something that moves, so both rot: Peek's named a * Huddles heading for a day after its stories were deleted and said 49 reusable * parts when the catalogue built 41; Ship's named a Feedback heading it does not * have and left out four it does. * * The real headings are read from Storybook's own index when the command has * one, so a title written any way at all counts; otherwise from the story files. * Every check runs only when the repository states the thing it checks. */ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import type { Registry } from './schema' const PREVIEW = '.storybook/preview.tsx' const INTRODUCTIONS = ['src/stories/Introduction.mdx', 'stories/Introduction.mdx'] /** The classes an app's Introduction counts as "worth reusing". */ const WORTH_REUSING = ['reusable', 'promote-candidate'] function storyFiles(root: string): string[] { const out: string[] = [] const walk = (dir: string) => { if (!existsSync(dir)) return for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue const path = join(dir, entry.name) if (entry.isDirectory()) walk(path) else if (entry.name.endsWith('.stories.tsx') || entry.name.endsWith('.mdx')) out.push(path) } } walk(join(root, 'src')) walk(join(root, 'stories')) return out } /** Every top-level heading the sidebar has, read from the story files: the first segment of each title. */ export function sidebarHeadings(root: string): Set { const real = new Set() for (const file of storyFiles(root)) { const text = readFileSync(file, 'utf8') const start = text.indexOf('const meta') const meta = start === -1 ? '' : text.slice(start, text.indexOf('satisfies Meta', start) + 1 || undefined) const title = meta.match(/\btitle: *['"]([^'"]+)['"]/) ?? text.match(/): Set { return new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title.split('/')[0].trim()] : []))) } /** The strings at the top level of the array that starts at `open` (the index of its `[`), skipping nested arrays. */ function topLevelStrings(text: string, open: number): string[] { const out: string[] = [] let depth = 0 for (let i = open; i < text.length; i++) { const c = text[i] if (c === '/' && text[i + 1] === '*') { const end = text.indexOf('*/', i + 2) if (end < 0) break i = end + 1 } else if (c === '/' && text[i + 1] === '/') i = text.indexOf('\n', i) else if (c === '[') depth++ else if (c === ']') { if (--depth === 0) return out } else if (c === "'" || c === '"') { const end = text.indexOf(c, i + 1) if (depth === 1) out.push(text.slice(i + 1, end)) i = end } if (i < 0) break } return out } /** The headings the preview orders — a `HEADINGS` list or `storySort: { order: [...] }` — or null when it keeps no order. */ export function orderedHeadings(previewText: string): string[] | null { const list = /const HEADINGS = \[/.exec(previewText) ?? /\border:\s*\[/.exec(previewText) if (!list) return null return topLevelStrings(previewText, list.index + list[0].length - 1) } /** * The headings the Introduction names: a table row that opens with a bold name, * or a bullet that opens with bold names joined by " · ". */ export function introductionHeadings(introText: string): Set { const rows = [...introText.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gm)].map((m) => m[1]) const bullets = [...introText.matchAll(/^[-*]\s+\*\*([^*]+)\*\*\s*[—–-]/gm)].flatMap((m) => m[1].split('·')) return new Set([...rows, ...bullets].map((h) => h.trim()).filter(Boolean)) } /** The number the Introduction states, or null when it states none. */ export function introductionCount(introText: string): number | null { const said = introText.match(/\*\*(\d+) parts worth reusing\*\*/) return said ? Number(said[1]) : null } /** * What is wrong with the repository's hand-written map of its Storybook; empty * when nothing is. `real` is the sidebar's top-level headings from Storybook's * index, when the caller has it; `registry` is needed only for an app's count. */ export function storyMapProblems(root: string, { registry, real = sidebarHeadings(root) }: { registry?: Registry; real?: Set } = {}): string[] { const problems: string[] = [] const previewPath = join(root, PREVIEW) if (existsSync(previewPath)) { const ordered = orderedHeadings(readFileSync(previewPath, 'utf8')) if (ordered) { const unordered = [...real].filter((r) => !ordered.includes(r)) const empty = ordered.filter((o) => !real.has(o)) if (unordered.length) problems.push(`${PREVIEW}: not in the heading order, so they fall to the bottom of the sidebar: ${unordered.join(', ')}`) if (empty.length) problems.push(`${PREVIEW}: in the heading order but no story uses them: ${empty.join(', ')}`) } } const intro = INTRODUCTIONS.find((p) => existsSync(join(root, p))) if (intro) { const text = readFileSync(join(root, intro), 'utf8') const named = introductionHeadings(text) if (named.size) { const missing = [...real].filter((r) => !named.has(r)) const stale = [...named].filter((n) => !real.has(n)) if (missing.length) problems.push(`${intro}: the sidebar has, the page does not name: ${missing.join(', ')}`) if (stale.length) problems.push(`${intro}: the page names, the sidebar does not have: ${stale.join(', ')}`) } const said = introductionCount(text) if (said !== null && registry) { const reusable = registry.entries.filter((entry) => WORTH_REUSING.includes(entry.app?.class ?? '')).length if (said !== reusable) problems.push(`${intro}: says ${said} parts worth reusing; the catalogue builds ${reusable}`) } } return problems }