@estiva-app/ui 0.22.0 → 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 (82) hide show
  1. package/README.md +21 -0
  2. package/dist/Banner.d.ts +20 -2
  3. package/dist/Banner.d.ts.map +1 -1
  4. package/dist/ContainerHeader.d.ts +25 -0
  5. package/dist/ContainerHeader.d.ts.map +1 -0
  6. package/dist/EmptyState.d.ts +10 -1
  7. package/dist/EmptyState.d.ts.map +1 -1
  8. package/dist/gates/app-checks.d.ts.map +1 -1
  9. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  10. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  11. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  12. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  13. package/dist/gates/cli.js +1 -1
  14. package/dist/gates/create-app.d.ts +2 -0
  15. package/dist/gates/create-app.d.ts.map +1 -1
  16. package/dist/gates/create-app.js +1 -1
  17. package/dist/gates/index.js +25 -3
  18. package/dist/gates/index.js.map +2 -2
  19. package/dist/gates/status.d.ts +2 -0
  20. package/dist/gates/status.d.ts.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +69 -48
  24. package/dist/index.js.map +4 -4
  25. package/dist/registry/app-DQI6WLHR.js +10 -0
  26. package/dist/registry/app.d.ts +21 -0
  27. package/dist/registry/app.d.ts.map +1 -0
  28. package/dist/registry/build-LGFCCOLR.js +27 -0
  29. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  30. package/dist/registry/build.d.ts +68 -2
  31. package/dist/registry/build.d.ts.map +1 -1
  32. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  33. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  34. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  35. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  36. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  37. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  38. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  39. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  40. package/dist/registry/cli.js +81 -26
  41. package/dist/registry/cli.js.map +3 -3
  42. package/dist/registry/find.d.ts +12 -1
  43. package/dist/registry/find.d.ts.map +1 -1
  44. package/dist/registry/index.d.ts +5 -2
  45. package/dist/registry/index.d.ts.map +1 -1
  46. package/dist/registry/index.js +11 -3
  47. package/dist/registry/schema.d.ts +83 -6
  48. package/dist/registry/schema.d.ts.map +1 -1
  49. package/package.json +2 -2
  50. package/registry.json +233 -88
  51. package/src/Banner.mdx +17 -4
  52. package/src/Banner.stories.tsx +22 -0
  53. package/src/Banner.test.tsx +35 -0
  54. package/src/Banner.tsx +34 -9
  55. package/src/ContainerHeader.mdx +56 -0
  56. package/src/ContainerHeader.stories.tsx +62 -0
  57. package/src/ContainerHeader.test.tsx +47 -0
  58. package/src/ContainerHeader.tsx +45 -0
  59. package/src/EmptyState.mdx +12 -0
  60. package/src/EmptyState.stories.tsx +6 -1
  61. package/src/EmptyState.test.tsx +18 -1
  62. package/src/EmptyState.tsx +14 -1
  63. package/src/gates/app-checks.ts +23 -1
  64. package/src/gates/create-app.test.ts +77 -6
  65. package/src/gates/create-app.ts +288 -15
  66. package/src/gates/status.ts +16 -0
  67. package/src/index.ts +1 -0
  68. package/src/registry/app.test.ts +562 -0
  69. package/src/registry/app.ts +854 -0
  70. package/src/registry/build.ts +114 -25
  71. package/src/registry/cli.ts +105 -42
  72. package/src/registry/find.ts +45 -8
  73. package/src/registry/index.ts +8 -1
  74. package/src/registry/registry.test.ts +5 -5
  75. package/src/registry/schema.ts +163 -12
  76. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  77. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  78. package/dist/registry/build-GOVLABI6.js +0 -13
  79. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  80. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  81. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  82. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
