/// /** * The catalogue's builder (UIG-12): it reads the code and writes * `registry.json`. Nothing here is kept by hand, and CI rebuilds it and fails * on a difference, so the file cannot drift from what it describes. * * **Why a parser and not a regex.** This package's components are flat in * `src/`, with `.tsx`, `.mdx`, `.stories.tsx` and sometimes `.test.tsx` side by * side, and **a file is not a component**: eleven files export more than one * name — `Menu.tsx` alone exports seven — and `Skeleton.tsx` exports no * `Skeleton` at all. Pairing by filename gets `FieldLine`, `MenuItem` and every * one of those wrong. The target set is what `src/index.ts` exports, read with * TypeScript's own parser, and everything else is looked up from there. * * TypeScript is the app's own install, as it is for the gate pieces * (`build.mjs`): every package this names stays external. */ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' import ts from 'typescript' import { OWNED_BEHAVIOURS } from '../eslint/index' import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, importsOf, isBaseUi, rootsIn, splitTag } from './behaviours' import { looksOf, parseForLooks } from '../eslint/looks-of' import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema' export interface BuildOptions { /** The repository's top folder. */ root?: string /** The name this registry's entries are recorded under. */ repo?: string } /** One name as `index.ts` exports it. */ interface RawExport { name: string /** The sibling it comes from, without `./`. */ module: string isType: boolean } /** What the parser found above and about one top-level declaration. */ interface Declared { /** The doc comment, cleaned, or `''`. */ doc: string deprecated: boolean /** * Its first parameter's type, as written. Kept as the node and resolved after * the whole file is walked: a props interface is often declared *below* the * component that takes it. */ propsType?: ts.TypeNode } export const PACKAGE_IMPORT = '@estiva-app/ui' /** The editor corner (UIG-31): parts that import Tiptap, kept off the main entry. */ export const EDITOR_IMPORT = '@estiva-app/ui/editor' /** Whether an import names the package: its main entry, or its editor corner. */ export const isPackageImport = (specifier: string) => specifier === PACKAGE_IMPORT || specifier === EDITOR_IMPORT /** * Storybook's own `sanitize`, which is what turns a title and a story's export * name into the id in the URL. Copied rather than imported: `storybook` is a * dev dependency of this repo and must not become a runtime dependency of the * builder, which apps run. A test holds this to the ids a real Storybook build * produces. */ export function sanitize(name: string): string { return name .toLowerCase() .replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>{}[\]\\/]/g, '-') .replace(/-+/g, '-') .replace(/^-+/, '') .replace(/-+$/, '') } /** * Storybook's `storyNameFromExport`, which is lodash's `startCase`: an export * called `WithCounts` is the story "With Counts", and its id is * `navigation-tabs--with-counts`, not `--withcounts`. * * Measured against a real `storybook build` (2026-09-18): without this, 11 of * the 81 ids resolved nowhere — every export whose name is more than one word. * Written out rather than pulled from lodash or `@storybook/csf`, neither of * which may become a runtime dependency of a builder that apps run. */ function storyNameFromExport(key: string): string { return key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .replace(/([a-zA-Z])([0-9])/g, '$1 $2') .replace(/([0-9])([a-zA-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .trim() } export const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}` /** Read `src/index.ts`. Its `export { … } from './Sibling'` statements are the target set. */ export function readIndexExports(source: string): RawExport[] { const file = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true) const found: RawExport[] = [] for (const statement of file.statements) { if (!ts.isExportDeclaration(statement)) continue const from = statement.moduleSpecifier const clause = statement.exportClause if (!from || !ts.isStringLiteral(from) || !clause || !ts.isNamedExports(clause)) continue for (const element of clause.elements) { found.push({ name: element.name.text, module: from.text.replace(/^\.\//, ''), isType: statement.isTypeOnly || element.isTypeOnly }) } } return found } /** * The doc comment immediately above a declaration, cleaned of its markers. * `''` when there is none. * * `headerPos` is where the **file's** own header comment starts, and a * declaration never gets to claim it. Measured: `Skeleton.tsx` opens with a * paragraph about the whole family and `export function SkeletonBar` follows it * directly, so the bar was given the family's description — right-looking, and * wrong. Refusing it turns that into a build failure with the export's name in * it, which is a line somebody then writes. */ function docAbove(source: string, node: ts.Node, headerPos: number | null = null): string { const ranges = ts.getLeadingCommentRanges(source, node.getFullStart()) ?? [] const jsdoc = ranges.filter((range) => source.slice(range.pos, range.pos + 3) === '/**').pop() if (!jsdoc) return '' if (headerPos !== null && jsdoc.pos === headerPos) return '' // Only a comment that sits against the declaration describes it: anything // with another statement between them is about something else. A `//` line // between is still its own — the written note an escape needs sits there, and // TypeScript attaches the doc comment through it too. const between = source.slice(jsdoc.end, node.getStart()) if (between.replace(/\/\/[^\n]*/g, '').trim() !== '') return '' return source .slice(jsdoc.pos, jsdoc.end) .split(/\r?\n/) .map((line) => line.trim().replace(/^\/\*\*/, '').replace(/\*\/$/, '').replace(/^\*/, '').trim()) .join('\n') .trim() } /** * The first sentence of a block of prose, which is the purpose. * * A sentence ends at `.`, `!` or `?` followed by a space and something that * starts a new one. A very short first sentence takes the next as well — * "A list of actions from a trigger." is a purpose; "One row, two parts." is * not, on its own. */ export function firstSentence(prose: string): string { const flat = prose .split(/\n\s*\n/)[0] .split('\n') .filter((line) => !line.trim().startsWith('@')) .join(' ') .replace(/\*\*/g, '') .replace(/\s+/g, ' ') .trim() const parts = flat.split(/(?<=[.!?])\s+(?=[A-Z`'"‘“])/) let line = parts[0] ?? '' if (line.length < 40 && parts[1]) line = `${line} ${parts[1]}` return line.trim() } /** A page's opening paragraph: everything between its `# Heading` and the first blank line. */ export function pageOpening(mdx: string): string { const lines = mdx.split(/\r?\n/) const heading = lines.findIndex((line) => /^#\s+\S/.test(line)) if (heading < 0) return '' const paragraph: string[] = [] for (const line of lines.slice(heading + 1)) { const text = line.trim() if (text === '') { if (paragraph.length) break continue } // A canvas, an import or the next heading: the opening paragraph is over, // or there never was one. if (text.startsWith('<') || text.startsWith('#')) break paragraph.push(text) } return paragraph.join(' ') } /** * A property's name, however it is written. * * **A quoted name is a name.** `'aria-label'?: string` cannot be written as an * identifier, so a reader that takes identifiers only drops it — and with it * every accessible name this package declares. Found by comparing against * `react-docgen`, a parser with none of this one's assumptions: 13 props over * 10 files, two of them required (`Reaction`, `Toolbar`). */ function propName(member: ts.TypeElement): string | null { if (!member.name) return null if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) return member.name.text return null } /** A union of string literals, following one local type alias if the property points at one. */ function literalUnion(type: ts.TypeNode | undefined, aliases: Map, seen = new Set()): string[] | null { if (!type) return null if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { const name = type.typeName.text if (seen.has(name)) return null seen.add(name) const alias = aliases.get(name) return alias ? literalUnion(alias, aliases, seen) : null } if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) return [type.literal.text] if (!ts.isUnionTypeNode(type)) return null const values: string[] = [] for (const member of type.types) { // `'small' | 'default' | undefined` is still a set of two words. if (member.kind === ts.SyntaxKind.UndefinedKeyword || member.kind === ts.SyntaxKind.NullKeyword) continue const inner = literalUnion(member, aliases, seen) if (!inner) return null values.push(...inner) } return values.length > 1 ? values : null } /** A type’s finished props and word-choices, read by the module that declares them. */ export interface Resolved { props: EntryProp[] variants: EntryVariant[] } /** * Ask another file for a type it declares, already resolved. `specifier` is the * import as written (`./IconButton`, `@/lib/types`); the caller decides what it * points at, and answers `undefined` for anything outside the library it reads. */ export type Sibling = (specifier: string, typeName: string) => Resolved | undefined export interface ReadOptions { /** * Whether a file with no comment of its own at the top lends the comment on * its first declaration to the file. The package: yes — `Skeleton.tsx`'s * family paragraph sits there, and taking it as `SkeletonBar`'s was wrong. An * app: no — a comment directly above a part is that part's, which is what a * person writing one expects; a file's own goes at the very top. */ lendFirstComment?: boolean } export function readModule(source: string, sibling: Sibling = () => undefined, { lendFirstComment = true }: ReadOptions = {}) { const file = ts.createSourceFile('module.tsx', source, ts.ScriptTarget.Latest, true) const declarations = new Map() const aliases = new Map() const shapes = new Map() /** What each interface extends, as written — `Omit` and all. */ const bases = new Map() /** Which module each imported name came from. */ const importedFrom = new Map() const propsTypeOf = (parameters: readonly ts.ParameterDeclaration[]): ts.TypeNode | undefined => parameters[0]?.type /** * The props type of `const X = …`. * * A plain function expression carries it on its first parameter. `forwardRef` * carries it as its **second type argument** and leaves the inner function's * parameter bare — `TextInput`, whose `size` was missed until this read it. */ const propsOfInitializer = (initializer: ts.Expression | undefined): ts.TypeNode | undefined => { if (!initializer) return undefined if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return propsTypeOf(initializer.parameters) if (ts.isCallExpression(initializer)) { const callee = ts.isPropertyAccessExpression(initializer.expression) ? initializer.expression.name.text : ts.isIdentifier(initializer.expression) ? initializer.expression.text : '' if (callee === 'forwardRef' && initializer.typeArguments?.[1]) return initializer.typeArguments[1] // `memo(function X({ … }: Props) { … })` and anything else that wraps a // function written out at the call. const wrapped = initializer.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) if (wrapped) return propsTypeOf((wrapped as ts.ArrowFunction | ts.FunctionExpression).parameters) } return undefined } /** * `const Row: FC = ({ … }) => …` leaves the parameter bare: the * props are the annotation's type argument — `FC`, `FunctionComponent`, * `React.FC`, `React.FunctionComponent`. */ const propsOfAnnotation = (type: ts.TypeNode | undefined): ts.TypeNode | undefined => { if (!type || !ts.isTypeReferenceNode(type)) return undefined const named = ts.isIdentifier(type.typeName) ? type.typeName.text : type.typeName.right.text return named === 'FC' || named === 'FunctionComponent' ? type.typeArguments?.[0] : undefined } // The file's header comment: the first `/**` above the first thing that is // not an import. It belongs to the file, whatever export happens to follow it. const opening = file.statements.find((statement) => !ts.isImportDeclaration(statement)) const headerPos = opening ? ((ts.getLeadingCommentRanges(source, opening.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null // A file that opens with its own comment, above its imports — Peek's and Ship's // way — has its header there, so the comment on its first declaration is that // declaration's own. Only a file with no such comment lends the first one it has. const topPos = file.statements[0] ? ((ts.getLeadingCommentRanges(source, file.statements[0].getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null // An app lends no comment to the file but one above its imports: in a file // with no imports the first comment sits on the first part, and is the part's. const aboveImports = !!file.statements[0] && ts.isImportDeclaration(file.statements[0]) const refused = lendFirstComment ? (topPos ?? headerPos) : aboveImports ? topPos : null for (const statement of file.statements) { if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { const bindings = statement.importClause?.namedBindings if (bindings && ts.isNamedImports(bindings)) { for (const element of bindings.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text }) } } // `export type { TopicRowProps } from './TopicRow'` hands a type on without // declaring it: a file that asks this one for it is sent on to the next. if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) && statement.exportClause && ts.isNamedExports(statement.exportClause)) { for (const element of statement.exportClause.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text }) } if (ts.isTypeAliasDeclaration(statement)) aliases.set(statement.name.text, statement.type) if (ts.isInterfaceDeclaration(statement)) { shapes.set(statement.name.text, [...statement.members]) // The whole clause, not its name: `extends Omit` reads as the name `Omit` and loses both the type it wraps // and the key it drops. const extended = (statement.heritageClauses ?? []).flatMap((clause) => [...clause.types]) if (extended.length) bases.set(statement.name.text, extended) } if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) shapes.set(statement.name.text, [...statement.type.members]) // Every top-level function, class and constant, exported or not. The package // exports at the declaration, so for it the two are the same; an app also // writes `function Row() {…}` and `export { Row }` or `export default Row` // further down, and the name is still read here, with its comment and props. if (ts.isClassDeclaration(statement)) { // `class ErrorBoundary extends Component`: its props are the // first type argument of what it extends. `export default class extends …` // has no name of its own, and is kept under `default`. const base = (statement.heritageClauses ?? []).find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0] const name = statement.name?.text ?? (ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ? 'default' : null) if (name) declarations.set(name, { doc: docAbove(source, statement, refused), deprecated: false, propsType: base?.typeArguments?.[0] }) continue } // `export default memo(() => …)` and `export default function () {…}`: a part // an app writes as its file's default, with no name of its own. Kept under // `default`, with its comment and its props. if (ts.isExportAssignment(statement) && !statement.isExportEquals && !ts.isIdentifier(statement.expression)) { declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsOfInitializer(statement.expression) }) continue } if (ts.isFunctionDeclaration(statement) && !statement.name && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) { declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsTypeOf(statement.parameters) }) continue } if (!ts.isFunctionDeclaration(statement) && !ts.isVariableStatement(statement)) continue const doc = docAbove(source, statement, refused) const deprecated = /(^|\n)@deprecated\b/.test(doc) || /\*\s*@deprecated\b/.test(source.slice(Math.max(0, statement.getFullStart()), statement.getStart())) if (ts.isFunctionDeclaration(statement) && statement.name) { // Overloads: the first signature carries the comment and the props a caller // sees; a later one, or the body, only fills in a comment the first lacks. const held = declarations.get(statement.name.text) if (held) { if (!held.doc && doc) held.doc = doc continue } declarations.set(statement.name.text, { doc, deprecated, propsType: propsTypeOf(statement.parameters) }) continue } if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name)) continue declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) ?? propsOfAnnotation(declaration.type) }) } } } /** * A type's props and its word-choices, **resolved in the file that declares * them**. * * This is the whole shape of the thing, and the reason for it is a bug that * looked like nothing: a `ts.TypeElement` carries positions into *its own* * source text, so handing a sibling's node to this file's `getText`, `aliases` * and `docAbove` reads the wrong file at those offsets. `ToolbarButton` came * out with `variant: "ats over what it a"` and `children: "omeAndEndK"` — * real prop names, and slices of another file for their types. Nothing caught * it: the names were right, and the cross-check against `react-docgen` * compared names. * * So a sibling never returns nodes. It returns finished props, read by the * module that owns them, and this file only merges them. */ const EMPTY: Resolved = { props: [], variants: [] } /** The keys named by an `Omit`/`Pick` argument: `'a'` or `'a' | 'b'`. */ const keysOf = (type: ts.TypeNode | undefined): string[] | null => { if (!type) return null if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) return [type.literal.text] if (!ts.isUnionTypeNode(type)) return null const keys: string[] = [] for (const member of type.types) { if (!ts.isLiteralTypeNode(member) || !ts.isStringLiteral(member.literal)) return null keys.push(member.literal.text) } return keys } /** `Omit` drops the keys it names, `Pick` keeps only them. Unreadable keys change nothing. */ const narrow = (resolved: Resolved, how: 'Omit' | 'Pick', keys: string[] | null): Resolved => { if (!keys) return resolved const named = new Set(keys) const keep = (name: string) => (how === 'Omit' ? !named.has(name) : named.has(name)) return { props: resolved.props.filter((prop) => keep(prop.name)), variants: resolved.variants.filter((variant) => keep(variant.prop)) } } /** The first of each name wins, so what a type declares itself beats what it inherits. */ const merge = (parts: Resolved[]): Resolved => { const props: EntryProp[] = [] const variants: EntryVariant[] = [] const seenProp = new Set() const seenVariant = new Set() for (const part of parts) { for (const prop of part.props) if (!seenProp.has(prop.name)) (seenProp.add(prop.name), props.push(prop)) for (const variant of part.variants) if (!seenVariant.has(variant.prop)) (seenVariant.add(variant.prop), variants.push(variant)) } return { props, variants } } /** * What a prop takes, in a word a person reads. The type as written is the * fallback, not the answer: `(next: string) => void` tells a reader nothing * they cannot guess, and "a handler" tells them what to pass. */ const takes = (type: ts.TypeNode | undefined, values: string[] | null): string => { if (values) return values.join(' | ') if (!type) return 'anything' const written = type.getText(file).replace(/\s+/g, ' ').trim() if (written === 'boolean') return 'true/false' if (written === 'number') return 'number' if (written === 'string') return 'text' if (/^React(Node|Element)\b/.test(written)) return 'anything' if (written.includes('=>')) return 'a handler' return written.length > 60 ? `${written.slice(0, 57)}…` : written } /** Members of this file, read with this file's text and aliases. */ const fromMembers = (members: readonly ts.TypeElement[]): Resolved => { const props: EntryProp[] = [] const variants: EntryVariant[] = [] for (const member of members) { const name = propName(member) if (name === null || !ts.isPropertySignature(member)) continue const values = literalUnion(member.type, aliases) const note = firstSentence(docAbove(source, member)) props.push({ name, takes: takes(member.type, values), required: !member.questionToken, note: note || null }) if (values) variants.push({ prop: name, values }) } return { props, variants } } const resolveNode = (type: ts.TypeNode | undefined, seen: Set): Resolved => { if (!type || seen.size > 12) return EMPTY // `{ tone?: 'primary' | 'secondary' }` written out at the parameter. if (ts.isTypeLiteralNode(type)) return fromMembers(type.members) if (ts.isIntersectionTypeNode(type)) return merge(type.types.map((side) => resolveNode(side, seen))) if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { const named = type.typeName.text if (named === 'Omit' || named === 'Pick') { return narrow(resolveNode(type.typeArguments?.[0], seen), named, keysOf(type.typeArguments?.[1])) } return resolveName(named, seen) } return EMPTY } /** A heritage clause is not a `TypeNode`, and carries the same `Omit<…>` shapes. */ const resolveBase = (base: ts.ExpressionWithTypeArguments, seen: Set): Resolved => { if (!ts.isIdentifier(base.expression)) return EMPTY const named = base.expression.text if (named === 'Omit' || named === 'Pick') { return narrow(resolveNode(base.typeArguments?.[0], seen), named, keysOf(base.typeArguments?.[1])) } return resolveName(named, seen) } /** * A named type: its own props, plus everything it extends **within this * package**. * * It stops at React and Base UI on purpose. `ToolbarButtonProps extends * IconButtonProps` is ours, and `variant`, `pressed` and `tooltip` are things * `ToolbarButton` genuinely takes; `ButtonProps extends * ComponentPropsWithRef<'button'>` is the DOM, and listing `onCopy` and * `spellCheck` would bury the answer. The rule is where the type is declared, * not what it is called. */ function resolveName(name: string, seen: Set): Resolved { if (seen.has(name) || seen.size > 12) return EMPTY seen.add(name) const own = shapes.get(name) if (own) { const inherited = (bases.get(name) ?? []).map((base) => resolveBase(base, seen)) return merge([fromMembers(own), ...inherited]) } // `type ToolbarInputProps = TextInputProps` — an alias, not an interface. const alias = aliases.get(name) if (alias) return resolveNode(alias, seen) // Not declared here at all. Follow the import; the caller says where it may go. const from = importedFrom.get(name) return (from ? sibling(from.specifier, from.imported) : undefined) ?? EMPTY } /** What one declaration's first parameter takes. */ const resolve = (propsType: ts.TypeNode | undefined): Resolved => resolveNode(propsType, new Set()) // The file's own description, for an app's file of one part: the first `/**` // at the very top, above the imports — where Peek and Ship write it — or else // the one directly above the first thing that is not an import. const jsdocAt = (node: ts.Node | undefined) => (node ? (ts.getLeadingCommentRanges(source, node.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**') : undefined) const headerRange = jsdocAt(file.statements[0]) ?? jsdocAt(opening) return { declarations, headerDoc: headerRange ? headerComment(source, headerRange) : '', resolve, resolveName: (name: string) => resolveName(name, new Set()) } } /** The file's own header comment, cleaned the way `docAbove` cleans a declaration's. */ function headerComment(source: string, range: ts.CommentRange): string { return source .slice(range.pos, range.end) .split(/\r?\n/) .map((line) => line.trim().replace(/^\/\*\*/, '').replace(/\*\/$/, '').replace(/^\*/, '').trim()) .join('\n') .trim() } /** The `title` a stories file gives Storybook, and the stories it exports. */ export function readStories(source: string): { title: string | null; stories: string[] } { const file = ts.createSourceFile('x.stories.tsx', source, ts.ScriptTarget.Latest, true) let title: string | null = null const stories: string[] = [] for (const statement of file.statements) { if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name)) continue const initializer = declaration.initializer const object = initializer && ts.isSatisfiesExpression(initializer) ? initializer.expression : initializer if (declaration.name.text === 'meta' && object && ts.isObjectLiteralExpression(object)) { for (const property of object.properties) { if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'title' && ts.isStringLiteral(property.initializer)) { title = property.initializer.text } } } const exported = ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) if (exported && declaration.name.text !== 'meta') stories.push(declaration.name.text) } } } return { title, stories } } const isAllCaps = (name: string) => /^[A-Z0-9_]+$/.test(name) /** What a name is. A constant and a function are both helpers; only a `use…` is a hook. */ function kindOf(name: string): EntryKind { if (/^use[A-Z]/.test(name)) return 'hook' return /^[A-Z]/.test(name) && !isAllCaps(name) ? 'component' : 'helper' } /** What UIG-8's enumeration says this component owns. */ function behavioursOf(name: string): EntryBehaviour[] { return OWNED_BEHAVIOURS.filter((owned) => owned.owners.includes(name)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour })) } /** * Read the repository and return the catalogue. * * Throws on anything it cannot explain — a missing sibling, a name with no * purpose anywhere. A registry that quietly drops what it could not read is * worse than no registry, because a reader cannot tell the difference between * "we have nothing for that" and "the builder gave up". */ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: BuildOptions = {}): Registry { const src = join(root, 'src') const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name: string; version: string } const files = new Set(readdirSync(src)) // Two entries: the main one, and the editor corner beside it (UIG-31), whose // parts are imported from their own path. const fromEntry = (file: string, importPath: string) => existsSync(join(src, file)) ? readIndexExports(readFileSync(join(src, file), 'utf8')).map((entry) => ({ ...entry, importPath })) : [] const exported = [...fromEntry('index.ts', PACKAGE_IMPORT), ...fromEntry('editor.ts', EDITOR_IMPORT)] const values = exported.filter((entry) => !entry.isType) type Module = ReturnType & { file: string } const modules = new Map() const moduleOf = (name: string): Module => { const held = modules.get(name) if (held) return held const file = ['.tsx', '.ts'].map((extension) => `${name}${extension}`).find((candidate) => files.has(candidate)) if (!file) throw new Error(`src/index.ts exports from './${name}', which is not a file in src/`) // The callback lets one file's props type reach a type declared in another // — `ToolbarButtonProps extends IconButtonProps`. It is only called later, // by which time this module is in the cache, so the recursion terminates. const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (specifier, typeName) => (specifier.startsWith('./') ? moduleOf(specifier.slice(2)).resolveName(typeName) : undefined)), file: `src/${file}` } modules.set(name, read) return read } const problems: string[] = [] const entries: RegistryEntry[] = [] // What each part looks like (UIG-25), read once per file. const looksCache = new Map>() const looksFor = (file: string, name: string) => { if (!looksCache.has(file)) looksCache.set(file, parseForLooks(join(src, file.replace(/^src\//, '')), readFileSync(join(src, file.replace(/^src\//, '')), 'utf8'))) return looksOf(looksCache.get(file)!, [name]).get(name) ?? [] } for (const value of values) { const module = moduleOf(value.module) const declared = module.declarations.get(value.name) if (!declared) { problems.push(`${value.name} is exported from './${value.module}' but is not declared there`) continue } // A name with a page of its own takes its purpose from the page; the rest // are documented on a sibling's page, and take it from the comment above // the export. Every one of them has one — the build fails below if not. const pageFile = files.has(`${value.name}.mdx`) ? `src/${value.name}.mdx` : null const fromPage = pageFile ? firstSentence(pageOpening(readFileSync(join(root, pageFile), 'utf8'))) : '' const fromComment = firstSentence(declared.doc) const purpose = fromPage || fromComment if (!purpose) { problems.push(`${value.name} has no purpose: no opening paragraph on its page, and no doc comment above it in ${module.file}`) continue } // A name with stories of its own uses them; otherwise the sibling's, which // is the page that documents it. const storiesFile = files.has(`${value.name}.stories.tsx`) ? `${value.name}.stories.tsx` : files.has(`${value.module}.stories.tsx`) ? `${value.module}.stories.tsx` : null const stories = storiesFile ? readStories(readFileSync(join(src, storiesFile), 'utf8')) : { title: null, stories: [] } const title = stories.title const story = stories.stories.includes(value.name) ? value.name : stories.stories[0] const shape = module.resolve(declared.propsType) entries.push({ name: value.name, repo, kind: kindOf(value.name), importPath: value.importPath, sourceFile: module.file, purpose, purposeFrom: fromPage ? 'page' : 'comment', props: shape.props, variants: shape.variants, ownsBehaviours: behavioursOf(value.name), status: declared.deprecated ? 'deprecated' : 'stable', migrationStage: null, docsId: title ? `${sanitize(title)}--docs` : null, storyId: title && story ? toId(title, story) : null, docPage: pageFile, app: null, looks: kindOf(value.name) === 'component' ? looksFor(module.file, value.name) : [], }) } if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`) // What each part owns, from what it draws (B9): the Base UI parts in it, and // every part of the package inside it, followed; UIG-8's list is the floor. const moduleByName = new Map(values.map((v) => [v.name, v.module])) const drawing = new Map>; roots: Map>; imports: ReturnType }>() const drawingOf = (moduleName: string) => { if (!drawing.has(moduleName)) { const file = moduleOf(moduleName).file const sf = ts.createSourceFile(file, readFileSync(join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS) drawing.set(moduleName, { drawn: drawnIn(sf), roots: rootsIn(sf), imports: importsOf(sf) }) } return drawing.get(moduleName)! } const owns = (name: string, seen: Set): Set => { const out = new Set(behavioursOf(name).map((b) => b.id)) const moduleName = moduleByName.get(name) if (!moduleName || seen.has(name)) return out seen.add(name) const { drawn, roots, imports } = drawingOf(moduleName) // The Base UI parts it draws, anywhere in it. for (const tag of drawn.get(name) ?? []) { const [local, piece] = splitTag(tag) const from = imports.get(local) if (from && isBaseUi(from.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id) } // The parts it is: what it hands back at its root. for (const tag of roots.get(name) ?? []) { const [local] = splitTag(tag) const from = imports.get(local) const part = from?.specifier.startsWith('./') ? from.imported : !from && local !== name ? local : undefined if (part && moduleByName.has(part)) for (const id of handedOn(part, owns(part, seen))) out.add(id) } return out } for (const entry of entries) if (entry.kind === 'component') entry.ownsBehaviours = asBehaviours(owns(entry.name, new Set())) entries.sort((a, b) => a.name.localeCompare(b.name)) return { schemaVersion: SCHEMA_VERSION, builtFrom: { kind: 'package', repo, package: manifest.name, packageVersion: manifest.version, exports: values.length, typeExports: exported.length - values.length, files: null, }, storybook: { docsPath: '/?path=/docs/{docsId}', storyPath: '/?path=/story/{storyId}', devUrl: 'http://localhost:6008', }, entries, // Nothing is excluded: every value export is an entry, helpers and the one // hook included, each marked by `kind`. The field stays because the count // has to reconcile out loud — see validateRegistry. excluded: [], filesWithoutParts: [], } } /** The committed file's text, so a check and a write produce the same bytes. */ export function serializeRegistry(registry: Registry): string { return `${JSON.stringify(registry, null, 2)}\n` }