@estiva-app/ui 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +21 -0
  2. package/dist/Avatar.d.ts +3 -0
  3. package/dist/Avatar.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts +3 -0
  7. package/dist/ChipInput.d.ts.map +1 -1
  8. package/dist/CommandPalette.d.ts +6 -0
  9. package/dist/CommandPalette.d.ts.map +1 -1
  10. package/dist/ContainerHeader.d.ts +25 -0
  11. package/dist/ContainerHeader.d.ts.map +1 -0
  12. package/dist/EmptyState.d.ts +10 -1
  13. package/dist/EmptyState.d.ts.map +1 -1
  14. package/dist/IdentityMenu.d.ts +3 -0
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Menu.d.ts +7 -0
  17. package/dist/Menu.d.ts.map +1 -1
  18. package/dist/Skeleton.d.ts +2 -0
  19. package/dist/Skeleton.d.ts.map +1 -1
  20. package/dist/Toast.d.ts +5 -0
  21. package/dist/Toast.d.ts.map +1 -1
  22. package/dist/Toolbar.d.ts +4 -0
  23. package/dist/Toolbar.d.ts.map +1 -1
  24. package/dist/Tooltip.d.ts +4 -0
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/gates/app-checks.d.ts.map +1 -1
  27. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  28. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  29. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  30. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  31. package/dist/gates/cli.js +1 -1
  32. package/dist/gates/create-app.d.ts +2 -0
  33. package/dist/gates/create-app.d.ts.map +1 -1
  34. package/dist/gates/create-app.js +1 -1
  35. package/dist/gates/index.js +25 -3
  36. package/dist/gates/index.js.map +2 -2
  37. package/dist/gates/status.d.ts +2 -0
  38. package/dist/gates/status.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +69 -48
  42. package/dist/index.js.map +4 -4
  43. package/dist/registry/app-DQI6WLHR.js +10 -0
  44. package/dist/registry/app-DQI6WLHR.js.map +7 -0
  45. package/dist/registry/app.d.ts +21 -0
  46. package/dist/registry/app.d.ts.map +1 -0
  47. package/dist/registry/build-LGFCCOLR.js +27 -0
  48. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  49. package/dist/registry/build.d.ts +95 -0
  50. package/dist/registry/build.d.ts.map +1 -0
  51. package/dist/registry/chunk-E4JNV7PC.js +155 -0
  52. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  53. package/dist/registry/chunk-NJN4MQAM.js +397 -0
  54. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  55. package/dist/registry/chunk-W2B2G7OE.js +188 -0
  56. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  57. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  58. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  59. package/dist/registry/cli.d.ts +2 -0
  60. package/dist/registry/cli.d.ts.map +1 -0
  61. package/dist/registry/cli.js +176 -0
  62. package/dist/registry/cli.js.map +7 -0
  63. package/dist/registry/find.d.ts +54 -0
  64. package/dist/registry/find.d.ts.map +1 -0
  65. package/dist/registry/index.d.ts +17 -0
  66. package/dist/registry/index.d.ts.map +1 -0
  67. package/dist/registry/index.js +34 -0
  68. package/dist/registry/index.js.map +7 -0
  69. package/dist/registry/schema.d.ts +224 -0
  70. package/dist/registry/schema.d.ts.map +1 -0
  71. package/package.json +15 -2
  72. package/registry.json +4615 -0
  73. package/src/Avatar.tsx +3 -0
  74. package/src/Banner.mdx +17 -4
  75. package/src/Banner.stories.tsx +22 -0
  76. package/src/Banner.test.tsx +35 -0
  77. package/src/Banner.tsx +34 -9
  78. package/src/ChipInput.tsx +3 -0
  79. package/src/CommandPalette.tsx +6 -0
  80. package/src/ContainerHeader.mdx +56 -0
  81. package/src/ContainerHeader.stories.tsx +62 -0
  82. package/src/ContainerHeader.test.tsx +47 -0
  83. package/src/ContainerHeader.tsx +45 -0
  84. package/src/EmptyState.mdx +12 -0
  85. package/src/EmptyState.stories.tsx +6 -1
  86. package/src/EmptyState.test.tsx +18 -1
  87. package/src/EmptyState.tsx +14 -1
  88. package/src/IdentityMenu.tsx +3 -0
  89. package/src/Menu.tsx +7 -0
  90. package/src/Skeleton.tsx +2 -0
  91. package/src/Toast.tsx +5 -0
  92. package/src/Toolbar.tsx +4 -0
  93. package/src/Tooltip.tsx +4 -0
  94. package/src/gates/app-checks.ts +23 -1
  95. package/src/gates/create-app.test.ts +77 -6
  96. package/src/gates/create-app.ts +288 -15
  97. package/src/gates/status.ts +16 -0
  98. package/src/index.ts +1 -0
  99. package/src/registry/app.test.ts +562 -0
  100. package/src/registry/app.ts +854 -0
  101. package/src/registry/build.ts +679 -0
  102. package/src/registry/cli.ts +204 -0
  103. package/src/registry/find.ts +232 -0
  104. package/src/registry/index.ts +31 -0
  105. package/src/registry/registry.test.ts +448 -0
  106. package/src/registry/schema.ts +419 -0
  107. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  108. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
