@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.
- package/README.md +21 -0
- package/dist/Banner.d.ts +20 -2
- package/dist/Banner.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +25 -0
- package/dist/ContainerHeader.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +10 -1
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
- package/dist/gates/chunk-EA33NP5B.js.map +7 -0
- package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
- package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts +2 -0
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +25 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -48
- package/dist/index.js.map +4 -4
- package/dist/registry/app-DQI6WLHR.js +10 -0
- package/dist/registry/app.d.ts +21 -0
- package/dist/registry/app.d.ts.map +1 -0
- package/dist/registry/build-LGFCCOLR.js +27 -0
- package/dist/registry/build-LGFCCOLR.js.map +7 -0
- package/dist/registry/build.d.ts +68 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
- package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
- package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
- package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
- package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
- package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
- package/dist/registry/chunk-WMFF3MPP.js +648 -0
- package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
- package/dist/registry/cli.js +81 -26
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/find.d.ts +12 -1
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.d.ts +5 -2
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -3
- package/dist/registry/schema.d.ts +83 -6
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +2 -2
- package/registry.json +233 -88
- package/src/Banner.mdx +17 -4
- package/src/Banner.stories.tsx +22 -0
- package/src/Banner.test.tsx +35 -0
- package/src/Banner.tsx +34 -9
- package/src/ContainerHeader.mdx +56 -0
- package/src/ContainerHeader.stories.tsx +62 -0
- package/src/ContainerHeader.test.tsx +47 -0
- package/src/ContainerHeader.tsx +45 -0
- package/src/EmptyState.mdx +12 -0
- package/src/EmptyState.stories.tsx +6 -1
- package/src/EmptyState.test.tsx +18 -1
- package/src/EmptyState.tsx +14 -1
- package/src/gates/app-checks.ts +23 -1
- package/src/gates/create-app.test.ts +77 -6
- package/src/gates/create-app.ts +288 -15
- package/src/gates/status.ts +16 -0
- package/src/index.ts +1 -0
- package/src/registry/app.test.ts +562 -0
- package/src/registry/app.ts +854 -0
- package/src/registry/build.ts +114 -25
- package/src/registry/cli.ts +105 -42
- package/src/registry/find.ts +45 -8
- package/src/registry/index.ts +8 -1
- package/src/registry/registry.test.ts +5 -5
- package/src/registry/schema.ts +163 -12
- package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
- package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
- package/dist/registry/build-GOVLABI6.js +0 -13
- package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
- package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
- package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
- /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
package/src/registry/build.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, ty
|
|
|
24
24
|
export interface BuildOptions {
|
|
25
25
|
/** The repository's top folder. */
|
|
26
26
|
root?: string
|
|
27
|
-
/** The name this registry's entries are recorded under.
|
|
27
|
+
/** The name this registry's entries are recorded under. */
|
|
28
28
|
repo?: string
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -36,7 +36,7 @@ interface RawExport {
|
|
|
36
36
|
isType: boolean
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** What the parser found above and about one
|
|
39
|
+
/** What the parser found above and about one top-level declaration. */
|
|
40
40
|
interface Declared {
|
|
41
41
|
/** The doc comment, cleaned, or `''`. */
|
|
42
42
|
doc: string
|
|
@@ -49,7 +49,7 @@ interface Declared {
|
|
|
49
49
|
propsType?: ts.TypeNode
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const PACKAGE_IMPORT = '@estiva-app/ui'
|
|
52
|
+
export const PACKAGE_IMPORT = '@estiva-app/ui'
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Storybook's own `sanitize`, which is what turns a title and a story's export
|
|
@@ -58,7 +58,7 @@ const PACKAGE_IMPORT = '@estiva-app/ui'
|
|
|
58
58
|
* builder, which apps run. A test holds this to the ids a real Storybook build
|
|
59
59
|
* produces.
|
|
60
60
|
*/
|
|
61
|
-
function sanitize(name: string): string {
|
|
61
|
+
export function sanitize(name: string): string {
|
|
62
62
|
return name
|
|
63
63
|
.toLowerCase()
|
|
64
64
|
.replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>{}[\]\\/]/g, '-')
|
|
@@ -87,7 +87,7 @@ function storyNameFromExport(key: string): string {
|
|
|
87
87
|
.trim()
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}`
|
|
90
|
+
export const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}`
|
|
91
91
|
|
|
92
92
|
/** Read `src/index.ts`. Its `export { … } from './Sibling'` statements are the target set. */
|
|
93
93
|
export function readIndexExports(source: string): RawExport[] {
|
|
@@ -122,9 +122,11 @@ function docAbove(source: string, node: ts.Node, headerPos: number | null = null
|
|
|
122
122
|
if (!jsdoc) return ''
|
|
123
123
|
if (headerPos !== null && jsdoc.pos === headerPos) return ''
|
|
124
124
|
// Only a comment that sits against the declaration describes it: anything
|
|
125
|
-
// with
|
|
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.
|
|
126
128
|
const between = source.slice(jsdoc.end, node.getStart())
|
|
127
|
-
if (between.trim() !== '') return ''
|
|
129
|
+
if (between.replace(/\/\/[^\n]*/g, '').trim() !== '') return ''
|
|
128
130
|
return source
|
|
129
131
|
.slice(jsdoc.pos, jsdoc.end)
|
|
130
132
|
.split(/\r?\n/)
|
|
@@ -141,7 +143,7 @@ function docAbove(source: string, node: ts.Node, headerPos: number | null = null
|
|
|
141
143
|
* "A list of actions from a trigger." is a purpose; "One row, two parts." is
|
|
142
144
|
* not, on its own.
|
|
143
145
|
*/
|
|
144
|
-
function firstSentence(prose: string): string {
|
|
146
|
+
export function firstSentence(prose: string): string {
|
|
145
147
|
const flat = prose
|
|
146
148
|
.split(/\n\s*\n/)[0]
|
|
147
149
|
.split('\n')
|
|
@@ -157,7 +159,7 @@ function firstSentence(prose: string): string {
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
/** A page's opening paragraph: everything between its `# Heading` and the first blank line. */
|
|
160
|
-
function pageOpening(mdx: string): string {
|
|
162
|
+
export function pageOpening(mdx: string): string {
|
|
161
163
|
const lines = mdx.split(/\r?\n/)
|
|
162
164
|
const heading = lines.findIndex((line) => /^#\s+\S/.test(line))
|
|
163
165
|
if (heading < 0) return ''
|
|
@@ -214,17 +216,31 @@ function literalUnion(type: ts.TypeNode | undefined, aliases: Map<string, ts.Typ
|
|
|
214
216
|
return values.length > 1 ? values : null
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
/** Everything one sibling file says about the names it exports. */
|
|
218
219
|
/** A type’s finished props and word-choices, read by the module that declares them. */
|
|
219
|
-
interface Resolved {
|
|
220
|
+
export interface Resolved {
|
|
220
221
|
props: EntryProp[]
|
|
221
222
|
variants: EntryVariant[]
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
/**
|
|
225
|
-
|
|
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
|
|
226
231
|
|
|
227
|
-
|
|
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 = {}) {
|
|
228
244
|
const file = ts.createSourceFile('module.tsx', source, ts.ScriptTarget.Latest, true)
|
|
229
245
|
const declarations = new Map<string, Declared>()
|
|
230
246
|
const aliases = new Map<string, ts.TypeNode>()
|
|
@@ -232,7 +248,7 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
|
|
|
232
248
|
/** What each interface extends, as written — `Omit<IdentityMenuProps, 'compact'>` and all. */
|
|
233
249
|
const bases = new Map<string, ts.ExpressionWithTypeArguments[]>()
|
|
234
250
|
/** Which module each imported name came from. */
|
|
235
|
-
const importedFrom = new Map<string, string>()
|
|
251
|
+
const importedFrom = new Map<string, { specifier: string; imported: string }>()
|
|
236
252
|
|
|
237
253
|
const propsTypeOf = (parameters: readonly ts.ParameterDeclaration[]): ts.TypeNode | undefined => parameters[0]?.type
|
|
238
254
|
|
|
@@ -257,18 +273,42 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
|
|
|
257
273
|
return undefined
|
|
258
274
|
}
|
|
259
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
|
+
|
|
260
287
|
// The file's header comment: the first `/**` above the first thing that is
|
|
261
288
|
// not an import. It belongs to the file, whatever export happens to follow it.
|
|
262
289
|
const opening = file.statements.find((statement) => !ts.isImportDeclaration(statement))
|
|
263
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
|
|
264
299
|
|
|
265
300
|
for (const statement of file.statements) {
|
|
266
301
|
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
267
302
|
const bindings = statement.importClause?.namedBindings
|
|
268
303
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
269
|
-
for (const element of bindings.elements) importedFrom.set(element.name.text, statement.moduleSpecifier.text)
|
|
304
|
+
for (const element of bindings.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text })
|
|
270
305
|
}
|
|
271
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
|
+
}
|
|
272
312
|
if (ts.isTypeAliasDeclaration(statement)) aliases.set(statement.name.text, statement.type)
|
|
273
313
|
if (ts.isInterfaceDeclaration(statement)) {
|
|
274
314
|
shapes.set(statement.name.text, [...statement.members])
|
|
@@ -280,19 +320,49 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
|
|
|
280
320
|
}
|
|
281
321
|
if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) shapes.set(statement.name.text, [...statement.type.members])
|
|
282
322
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
349
|
const deprecated = /(^|\n)@deprecated\b/.test(doc) || /\*\s*@deprecated\b/.test(source.slice(Math.max(0, statement.getFullStart()), statement.getStart()))
|
|
287
350
|
|
|
288
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
|
+
}
|
|
289
359
|
declarations.set(statement.name.text, { doc, deprecated, propsType: propsTypeOf(statement.parameters) })
|
|
290
360
|
continue
|
|
291
361
|
}
|
|
292
362
|
if (ts.isVariableStatement(statement)) {
|
|
293
363
|
for (const declaration of statement.declarationList.declarations) {
|
|
294
364
|
if (!ts.isIdentifier(declaration.name)) continue
|
|
295
|
-
declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) })
|
|
365
|
+
declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) ?? propsOfAnnotation(declaration.type) })
|
|
296
366
|
}
|
|
297
367
|
}
|
|
298
368
|
}
|
|
@@ -427,19 +497,34 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
|
|
|
427
497
|
// `type ToolbarInputProps = TextInputProps` — an alias, not an interface.
|
|
428
498
|
const alias = aliases.get(name)
|
|
429
499
|
if (alias) return resolveNode(alias, seen)
|
|
430
|
-
// Not declared here at all. Follow the import
|
|
500
|
+
// Not declared here at all. Follow the import; the caller says where it may go.
|
|
431
501
|
const from = importedFrom.get(name)
|
|
432
|
-
return (from
|
|
502
|
+
return (from ? sibling(from.specifier, from.imported) : undefined) ?? EMPTY
|
|
433
503
|
}
|
|
434
504
|
|
|
435
505
|
/** What one declaration's first parameter takes. */
|
|
436
506
|
const resolve = (propsType: ts.TypeNode | undefined): Resolved => resolveNode(propsType, new Set())
|
|
437
507
|
|
|
438
|
-
|
|
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()
|
|
439
524
|
}
|
|
440
525
|
|
|
441
526
|
/** The `title` a stories file gives Storybook, and the stories it exports. */
|
|
442
|
-
function readStories(source: string): { title: string | null; stories: string[] } {
|
|
527
|
+
export function readStories(source: string): { title: string | null; stories: string[] } {
|
|
443
528
|
const file = ts.createSourceFile('x.stories.tsx', source, ts.ScriptTarget.Latest, true)
|
|
444
529
|
let title: string | null = null
|
|
445
530
|
const stories: string[] = []
|
|
@@ -502,7 +587,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
502
587
|
// The callback lets one file's props type reach a type declared in another
|
|
503
588
|
// — `ToolbarButtonProps extends IconButtonProps`. It is only called later,
|
|
504
589
|
// by which time this module is in the cache, so the recursion terminates.
|
|
505
|
-
const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (
|
|
590
|
+
const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (specifier, typeName) => (specifier.startsWith('./') ? moduleOf(specifier.slice(2)).resolveName(typeName) : undefined)), file: `src/${file}` }
|
|
506
591
|
modules.set(name, read)
|
|
507
592
|
return read
|
|
508
593
|
}
|
|
@@ -555,6 +640,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
555
640
|
docsId: title ? `${sanitize(title)}--docs` : null,
|
|
556
641
|
storyId: title && story ? toId(title, story) : null,
|
|
557
642
|
docPage: pageFile,
|
|
643
|
+
app: null,
|
|
558
644
|
})
|
|
559
645
|
}
|
|
560
646
|
|
|
@@ -565,11 +651,13 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
565
651
|
return {
|
|
566
652
|
schemaVersion: SCHEMA_VERSION,
|
|
567
653
|
builtFrom: {
|
|
654
|
+
kind: 'package',
|
|
568
655
|
repo,
|
|
569
656
|
package: manifest.name,
|
|
570
657
|
packageVersion: manifest.version,
|
|
571
658
|
exports: values.length,
|
|
572
659
|
typeExports: exported.length - values.length,
|
|
660
|
+
files: null,
|
|
573
661
|
},
|
|
574
662
|
storybook: {
|
|
575
663
|
docsPath: '/?path=/docs/{docsId}',
|
|
@@ -581,6 +669,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
581
669
|
// hook included, each marked by `kind`. The field stays because the count
|
|
582
670
|
// has to reconcile out loud — see validateRegistry.
|
|
583
671
|
excluded: [],
|
|
672
|
+
filesWithoutParts: [],
|
|
584
673
|
}
|
|
585
674
|
}
|
|
586
675
|
|
package/src/registry/cli.ts
CHANGED
|
@@ -1,47 +1,59 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/**
|
|
3
|
-
* `estiva-ui` — the catalogue as a command (UIG-12; docs/GATES.md §23
|
|
4
|
-
* it ships in the package rather than as a script pasted into each repo).
|
|
3
|
+
* `estiva-ui` — the catalogue as a command (UIG-12, UIG-13; docs/GATES.md §23
|
|
4
|
+
* for why it ships in the package rather than as a script pasted into each repo).
|
|
5
5
|
*
|
|
6
|
-
* estiva-ui find <words…> [--json] [--limit n]
|
|
7
|
-
* estiva-ui build [--out <path>]
|
|
8
|
-
* estiva-ui check
|
|
6
|
+
* estiva-ui find <words…> [--also [name=]<folder>]… [--json] [--limit n]
|
|
7
|
+
* estiva-ui build [--out <path>]
|
|
8
|
+
* estiva-ui check
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Every command takes `--root <folder>` (the library it reads; default: here)
|
|
11
|
+
* and `--repo <name>` (what an app's entries are called; default: its package name).
|
|
12
|
+
*
|
|
13
|
+
* **In the package** (`@estiva-app/ui` itself): `build` writes the committed
|
|
14
|
+
* `registry.json`, and `check` rebuilds it and fails on a single byte of drift.
|
|
15
|
+
*
|
|
16
|
+
* **In an app**: nothing is committed (Katerina, 18 September 2026 — the
|
|
17
|
+
* package is public, the apps are private). `check` builds the app's catalogue
|
|
18
|
+
* and fails when a part has no one-line description or cannot be sorted; CI's
|
|
19
|
+
* `gate` job runs it. `build` writes the catalogue to a file only when asked.
|
|
20
|
+
*
|
|
21
|
+
* `find` searches the package's catalogue — the one shipped inside the installed
|
|
22
|
+
* package, or this repo's own in the package — plus, in an app, the app's own,
|
|
23
|
+
* built fresh, plus each `--also` app that sits beside it.
|
|
14
24
|
*/
|
|
15
25
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
-
import { resolve } from 'node:path'
|
|
26
|
+
import { basename, resolve } from 'node:path'
|
|
17
27
|
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import {
|
|
19
|
-
import { validateRegistry, type Registry } from './schema'
|
|
28
|
+
import { findInRegistries, formatFindings } from './find'
|
|
29
|
+
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
|
-
* The
|
|
32
|
+
* The builders, loaded only when they are used.
|
|
23
33
|
*
|
|
24
|
-
*
|
|
34
|
+
* They read TypeScript with TypeScript, and `typescript` is a dev dependency of
|
|
25
35
|
* the app that installs this package — a real one in Peek, Ship and this repo,
|
|
26
|
-
* and possibly absent elsewhere.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* opening the file it needed.
|
|
36
|
+
* and possibly absent elsewhere. A static import made the bundler put the whole
|
|
37
|
+
* builder in the same chunk as `find`, so `npx estiva-ui find …` died with
|
|
38
|
+
* ERR_MODULE_NOT_FOUND before opening the file it needed.
|
|
30
39
|
*/
|
|
31
|
-
const
|
|
40
|
+
const packageBuilder = () => import('./build')
|
|
41
|
+
const appBuilder = () => import('./app')
|
|
32
42
|
|
|
33
43
|
const [command, ...rest] = process.argv.slice(2)
|
|
44
|
+
const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
|
|
34
45
|
const flag = (name: string) => rest.includes(`--${name}`)
|
|
35
46
|
const value = (name: string) => {
|
|
36
47
|
const at = rest.indexOf(`--${name}`)
|
|
37
48
|
return at === -1 ? undefined : rest[at + 1]
|
|
38
49
|
}
|
|
50
|
+
const values = (name: string) => rest.flatMap((word, i) => (word === `--${name}` && rest[i + 1] !== undefined ? [rest[i + 1]] : []))
|
|
39
51
|
/** Everything that is not a flag or a flag's value: the words of the question. */
|
|
40
52
|
const plain = () => {
|
|
41
53
|
const out: string[] = []
|
|
42
54
|
for (let i = 0; i < rest.length; i++) {
|
|
43
55
|
if (rest[i].startsWith('--')) {
|
|
44
|
-
if (rest[i]
|
|
56
|
+
if (VALUED.includes(rest[i])) i += 1
|
|
45
57
|
continue
|
|
46
58
|
}
|
|
47
59
|
out.push(rest[i])
|
|
@@ -49,19 +61,24 @@ const plain = () => {
|
|
|
49
61
|
return out
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
const root = resolve(value('root') ?? '.')
|
|
65
|
+
const manifestAt = (folder: string) => JSON.parse(readFileSync(resolve(folder, 'package.json'), 'utf8')) as { name?: string }
|
|
66
|
+
/** The package itself, or an app that installs it. */
|
|
67
|
+
const isPackage = existsSync(resolve(root, 'package.json')) && manifestAt(root).name === '@estiva-app/ui'
|
|
68
|
+
|
|
69
|
+
/** The package's committed catalogue: `--file`, this repo's own in the package, else the copy inside the installed package. */
|
|
70
|
+
function packageRegistryPath(): string {
|
|
54
71
|
const named = value('file')
|
|
55
72
|
if (named) return resolve(named)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
73
|
+
if (isPackage) return resolve(root, 'registry.json')
|
|
74
|
+
// dist/registry/cli.js → the package's own root. Never an app's own folder:
|
|
75
|
+
// a registry.json there is the app's, written by `estiva-ui build`.
|
|
59
76
|
return fileURLToPath(new URL('../../registry.json', import.meta.url))
|
|
60
77
|
}
|
|
61
78
|
|
|
62
|
-
function
|
|
63
|
-
const path =
|
|
64
|
-
if (!existsSync(path)) throw new Error(`no registry.json at ${path} — run "estiva-ui build" in the
|
|
79
|
+
function readPackageRegistry(): Registry {
|
|
80
|
+
const path = packageRegistryPath()
|
|
81
|
+
if (!existsSync(path)) throw new Error(`no registry.json at ${path} — run "estiva-ui build" in the package first`)
|
|
65
82
|
const registry = JSON.parse(readFileSync(path, 'utf8')) as Registry
|
|
66
83
|
const problems = validateRegistry(registry)
|
|
67
84
|
// A malformed catalogue is worse than none: it would answer, wrongly.
|
|
@@ -69,6 +86,22 @@ function readRegistry(): Registry {
|
|
|
69
86
|
return registry
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
async function buildApp(folder: string, repo: string | undefined): Promise<Registry> {
|
|
90
|
+
const { buildAppRegistry } = await appBuilder()
|
|
91
|
+
const registry = buildAppRegistry({ root: folder, repo })
|
|
92
|
+
const problems = validateRegistry(registry)
|
|
93
|
+
if (problems.length) throw new Error(`the catalogue of ${registry.builtFrom.repo} does not match its own schema:\n ${problems.join('\n ')}`)
|
|
94
|
+
return registry
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One line per kind, in a fixed order, so two runs read the same. */
|
|
98
|
+
function summary(registry: Registry): string {
|
|
99
|
+
const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
|
|
100
|
+
const kinds = CLASSES.map((cls) => `${count(cls)} ${cls}`).join(', ')
|
|
101
|
+
const files = registry.builtFrom.files ?? 0
|
|
102
|
+
return `${registry.builtFrom.repo}: ${registry.entries.length} parts in ${files - registry.filesWithoutParts.length} files (${registry.filesWithoutParts.length} more hold none) — ${kinds}. Every part is described and sorted.`
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
async function main(): Promise<number> {
|
|
73
106
|
switch (command) {
|
|
74
107
|
case 'find': {
|
|
@@ -77,37 +110,67 @@ async function main(): Promise<number> {
|
|
|
77
110
|
process.stderr.write('estiva-ui find <words…> — what the thing you need does, in words\n')
|
|
78
111
|
return 1
|
|
79
112
|
}
|
|
80
|
-
const
|
|
113
|
+
const registries: Registry[] = [readPackageRegistry()]
|
|
114
|
+
// An app that cannot be read is said out loud, and the rest still answer.
|
|
115
|
+
const add = async (folder: string, repo: string | undefined) => {
|
|
116
|
+
try {
|
|
117
|
+
registries.push(await buildApp(folder, repo))
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const [first, ...more] = String(error instanceof Error ? error.message : error).split('\n')
|
|
120
|
+
process.stderr.write(`not searched: ${first}${more.length ? `\n${more.slice(0, 3).join('\n')}${more.length > 3 ? `\n …and ${more.length - 3} more — run "estiva-ui check" there` : ''}` : ''}\n`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!isPackage) await add(root, value('repo'))
|
|
124
|
+
for (const also of values('also')) {
|
|
125
|
+
const [name, folder] = also.includes('=') ? [also.slice(0, also.indexOf('=')), also.slice(also.indexOf('=') + 1)] : [undefined, also]
|
|
126
|
+
const at = resolve(root, folder)
|
|
127
|
+
if (!existsSync(resolve(at, 'package.json'))) {
|
|
128
|
+
process.stderr.write(`not searched: ${name ?? basename(at)} — nothing at ${at}\n`)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
await add(at, name)
|
|
132
|
+
}
|
|
81
133
|
const limit = Number(value('limit') ?? 5)
|
|
82
|
-
const findings =
|
|
134
|
+
const findings = findInRegistries(registries, query, { limit: Number.isFinite(limit) && limit > 0 ? limit : 5 })
|
|
83
135
|
if (flag('json')) {
|
|
84
136
|
process.stdout.write(`${JSON.stringify(findings.map((finding) => finding.entry), null, 2)}\n`)
|
|
85
137
|
return 0
|
|
86
138
|
}
|
|
87
|
-
process.stdout.write(`${formatFindings(
|
|
139
|
+
process.stdout.write(`${formatFindings(registries, findings, query)}\n`)
|
|
88
140
|
// Nothing found is not a failure — it is the answer that sends a session
|
|
89
141
|
// to ask rather than to invent.
|
|
90
142
|
return 0
|
|
91
143
|
}
|
|
92
144
|
|
|
93
145
|
case 'build': {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
146
|
+
const out = resolve(root, value('out') ?? 'registry.json')
|
|
147
|
+
if (isPackage) {
|
|
148
|
+
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
149
|
+
const registry = buildRegistry({ root })
|
|
150
|
+
const problems = validateRegistry(registry)
|
|
151
|
+
if (problems.length) {
|
|
152
|
+
process.stderr.write(`the registry this build produced does not match its own schema:\n ${problems.join('\n ')}\n`)
|
|
153
|
+
return 1
|
|
154
|
+
}
|
|
155
|
+
writeFileSync(out, serializeRegistry(registry), 'utf8')
|
|
156
|
+
process.stdout.write(`${out}: ${registry.entries.length} entries from ${registry.builtFrom.exports} exports\n`)
|
|
157
|
+
return 0
|
|
101
158
|
}
|
|
159
|
+
const { serializeRegistry } = await packageBuilder()
|
|
160
|
+
const registry = await buildApp(root, value('repo'))
|
|
102
161
|
writeFileSync(out, serializeRegistry(registry), 'utf8')
|
|
103
|
-
process.stdout.write(`${out}: ${registry
|
|
162
|
+
process.stdout.write(`${out}: ${summary(registry)}\n`)
|
|
104
163
|
return 0
|
|
105
164
|
}
|
|
106
165
|
|
|
107
166
|
case 'check': {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
167
|
+
if (!isPackage) {
|
|
168
|
+
process.stdout.write(`${summary(await buildApp(root, value('repo')))}\n`)
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
172
|
+
const path = packageRegistryPath()
|
|
173
|
+
const built = buildRegistry({ root })
|
|
111
174
|
const problems = validateRegistry(built)
|
|
112
175
|
if (problems.length) {
|
|
113
176
|
process.stderr.write(`the registry does not match its own schema:\n ${problems.join('\n ')}\n`)
|
package/src/registry/find.ts
CHANGED
|
@@ -86,6 +86,18 @@ const hit = (query: string, text: string) => words(text).some((word) => alike(qu
|
|
|
86
86
|
* over `MenuPanel`, whose name carries one of them loudly.
|
|
87
87
|
*/
|
|
88
88
|
export function findInRegistry(registry: Registry, query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
|
|
89
|
+
return findInRegistries([registry], query, { limit })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Search several catalogues as one: the package's, the app's own, and any app
|
|
94
|
+
* beside it (UIG-13).
|
|
95
|
+
*
|
|
96
|
+
* An app's pass-on is not a result of its own — it is the package's part, and
|
|
97
|
+
* the package's entry answers for it, saying which apps hand it on. At the
|
|
98
|
+
* same strength the package's part comes first: it is the one to reach for.
|
|
99
|
+
*/
|
|
100
|
+
export function findInRegistries(registries: Registry[], query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
|
|
89
101
|
const all = words(query)
|
|
90
102
|
// Drop the words that mean nothing. If the question was only those, keep them
|
|
91
103
|
// rather than answer nothing at all.
|
|
@@ -94,7 +106,8 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
|
|
|
94
106
|
if (asked.length === 0) return []
|
|
95
107
|
|
|
96
108
|
const findings: Finding[] = []
|
|
97
|
-
|
|
109
|
+
const entries = registries.flatMap((registry) => registry.entries).filter((entry) => entry.app?.class !== 're-export')
|
|
110
|
+
for (const entry of entries) {
|
|
98
111
|
let score = 0
|
|
99
112
|
let matched = 0
|
|
100
113
|
const where = new Set<string>()
|
|
@@ -148,7 +161,8 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
|
|
|
148
161
|
if (matched > 0) findings.push({ entry, matched, score, where: [...where], props: [...byName] })
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
|
|
164
|
+
const fromPackage = (finding: Finding) => (finding.entry.app === null ? 0 : 1)
|
|
165
|
+
findings.sort((a, b) => b.matched - a.matched || b.score - a.score || fromPackage(a) - fromPackage(b) || a.entry.name.localeCompare(b.entry.name))
|
|
152
166
|
return findings.slice(0, limit)
|
|
153
167
|
}
|
|
154
168
|
|
|
@@ -158,22 +172,45 @@ export function docsLink(registry: Registry, entry: RegistryEntry): string | nul
|
|
|
158
172
|
return registry.storybook.devUrl + registry.storybook.docsPath.replace('{docsId}', entry.docsId)
|
|
159
173
|
}
|
|
160
174
|
|
|
175
|
+
/** What an app's part is, in the words the search prints. */
|
|
176
|
+
const CLASS_WORDS: Record<string, string> = {
|
|
177
|
+
'one-off': 'used in one place',
|
|
178
|
+
reusable: 'used in several places',
|
|
179
|
+
'promote-candidate': 'used in several places; could move into the package',
|
|
180
|
+
unused: 'used nowhere in the app',
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
/** What the command prints: the import line first, because that is what the reader came for. */
|
|
162
|
-
export function formatFindings(
|
|
184
|
+
export function formatFindings(searched: Registry | Registry[], findings: Finding[], query: string): string {
|
|
185
|
+
const registries = Array.isArray(searched) ? searched : [searched]
|
|
163
186
|
if (findings.length === 0) {
|
|
164
187
|
return [
|
|
165
|
-
`Nothing in ${registry.builtFrom.repo} matches "${query}".`,
|
|
188
|
+
`Nothing in ${registries.map((registry) => registry.builtFrom.repo).join(', ')} matches "${query}".`,
|
|
166
189
|
'If nothing here does what you need, say so and ask — do not invent a component.',
|
|
167
190
|
].join('\n')
|
|
168
191
|
}
|
|
192
|
+
const registryOf = (entry: RegistryEntry) => registries.find((registry) => registry.builtFrom.repo === entry.repo) ?? registries[0]
|
|
193
|
+
// Which apps hand a package part on, and from where: in Peek, `Button` is
|
|
194
|
+
// imported from `@/components/ui/Button`, and the answer should say so.
|
|
195
|
+
const handedOn = new Map<string, string[]>()
|
|
196
|
+
for (const registry of registries) {
|
|
197
|
+
for (const entry of registry.entries) {
|
|
198
|
+
if (entry.app?.class !== 're-export' || entry.app.handsOn === null) continue
|
|
199
|
+
handedOn.set(entry.app.handsOn, [...(handedOn.get(entry.app.handsOn) ?? []), `${entry.repo} hands it on${entry.name === entry.app.handsOn ? '' : ` as ${entry.name}`} from ${entry.importPath}`])
|
|
200
|
+
}
|
|
201
|
+
}
|
|
169
202
|
|
|
170
203
|
const blocks = findings.map((finding) => {
|
|
171
204
|
const { entry } = finding
|
|
205
|
+
const facts = entry.app
|
|
172
206
|
const lines = [
|
|
173
|
-
`${entry.name} · ${entry.kind} · ${entry.repo} (matched ${finding.where.join(', ')})`,
|
|
207
|
+
`${entry.name} · ${facts ? CLASS_WORDS[facts.class] ?? facts.class : entry.kind} · ${entry.repo} (matched ${finding.where.join(', ')})`,
|
|
174
208
|
` ${entry.purpose}`,
|
|
175
|
-
` import { ${entry.name} } from '${entry.importPath}'`,
|
|
209
|
+
facts?.defaultExport ? ` import ${entry.name} from '${entry.importPath}'` : ` import { ${entry.name} } from '${entry.importPath}'`,
|
|
176
210
|
]
|
|
211
|
+
if (!facts) for (const said of handedOn.get(entry.name) ?? []) lines.push(` ${said}`)
|
|
212
|
+
if (facts?.packageNamesake) lines.push(` the package has a ${facts.packageNamesake} too: check it first`)
|
|
213
|
+
if (facts && facts.usedIn.length) lines.push(` used in ${facts.usedIn.slice(0, 2).join(', ')}${facts.usedIn.length > 2 ? ` and ${facts.usedIn.length - 2} more` : ''}`)
|
|
177
214
|
if (entry.ownsBehaviours.length) lines.push(` owns: ${entry.ownsBehaviours.map((owned) => owned.behaviour).join(' · ')}`)
|
|
178
215
|
if (entry.variants.length) lines.push(` ${entry.variants.map((variant) => `${variant.prop}: ${variant.values.join(' | ')}`).join(' ')}`)
|
|
179
216
|
// The props the question named, with their own line — not all of them. A
|
|
@@ -185,8 +222,8 @@ export function formatFindings(registry: Registry, findings: Finding[], query: s
|
|
|
185
222
|
for (const prop of shown) {
|
|
186
223
|
if (prop) lines.push(` ${prop.name}: ${prop.takes}${prop.note ? ` — ${prop.note}` : ''}`)
|
|
187
224
|
}
|
|
188
|
-
if (entry.props.length) lines.push(` ${entry.props.length} props in all; see the page for the rest`)
|
|
189
|
-
const link = docsLink(
|
|
225
|
+
if (entry.props.length) lines.push(` ${entry.props.length} props in all; see ${entry.docsId ? 'the page' : entry.sourceFile} for the rest`)
|
|
226
|
+
const link = docsLink(registryOf(entry), entry)
|
|
190
227
|
if (link) lines.push(` ${link}`)
|
|
191
228
|
return lines.join('\n')
|
|
192
229
|
})
|
package/src/registry/index.ts
CHANGED
|
@@ -7,13 +7,20 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The data itself is `registry.json` at the package's root, committed and
|
|
9
9
|
* shipped. Read it with `estiva-ui find`, or import these to read it yourself.
|
|
10
|
+
* An app's own catalogue (UIG-13) is built from its code with
|
|
11
|
+
* `buildAppRegistry` each time it is read, and never committed.
|
|
10
12
|
*/
|
|
11
13
|
export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
|
|
12
|
-
export {
|
|
14
|
+
export { buildAppRegistry, type AppBuildOptions } from './app'
|
|
15
|
+
export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
|
|
13
16
|
export {
|
|
17
|
+
CLASSES,
|
|
14
18
|
SCHEMA_VERSION,
|
|
15
19
|
validateRegistry,
|
|
20
|
+
type AppFacts,
|
|
16
21
|
type EntryBehaviour,
|
|
22
|
+
type EntryClass,
|
|
23
|
+
type FileWithoutPart,
|
|
17
24
|
type EntryKind,
|
|
18
25
|
type EntryStatus,
|
|
19
26
|
type EntryVariant,
|