@estiva-app/ui 0.35.1 → 0.36.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/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +70 -9
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +41 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +93 -34
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +27 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/dist/registry/siblings.d.ts +9 -0
- package/dist/registry/siblings.d.ts.map +1 -1
- package/dist/registry/storymap.d.ts +19 -5
- package/dist/registry/storymap.d.ts.map +1 -1
- package/package.json +4 -1
- package/registry.json +293 -17
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +4 -0
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +2 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +4 -3
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +6 -2
- package/src/gates/gates.test.ts +93 -1
- package/src/gates/hook.ts +44 -6
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/pages.test.ts +17 -0
- package/src/registry/app.test.ts +34 -0
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +37 -18
- package/src/registry/contract.test.ts +63 -3
- package/src/registry/contract.ts +75 -2
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +14 -0
- package/src/registry/siblings.test.ts +24 -1
- package/src/registry/siblings.ts +23 -0
- package/src/registry/storymap.test.ts +52 -31
- package/src/registry/storymap.ts +76 -37
- package/stories/Choosing.mdx +8 -2
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/gates/chunk-GI6M4P7H.js +0 -606
- package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
package/src/registry/app.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
37
37
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
38
38
|
import ts from 'typescript'
|
|
39
|
+
import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, isBaseUi, rootsIn, splitTag } from './behaviours'
|
|
39
40
|
import { firstSentence, pageOpening, readModule, sanitize, toId, PACKAGE_IMPORT, type Resolved } from './build'
|
|
40
41
|
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
41
42
|
import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema'
|
|
@@ -722,6 +723,50 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
722
723
|
}
|
|
723
724
|
const port = /(?:-p|--port)[\s=]+(\d+)/.exec(manifest.scripts?.storybook ?? '')?.[1] ?? '6006'
|
|
724
725
|
|
|
726
|
+
// What each part owns, from what it draws (B9): a pass-on owns what its package
|
|
727
|
+
// part owns; any other part what the whole of each part it draws does, followed
|
|
728
|
+
// through the app's own parts, and what the Base UI parts it draws do.
|
|
729
|
+
const packageOwns = new Map((theRegistry?.entries ?? []).map((e) => [e.name, e.ownsBehaviours.map((b) => b.id)]))
|
|
730
|
+
const drawnByFile = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>> }>()
|
|
731
|
+
const owned = new Map<string, Set<string>>()
|
|
732
|
+
const ownsOf = (p: Found, seen: Set<string>): Set<string> => {
|
|
733
|
+
const at = key(p.file, p.name)
|
|
734
|
+
const held = owned.get(at)
|
|
735
|
+
if (held) return held
|
|
736
|
+
const out = new Set<string>()
|
|
737
|
+
if (p.from) {
|
|
738
|
+
if (p.from.specifier === PACKAGE_IMPORT) for (const id of packageOwns.get(p.from.name) ?? []) out.add(id)
|
|
739
|
+
return out
|
|
740
|
+
}
|
|
741
|
+
const f = facts.get(p.file)
|
|
742
|
+
if (!f || !p.local || seen.has(at)) return out
|
|
743
|
+
seen.add(at)
|
|
744
|
+
if (!drawnByFile.has(p.file)) drawnByFile.set(p.file, { drawn: drawnIn(f.sf), roots: rootsIn(f.sf) })
|
|
745
|
+
const { drawn, roots } = drawnByFile.get(p.file)!
|
|
746
|
+
const imported = new Map(f.links.filter((l) => l.kind === 'import').flatMap((l) => l.names.map((n) => [n.local, { link: l, imported: n.imported }] as const)))
|
|
747
|
+
// The Base UI parts it draws, anywhere in it (rare in an app: the gate refuses most).
|
|
748
|
+
for (const tag of drawn.get(p.local) ?? []) {
|
|
749
|
+
const [local, piece] = splitTag(tag)
|
|
750
|
+
const from = imported.get(local)
|
|
751
|
+
if (from && isBaseUi(from.link.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
|
|
752
|
+
}
|
|
753
|
+
// The parts it is: what it hands back at its root, the package's or the app's own.
|
|
754
|
+
for (const tag of roots.get(p.local) ?? []) {
|
|
755
|
+
const from = imported.get(splitTag(tag)[0])
|
|
756
|
+
if (!from) continue
|
|
757
|
+
let inner: Iterable<string> = []
|
|
758
|
+
if (from.link.specifier === PACKAGE_IMPORT) inner = handedOn(from.imported, packageOwns.get(from.imported) ?? [])
|
|
759
|
+
else if (from.link.target && from.link.target !== '?') {
|
|
760
|
+
const part = origin(from.link.target, from.imported)
|
|
761
|
+
if (part) inner = handedOn(part.from?.name ?? part.name, ownsOf(part, seen))
|
|
762
|
+
}
|
|
763
|
+
for (const id of inner) out.add(id)
|
|
764
|
+
}
|
|
765
|
+
if (seen.size === 1) owned.set(at, out)
|
|
766
|
+
seen.delete(at)
|
|
767
|
+
return out
|
|
768
|
+
}
|
|
769
|
+
|
|
725
770
|
const entries: RegistryEntry[] = []
|
|
726
771
|
for (const p of found) {
|
|
727
772
|
const u = users.get(key(p.file, p.name))!
|
|
@@ -803,7 +848,12 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
803
848
|
packageNamesake: namesakes.has(p.name) && !(pass && pass.specifier === PACKAGE_IMPORT && pass.name === p.name) ? p.name : null,
|
|
804
849
|
defaultExport: p.defaultExport,
|
|
805
850
|
}
|
|
806
|
-
const
|
|
851
|
+
const found = pass ? { docsId: null, storyId: null } : storyOf(p)
|
|
852
|
+
// A part with a page of its own and no story file links to its page, by the
|
|
853
|
+
// page's own title (Katerina's ruling B10, 25 September): Peek's EditedMarker.mdx
|
|
854
|
+
// shows the part in other parts' stories, and nothing linked it.
|
|
855
|
+
const titled = !found.docsId && pageText ? /<Meta\s+title=["'`]([^"'`]+)["'`]/.exec(readFileSync(join(app, page!), 'utf8'))?.[1] : undefined
|
|
856
|
+
const { docsId, storyId } = titled ? { docsId: `${sanitize(titled)}--docs`, storyId: found.storyId } : found
|
|
807
857
|
entries.push({
|
|
808
858
|
name: p.name,
|
|
809
859
|
repo: repoName,
|
|
@@ -814,7 +864,7 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
814
864
|
purposeFrom,
|
|
815
865
|
props: shape.props,
|
|
816
866
|
variants: shape.variants,
|
|
817
|
-
ownsBehaviours:
|
|
867
|
+
ownsBehaviours: asBehaviours(ownsOf(p, new Set())),
|
|
818
868
|
status: declared?.deprecated ? 'deprecated' : 'stable',
|
|
819
869
|
migrationStage: null,
|
|
820
870
|
docsId,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a part owns, worked out from what it draws (Katerina's ruling B9,
|
|
3
|
+
* 25 September).
|
|
4
|
+
*
|
|
5
|
+
* The plan's Gate 0 asked for "the behaviours it owns — derived from which Base
|
|
6
|
+
* UI parts it imports". Until 0.36.0 only "is built on Base UI" was; the rest
|
|
7
|
+
* was UIG-8's hand list of owners, which is right for the part that does the
|
|
8
|
+
* work and silent for every part built from it: ConfirmDialog draws DialogShell,
|
|
9
|
+
* whose page says it keeps focus inside, and the catalogue listed no focus for
|
|
10
|
+
* ConfirmDialog. Every app entry listed nothing at all.
|
|
11
|
+
*
|
|
12
|
+
* Now a part owns:
|
|
13
|
+
*
|
|
14
|
+
* - what the Base UI parts it draws do, from UIG-8's enumeration (`baseUi` on
|
|
15
|
+
* each behaviour, read from Base UI's source): all of it for a whole part
|
|
16
|
+
* (`Menu.Root`), what it is for a piece (`Menu.Item`);
|
|
17
|
+
* - what the whole of the part it **is** does (floating, focus, keys, scroll):
|
|
18
|
+
* the part it hands back at its root, followed through parts inside parts, in
|
|
19
|
+
* the package and in an app. ConfirmDialog returns a DialogShell, so it keeps
|
|
20
|
+
* focus inside; a page with a menu somewhere in it does not float, and a
|
|
21
|
+
* tooltip's floating stays the tooltip's;
|
|
22
|
+
* - and, in the package, what UIG-8's list says (the floor: a part like `Link`
|
|
23
|
+
* is reachable with Tab without any Base UI in it).
|
|
24
|
+
*
|
|
25
|
+
* Read from the code: a JSX tag inside the part's own declaration, or inside a
|
|
26
|
+
* function of the same file it draws. A root on one branch of a condition
|
|
27
|
+
* still counts, so the list says what a part can be, not what one screen shows.
|
|
28
|
+
*/
|
|
29
|
+
import ts from 'typescript'
|
|
30
|
+
import { OWNED_BEHAVIOURS } from '../eslint/index'
|
|
31
|
+
import type { EntryBehaviour } from './schema'
|
|
32
|
+
|
|
33
|
+
/** `@base-ui/react`, its sub-paths, and the old `@base-ui-components/react` spelling. */
|
|
34
|
+
export const isBaseUi = (specifier: string) => /^@base-ui(?:-components)?\/react(?:\/|$)/.test(specifier)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What drawing a Base UI part gives, by behaviour id. The whole part (`Menu.Root`,
|
|
38
|
+
* or a part of one piece: `<Separator>`, `<Toggle>`) does all UIG-8 lists for it;
|
|
39
|
+
* a piece of one (`Menu.Item`, `Toolbar.Button`) only says what it is.
|
|
40
|
+
*/
|
|
41
|
+
export function baseUiBehaviours(part: string, piece?: string): string[] {
|
|
42
|
+
const whole = !piece || piece === 'Root' || piece === 'Provider'
|
|
43
|
+
return OWNED_BEHAVIOURS.filter((owned) => owned.id === 'base-ui' || (owned.baseUi.includes(part) && (whole || owned.id === 'role'))).map((owned) => owned.id)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a part hands on to the parts drawn around it: what the whole thing does.
|
|
48
|
+
* Being built on Base UI, a role and a Tab stop stay the inner part's: a banner
|
|
49
|
+
* with a button in it is not itself reachable with Tab.
|
|
50
|
+
*/
|
|
51
|
+
export const HANDED_ON = new Set(['portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'scroll'])
|
|
52
|
+
|
|
53
|
+
/** A hint drawn beside a part: its floating is its own, never the part's. */
|
|
54
|
+
export const HINTS = new Set(['Tooltip', 'WithTooltip', 'TooltipProvider', 'PreviewCard'])
|
|
55
|
+
|
|
56
|
+
/** What a part drawn inside another hands on to it. */
|
|
57
|
+
export const handedOn = (name: string, owned: Iterable<string>) => (HINTS.has(name) ? [] : [...owned].filter((id) => HANDED_ON.has(id)))
|
|
58
|
+
|
|
59
|
+
/** Behaviour ids as the catalogue writes them: in UIG-8's order, each once. */
|
|
60
|
+
export function asBehaviours(ids: Iterable<string>): EntryBehaviour[] {
|
|
61
|
+
const held = new Set(ids)
|
|
62
|
+
return OWNED_BEHAVIOURS.filter((owned) => held.has(owned.id)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A tag as written: `Dialog.Popup`, `Button`. */
|
|
66
|
+
function tagText(tag: ts.JsxTagNameExpression): string | null {
|
|
67
|
+
if (ts.isIdentifier(tag)) return tag.text
|
|
68
|
+
if (ts.isPropertyAccessExpression(tag)) {
|
|
69
|
+
const inner = tagText(tag.expression as ts.JsxTagNameExpression)
|
|
70
|
+
return inner && `${inner}.${tag.name.text}`
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A tag's name and its piece: `['Dialog', 'Popup']`, `['Button', undefined]`. */
|
|
76
|
+
export const splitTag = (tag: string): [string, string | undefined] => {
|
|
77
|
+
const [root, piece] = tag.split('.')
|
|
78
|
+
return [root, piece]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every tag drawn anywhere under `node`, as written. */
|
|
82
|
+
function tagsUnder(node: ts.Node): Set<string> {
|
|
83
|
+
const found = new Set<string>()
|
|
84
|
+
const visit = (n: ts.Node) => {
|
|
85
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
86
|
+
const tag = tagText(n.tagName)
|
|
87
|
+
if (tag && /^[A-Z]/.test(tag)) found.add(tag)
|
|
88
|
+
}
|
|
89
|
+
ts.forEachChild(n, visit)
|
|
90
|
+
}
|
|
91
|
+
visit(node)
|
|
92
|
+
return found
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* For each name a file declares at its top (a function, a `const`, a class):
|
|
97
|
+
* the tags drawn inside it, with the file's own functions it draws followed, so
|
|
98
|
+
* what is left are names the file imports or declares without a body.
|
|
99
|
+
*/
|
|
100
|
+
export function drawnIn(sf: ts.SourceFile): Map<string, Set<string>> {
|
|
101
|
+
const direct = new Map<string, Set<string>>()
|
|
102
|
+
for (const statement of sf.statements) {
|
|
103
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) direct.set(statement.name.text, tagsUnder(statement))
|
|
104
|
+
else if (ts.isVariableStatement(statement)) {
|
|
105
|
+
for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) direct.set(d.name.text, tagsUnder(d.initializer))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const closed = new Map<string, Set<string>>()
|
|
109
|
+
for (const name of direct.keys()) {
|
|
110
|
+
const out = new Set<string>()
|
|
111
|
+
const seen = new Set([name])
|
|
112
|
+
const stack = [name]
|
|
113
|
+
while (stack.length) {
|
|
114
|
+
for (const tag of direct.get(stack.pop() as string) ?? []) {
|
|
115
|
+
out.add(tag)
|
|
116
|
+
if (direct.has(tag) && !seen.has(tag)) {
|
|
117
|
+
seen.add(tag)
|
|
118
|
+
stack.push(tag)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
closed.set(name, out)
|
|
123
|
+
}
|
|
124
|
+
return closed
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The tags an expression hands back at its top: both sides of a condition, never a fragment's children. */
|
|
128
|
+
function rootsOfExpression(e: ts.Node | undefined, out: Set<string>): void {
|
|
129
|
+
if (!e) return
|
|
130
|
+
if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e)) return rootsOfExpression(e.expression, out)
|
|
131
|
+
if (ts.isConditionalExpression(e)) {
|
|
132
|
+
rootsOfExpression(e.whenTrue, out)
|
|
133
|
+
rootsOfExpression(e.whenFalse, out)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (ts.isBinaryExpression(e)) {
|
|
137
|
+
if (e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) rootsOfExpression(e.left, out)
|
|
138
|
+
rootsOfExpression(e.right, out)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
// A fragment is several things side by side (a page and the dialog it opens):
|
|
142
|
+
// the part is none of them alone.
|
|
143
|
+
if (ts.isJsxFragment(e)) return
|
|
144
|
+
const tag = ts.isJsxElement(e) ? tagText(e.openingElement.tagName) : ts.isJsxSelfClosingElement(e) ? tagText(e.tagName) : null
|
|
145
|
+
if (tag && /^[A-Z]/.test(tag)) out.add(tag)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The tags a function hands back: every `return`, or an arrow's body, never a nested function's. */
|
|
149
|
+
function rootsOfFunction(fn: ts.Node): Set<string> {
|
|
150
|
+
const out = new Set<string>()
|
|
151
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) rootsOfExpression(fn.body, out)
|
|
152
|
+
const visit = (n: ts.Node) => {
|
|
153
|
+
if (n !== fn && (ts.isFunctionLike(n) || ts.isClassLike(n))) return
|
|
154
|
+
if (ts.isReturnStatement(n)) rootsOfExpression(n.expression, out)
|
|
155
|
+
ts.forEachChild(n, visit)
|
|
156
|
+
}
|
|
157
|
+
visit(fn)
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The function a declaration's value is: itself, or the one inside `forwardRef(…)`, `memo(…)`. */
|
|
162
|
+
function functionOf(node: ts.Node | undefined): ts.Node | undefined {
|
|
163
|
+
if (!node) return undefined
|
|
164
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return node
|
|
165
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return functionOf(node.expression)
|
|
166
|
+
if (ts.isCallExpression(node)) for (const argument of node.arguments) {
|
|
167
|
+
const fn = functionOf(argument)
|
|
168
|
+
if (fn) return fn
|
|
169
|
+
}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* For each function a file declares at its top: the parts it **is** — the tags it
|
|
175
|
+
* hands back at its root, following the file's own functions it hands back.
|
|
176
|
+
* `ConfirmDialog` returns a `DialogShell`, so it is one; a page with a menu
|
|
177
|
+
* somewhere in it is not a menu.
|
|
178
|
+
*/
|
|
179
|
+
export function rootsIn(sf: ts.SourceFile): Map<string, Set<string>> {
|
|
180
|
+
const direct = new Map<string, Set<string>>()
|
|
181
|
+
for (const statement of sf.statements) {
|
|
182
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) direct.set(statement.name.text, rootsOfFunction(statement))
|
|
183
|
+
else if (ts.isVariableStatement(statement)) {
|
|
184
|
+
for (const d of statement.declarationList.declarations) {
|
|
185
|
+
const fn = functionOf(d.initializer)
|
|
186
|
+
if (ts.isIdentifier(d.name) && fn) direct.set(d.name.text, rootsOfFunction(fn))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const closed = new Map<string, Set<string>>()
|
|
191
|
+
for (const name of direct.keys()) {
|
|
192
|
+
const out = new Set<string>()
|
|
193
|
+
const seen = new Set([name])
|
|
194
|
+
const stack = [name]
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
for (const tag of direct.get(stack.pop() as string) ?? []) {
|
|
197
|
+
if (direct.has(tag)) {
|
|
198
|
+
if (!seen.has(tag)) {
|
|
199
|
+
seen.add(tag)
|
|
200
|
+
stack.push(tag)
|
|
201
|
+
}
|
|
202
|
+
} else out.add(tag)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
closed.set(name, out)
|
|
206
|
+
}
|
|
207
|
+
return closed
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Each imported value name → where it came from. */
|
|
211
|
+
export function importsOf(sf: ts.SourceFile): Map<string, { specifier: string; imported: string }> {
|
|
212
|
+
const out = new Map<string, { specifier: string; imported: string }>()
|
|
213
|
+
for (const statement of sf.statements) {
|
|
214
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
|
|
215
|
+
const clause = statement.importClause
|
|
216
|
+
if (!clause || clause.isTypeOnly) continue
|
|
217
|
+
const specifier = statement.moduleSpecifier.text
|
|
218
|
+
if (clause.name) out.set(clause.name.text, { specifier, imported: 'default' })
|
|
219
|
+
const bindings = clause.namedBindings
|
|
220
|
+
if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) out.set(e.name.text, { specifier, imported: (e.propertyName ?? e.name).text })
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
package/src/registry/build.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import ts from 'typescript'
|
|
21
21
|
import { OWNED_BEHAVIOURS } from '../eslint/index'
|
|
22
|
+
import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, importsOf, isBaseUi, rootsIn, splitTag } from './behaviours'
|
|
22
23
|
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
23
24
|
import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
|
|
24
25
|
|
|
@@ -654,6 +655,41 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
654
655
|
|
|
655
656
|
if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`)
|
|
656
657
|
|
|
658
|
+
// What each part owns, from what it draws (B9): the Base UI parts in it, and
|
|
659
|
+
// every part of the package inside it, followed; UIG-8's list is the floor.
|
|
660
|
+
const moduleByName = new Map(values.map((v) => [v.name, v.module]))
|
|
661
|
+
const drawing = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>>; imports: ReturnType<typeof importsOf> }>()
|
|
662
|
+
const drawingOf = (moduleName: string) => {
|
|
663
|
+
if (!drawing.has(moduleName)) {
|
|
664
|
+
const file = moduleOf(moduleName).file
|
|
665
|
+
const sf = ts.createSourceFile(file, readFileSync(join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
666
|
+
drawing.set(moduleName, { drawn: drawnIn(sf), roots: rootsIn(sf), imports: importsOf(sf) })
|
|
667
|
+
}
|
|
668
|
+
return drawing.get(moduleName)!
|
|
669
|
+
}
|
|
670
|
+
const owns = (name: string, seen: Set<string>): Set<string> => {
|
|
671
|
+
const out = new Set(behavioursOf(name).map((b) => b.id))
|
|
672
|
+
const moduleName = moduleByName.get(name)
|
|
673
|
+
if (!moduleName || seen.has(name)) return out
|
|
674
|
+
seen.add(name)
|
|
675
|
+
const { drawn, roots, imports } = drawingOf(moduleName)
|
|
676
|
+
// The Base UI parts it draws, anywhere in it.
|
|
677
|
+
for (const tag of drawn.get(name) ?? []) {
|
|
678
|
+
const [local, piece] = splitTag(tag)
|
|
679
|
+
const from = imports.get(local)
|
|
680
|
+
if (from && isBaseUi(from.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
|
|
681
|
+
}
|
|
682
|
+
// The parts it is: what it hands back at its root.
|
|
683
|
+
for (const tag of roots.get(name) ?? []) {
|
|
684
|
+
const [local] = splitTag(tag)
|
|
685
|
+
const from = imports.get(local)
|
|
686
|
+
const part = from?.specifier.startsWith('./') ? from.imported : !from && local !== name ? local : undefined
|
|
687
|
+
if (part && moduleByName.has(part)) for (const id of handedOn(part, owns(part, seen))) out.add(id)
|
|
688
|
+
}
|
|
689
|
+
return out
|
|
690
|
+
}
|
|
691
|
+
for (const entry of entries) if (entry.kind === 'component') entry.ownsBehaviours = asBehaviours(owns(entry.name, new Set()))
|
|
692
|
+
|
|
657
693
|
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
658
694
|
|
|
659
695
|
return {
|
package/src/registry/cli.ts
CHANGED
|
@@ -36,11 +36,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
|
|
|
36
36
|
import { tmpdir } from 'node:os'
|
|
37
37
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
38
38
|
import { fileURLToPath } from 'node:url'
|
|
39
|
-
import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
|
|
39
|
+
import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
|
|
40
40
|
import { findInRegistries, formatFindings } from './find'
|
|
41
|
-
import { findSiblings, workspaceOf } from './siblings'
|
|
41
|
+
import { behindBy, findSiblings, workspaceOf } from './siblings'
|
|
42
42
|
import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
|
|
43
|
-
import { storyMapProblems } from './storymap'
|
|
43
|
+
import { indexHeadings, storyMapProblems } from './storymap'
|
|
44
44
|
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -132,11 +132,11 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
|
|
|
132
132
|
* Reports, and says whether it passed. A folder with no Storybook has nothing
|
|
133
133
|
* to check.
|
|
134
134
|
*/
|
|
135
|
-
function checkLinks(registry: Registry, folder: string): boolean {
|
|
135
|
+
function checkLinks(registry: Registry, folder: string): { ok: boolean; headings?: Set<string>; titles?: Set<string> } {
|
|
136
136
|
const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
|
|
137
137
|
if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
|
|
138
138
|
process.stdout.write('story links: no Storybook here, nothing to check\n')
|
|
139
|
-
return true
|
|
139
|
+
return { ok: true }
|
|
140
140
|
}
|
|
141
141
|
const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
|
|
142
142
|
// A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
|
|
@@ -147,17 +147,20 @@ function checkLinks(registry: Registry, folder: string): boolean {
|
|
|
147
147
|
: spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
|
|
148
148
|
if (run.status !== 0 || !existsSync(out)) {
|
|
149
149
|
process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
|
|
150
|
-
return false
|
|
150
|
+
return { ok: false }
|
|
151
151
|
}
|
|
152
|
-
const
|
|
152
|
+
const entries = (JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, { title?: string }> }).entries
|
|
153
|
+
const ids = new Set(Object.keys(entries))
|
|
154
|
+
const headings = indexHeadings(entries)
|
|
155
|
+
const titles = new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title] : [])))
|
|
153
156
|
const dead = linkProblems(registry, ids)
|
|
154
157
|
const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
|
|
155
158
|
if (dead.length) {
|
|
156
159
|
process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
|
|
157
|
-
return false
|
|
160
|
+
return { ok: false, headings, titles }
|
|
158
161
|
}
|
|
159
162
|
process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
|
|
160
|
-
return true
|
|
163
|
+
return { ok: true, headings, titles }
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
/**
|
|
@@ -179,12 +182,12 @@ async function checkGated(registry: Registry, folder: string, kinds?: string[]):
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
/**
|
|
182
|
-
* The
|
|
183
|
-
* Introduction — against the Storybook
|
|
184
|
-
* Reports, and says whether it passed.
|
|
185
|
+
* The repository's hand-written map of its Storybook — the heading order and the
|
|
186
|
+
* Introduction — against the Storybook (its own index, when there is one) and,
|
|
187
|
+
* in an app, the catalogue (`./storymap`). Reports, and says whether it passed.
|
|
185
188
|
*/
|
|
186
|
-
function checkStoryMap(
|
|
187
|
-
const problems = storyMapProblems(registry,
|
|
189
|
+
function checkStoryMap(folder: string, registry?: Registry, headings?: Set<string>): boolean {
|
|
190
|
+
const problems = storyMapProblems(folder, { ...(registry ? { registry } : {}), ...(headings ? { real: headings } : {}) })
|
|
188
191
|
if (problems.length) {
|
|
189
192
|
process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
|
|
190
193
|
return false
|
|
@@ -212,6 +215,8 @@ async function main(): Promise<number> {
|
|
|
212
215
|
const registries: Registry[] = [readPackageRegistry()]
|
|
213
216
|
// An app that cannot be read is said out loud, and the rest still answer.
|
|
214
217
|
const add = async (folder: string, repo: string | undefined) => {
|
|
218
|
+
const behind = behindBy(folder)
|
|
219
|
+
if (behind) process.stderr.write(`note: ${repo ?? basename(folder)} (${folder}) is ${behind} commit${behind === 1 ? '' : 's'} behind main as last fetched — its answers may name parts that are gone; pull it\n`)
|
|
215
220
|
try {
|
|
216
221
|
registries.push(await buildApp(folder, repo))
|
|
217
222
|
} catch (error) {
|
|
@@ -273,7 +278,10 @@ async function main(): Promise<number> {
|
|
|
273
278
|
const registry = await buildApp(root, value('repo'))
|
|
274
279
|
process.stdout.write(`${summary(registry)}\n`)
|
|
275
280
|
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
|
|
276
|
-
|
|
281
|
+
// A When not may name a part of the package or of this app (B8).
|
|
282
|
+
const pkg = readPackageRegistry()
|
|
283
|
+
const known = new Set([...pkg.entries, ...registry.entries].map((e) => e.name))
|
|
284
|
+
const broken = contractProblems(registry, root, known)
|
|
277
285
|
if (broken.length) {
|
|
278
286
|
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
|
|
279
287
|
} else {
|
|
@@ -283,8 +291,17 @@ async function main(): Promise<number> {
|
|
|
283
291
|
const linked = checkLinks(registry, root)
|
|
284
292
|
const shown = await checkGated(registry, root)
|
|
285
293
|
const loaded = checkLoader()
|
|
286
|
-
const mapped = checkStoryMap(registry,
|
|
287
|
-
|
|
294
|
+
const mapped = checkStoryMap(root, registry, linked.headings)
|
|
295
|
+
// What the pages name must still be there (R14): parts under When and When not,
|
|
296
|
+
// and the stories a Seen in line sends a reader to, here or in the package.
|
|
297
|
+
const stale = nameProblems(registry, root, {
|
|
298
|
+
known,
|
|
299
|
+
...(linked.titles ? { titles: linked.titles } : {}),
|
|
300
|
+
packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
|
|
301
|
+
})
|
|
302
|
+
if (stale.length) process.stderr.write(`usage pages: ${stale.length === 1 ? 'one name leads' : `${stale.length} names lead`} nowhere:\n ${stale.join('\n ')}\n`)
|
|
303
|
+
else process.stdout.write('usage pages: every part and story they name is there\n')
|
|
304
|
+
return broken.length || stale.length || !linked.ok || !shown || !loaded || !mapped ? 1 : 0
|
|
288
305
|
}
|
|
289
306
|
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
290
307
|
const path = packageRegistryPath()
|
|
@@ -308,7 +325,9 @@ async function main(): Promise<number> {
|
|
|
308
325
|
const linked = checkLinks(built, root)
|
|
309
326
|
const shown = await checkGated(built, root, ['component'])
|
|
310
327
|
const loaded = checkLoader()
|
|
311
|
-
|
|
328
|
+
// The package keeps a heading order too (R16): it left out Layout, and nothing saw.
|
|
329
|
+
const mapped = checkStoryMap(root, undefined, linked.headings)
|
|
330
|
+
return linked.ok && shown && loaded && mapped ? 0 : 1
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
case 'skill': {
|
|
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
|
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
20
|
import { buildAppRegistry } from './app'
|
|
21
|
-
import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
21
|
+
import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem, whenNotProblem } from './contract'
|
|
22
22
|
|
|
23
23
|
const made: string[] = []
|
|
24
24
|
afterAll(() => {
|
|
@@ -53,6 +53,8 @@ const PAGE = [
|
|
|
53
53
|
'',
|
|
54
54
|
'A word.',
|
|
55
55
|
'',
|
|
56
|
+
'No alternative: a count has only this badge.',
|
|
57
|
+
'',
|
|
56
58
|
'## How',
|
|
57
59
|
'',
|
|
58
60
|
'```tsx',
|
|
@@ -78,7 +80,7 @@ describe('a usage page', () => {
|
|
|
78
80
|
})
|
|
79
81
|
|
|
80
82
|
it('fails with its sections out of order', () => {
|
|
81
|
-
const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
|
|
83
|
+
const swapped = PAGE.replace('## When not\n\nA word.\n\nNo alternative: a count has only this badge.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
|
|
82
84
|
expect(pageProblem(swapped)).toMatch(/^sections out of order/)
|
|
83
85
|
})
|
|
84
86
|
|
|
@@ -86,8 +88,23 @@ describe('a usage page', () => {
|
|
|
86
88
|
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
|
|
87
89
|
})
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
// R18: the code must be under How itself; one in a later section passed.
|
|
92
|
+
it('fails with no code under How, even with code further down', () => {
|
|
90
93
|
expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
|
|
94
|
+
const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
|
|
95
|
+
expect(pageProblem(later)).toBe('no code under "How"')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// B8: When not names the part to use instead, or says there is none.
|
|
99
|
+
it('asks When not to name another real part, or to say there is none', () => {
|
|
100
|
+
const known = new Set(['Badge', 'Once', 'Chip'])
|
|
101
|
+
const whenNot = (body: string) => PAGE.replace('A word.\n\nNo alternative: a count has only this badge.', body)
|
|
102
|
+
expect(whenNotProblem(PAGE, ['Badge'], known)).toBeNull()
|
|
103
|
+
expect(whenNotProblem(whenNot('A word. That is a **Chip**.'), ['Badge'], known)).toBeNull()
|
|
104
|
+
expect(whenNotProblem(whenNot('- A word.\n\nNo alternative: a count has only this.'), ['Badge'], known)).toBeNull()
|
|
105
|
+
expect(whenNotProblem(whenNot('A word.'), ['Badge'], known)).toMatch(/^"When not" names no other part/)
|
|
106
|
+
// Its own name, a name nothing has, and "No alternative:" mid-line do not count.
|
|
107
|
+
expect(whenNotProblem(whenNot('Not **Badge** twice, nor a `Widget`. See: No alternative: here.'), ['Badge'], known)).not.toBeNull()
|
|
91
108
|
})
|
|
92
109
|
|
|
93
110
|
it('reads a Windows checkout', () => {
|
|
@@ -133,6 +150,17 @@ describe('an app against the contract', () => {
|
|
|
133
150
|
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
134
151
|
})
|
|
135
152
|
|
|
153
|
+
it("links a part with no story to its page by the page's title, and names one that links to nothing (B10)", () => {
|
|
154
|
+
const seen = PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n')
|
|
155
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': seen })
|
|
156
|
+
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
|
|
157
|
+
expect(registry.entries.find((e) => e.name === 'Badge')).toMatchObject({ docsId: 'parts-badge--docs', storyId: null })
|
|
158
|
+
writeFileSync(join(dir, 'src/Badge.mdx'), seen.replace("<Meta title='Parts/Badge' />", '<Meta />'))
|
|
159
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
160
|
+
'Badge (reusable, src/Badge.tsx) links to nothing in Storybook: give src/Badge.mdx a `<Meta title="…" />`, or give the part a story',
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
136
164
|
it("counts a story file named after the part that draws its view, from the part's own file", () => {
|
|
137
165
|
const dir = app({
|
|
138
166
|
'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
|
|
@@ -156,3 +184,35 @@ describe('story links', () => {
|
|
|
156
184
|
])
|
|
157
185
|
})
|
|
158
186
|
})
|
|
187
|
+
|
|
188
|
+
// R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
|
|
189
|
+
// StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
|
|
190
|
+
// deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
|
|
191
|
+
// named the package's story by a title it had lost.
|
|
192
|
+
describe('nameProblems', () => {
|
|
193
|
+
const page = (whenNot: string, seenIn = '') =>
|
|
194
|
+
PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
|
|
195
|
+
const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
|
|
196
|
+
const known = new Set(['Badge', 'Chip'])
|
|
197
|
+
|
|
198
|
+
it('names a part under When not that no catalogue has, and passes one that exists', () => {
|
|
199
|
+
const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
|
|
200
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
|
|
204
|
+
const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
|
|
205
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
|
|
206
|
+
const titles = new Set(['Parts/Badge'])
|
|
207
|
+
expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
|
|
208
|
+
'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
|
|
209
|
+
'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
|
|
210
|
+
])
|
|
211
|
+
expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('leaves the Seen in line alone when there is no Storybook index to read', () => {
|
|
215
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
|
|
216
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual([])
|
|
217
|
+
})
|
|
218
|
+
})
|