@@ -0,0 +1,679 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The catalogue's builder (UIG-12): it reads the code and writes
4
+ * `registry.json`. Nothing here is kept by hand, and CI rebuilds it and fails
5
+ * on a difference, so the file cannot drift from what it describes.
6
+ *
7
+ * **Why a parser and not a regex.** This package's components are flat in
8
+ * `src/`, with `.tsx`, `.mdx`, `.stories.tsx` and sometimes `.test.tsx` side by
9
+ * side, and **a file is not a component**: eleven files export more than one
10
+ * name — `Menu.tsx` alone exports seven — and `Skeleton.tsx` exports no
11
+ * `Skeleton` at all. Pairing by filename gets `FieldLine`, `MenuItem` and every
12
+ * one of those wrong. The target set is what `src/index.ts` exports, read with
13
+ * TypeScript's own parser, and everything else is looked up from there.
14
+ *
15
+ * TypeScript is the app's own install, as it is for the gate pieces
16
+ * (`build.mjs`): every package this names stays external.
17
+ */
18
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import ts from 'typescript'
21
+ import { OWNED_BEHAVIOURS } from '../eslint/index'
22
+ import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
23
+
24
+ export interface BuildOptions {
25
+ /** The repository's top folder. */
26
+ root?: string
27
+ /** The name this registry's entries are recorded under. */
28
+ repo?: string
29
+ }
30
+
31
+ /** One name as `index.ts` exports it. */
32
+ interface RawExport {
33
+ name: string
34
+ /** The sibling it comes from, without `./`. */
35
+ module: string
36
+ isType: boolean
37
+ }
38
+
39
+ /** What the parser found above and about one top-level declaration. */
40
+ interface Declared {
41
+ /** The doc comment, cleaned, or `''`. */
42
+ doc: string
43
+ deprecated: boolean
44
+ /**
45
+ * Its first parameter's type, as written. Kept as the node and resolved after
46
+ * the whole file is walked: a props interface is often declared *below* the
47
+ * component that takes it.
48
+ */
49
+ propsType?: ts.TypeNode
50
+ }
51
+
52
+ export const PACKAGE_IMPORT = '@estiva-app/ui'
53
+
54
+ /**
55
+ * Storybook's own `sanitize`, which is what turns a title and a story's export
56
+ * name into the id in the URL. Copied rather than imported: `storybook` is a
57
+ * dev dependency of this repo and must not become a runtime dependency of the
58
+ * builder, which apps run. A test holds this to the ids a real Storybook build
59
+ * produces.
60
+ */
61
+ export function sanitize(name: string): string {
62
+ return name
63
+ .toLowerCase()
64
+ .replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>{}[\]\\/]/g, '-')
65
+ .replace(/-+/g, '-')
66
+ .replace(/^-+/, '')
67
+ .replace(/-+$/, '')
68
+ }
69
+
70
+ /**
71
+ * Storybook's `storyNameFromExport`, which is lodash's `startCase`: an export
72
+ * called `WithCounts` is the story "With Counts", and its id is
73
+ * `navigation-tabs--with-counts`, not `--withcounts`.
74
+ *
75
+ * Measured against a real `storybook build` (2026-09-18): without this, 11 of
76
+ * the 81 ids resolved nowhere — every export whose name is more than one word.
77
+ * Written out rather than pulled from lodash or `@storybook/csf`, neither of
78
+ * which may become a runtime dependency of a builder that apps run.
79
+ */
80
+ function storyNameFromExport(key: string): string {
81
+ return key
82
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
83
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
84
+ .replace(/([a-zA-Z])([0-9])/g, '$1 $2')
85
+ .replace(/([0-9])([a-zA-Z])/g, '$1 $2')
86
+ .replace(/[_-]+/g, ' ')
87
+ .trim()
88
+ }
89
+
90
+ export const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}`
91
+
92
+ /** Read `src/index.ts`. Its `export { … } from './Sibling'` statements are the target set. */
93
+ export function readIndexExports(source: string): RawExport[] {
94
+ const file = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true)
95
+ const found: RawExport[] = []
96
+ for (const statement of file.statements) {
97
+ if (!ts.isExportDeclaration(statement)) continue
98
+ const from = statement.moduleSpecifier
99
+ const clause = statement.exportClause
100
+ if (!from || !ts.isStringLiteral(from) || !clause || !ts.isNamedExports(clause)) continue
101
+ for (const element of clause.elements) {
102
+ found.push({ name: element.name.text, module: from.text.replace(/^\.\//, ''), isType: statement.isTypeOnly || element.isTypeOnly })
103
+ }
104
+ }
105
+ return found
106
+ }
107
+
108
+ /**
109
+ * The doc comment immediately above a declaration, cleaned of its markers.
110
+ * `''` when there is none.
111
+ *
112
+ * `headerPos` is where the **file's** own header comment starts, and a
113
+ * declaration never gets to claim it. Measured: `Skeleton.tsx` opens with a
114
+ * paragraph about the whole family and `export function SkeletonBar` follows it
115
+ * directly, so the bar was given the family's description — right-looking, and
116
+ * wrong. Refusing it turns that into a build failure with the export's name in
117
+ * it, which is a line somebody then writes.
118
+ */
119
+ function docAbove(source: string, node: ts.Node, headerPos: number | null = null): string {
120
+ const ranges = ts.getLeadingCommentRanges(source, node.getFullStart()) ?? []
121
+ const jsdoc = ranges.filter((range) => source.slice(range.pos, range.pos + 3) === '/**').pop()
122
+ if (!jsdoc) return ''
123
+ if (headerPos !== null && jsdoc.pos === headerPos) return ''
124
+ // Only a comment that sits against the declaration describes it: anything
125
+ // with another statement between them is about something else. A `//` line
126
+ // between is still its own — the written note an escape needs sits there, and
127
+ // TypeScript attaches the doc comment through it too.
128
+ const between = source.slice(jsdoc.end, node.getStart())
129
+ if (between.replace(/\/\/[^\n]*/g, '').trim() !== '') return ''
130
+ return source
131
+ .slice(jsdoc.pos, jsdoc.end)
132
+ .split(/\r?\n/)
133
+ .map((line) => line.trim().replace(/^\/\*\*/, '').replace(/\*\/$/, '').replace(/^\*/, '').trim())
134
+ .join('\n')
135
+ .trim()
136
+ }
137
+
138
+ /**
139
+ * The first sentence of a block of prose, which is the purpose.
140
+ *
141
+ * A sentence ends at `.`, `!` or `?` followed by a space and something that
142
+ * starts a new one. A very short first sentence takes the next as well —
143
+ * "A list of actions from a trigger." is a purpose; "One row, two parts." is
144
+ * not, on its own.
145
+ */
146
+ export function firstSentence(prose: string): string {
147
+ const flat = prose
148
+ .split(/\n\s*\n/)[0]
149
+ .split('\n')
150
+ .filter((line) => !line.trim().startsWith('@'))
151
+ .join(' ')
152
+ .replace(/\*\*/g, '')
153
+ .replace(/\s+/g, ' ')
154
+ .trim()
155
+ const parts = flat.split(/(?<=[.!?])\s+(?=[A-Z`'"‘“])/)
156
+ let line = parts[0] ?? ''
157
+ if (line.length < 40 && parts[1]) line = `${line} ${parts[1]}`
158
+ return line.trim()
159
+ }
160
+
161
+ /** A page's opening paragraph: everything between its `# Heading` and the first blank line. */
162
+ export function pageOpening(mdx: string): string {
163
+ const lines = mdx.split(/\r?\n/)
164
+ const heading = lines.findIndex((line) => /^#\s+\S/.test(line))
165
+ if (heading < 0) return ''
166
+ const paragraph: string[] = []
167
+ for (const line of lines.slice(heading + 1)) {
168
+ const text = line.trim()
169
+ if (text === '') {
170
+ if (paragraph.length) break
171
+ continue
172
+ }
173
+ // A canvas, an import or the next heading: the opening paragraph is over,
174
+ // or there never was one.
175
+ if (text.startsWith('<') || text.startsWith('#')) break
176
+ paragraph.push(text)
177
+ }
178
+ return paragraph.join(' ')
179
+ }
180
+
181
+ /**
182
+ * A property's name, however it is written.
183
+ *
184
+ * **A quoted name is a name.** `'aria-label'?: string` cannot be written as an
185
+ * identifier, so a reader that takes identifiers only drops it — and with it
186
+ * every accessible name this package declares. Found by comparing against
187
+ * `react-docgen`, a parser with none of this one's assumptions: 13 props over
188
+ * 10 files, two of them required (`Reaction`, `Toolbar`).
189
+ */
190
+ function propName(member: ts.TypeElement): string | null {
191
+ if (!member.name) return null
192
+ if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) return member.name.text
193
+ return null
194
+ }
195
+
196
+ /** A union of string literals, following one local type alias if the property points at one. */
197
+ function literalUnion(type: ts.TypeNode | undefined, aliases: Map<string, ts.TypeNode>, seen = new Set<string>()): string[] | null {
198
+ if (!type) return null
199
+ if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
200
+ const name = type.typeName.text
201
+ if (seen.has(name)) return null
202
+ seen.add(name)
203
+ const alias = aliases.get(name)
204
+ return alias ? literalUnion(alias, aliases, seen) : null
205
+ }
206
+ if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) return [type.literal.text]
207
+ if (!ts.isUnionTypeNode(type)) return null
208
+ const values: string[] = []
209
+ for (const member of type.types) {
210
+ // `'small' | 'default' | undefined` is still a set of two words.
211
+ if (member.kind === ts.SyntaxKind.UndefinedKeyword || member.kind === ts.SyntaxKind.NullKeyword) continue
212
+ const inner = literalUnion(member, aliases, seen)
213
+ if (!inner) return null
214
+ values.push(...inner)
215
+ }
216
+ return values.length > 1 ? values : null
217
+ }
218
+
219
+ /** A type’s finished props and word-choices, read by the module that declares them. */
220
+ export interface Resolved {
221
+ props: EntryProp[]
222
+ variants: EntryVariant[]
223
+ }
224
+
225
+ /**
226
+ * Ask another file for a type it declares, already resolved. `specifier` is the
227
+ * import as written (`./IconButton`, `@/lib/types`); the caller decides what it
228
+ * points at, and answers `undefined` for anything outside the library it reads.
229
+ */
230
+ export type Sibling = (specifier: string, typeName: string) => Resolved | undefined
231
+
232
+ export interface ReadOptions {
233
+ /**
234
+ * Whether a file with no comment of its own at the top lends the comment on
235
+ * its first declaration to the file. The package: yes — `Skeleton.tsx`'s
236
+ * family paragraph sits there, and taking it as `SkeletonBar`'s was wrong. An
237
+ * app: no — a comment directly above a part is that part's, which is what a
238
+ * person writing one expects; a file's own goes at the very top.
239
+ */
240
+ lendFirstComment?: boolean
241
+ }
242
+
243
+ export function readModule(source: string, sibling: Sibling = () => undefined, { lendFirstComment = true }: ReadOptions = {}) {
244
+ const file = ts.createSourceFile('module.tsx', source, ts.ScriptTarget.Latest, true)
245
+ const declarations = new Map<string, Declared>()
246
+ const aliases = new Map<string, ts.TypeNode>()
247
+ const shapes = new Map<string, ts.TypeElement[]>()
248
+ /** What each interface extends, as written — `Omit<IdentityMenuProps, 'compact'>` and all. */
249
+ const bases = new Map<string, ts.ExpressionWithTypeArguments[]>()
250
+ /** Which module each imported name came from. */
251
+ const importedFrom = new Map<string, { specifier: string; imported: string }>()
252
+
253
+ const propsTypeOf = (parameters: readonly ts.ParameterDeclaration[]): ts.TypeNode | undefined => parameters[0]?.type
254
+
255
+ /**
256
+ * The props type of `const X = …`.
257
+ *
258
+ * A plain function expression carries it on its first parameter. `forwardRef`
259
+ * carries it as its **second type argument** and leaves the inner function's
260
+ * parameter bare — `TextInput`, whose `size` was missed until this read it.
261
+ */
262
+ const propsOfInitializer = (initializer: ts.Expression | undefined): ts.TypeNode | undefined => {
263
+ if (!initializer) return undefined
264
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return propsTypeOf(initializer.parameters)
265
+ if (ts.isCallExpression(initializer)) {
266
+ const callee = ts.isPropertyAccessExpression(initializer.expression) ? initializer.expression.name.text : ts.isIdentifier(initializer.expression) ? initializer.expression.text : ''
267
+ if (callee === 'forwardRef' && initializer.typeArguments?.[1]) return initializer.typeArguments[1]
268
+ // `memo(function X({ … }: Props) { … })` and anything else that wraps a
269
+ // function written out at the call.
270
+ const wrapped = initializer.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument))
271
+ if (wrapped) return propsTypeOf((wrapped as ts.ArrowFunction | ts.FunctionExpression).parameters)
272
+ }
273
+ return undefined
274
+ }
275
+
276
+ /**
277
+ * `const Row: FC<RowProps> = ({ … }) => …` leaves the parameter bare: the
278
+ * props are the annotation's type argument — `FC`, `FunctionComponent`,
279
+ * `React.FC`, `React.FunctionComponent`.
280
+ */
281
+ const propsOfAnnotation = (type: ts.TypeNode | undefined): ts.TypeNode | undefined => {
282
+ if (!type || !ts.isTypeReferenceNode(type)) return undefined
283
+ const named = ts.isIdentifier(type.typeName) ? type.typeName.text : type.typeName.right.text
284
+ return named === 'FC' || named === 'FunctionComponent' ? type.typeArguments?.[0] : undefined
285
+ }
286
+
287
+ // The file's header comment: the first `/**` above the first thing that is
288
+ // not an import. It belongs to the file, whatever export happens to follow it.
289
+ const opening = file.statements.find((statement) => !ts.isImportDeclaration(statement))
290
+ const headerPos = opening ? ((ts.getLeadingCommentRanges(source, opening.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null
291
+ // A file that opens with its own comment, above its imports — Peek's and Ship's
292
+ // way — has its header there, so the comment on its first declaration is that
293
+ // declaration's own. Only a file with no such comment lends the first one it has.
294
+ const topPos = file.statements[0] ? ((ts.getLeadingCommentRanges(source, file.statements[0].getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null
295
+ // An app lends no comment to the file but one above its imports: in a file
296
+ // with no imports the first comment sits on the first part, and is the part's.
297
+ const aboveImports = !!file.statements[0] && ts.isImportDeclaration(file.statements[0])
298
+ const refused = lendFirstComment ? (topPos ?? headerPos) : aboveImports ? topPos : null
299
+
300
+ for (const statement of file.statements) {
301
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
302
+ const bindings = statement.importClause?.namedBindings
303
+ if (bindings && ts.isNamedImports(bindings)) {
304
+ for (const element of bindings.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text })
305
+ }
306
+ }
307
+ // `export type { TopicRowProps } from './TopicRow'` hands a type on without
308
+ // declaring it: a file that asks this one for it is sent on to the next.
309
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
310
+ for (const element of statement.exportClause.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text })
311
+ }
312
+ if (ts.isTypeAliasDeclaration(statement)) aliases.set(statement.name.text, statement.type)
313
+ if (ts.isInterfaceDeclaration(statement)) {
314
+ shapes.set(statement.name.text, [...statement.members])
315
+ // The whole clause, not its name: `extends Omit<IdentityMenuProps,
316
+ // 'compact'>` reads as the name `Omit` and loses both the type it wraps
317
+ // and the key it drops.
318
+ const extended = (statement.heritageClauses ?? []).flatMap((clause) => [...clause.types])
319
+ if (extended.length) bases.set(statement.name.text, extended)
320
+ }
321
+ if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) shapes.set(statement.name.text, [...statement.type.members])
322
+
323
+ // Every top-level function, class and constant, exported or not. The package
324
+ // exports at the declaration, so for it the two are the same; an app also
325
+ // writes `function Row() {…}` and `export { Row }` or `export default Row`
326
+ // further down, and the name is still read here, with its comment and props.
327
+ if (ts.isClassDeclaration(statement)) {
328
+ // `class ErrorBoundary extends Component<Props, State>`: its props are the
329
+ // first type argument of what it extends. `export default class extends …`
330
+ // has no name of its own, and is kept under `default`.
331
+ const base = (statement.heritageClauses ?? []).find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
332
+ const name = statement.name?.text ?? (ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ? 'default' : null)
333
+ if (name) declarations.set(name, { doc: docAbove(source, statement, refused), deprecated: false, propsType: base?.typeArguments?.[0] })
334
+ continue
335
+ }
336
+ // `export default memo(() => …)` and `export default function () {…}`: a part
337
+ // an app writes as its file's default, with no name of its own. Kept under
338
+ // `default`, with its comment and its props.
339
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals && !ts.isIdentifier(statement.expression)) {
340
+ declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsOfInitializer(statement.expression) })
341
+ continue
342
+ }
343
+ if (ts.isFunctionDeclaration(statement) && !statement.name && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
344
+ declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsTypeOf(statement.parameters) })
345
+ continue
346
+ }
347
+ if (!ts.isFunctionDeclaration(statement) && !ts.isVariableStatement(statement)) continue
348
+ const doc = docAbove(source, statement, refused)
349
+ const deprecated = /(^|\n)@deprecated\b/.test(doc) || /\*\s*@deprecated\b/.test(source.slice(Math.max(0, statement.getFullStart()), statement.getStart()))
350
+
351
+ if (ts.isFunctionDeclaration(statement) && statement.name) {
352
+ // Overloads: the first signature carries the comment and the props a caller
353
+ // sees; a later one, or the body, only fills in a comment the first lacks.
354
+ const held = declarations.get(statement.name.text)
355
+ if (held) {
356
+ if (!held.doc && doc) held.doc = doc
357
+ continue
358
+ }
359
+ declarations.set(statement.name.text, { doc, deprecated, propsType: propsTypeOf(statement.parameters) })
360
+ continue
361
+ }
362
+ if (ts.isVariableStatement(statement)) {
363
+ for (const declaration of statement.declarationList.declarations) {
364
+ if (!ts.isIdentifier(declaration.name)) continue
365
+ declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) ?? propsOfAnnotation(declaration.type) })
366
+ }
367
+ }
368
+ }
369
+ /**
370
+ * A type's props and its word-choices, **resolved in the file that declares
371
+ * them**.
372
+ *
373
+ * This is the whole shape of the thing, and the reason for it is a bug that
374
+ * looked like nothing: a `ts.TypeElement` carries positions into *its own*
375
+ * source text, so handing a sibling's node to this file's `getText`, `aliases`
376
+ * and `docAbove` reads the wrong file at those offsets. `ToolbarButton` came
377
+ * out with `variant: "ats over what it a"` and `children: "omeAndEndK"` —
378
+ * real prop names, and slices of another file for their types. Nothing caught
379
+ * it: the names were right, and the cross-check against `react-docgen`
380
+ * compared names.
381
+ *
382
+ * So a sibling never returns nodes. It returns finished props, read by the
383
+ * module that owns them, and this file only merges them.
384
+ */
385
+ const EMPTY: Resolved = { props: [], variants: [] }
386
+
387
+ /** The keys named by an `Omit`/`Pick` argument: `'a'` or `'a' | 'b'`. */
388
+ const keysOf = (type: ts.TypeNode | undefined): string[] | null => {
389
+ if (!type) return null
390
+ if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) return [type.literal.text]
391
+ if (!ts.isUnionTypeNode(type)) return null
392
+ const keys: string[] = []
393
+ for (const member of type.types) {
394
+ if (!ts.isLiteralTypeNode(member) || !ts.isStringLiteral(member.literal)) return null
395
+ keys.push(member.literal.text)
396
+ }
397
+ return keys
398
+ }
399
+
400
+ /** `Omit` drops the keys it names, `Pick` keeps only them. Unreadable keys change nothing. */
401
+ const narrow = (resolved: Resolved, how: 'Omit' | 'Pick', keys: string[] | null): Resolved => {
402
+ if (!keys) return resolved
403
+ const named = new Set(keys)
404
+ const keep = (name: string) => (how === 'Omit' ? !named.has(name) : named.has(name))
405
+ return { props: resolved.props.filter((prop) => keep(prop.name)), variants: resolved.variants.filter((variant) => keep(variant.prop)) }
406
+ }
407
+
408
+ /** The first of each name wins, so what a type declares itself beats what it inherits. */
409
+ const merge = (parts: Resolved[]): Resolved => {
410
+ const props: EntryProp[] = []
411
+ const variants: EntryVariant[] = []
412
+ const seenProp = new Set<string>()
413
+ const seenVariant = new Set<string>()
414
+ for (const part of parts) {
415
+ for (const prop of part.props) if (!seenProp.has(prop.name)) (seenProp.add(prop.name), props.push(prop))
416
+ for (const variant of part.variants) if (!seenVariant.has(variant.prop)) (seenVariant.add(variant.prop), variants.push(variant))
417
+ }
418
+ return { props, variants }
419
+ }
420
+
421
+ /**
422
+ * What a prop takes, in a word a person reads. The type as written is the
423
+ * fallback, not the answer: `(next: string) => void` tells a reader nothing
424
+ * they cannot guess, and "a handler" tells them what to pass.
425
+ */
426
+ const takes = (type: ts.TypeNode | undefined, values: string[] | null): string => {
427
+ if (values) return values.join(' | ')
428
+ if (!type) return 'anything'
429
+ const written = type.getText(file).replace(/\s+/g, ' ').trim()
430
+ if (written === 'boolean') return 'true/false'
431
+ if (written === 'number') return 'number'
432
+ if (written === 'string') return 'text'
433
+ if (/^React(Node|Element)\b/.test(written)) return 'anything'
434
+ if (written.includes('=>')) return 'a handler'
435
+ return written.length > 60 ? `${written.slice(0, 57)}…` : written
436
+ }
437
+
438
+ /** Members of this file, read with this file's text and aliases. */
439
+ const fromMembers = (members: readonly ts.TypeElement[]): Resolved => {
440
+ const props: EntryProp[] = []
441
+ const variants: EntryVariant[] = []
442
+ for (const member of members) {
443
+ const name = propName(member)
444
+ if (name === null || !ts.isPropertySignature(member)) continue
445
+ const values = literalUnion(member.type, aliases)
446
+ const note = firstSentence(docAbove(source, member))
447
+ props.push({ name, takes: takes(member.type, values), required: !member.questionToken, note: note || null })
448
+ if (values) variants.push({ prop: name, values })
449
+ }
450
+ return { props, variants }
451
+ }
452
+
453
+ const resolveNode = (type: ts.TypeNode | undefined, seen: Set<string>): Resolved => {
454
+ if (!type || seen.size > 12) return EMPTY
455
+ // `{ tone?: 'primary' | 'secondary' }` written out at the parameter.
456
+ if (ts.isTypeLiteralNode(type)) return fromMembers(type.members)
457
+ if (ts.isIntersectionTypeNode(type)) return merge(type.types.map((side) => resolveNode(side, seen)))
458
+ if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
459
+ const named = type.typeName.text
460
+ if (named === 'Omit' || named === 'Pick') {
461
+ return narrow(resolveNode(type.typeArguments?.[0], seen), named, keysOf(type.typeArguments?.[1]))
462
+ }
463
+ return resolveName(named, seen)
464
+ }
465
+ return EMPTY
466
+ }
467
+
468
+ /** A heritage clause is not a `TypeNode`, and carries the same `Omit<…>` shapes. */
469
+ const resolveBase = (base: ts.ExpressionWithTypeArguments, seen: Set<string>): Resolved => {
470
+ if (!ts.isIdentifier(base.expression)) return EMPTY
471
+ const named = base.expression.text
472
+ if (named === 'Omit' || named === 'Pick') {
473
+ return narrow(resolveNode(base.typeArguments?.[0], seen), named, keysOf(base.typeArguments?.[1]))
474
+ }
475
+ return resolveName(named, seen)
476
+ }
477
+
478
+ /**
479
+ * A named type: its own props, plus everything it extends **within this
480
+ * package**.
481
+ *
482
+ * It stops at React and Base UI on purpose. `ToolbarButtonProps extends
483
+ * IconButtonProps` is ours, and `variant`, `pressed` and `tooltip` are things
484
+ * `ToolbarButton` genuinely takes; `ButtonProps extends
485
+ * ComponentPropsWithRef<'button'>` is the DOM, and listing `onCopy` and
486
+ * `spellCheck` would bury the answer. The rule is where the type is declared,
487
+ * not what it is called.
488
+ */
489
+ function resolveName(name: string, seen: Set<string>): Resolved {
490
+ if (seen.has(name) || seen.size > 12) return EMPTY
491
+ seen.add(name)
492
+ const own = shapes.get(name)
493
+ if (own) {
494
+ const inherited = (bases.get(name) ?? []).map((base) => resolveBase(base, seen))
495
+ return merge([fromMembers(own), ...inherited])
496
+ }
497
+ // `type ToolbarInputProps = TextInputProps` — an alias, not an interface.
498
+ const alias = aliases.get(name)
499
+ if (alias) return resolveNode(alias, seen)
500
+ // Not declared here at all. Follow the import; the caller says where it may go.
501
+ const from = importedFrom.get(name)
502
+ return (from ? sibling(from.specifier, from.imported) : undefined) ?? EMPTY
503
+ }
504
+
505
+ /** What one declaration's first parameter takes. */
506
+ const resolve = (propsType: ts.TypeNode | undefined): Resolved => resolveNode(propsType, new Set())
507
+
508
+ // The file's own description, for an app's file of one part: the first `/**`
509
+ // at the very top, above the imports — where Peek and Ship write it — or else
510
+ // the one directly above the first thing that is not an import.
511
+ const jsdocAt = (node: ts.Node | undefined) => (node ? (ts.getLeadingCommentRanges(source, node.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**') : undefined)
512
+ const headerRange = jsdocAt(file.statements[0]) ?? jsdocAt(opening)
513
+ return { declarations, headerDoc: headerRange ? headerComment(source, headerRange) : '', resolve, resolveName: (name: string) => resolveName(name, new Set()) }
514
+ }
515
+
516
+ /** The file's own header comment, cleaned the way `docAbove` cleans a declaration's. */
517
+ function headerComment(source: string, range: ts.CommentRange): string {
518
+ return source
519
+ .slice(range.pos, range.end)
520
+ .split(/\r?\n/)
521
+ .map((line) => line.trim().replace(/^\/\*\*/, '').replace(/\*\/$/, '').replace(/^\*/, '').trim())
522
+ .join('\n')
523
+ .trim()
524
+ }
525
+
526
+ /** The `title` a stories file gives Storybook, and the stories it exports. */
527
+ export function readStories(source: string): { title: string | null; stories: string[] } {
528
+ const file = ts.createSourceFile('x.stories.tsx', source, ts.ScriptTarget.Latest, true)
529
+ let title: string | null = null
530
+ const stories: string[] = []
531
+ for (const statement of file.statements) {
532
+ if (ts.isVariableStatement(statement)) {
533
+ for (const declaration of statement.declarationList.declarations) {
534
+ if (!ts.isIdentifier(declaration.name)) continue
535
+ const initializer = declaration.initializer
536
+ const object = initializer && ts.isSatisfiesExpression(initializer) ? initializer.expression : initializer
537
+ if (declaration.name.text === 'meta' && object && ts.isObjectLiteralExpression(object)) {
538
+ for (const property of object.properties) {
539
+ if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'title' && ts.isStringLiteral(property.initializer)) {
540
+ title = property.initializer.text
541
+ }
542
+ }
543
+ }
544
+ const exported = ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
545
+ if (exported && declaration.name.text !== 'meta') stories.push(declaration.name.text)
546
+ }
547
+ }
548
+ }
549
+ return { title, stories }
550
+ }
551
+
552
+ const isAllCaps = (name: string) => /^[A-Z0-9_]+$/.test(name)
553
+
554
+ /** What a name is. A constant and a function are both helpers; only a `use…` is a hook. */
555
+ function kindOf(name: string): EntryKind {
556
+ if (/^use[A-Z]/.test(name)) return 'hook'
557
+ return /^[A-Z]/.test(name) && !isAllCaps(name) ? 'component' : 'helper'
558
+ }
559
+
560
+ /** What UIG-8's enumeration says this component owns. */
561
+ function behavioursOf(name: string): EntryBehaviour[] {
562
+ return OWNED_BEHAVIOURS.filter((owned) => owned.owners.includes(name)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour }))
563
+ }
564
+
565
+ /**
566
+ * Read the repository and return the catalogue.
567
+ *
568
+ * Throws on anything it cannot explain — a missing sibling, a name with no
569
+ * purpose anywhere. A registry that quietly drops what it could not read is
570
+ * worse than no registry, because a reader cannot tell the difference between
571
+ * "we have nothing for that" and "the builder gave up".
572
+ */
573
+ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: BuildOptions = {}): Registry {
574
+ const src = join(root, 'src')
575
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name: string; version: string }
576
+ const files = new Set(readdirSync(src))
577
+ const exported = readIndexExports(readFileSync(join(src, 'index.ts'), 'utf8'))
578
+ const values = exported.filter((entry) => !entry.isType)
579
+
580
+ type Module = ReturnType<typeof readModule> & { file: string }
581
+ const modules = new Map<string, Module>()
582
+ const moduleOf = (name: string): Module => {
583
+ const held = modules.get(name)
584
+ if (held) return held
585
+ const file = ['.tsx', '.ts'].map((extension) => `${name}${extension}`).find((candidate) => files.has(candidate))
586
+ if (!file) throw new Error(`src/index.ts exports from './${name}', which is not a file in src/`)
587
+ // The callback lets one file's props type reach a type declared in another
588
+ // — `ToolbarButtonProps extends IconButtonProps`. It is only called later,
589
+ // by which time this module is in the cache, so the recursion terminates.
590
+ const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (specifier, typeName) => (specifier.startsWith('./') ? moduleOf(specifier.slice(2)).resolveName(typeName) : undefined)), file: `src/${file}` }
591
+ modules.set(name, read)
592
+ return read
593
+ }
594
+
595
+ const problems: string[] = []
596
+ const entries: RegistryEntry[] = []
597
+
598
+ for (const value of values) {
599
+ const module = moduleOf(value.module)
600
+ const declared = module.declarations.get(value.name)
601
+ if (!declared) {
602
+ problems.push(`${value.name} is exported from './${value.module}' but is not declared there`)
603
+ continue
604
+ }
605
+
606
+ // A name with a page of its own takes its purpose from the page; the rest
607
+ // are documented on a sibling's page, and take it from the comment above
608
+ // the export. Every one of them has one — the build fails below if not.
609
+ const pageFile = files.has(`${value.name}.mdx`) ? `src/${value.name}.mdx` : null
610
+ const fromPage = pageFile ? firstSentence(pageOpening(readFileSync(join(root, pageFile), 'utf8'))) : ''
611
+ const fromComment = firstSentence(declared.doc)
612
+ const purpose = fromPage || fromComment
613
+ if (!purpose) {
614
+ problems.push(`${value.name} has no purpose: no opening paragraph on its page, and no doc comment above it in ${module.file}`)
615
+ continue
616
+ }
617
+
618
+ // A name with stories of its own uses them; otherwise the sibling's, which
619
+ // is the page that documents it.
620
+ const storiesFile = files.has(`${value.name}.stories.tsx`) ? `${value.name}.stories.tsx` : files.has(`${value.module}.stories.tsx`) ? `${value.module}.stories.tsx` : null
621
+ const stories = storiesFile ? readStories(readFileSync(join(src, storiesFile), 'utf8')) : { title: null, stories: [] }
622
+ const title = stories.title
623
+ const story = stories.stories.includes(value.name) ? value.name : stories.stories[0]
624
+
625
+ const shape = module.resolve(declared.propsType)
626
+
627
+ entries.push({
628
+ name: value.name,
629
+ repo,
630
+ kind: kindOf(value.name),
631
+ importPath: PACKAGE_IMPORT,
632
+ sourceFile: module.file,
633
+ purpose,
634
+ purposeFrom: fromPage ? 'page' : 'comment',
635
+ props: shape.props,
636
+ variants: shape.variants,
637
+ ownsBehaviours: behavioursOf(value.name),
638
+ status: declared.deprecated ? 'deprecated' : 'stable',
639
+ migrationStage: null,
640
+ docsId: title ? `${sanitize(title)}--docs` : null,
641
+ storyId: title && story ? toId(title, story) : null,
642
+ docPage: pageFile,
643
+ app: null,
644
+ })
645
+ }
646
+
647
+ if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`)
648
+
649
+ entries.sort((a, b) => a.name.localeCompare(b.name))
650
+
651
+ return {
652
+ schemaVersion: SCHEMA_VERSION,
653
+ builtFrom: {
654
+ kind: 'package',
655
+ repo,
656
+ package: manifest.name,
657
+ packageVersion: manifest.version,
658
+ exports: values.length,
659
+ typeExports: exported.length - values.length,
660
+ files: null,
661
+ },
662
+ storybook: {
663
+ docsPath: '/?path=/docs/{docsId}',
664
+ storyPath: '/?path=/story/{storyId}',
665
+ devUrl: 'http://localhost:6008',
666
+ },
667
+ entries,
668
+ // Nothing is excluded: every value export is an entry, helpers and the one
669
+ // hook included, each marked by `kind`. The field stays because the count
670
+ // has to reconcile out loud — see validateRegistry.
671
+ excluded: [],
672
+ filesWithoutParts: [],
673
+ }
674
+ }
675
+
676
+ /** The committed file's text, so a check and a write produce the same bytes. */
677
+ export function serializeRegistry(registry: Registry): string {
678
+ return `${JSON.stringify(registry, null, 2)}\n`
679
+ }