@@ -0,0 +1,854 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * An app's catalogue (UIG-13): every part in an app's own code, what it is for,
4
+ * where it is used, and which of four kinds it is.
5
+ *
6
+ * **Built fresh, never committed** (Katerina, 18 September 2026). The package is
7
+ * public and Peek and Ship are private, so an app's catalogue lives nowhere but
8
+ * in its own code: `estiva-ui find` and `estiva-ui check` build it each time,
9
+ * which takes about a second, and `estiva-ui build` writes it to a file only
10
+ * when asked. A file that is never kept can never drift.
11
+ *
12
+ * **A file is not a part, and a folder says nothing.** Peek's `components/ui`
13
+ * holds one-line pass-ons beside 187-line components, one file can hold six
14
+ * parts, and a part can draw nothing at all (a provider, a listener). So the
15
+ * target set is every name an app's `.tsx` files export that is a part: a
16
+ * function that draws, or a name drawn as a tag somewhere in the app. The kind
17
+ * is read from the code, and only from the code:
18
+ *
19
+ * - **re-export** — its file only hands a package part on.
20
+ * - **one-off** — one file of the app uses it.
21
+ * - **reusable** — two or more do.
22
+ * - **promote-candidate** — two or more do, and everything it uses is already in
23
+ * the package, so it could move there as it is. Katerina rules on each.
24
+ * - **unused** — no file of the app uses it; its stories or tests may.
25
+ *
26
+ * Where the count is wrong — a general part only one screen uses yet — a person
27
+ * writes the kind beside the part, with the reason, and the catalogue shows it:
28
+ *
29
+ * /**
30
+ * * A timeline of what happened to a thing.
31
+ * * @registry reusable: general; one screen uses it so far
32
+ * *\/
33
+ *
34
+ * TypeScript is the app's own install, as it is for the package's builder.
35
+ */
36
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
37
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
38
+ import ts from 'typescript'
39
+ import { firstSentence, pageOpening, readModule, sanitize, toId, PACKAGE_IMPORT, type Resolved } from './build'
40
+ import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema'
41
+
42
+ export interface AppBuildOptions {
43
+ /** The app's folder: where its `package.json` and `src/` are. Ship's is `web`. */
44
+ root?: string
45
+ /** The name its entries are recorded under — `peek`, `ship`. Defaults to the app's package name. */
46
+ repo?: string
47
+ /** The package's own catalogue, for the parts an app part shares a name with. Defaults to the one this builder ships beside. */
48
+ packageRegistry?: Registry | null
49
+ /** The packages the package itself depends on. An import of one never ties a part to its app. Defaults to the package's own. */
50
+ packageDependencies?: string[]
51
+ }
52
+
53
+ /** The kinds a person may write beside a part. A pass-on is a fact about its file; so is being used nowhere. */
54
+ const WRITABLE: EntryClass[] = ['reusable', 'one-off', 'promote-candidate']
55
+ const WRITTEN = /^@registry\s+([a-z-]+)\s*:\s*(.+)$/m
56
+
57
+ const isStory = (file: string) => /\.stories\.[cm]?[jt]sx?$/.test(file)
58
+ const isTest = (file: string) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)__(tests|mocks)__\//.test(file)
59
+ const isPascal = (name: string) => /^[A-Z][A-Za-z0-9]*$/.test(name) && !/^[A-Z0-9_]+$/.test(name)
60
+ /**
61
+ * What a dynamic `import('./x')` takes from its file. Written as
62
+ * `import('./Settings').then((m) => ({ default: m.SettingsPage }))`, only the names
63
+ * read off the module; otherwise the whole module (`*`), whose default a caller
64
+ * then takes.
65
+ */
66
+ function pickedFrom(call: ts.CallExpression): { imported: string; local: string }[] {
67
+ const then = call.parent
68
+ const invoked = then?.parent
69
+ if (then && ts.isPropertyAccessExpression(then) && then.name.text === 'then' && invoked && ts.isCallExpression(invoked)) {
70
+ const callback = invoked.arguments[0]
71
+ const param = callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ? callback.parameters[0]?.name : undefined
72
+ if (param && ts.isIdentifier(param)) {
73
+ const names = new Set<string>()
74
+ const visit = (n: ts.Node) => {
75
+ if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === param.text) names.add(n.name.text)
76
+ ts.forEachChild(n, visit)
77
+ }
78
+ visit(callback)
79
+ if (names.size) return [...names].map((imported) => ({ imported, local: '(dynamic)' }))
80
+ }
81
+ }
82
+ return [{ imported: '*', local: '(dynamic)' }]
83
+ }
84
+
85
+ /**
86
+ * `export default memo(TopicRow)`: the default is TopicRow, wrapped. A call on
87
+ * a name, with no function written into it, hands that name out, so the export
88
+ * is read as if it named it — one part, whose comment and props are its own.
89
+ */
90
+ function unwrapDefault<T extends { isDefault: boolean; local: string | null; node: ts.Node | null }>(ex: T): T {
91
+ if (!ex.isDefault || ex.local || !ex.node || !ts.isExpression(ex.node)) return ex
92
+ const name = handedOut(ex.node)
93
+ return name ? { ...ex, local: name, node: null } : ex
94
+ }
95
+
96
+ /**
97
+ * The name an `export default …` hands out, under whatever it is wrapped in:
98
+ * `memo(forwardRef(Row))`, `Row as ComponentType`, `Row satisfies FC`,
99
+ * `styled(Row)\`…\``. `null` when what is exported is written right there.
100
+ */
101
+ function handedOut(expression: ts.Expression): string | null {
102
+ let e: ts.Expression = expression
103
+ for (;;) {
104
+ if (ts.isIdentifier(e)) return e.text
105
+ if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression
106
+ else if (ts.isTaggedTemplateExpression(e)) e = e.tag
107
+ else if (ts.isCallExpression(e) && e.arguments[0] && !e.arguments.some((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))) e = e.arguments[0]
108
+ else return null
109
+ }
110
+ }
111
+
112
+ /** `memo-page` → `MemoPage`, `topic_view` → `TopicView`. */
113
+ const pascalOf = (stem: string) => stem.split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join('')
114
+ /** A default with no name of its own takes its file's — or, for `settings/index.tsx`, its folder's. */
115
+ const defaultNameOf = (file: string) => {
116
+ const stem = basename(file, extname(file))
117
+ return pascalOf(stem === 'index' ? basename(dirname(file)) : stem)
118
+ }
119
+ const CODE = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts']
120
+
121
+ /** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */
122
+ function packageOf(specifier: string): string {
123
+ const parts = specifier.split('/')
124
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
125
+ }
126
+
127
+ /** What the package this builder belongs to depends on, and its catalogue. Read from beside the builder. */
128
+ function packageSelf(): { dependencies: string[]; registry: Registry | null } {
129
+ const at = (rel: string) => new URL(rel, import.meta.url)
130
+ const manifest = JSON.parse(readFileSync(at('../../package.json'), 'utf8')) as { name: string; dependencies?: Record<string, string>; peerDependencies?: Record<string, string> }
131
+ const registryUrl = at('../../registry.json')
132
+ const registry = existsSync(registryUrl) ? (JSON.parse(readFileSync(registryUrl, 'utf8')) as Registry) : null
133
+ return { dependencies: [manifest.name, ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})], registry }
134
+ }
135
+
136
+ /**
137
+ * The import aliases an app's `tsconfig*.json` files declare — `@/*` →
138
+ * `<root>/src/*` — and the `baseUrl` folders a bare `src/Card` is read from.
139
+ */
140
+ function readAliases(root: string): { aliases: { prefix: string; target: string }[]; bases: string[] } {
141
+ const aliases: { prefix: string; target: string }[] = []
142
+ const bases: string[] = []
143
+ for (const name of readdirSync(root).filter((file) => /^tsconfig.*\.json$/.test(file)).sort()) {
144
+ const read = ts.readConfigFile(join(root, name), ts.sys.readFile)
145
+ const options = (read.config?.compilerOptions ?? {}) as { paths?: Record<string, string[]>; baseUrl?: string }
146
+ const base = resolve(root, options.baseUrl ?? '.')
147
+ if (options.baseUrl !== undefined && !bases.includes(base)) bases.push(base)
148
+ for (const [pattern, targets] of Object.entries(options.paths ?? {})) {
149
+ if (!pattern.endsWith('/*') || !targets[0]?.endsWith('/*')) continue
150
+ const prefix = pattern.slice(0, -1)
151
+ if (!aliases.some((alias) => alias.prefix === prefix)) aliases.push({ prefix, target: resolve(base, targets[0].slice(0, -2)) })
152
+ }
153
+ }
154
+ return { aliases, bases }
155
+ }
156
+
157
+ function walk(dir: string): string[] {
158
+ return readdirSync(dir).flatMap((name) => {
159
+ if (name === 'node_modules' || name.startsWith('.')) return []
160
+ const path = join(dir, name)
161
+ return statSync(path).isDirectory() ? walk(path) : [path]
162
+ })
163
+ }
164
+
165
+ /** One import, re-export or dynamic import of a file, resolved. */
166
+ interface Link {
167
+ specifier: string
168
+ /** An app code file, relative to the app; `null` for a package; `'?'` for anything else. */
169
+ target: string | null
170
+ /** `imported` name → local name; `*` for a namespace or a dynamic import. Type-only names are left out. */
171
+ names: { imported: string; local: string }[]
172
+ typeOnly: boolean
173
+ kind: 'import' | 're-export' | 'dynamic'
174
+ }
175
+
176
+ /** A part a file exports, before anything is known about its use. */
177
+ interface Found {
178
+ file: string
179
+ /** The name it is exported as; a default export takes its own name, or the file's. */
180
+ name: string
181
+ /** The name it has inside its file, where it has one. */
182
+ local: string | null
183
+ defaultExport: boolean
184
+ /** A pass-on: the package and the name it hands on. */
185
+ from: { specifier: string; name: string } | null
186
+ }
187
+
188
+ /** Everything one file says, read once. */
189
+ interface FileFacts {
190
+ file: string
191
+ source: string
192
+ sf: ts.SourceFile
193
+ links: Link[]
194
+ /** `exported name` → local name, and whether it is the default. */
195
+ exports: { name: string; local: string | null; isDefault: boolean; node: ts.Node | null }[]
196
+ /** Pass-ons and barrel entries: `export { A } from '…'`. */
197
+ reexports: { name: string; source: string; specifier: string; target: string | null }[]
198
+ starFrom: { specifier: string; target: string | null }[]
199
+ /** `export * as Parts from './parts'`: a name that stands for another file's parts. */
200
+ namespaces: { name: string; target: string | null }[]
201
+ /** Every name drawn as a JSX tag in this file. */
202
+ tags: Set<string>
203
+ /** Each imported value name → where it came from, for `import { X } from '…'` then `export { X }`. */
204
+ bindings: Map<string, { specifier: string; imported: string; target: string | null }>
205
+ }
206
+
207
+ /** Whether a node draws: JSX, or `createElement` / `createPortal`. */
208
+ function draws(node: ts.Node): boolean {
209
+ let found = false
210
+ const visit = (n: ts.Node) => {
211
+ if (found) return
212
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
213
+ found = true
214
+ return
215
+ }
216
+ if (ts.isCallExpression(n)) {
217
+ const callee = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text : ts.isIdentifier(n.expression) ? n.expression.text : ''
218
+ if (callee === 'createElement' || callee === 'createPortal') {
219
+ found = true
220
+ return
221
+ }
222
+ }
223
+ ts.forEachChild(n, visit)
224
+ }
225
+ visit(node)
226
+ return found
227
+ }
228
+
229
+ /**
230
+ * Whether a file names `local` anywhere but in `home` (its own declaration), its
231
+ * imports and its export lines — as a tag, a value, or passed along. A property
232
+ * name, an attribute name and a type are not uses of the part.
233
+ */
234
+ function mentions(sf: ts.SourceFile, local: string, home: ts.Node | null): boolean {
235
+ let named = false
236
+ const visit = (n: ts.Node) => {
237
+ if (named) return
238
+ if (ts.isIdentifier(n) && n.text === local) {
239
+ const parent = n.parent
240
+ // The name of something else that happens to be spelled the same — a
241
+ // property of a type or a class, a member, another declaration — is not the part.
242
+ const declares = (ts.isPropertySignature(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodDeclaration(parent) || ts.isMethodSignature(parent) || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent) || ts.isEnumMember(parent) || ts.isVariableDeclaration(parent) || ts.isFunctionDeclaration(parent) || ts.isClassDeclaration(parent) || ts.isParameter(parent) || ts.isBindingElement(parent)) && (parent as { name?: ts.Node }).name === n
243
+ const bindingKey = ts.isBindingElement(parent) && parent.propertyName === n
244
+ const isName = declares || bindingKey || (ts.isPropertyAccessExpression(parent) && parent.name === n) || (ts.isPropertyAssignment(parent) && parent.name === n) || ts.isJsxAttribute(parent) || ts.isExportSpecifier(parent) || ts.isImportSpecifier(parent) || ts.isJsxClosingElement(parent)
245
+ // `SlashMenu.displayName = 'SlashMenu'` sets something on the part; it does not use it.
246
+ const configures = ts.isPropertyAccessExpression(parent) && parent.expression === n && ts.isBinaryExpression(parent.parent) && parent.parent.left === parent && parent.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
247
+ if (configures) {
248
+ ts.forEachChild(n, visit)
249
+ return
250
+ }
251
+ let inType = false
252
+ for (let a: ts.Node | undefined = parent; a && a !== sf; a = a.parent) if (ts.isTypeNode(a)) inType = true
253
+ if (!isName && !inType && !shadowed(n, local, sf)) named = true
254
+ }
255
+ ts.forEachChild(n, visit)
256
+ }
257
+ for (const s of sf.statements) {
258
+ if (s === home || ts.isExportDeclaration(s) || ts.isImportDeclaration(s)) continue
259
+ // `export default Row`, `memo(forwardRef(Row))`, `Row as ComponentType` hand the
260
+ // part out; they do not use it. Anything else written into the default is code.
261
+ if (ts.isExportAssignment(s) && handedOut(s.expression)) continue
262
+ visit(s)
263
+ }
264
+ return named
265
+ }
266
+
267
+ /** Whether a binding name — `Icon`, `{ icon: Icon }`, `[Icon]` — binds `local`. */
268
+ function binds(name: ts.BindingName | undefined, local: string): boolean {
269
+ if (!name) return false
270
+ if (ts.isIdentifier(name)) return name.text === local
271
+ return name.elements.some((element) => !ts.isOmittedExpression(element) && binds(element.name, local))
272
+ }
273
+
274
+ /**
275
+ * Whether `n` means something declared closer than the file: a parameter
276
+ * (`Item({ icon: Icon }) { return <Icon /> }`), or a constant, function or class
277
+ * of an enclosing block. That `Icon` is not the file's part called Icon.
278
+ */
279
+ function shadowed(n: ts.Node, local: string, sf: ts.SourceFile): boolean {
280
+ for (let a: ts.Node | undefined = n.parent; a && a !== sf; a = a.parent) {
281
+ if (ts.isFunctionLike(a)) {
282
+ if (a.parameters.some((p) => binds(p.name, local))) return true
283
+ if ((ts.isFunctionExpression(a) || ts.isClassExpression(a)) && a.name?.text === local) return true
284
+ }
285
+ if (ts.isCatchClause(a) && binds(a.variableDeclaration?.name, local)) return true
286
+ if (ts.isBlock(a) || ts.isModuleBlock(a) || ts.isCaseClause(a) || ts.isDefaultClause(a)) {
287
+ for (const s of a.statements) {
288
+ if (ts.isVariableStatement(s) && s.declarationList.declarations.some((d) => binds(d.name, local))) return true
289
+ if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && s.name?.text === local) return true
290
+ }
291
+ }
292
+ if ((ts.isForStatement(a) || ts.isForOfStatement(a) || ts.isForInStatement(a)) && a.initializer && ts.isVariableDeclarationList(a.initializer) && a.initializer.declarations.some((d) => binds(d.name, local))) return true
293
+ }
294
+ return false
295
+ }
296
+
297
+ /**
298
+ * The thing a declaration holds, if it is a function, a class, or a call that
299
+ * wraps one (`forwardRef`, `memo`) — as a constant, or written straight into
300
+ * `export default …`.
301
+ */
302
+ function drawingBody(node: ts.Node): ts.Node | null {
303
+ if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return node
304
+ const init = ts.isVariableDeclaration(node) ? node.initializer : ts.isExpression(node) ? node : undefined
305
+ if (!init) return null
306
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init) || ts.isClassExpression(init)) return init
307
+ if (ts.isCallExpression(init)) {
308
+ const callee = init.expression.getText()
309
+ if (/(^|\.)createContext$/.test(callee)) return null
310
+ if (init.arguments.some((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))) return init
311
+ }
312
+ return null
313
+ }
314
+
315
+ /**
316
+ * Read one app and return its catalogue.
317
+ *
318
+ * Throws on anything it cannot explain, with every problem at once: a part with
319
+ * no purpose, a written kind it does not know. A catalogue that quietly drops
320
+ * what it could not read would answer "we have nothing for that" when the
321
+ * truth is "the builder gave up".
322
+ */
323
+ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry, packageDependencies }: AppBuildOptions = {}): Registry {
324
+ const app = resolve(root)
325
+ const src = join(app, 'src')
326
+ if (!existsSync(src)) throw new Error(`${app} has no src/ folder: estiva-ui reads an app's parts from src/`)
327
+ const manifest = JSON.parse(readFileSync(join(app, 'package.json'), 'utf8')) as { name?: string; version?: string; scripts?: Record<string, string> }
328
+ const self = packageRegistry === undefined || packageDependencies === undefined ? packageSelf() : null
329
+ const theRegistry = packageRegistry === undefined ? (self?.registry ?? null) : packageRegistry
330
+ const allowed = new Set(packageDependencies ?? self?.dependencies ?? [])
331
+ const repoName = repo ?? manifest.name ?? basename(app)
332
+ const { aliases, bases } = readAliases(app)
333
+ const rel = (abs: string) => relative(app, abs).split(sep).join('/')
334
+
335
+ const files = walk(src)
336
+ .filter((path) => CODE.includes(extname(path)) && !path.endsWith('.d.ts'))
337
+ .map(rel)
338
+ .sort()
339
+ const known = new Set(files)
340
+
341
+ /** Where an import points: an app code file, a package (`null`), or something else (`'?'`). */
342
+ const resolveSpecifier = (from: string, specifier: string): string | null => {
343
+ let base: string | null = null
344
+ if (specifier.startsWith('.')) base = resolve(app, dirname(from), specifier)
345
+ else {
346
+ const alias = aliases.find((a) => specifier.startsWith(a.prefix))
347
+ if (alias) base = join(alias.target, specifier.slice(alias.prefix.length))
348
+ }
349
+ const lookUp = (at: string): string | null => {
350
+ const stem = at.replace(/\.(js|jsx)$/, '')
351
+ for (const candidate of [at, ...CODE.map((e) => stem + e), ...CODE.map((e) => join(at, `index${e}`))]) {
352
+ const r = rel(candidate)
353
+ if (known.has(r)) return r
354
+ }
355
+ return null
356
+ }
357
+ if (base === null) {
358
+ // A bare `src/Card` is the app's own when its tsconfig's baseUrl says so; otherwise a package.
359
+ for (const folder of bases) {
360
+ const hit = lookUp(join(folder, specifier))
361
+ if (hit) return hit
362
+ }
363
+ return null
364
+ }
365
+ return lookUp(base) ?? '?'
366
+ }
367
+
368
+ // ── Read every file once ────────────────────────────────────────────────
369
+ const facts = new Map<string, FileFacts>()
370
+ for (const file of files) {
371
+ const source = readFileSync(join(app, file), 'utf8')
372
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, /x$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
373
+ const links: Link[] = []
374
+ const exports: FileFacts['exports'] = []
375
+ const reexports: FileFacts['reexports'] = []
376
+ const starFrom: FileFacts['starFrom'] = []
377
+ const namespaces: FileFacts['namespaces'] = []
378
+ for (const statement of sf.statements) {
379
+ if (ts.isImportDeclaration(statement)) {
380
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue
381
+ const specifier = statement.moduleSpecifier.text
382
+ const clause = statement.importClause
383
+ const names: Link['names'] = []
384
+ if (clause && !clause.isTypeOnly) {
385
+ if (clause.name) names.push({ imported: 'default', local: clause.name.text })
386
+ const bindings = clause.namedBindings
387
+ if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) names.push({ imported: (e.propertyName ?? e.name).text, local: e.name.text })
388
+ if (bindings && ts.isNamespaceImport(bindings)) names.push({ imported: '*', local: bindings.name.text })
389
+ }
390
+ links.push({ specifier, target: resolveSpecifier(file, specifier), names, typeOnly: !!clause?.isTypeOnly || (clause !== undefined && names.length === 0 && !!clause.namedBindings), kind: 'import' })
391
+ continue
392
+ }
393
+ if (ts.isExportDeclaration(statement)) {
394
+ const specifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null
395
+ if (specifier === null) {
396
+ if (!statement.isTypeOnly && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
397
+ // `export { Page as default }` is the file's default, named Page.
398
+ for (const e of statement.exportClause.elements) if (!e.isTypeOnly) exports.push({ name: e.name.text, local: (e.propertyName ?? e.name).text, isDefault: e.name.text === 'default', node: null })
399
+ }
400
+ continue
401
+ }
402
+ const target = resolveSpecifier(file, specifier)
403
+ links.push({ specifier, target, names: [], typeOnly: statement.isTypeOnly, kind: 're-export' })
404
+ if (statement.isTypeOnly) continue
405
+ if (!statement.exportClause) starFrom.push({ specifier, target })
406
+ else if (ts.isNamedExports(statement.exportClause)) {
407
+ for (const e of statement.exportClause.elements) if (!e.isTypeOnly) reexports.push({ name: e.name.text, source: (e.propertyName ?? e.name).text, specifier, target })
408
+ } else if (ts.isNamespaceExport(statement.exportClause)) {
409
+ // `export * as Parts from './parts'`: a caller's `Parts.Card` is the parts file's Card.
410
+ namespaces.push({ name: statement.exportClause.name.text, target })
411
+ }
412
+ continue
413
+ }
414
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
415
+ const e = statement.expression
416
+ exports.push({ name: 'default', local: ts.isIdentifier(e) ? e.text : null, isDefault: true, node: ts.isIdentifier(e) ? null : e })
417
+ continue
418
+ }
419
+ const mods = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []
420
+ if (!mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue
421
+ const isDefault = mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
422
+ // An overload's signature is not a part of its own: the part is the one with a body.
423
+ if (ts.isFunctionDeclaration(statement) && !statement.body) continue
424
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
425
+ exports.push({ name: isDefault ? 'default' : (statement.name?.text ?? 'default'), local: statement.name?.text ?? null, isDefault, node: statement })
426
+ } else if (ts.isVariableStatement(statement)) {
427
+ for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name)) exports.push({ name: d.name.text, local: d.name.text, isDefault: false, node: d })
428
+ }
429
+ }
430
+ const tags = new Set<string>()
431
+ const visit = (n: ts.Node) => {
432
+ if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword && n.arguments[0] && ts.isStringLiteralLike(n.arguments[0])) {
433
+ const specifier = n.arguments[0].text
434
+ links.push({ specifier, target: resolveSpecifier(file, specifier), names: pickedFrom(n), typeOnly: false, kind: 'dynamic' })
435
+ }
436
+ if ((ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && ts.isIdentifier(n.tagName)) tags.add(n.tagName.text)
437
+ ts.forEachChild(n, visit)
438
+ }
439
+ visit(sf)
440
+ const bindings: FileFacts['bindings'] = new Map()
441
+ for (const link of links) if (link.kind === 'import') for (const n of link.names) if (n.imported !== '*') bindings.set(n.local, { specifier: link.specifier, imported: n.imported, target: link.target })
442
+ facts.set(file, { file, source, sf, links, exports, reexports, starFrom, namespaces, tags, bindings })
443
+ }
444
+
445
+ const drawnAnywhere = new Set<string>()
446
+ for (const f of facts.values()) for (const tag of f.tags) drawnAnywhere.add(tag)
447
+
448
+ /** A top-level declaration of a file, by its local name. */
449
+ const declarationOf = (f: FileFacts, local: string): ts.Node | null => {
450
+ for (const s of f.sf.statements) {
451
+ // Past an overload's signature, to the function with a body.
452
+ if (ts.isFunctionDeclaration(s) && s.name?.text === local && !s.body) continue
453
+ if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && s.name?.text === local) return s
454
+ if (ts.isVariableStatement(s)) for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === local) return d
455
+ }
456
+ return null
457
+ }
458
+
459
+ // ── The parts ───────────────────────────────────────────────────────────
460
+ const problems: string[] = []
461
+ const found: Found[] = []
462
+ const targets = files.filter((file) => file.endsWith('.tsx') && !isStory(file) && !isTest(file))
463
+ for (const file of targets) {
464
+ const f = facts.get(file)!
465
+ for (const written of f.exports) {
466
+ const ex = unwrapDefault(written)
467
+ // `export default memo(Avatar)` with Avatar imported: not Avatar handed on, but a
468
+ // new part of this file that wraps it, named after the file.
469
+ // Only a name that is a part's: `createBrowserRouter(routes)` hands out no part.
470
+ if (ex !== written && ex.local && isPascal(ex.local) && !declarationOf(f, ex.local) && f.bindings.has(ex.local)) {
471
+ found.push({ file, name: defaultNameOf(file), local: null, defaultExport: true, from: null })
472
+ continue
473
+ }
474
+ // `export function App` and `export default App` — or `export default memo(App)`: one part, not two.
475
+ if (ex.isDefault && ex.local && f.exports.some((other) => !other.isDefault && other.local === ex.local)) continue
476
+ const node = ex.node ?? (ex.local ? declarationOf(f, ex.local) : null)
477
+ // A default with no name of its own takes its file's, as a name: `memo-page.tsx` → MemoPage.
478
+ const own = ex.local ?? (ex.isDefault ? defaultNameOf(file) : ex.name)
479
+ const name = ex.isDefault ? own : ex.name
480
+ if (!isPascal(name)) continue
481
+ // `import { SkeletonBar } from '@estiva-app/ui'` and later `export { SkeletonBar }`:
482
+ // handed on, exactly as `export { SkeletonBar } from '@estiva-app/ui'` is.
483
+ // From another file of the app it is a barrel, and the part is listed there.
484
+ const bound = node === null && ex.local ? f.bindings.get(ex.local) : undefined
485
+ if (bound) {
486
+ if (bound.target === null) found.push({ file, name, local: null, defaultExport: ex.isDefault, from: { specifier: bound.specifier, name: bound.imported } })
487
+ continue
488
+ }
489
+ // `export const SkeletonSidebarList = SkeletonList`, SkeletonList imported: the
490
+ // package's part under a second name, handed on. From an app file, a barrel.
491
+ const alias = node && ts.isVariableDeclaration(node) && node.initializer && ts.isIdentifier(node.initializer) ? f.bindings.get(node.initializer.text) : undefined
492
+ if (alias) {
493
+ if (alias.target === null) found.push({ file, name, local: null, defaultExport: ex.isDefault, from: { specifier: alias.specifier, name: alias.imported } })
494
+ continue
495
+ }
496
+ const body = node ? drawingBody(node) : null
497
+ const isPart = (body !== null && draws(body)) || drawnAnywhere.has(ex.local ?? name)
498
+ if (isPart) found.push({ file, name, local: ex.local, defaultExport: ex.isDefault, from: null })
499
+ }
500
+ for (const re of f.reexports) {
501
+ // Handing on another file of the app is a barrel: the part is listed where it is written.
502
+ if (re.target !== null || !isPascal(re.name)) continue
503
+ found.push({ file, name: re.name, local: null, defaultExport: false, from: { specifier: re.specifier, name: re.source } })
504
+ }
505
+ for (const star of f.starFrom) {
506
+ if (star.target === null) problems.push(`${file} hands on everything from '${star.specifier}' with export *: name the parts it hands on, so the catalogue can list them`)
507
+ }
508
+ }
509
+
510
+ // ── Where each is used ──────────────────────────────────────────────────
511
+ const key = (file: string, name: string) => `${file}#${name}`
512
+ const byKey = new Map(found.map((p) => [key(p.file, p.name), p]))
513
+ const partsIn = (file: string) => found.filter((p) => p.file === file)
514
+ /** The part a file's default export is, however it was written. */
515
+ const defaultPartOf = (file: string): Found | null => {
516
+ const written = facts.get(file)?.exports.find((e) => e.isDefault)
517
+ if (!written) return null
518
+ const d = unwrapDefault(written)
519
+ return partsIn(file).find((p) => p.defaultExport || (d.local !== null && p.local === d.local)) ?? null
520
+ }
521
+
522
+ /** What `name`, imported from `file`, really is: follows barrels to the file that writes it. */
523
+ const origin = (file: string, name: string, seen = new Set<string>()): Found | null => {
524
+ if (seen.has(key(file, name))) return null
525
+ seen.add(key(file, name))
526
+ const direct = byKey.get(key(file, name))
527
+ if (direct) return direct
528
+ const f = facts.get(file)
529
+ if (!f) return null
530
+ // `export default Foo` elsewhere in the file: the default is Foo's part.
531
+ if (name === 'default') {
532
+ const d = defaultPartOf(file)
533
+ if (d) return d
534
+ }
535
+ const re = f.reexports.find((r) => r.name === name)
536
+ if (re && re.target && re.target !== '?') return origin(re.target, re.source, seen)
537
+ // `import { X } from './X'` and `export { X }`: a barrel written in two lines.
538
+ const ex = f.exports.find((e) => (name === 'default' ? e.isDefault : e.name === name && !e.isDefault))
539
+ const renamed = ex?.node && ts.isVariableDeclaration(ex.node) && ex.node.initializer && ts.isIdentifier(ex.node.initializer) ? f.bindings.get(ex.node.initializer.text) : undefined
540
+ const bound = (ex?.local && !ex.node && !declarationOf(f, ex.local) ? f.bindings.get(ex.local) : undefined) ?? renamed
541
+ if (bound?.target && bound.target !== '?') return origin(bound.target, bound.imported, seen)
542
+ for (const star of f.starFrom) if (star.target && star.target !== '?') {
543
+ const hit = origin(star.target, name, seen)
544
+ if (hit) return hit
545
+ }
546
+ return null
547
+ }
548
+ /** Every part a file hands out, through its barrels too. */
549
+ const everyPartOf = (file: string, seen = new Set<string>()): Found[] => {
550
+ if (seen.has(file)) return []
551
+ seen.add(file)
552
+ const f = facts.get(file)
553
+ if (!f) return []
554
+ const out = [...partsIn(file)]
555
+ for (const re of f.reexports) if (re.target && re.target !== '?') { const o = origin(re.target, re.source); if (o) out.push(o) }
556
+ for (const star of f.starFrom) if (star.target && star.target !== '?') out.push(...everyPartOf(star.target, seen))
557
+ for (const ns of f.namespaces) if (ns.target && ns.target !== '?') out.push(...everyPartOf(ns.target, seen))
558
+ return out
559
+ }
560
+ /** The parts a namespace export stands for, followed through barrels. */
561
+ const namespaceParts = (file: string, name: string, seen = new Set<string>()): Found[] => {
562
+ if (seen.has(`${file}#${name}`)) return []
563
+ seen.add(`${file}#${name}`)
564
+ const f = facts.get(file)
565
+ if (!f) return []
566
+ const ns = f.namespaces.find((x) => x.name === name)
567
+ if (ns) return ns.target && ns.target !== '?' ? everyPartOf(ns.target) : []
568
+ const re = f.reexports.find((r) => r.name === name)
569
+ if (re?.target && re.target !== '?') return namespaceParts(re.target, re.source, seen)
570
+ return f.starFrom.flatMap((star) => (star.target && star.target !== '?' ? namespaceParts(star.target, name, seen) : []))
571
+ }
572
+
573
+ const users = new Map<string, { app: Set<string>; stories: Set<string>; tests: Set<string> }>()
574
+ for (const p of found) users.set(key(p.file, p.name), { app: new Set(), stories: new Set(), tests: new Set() })
575
+ for (const f of facts.values()) {
576
+ const bucket = (p: Found) => {
577
+ const u = users.get(key(p.file, p.name))!
578
+ return isStory(f.file) ? u.stories : isTest(f.file) ? u.tests : u.app
579
+ }
580
+ for (const link of f.links) {
581
+ if (link.kind === 're-export' || link.typeOnly || !link.target || link.target === '?') continue
582
+ for (const n of link.names) {
583
+ if (n.imported === '*') {
584
+ // `lazy(() => import('./Page'))` loads the default; a namespace may use any.
585
+ const theDefault = link.kind === 'dynamic' ? defaultPartOf(link.target) : null
586
+ const chosen = theDefault ? [theDefault] : everyPartOf(link.target)
587
+ for (const p of chosen) if (p.file !== f.file) bucket(p).add(f.file)
588
+ continue
589
+ }
590
+ const p = origin(link.target, n.imported)
591
+ // A file that imports a part only to export it again hands it on; it does not use it.
592
+ const handsOn = f.exports.some((e) => e.local === n.local && e.node === null) && !mentions(f.sf, n.local, null)
593
+ if (handsOn) continue
594
+ if (p) {
595
+ if (p.file !== f.file) bucket(p).add(f.file)
596
+ continue
597
+ }
598
+ // `import { Parts } from './barrel'`, where the barrel says `export * as Parts from './parts'`.
599
+ for (const part of namespaceParts(link.target, n.imported)) if (part.file !== f.file) bucket(part).add(f.file)
600
+ }
601
+ }
602
+ }
603
+ // Its own file uses it when something there, other than its own declaration and
604
+ // the export line, names it — a card drawn by the list beside it.
605
+ for (const p of found) {
606
+ if (p.from || !p.local) continue
607
+ const f = facts.get(p.file)!
608
+ const decl = declarationOf(f, p.local)
609
+ const home = decl ? (ts.isVariableDeclaration(decl) ? decl.parent.parent : decl) : null
610
+ if (mentions(f.sf, p.local, home)) users.get(key(p.file, p.name))!.app.add(p.file)
611
+ }
612
+
613
+ // ── What ties each file to the app ─────────────────────────────────────
614
+ // A file is tied when it imports a package the package does not itself depend
615
+ // on, something that is not code, or an app file that is tied. Types count: a
616
+ // part typed by Peek's data is Peek's.
617
+ const directTies = new Map<string, string[]>()
618
+ const appDeps = new Map<string, string[]>()
619
+ for (const f of facts.values()) {
620
+ const ties: string[] = []
621
+ const deps: string[] = []
622
+ for (const link of f.links) {
623
+ if (link.target === null) {
624
+ const tie = link.specifier.startsWith('node:') ? link.specifier : allowed.has(packageOf(link.specifier)) ? null : packageOf(link.specifier)
625
+ if (tie && !ties.includes(tie)) ties.push(tie)
626
+ } else if (link.target === '?') {
627
+ if (!ties.includes(link.specifier)) ties.push(link.specifier)
628
+ } else if (link.target !== f.file && !deps.includes(link.target)) deps.push(link.target)
629
+ }
630
+ directTies.set(f.file, ties)
631
+ appDeps.set(f.file, deps)
632
+ }
633
+ const tied = new Set([...directTies].filter(([, t]) => t.length > 0).map(([file]) => file))
634
+ for (let changed = true; changed; ) {
635
+ changed = false
636
+ for (const [file, deps] of appDeps) if (!tied.has(file) && deps.some((d) => tied.has(d))) (tied.add(file), (changed = true))
637
+ }
638
+ const tiesOf = (file: string) => [...directTies.get(file)!, ...appDeps.get(file)!.filter((d) => tied.has(d))].sort()
639
+
640
+ // ── Stories: which one shows a part ─────────────────────────────────────
641
+ const stories = files.filter(isStory).map((file) => {
642
+ const f = facts.get(file)!
643
+ let title: string | null = null
644
+ let component: string | null = null
645
+ let noDocs = false
646
+ const names: string[] = []
647
+ for (const s of f.sf.statements) {
648
+ if (!ts.isVariableStatement(s)) continue
649
+ const exported = (ts.getModifiers(s) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
650
+ for (const d of s.declarationList.declarations) {
651
+ if (!ts.isIdentifier(d.name)) continue
652
+ if (d.name.text === 'meta') {
653
+ let init = d.initializer
654
+ while (init && (ts.isSatisfiesExpression(init) || ts.isAsExpression(init) || ts.isParenthesizedExpression(init))) init = init.expression
655
+ if (init && ts.isObjectLiteralExpression(init)) {
656
+ for (const prop of init.properties) {
657
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
658
+ if (prop.name.text === 'title' && ts.isStringLiteralLike(prop.initializer)) title = prop.initializer.text
659
+ if (prop.name.text === 'component' && ts.isIdentifier(prop.initializer)) component = prop.initializer.text
660
+ if (prop.name.text === 'tags' && ts.isArrayLiteralExpression(prop.initializer)) noDocs = prop.initializer.elements.some((e) => ts.isStringLiteralLike(e) && e.text === '!autodocs')
661
+ }
662
+ }
663
+ } else if (exported) names.push(d.name.text)
664
+ }
665
+ }
666
+ // Which part each local name is, through the imports.
667
+ const imported = new Map<string, Found>()
668
+ for (const link of f.links) {
669
+ if (!link.target || link.target === '?') continue
670
+ for (const n of link.names) {
671
+ if (n.imported === '*') continue
672
+ const p = origin(link.target, n.imported)
673
+ if (p) imported.set(n.local, p)
674
+ }
675
+ }
676
+ return { file, title, component: component ? (imported.get(component) ?? null) : null, noDocs, names, imports: [...imported.values()] }
677
+ })
678
+ const storyOf = (p: Found) => {
679
+ const stem = (file: string) => basename(file).replace(/\.stories\.[cm]?[jt]sx?$/, '')
680
+ const own = basename(p.file, extname(p.file))
681
+ const candidates = stories.filter((s) => s.title && s.names.length && (s.component === p || (s.imports.includes(p) && (stem(s.file) === p.name || stem(s.file) === own))))
682
+ candidates.sort((a, b) => Number(b.component === p) - Number(a.component === p) || a.file.localeCompare(b.file))
683
+ const s = candidates[0]
684
+ if (!s || !s.title) return { docsId: null, storyId: null }
685
+ return { docsId: s.noDocs ? null : `${sanitize(s.title)}--docs`, storyId: toId(s.title, s.names.includes(p.name) ? p.name : s.names[0]) }
686
+ }
687
+
688
+ // ── Props, read the way the package's are ──────────────────────────────
689
+ type Module = ReturnType<typeof readModule>
690
+ const modules = new Map<string, Module>()
691
+ const moduleAt = (file: string): Module => {
692
+ const held = modules.get(file)
693
+ if (held) return held
694
+ const read = readModule(facts.get(file)!.source, (specifier, typeName): Resolved | undefined => {
695
+ // A wrapper typed by the package's own props takes what the package's part
696
+ // takes. The package names every part's props type after the part —
697
+ // `SearchInputProps` is `SearchInput`'s, all 65 of them — so its entry answers.
698
+ if (specifier === PACKAGE_IMPORT) {
699
+ const part = typeName.endsWith('Props') ? theRegistry?.entries.find((entry) => entry.name === typeName.slice(0, -'Props'.length)) : undefined
700
+ return part ? { props: part.props, variants: part.variants } : undefined
701
+ }
702
+ const target = resolveSpecifier(file, specifier)
703
+ return target && target !== '?' ? moduleAt(target).resolveName(typeName) : undefined
704
+ // A comment directly above a part is that part's, first in its file or not.
705
+ }, { lendFirstComment: false })
706
+ modules.set(file, read)
707
+ return read
708
+ }
709
+
710
+ // ── The entries ────────────────────────────────────────────────────────
711
+ const namesakes = new Set((theRegistry?.entries ?? []).map((e) => e.name))
712
+ const importPathOf = (file: string) => {
713
+ const abs = join(app, file)
714
+ const alias = aliases.find((a) => abs.startsWith(a.target + sep))
715
+ const stem = file.replace(/\.[cm]?[jt]sx?$/, '').replace(/\/index$/, '')
716
+ return alias ? `${alias.prefix}${relative(alias.target, join(app, stem)).split(sep).join('/')}` : stem
717
+ }
718
+ const port = /(?:-p|--port)[\s=]+(\d+)/.exec(manifest.scripts?.storybook ?? '')?.[1] ?? '6006'
719
+
720
+ const entries: RegistryEntry[] = []
721
+ for (const p of found) {
722
+ const u = users.get(key(p.file, p.name))!
723
+ const usedIn = [...u.app].sort()
724
+ const pass = p.from
725
+ const onePart = partsIn(p.file).filter((q) => !q.from).length === 1
726
+ const module = pass ? null : moduleAt(p.file)
727
+ // A default written as an expression has no name in its file: the builder keeps it as `default`.
728
+ const declared = pass ? undefined : module!.declarations.get(p.local ?? 'default')
729
+ const header = module?.headerDoc ?? ''
730
+ // The file's default may carry its comment on `export default memo(Row)` rather than on Row.
731
+ const doc = declared?.doc || (defaultPartOf(p.file) === p ? (module?.declarations.get('default')?.doc ?? '') : '')
732
+
733
+ // Purpose: its page, else its own comment, else — for a file of one part —
734
+ // the file's. A pass-on says whose it is.
735
+ const page = pass ? null : join(dirname(p.file), `${p.name}.mdx`).split(sep).join('/')
736
+ const pageText = page && existsSync(join(app, page)) ? pageOpening(readFileSync(join(app, page), 'utf8')) : ''
737
+ let purpose = ''
738
+ let purposeFrom: RegistryEntry['purposeFrom'] = 'comment'
739
+ if (pass) {
740
+ const as = pass.name === p.name ? '' : ` as ${p.name}`
741
+ purpose = pass.specifier === PACKAGE_IMPORT ? `The package's ${pass.name}, handed on${as} so the app can import it from ${importPathOf(p.file)}.` : `${pass.name} from ${pass.specifier}, handed on${as} so the app can import it from ${importPathOf(p.file)}.`
742
+ purposeFrom = 'package'
743
+ } else if (firstSentence(pageText)) {
744
+ purpose = firstSentence(pageText)
745
+ purposeFrom = 'page'
746
+ } else if (firstSentence(doc)) {
747
+ purpose = firstSentence(doc)
748
+ } else if (onePart && firstSentence(header)) {
749
+ purpose = firstSentence(header)
750
+ purposeFrom = 'file'
751
+ }
752
+ if (!purpose) {
753
+ problems.push(`${p.name} in ${p.file} has no one-line description. Write one as a /** … */ comment directly above it${onePart ? ', or at the top of the file' : ''}.`)
754
+ continue
755
+ }
756
+
757
+ // Its kind: written beside it, or read from the evidence.
758
+ const writtenLine = WRITTEN.exec(doc) ?? (onePart ? WRITTEN.exec(header) : null)
759
+ const ties = pass ? [] : tiesOf(p.file)
760
+ let cls: EntryClass
761
+ let reason: string
762
+ let written = false
763
+ if (pass) {
764
+ cls = 're-export'
765
+ reason = `Its file only hands on ${pass.specifier === PACKAGE_IMPORT ? `the package's ${pass.name}` : `${pass.name} from ${pass.specifier}`}.`
766
+ if (writtenLine) problems.push(`${p.name} in ${p.file} is a pass-on, so its kind is read from the file: remove "@registry ${writtenLine[1]}"`)
767
+ } else if (writtenLine) {
768
+ const said = writtenLine[1] as EntryClass
769
+ if (!WRITABLE.includes(said)) {
770
+ problems.push(`${p.name} in ${p.file} says "@registry ${writtenLine[1]}": the kinds a person may write are ${WRITABLE.join(', ')}`)
771
+ continue
772
+ }
773
+ cls = said
774
+ reason = writtenLine[2].trim()
775
+ written = true
776
+ } else if (usedIn.length === 0) {
777
+ cls = 'unused'
778
+ reason = u.stories.size ? 'No file of the app uses it; only its stories do.' : u.tests.size ? 'No file of the app uses it; only its tests do.' : 'Nothing uses it.'
779
+ } else if (usedIn.length === 1) {
780
+ cls = 'one-off'
781
+ reason = `Used in one place: ${usedIn[0]}.`
782
+ } else if (ties.length) {
783
+ cls = 'reusable'
784
+ reason = `Used in ${usedIn.length} places, and tied to the app by ${ties[0]}${ties.length > 1 ? ` and ${ties.length - 1} more` : ''}.`
785
+ } else {
786
+ cls = 'promote-candidate'
787
+ reason = `Used in ${usedIn.length} places, and everything it uses is already in the package.`
788
+ }
789
+
790
+ const shape: Resolved = declared ? module!.resolve(declared.propsType) : { props: [], variants: [] }
791
+ const facts_: AppFacts = {
792
+ class: cls,
793
+ reason,
794
+ written,
795
+ handsOn: pass ? (pass.specifier === PACKAGE_IMPORT ? pass.name : `${pass.name} from ${pass.specifier}`) : null,
796
+ usedIn,
797
+ tiedTo: ties,
798
+ packageNamesake: namesakes.has(p.name) && !(pass && pass.specifier === PACKAGE_IMPORT && pass.name === p.name) ? p.name : null,
799
+ defaultExport: p.defaultExport,
800
+ }
801
+ const { docsId, storyId } = pass ? { docsId: null, storyId: null } : storyOf(p)
802
+ entries.push({
803
+ name: p.name,
804
+ repo: repoName,
805
+ kind: 'component',
806
+ importPath: importPathOf(p.file),
807
+ sourceFile: p.file,
808
+ purpose,
809
+ purposeFrom,
810
+ props: shape.props,
811
+ variants: shape.variants,
812
+ ownsBehaviours: [],
813
+ status: declared?.deprecated ? 'deprecated' : 'stable',
814
+ migrationStage: null,
815
+ docsId,
816
+ storyId,
817
+ docPage: pageText ? page : null,
818
+ app: facts_,
819
+ })
820
+ }
821
+
822
+ if (problems.length) throw new Error(`the catalogue of ${repoName} cannot be built:\n ${problems.join('\n ')}`)
823
+
824
+ entries.sort((a, b) => a.name.localeCompare(b.name) || a.sourceFile.localeCompare(b.sourceFile))
825
+ const withParts = new Set(found.map((p) => p.file))
826
+ const filesWithoutParts: FileWithoutPart[] = targets
827
+ .filter((file) => !withParts.has(file))
828
+ .map((file) => {
829
+ const f = facts.get(file)!
830
+ const names = [...f.exports.map((e) => (e.isDefault ? (e.local ?? 'a default') : e.name)), ...f.reexports.map((r) => r.name)]
831
+ return { file, reason: names.length ? `It exports no part, only ${names.join(', ')}.` : 'It exports nothing.' }
832
+ })
833
+
834
+ return {
835
+ schemaVersion: SCHEMA_VERSION,
836
+ builtFrom: {
837
+ kind: 'app',
838
+ repo: repoName,
839
+ package: manifest.name ?? repoName,
840
+ packageVersion: manifest.version ?? '0.0.0',
841
+ exports: found.length,
842
+ typeExports: 0,
843
+ files: targets.length,
844
+ },
845
+ storybook: {
846
+ docsPath: '/?path=/docs/{docsId}',
847
+ storyPath: '/?path=/story/{storyId}',
848
+ devUrl: `http://localhost:${port}`,
849
+ },
850
+ entries,
851
+ excluded: [],
852
+ filesWithoutParts,
853
+ }
854
+ }