@estiva-app/ui 0.26.0 → 0.28.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/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
- package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
- package/dist/gates/{chunk-EA33NP5B.js → chunk-XE6HC5MJ.js} +8 -12
- package/dist/gates/chunk-XE6HC5MJ.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/gate-config.d.ts +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/index.d.ts +2 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +36 -6
- 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/gates/token-lint.d.ts +27 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
- package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
- package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
- package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
- package/dist/registry/cli.js +69 -8
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +29 -0
- package/dist/registry/contract.d.ts.map +1 -0
- package/dist/registry/gated-5CPIUQAW.js +230 -0
- package/dist/registry/gated-5CPIUQAW.js.map +7 -0
- package/dist/registry/gated.d.ts +31 -0
- package/dist/registry/gated.d.ts.map +1 -0
- package/dist/registry/index.d.ts +1 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +15 -3
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CollapsibleSection.stories.tsx +6 -0
- package/src/ListColumn.stories.tsx +6 -0
- package/src/MenuItem.stories.tsx +151 -135
- package/src/Person.stories.tsx +7 -0
- package/src/PersonTrigger.stories.tsx +7 -0
- package/src/SectionHeader.stories.tsx +5 -0
- package/src/gates/app-checks.ts +19 -2
- package/src/gates/create-app.ts +7 -11
- package/src/gates/gate-config.ts +3 -2
- package/src/gates/gates.test.ts +35 -1
- package/src/gates/index.ts +2 -1
- package/src/gates/status.ts +15 -0
- package/src/gates/token-lint.ts +47 -2
- package/src/registry/app.test.ts +14 -1
- package/src/registry/app.ts +5 -1
- package/src/registry/cli.ts +79 -7
- package/src/registry/contract.test.ts +158 -0
- package/src/registry/contract.ts +108 -0
- package/src/registry/gated.test.ts +98 -0
- package/src/registry/gated.ts +308 -0
- package/src/registry/index.ts +1 -0
- package/dist/gates/chunk-EA33NP5B.js.map +0 -7
- package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
- package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
- /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
package/src/registry/app.test.ts
CHANGED
|
@@ -540,11 +540,24 @@ describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
|
|
|
540
540
|
const run = (args: string[], cwd: string) => execFileSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
541
541
|
|
|
542
542
|
it('check prints what it found, and fails on a part with no description', () => {
|
|
543
|
-
expect(run(['check', '--repo', 'fixture'], root)).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
|
|
544
543
|
const bare = app({ 'src/One.tsx': 'export function One() {\n return <i />\n}\n' })
|
|
545
544
|
expect(() => run(['check'], bare)).toThrow(/One in src\/One\.tsx has no one-line description/)
|
|
546
545
|
})
|
|
547
546
|
|
|
547
|
+
it('check fails a reusable part with no usage page, naming the page to write (UIG-19)', () => {
|
|
548
|
+
// The fixture's reusable parts have no pages: it was written before the contract ran in `check`.
|
|
549
|
+
let failed: { stdout?: string; stderr?: string } = {}
|
|
550
|
+
try {
|
|
551
|
+
run(['check', '--repo', 'fixture'], root)
|
|
552
|
+
} catch (error) {
|
|
553
|
+
failed = error as { stdout?: string; stderr?: string }
|
|
554
|
+
}
|
|
555
|
+
expect(failed.stdout).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
|
|
556
|
+
expect(failed.stderr).toContain('Timeline (reusable, src/components/Timeline.tsx) has no usage page: write src/components/Timeline.mdx')
|
|
557
|
+
const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n' })
|
|
558
|
+
expect(run(['check'], one)).toContain('usage pages: all 0 reusable parts keep the contract')
|
|
559
|
+
})
|
|
560
|
+
|
|
548
561
|
it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
|
|
549
562
|
// What `estiva-ui build` leaves behind in an app, and git ignores.
|
|
550
563
|
run(['build'], root)
|
package/src/registry/app.ts
CHANGED
|
@@ -678,7 +678,11 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
678
678
|
const storyOf = (p: Found) => {
|
|
679
679
|
const stem = (file: string) => basename(file).replace(/\.stories\.[cm]?[jt]sx?$/, '')
|
|
680
680
|
const own = basename(p.file, extname(p.file))
|
|
681
|
-
|
|
681
|
+
// A story file named after the part draws it even when its stories are of a
|
|
682
|
+
// presentational half in the same file — `FilesPanel.stories.tsx` drawing
|
|
683
|
+
// `FilesPanelView` is FilesPanel's picture, and its page attaches there (UIG-19).
|
|
684
|
+
const sameFile = (s: (typeof stories)[number]) => s.imports.some((q) => q.file === p.file)
|
|
685
|
+
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)) || (stem(s.file) === p.name && sameFile(s))))
|
|
682
686
|
candidates.sort((a, b) => Number(b.component === p) - Number(a.component === p) || a.file.localeCompare(b.file))
|
|
683
687
|
const s = candidates[0]
|
|
684
688
|
if (!s || !s.title) return { docsId: null, storyId: null }
|
package/src/registry/cli.ts
CHANGED
|
@@ -15,16 +15,20 @@
|
|
|
15
15
|
*
|
|
16
16
|
* **In an app**: nothing is committed (Katerina, 18 September 2026 — the
|
|
17
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
|
|
19
|
-
*
|
|
18
|
+
* and fails when a part has no one-line description or cannot be sorted, or
|
|
19
|
+
* when a reusable part breaks the usage-page contract (UIG-19, `./contract`);
|
|
20
|
+
* CI's `gate` job runs it. `build` writes the catalogue to a file only when asked.
|
|
20
21
|
*
|
|
21
22
|
* `find` searches the package's catalogue — the one shipped inside the installed
|
|
22
23
|
* package, or this repo's own in the package — plus, in an app, the app's own,
|
|
23
24
|
* built fresh, plus each `--also` app that sits beside it.
|
|
24
25
|
*/
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { spawnSync } from 'node:child_process'
|
|
27
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { basename, join, resolve } from 'node:path'
|
|
27
30
|
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
|
|
28
32
|
import { findInRegistries, formatFindings } from './find'
|
|
29
33
|
import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
30
34
|
|
|
@@ -39,6 +43,7 @@ import { CLASSES, validateRegistry, type Registry } from './schema'
|
|
|
39
43
|
*/
|
|
40
44
|
const packageBuilder = () => import('./build')
|
|
41
45
|
const appBuilder = () => import('./app')
|
|
46
|
+
const gatedScan = () => import('./gated')
|
|
42
47
|
|
|
43
48
|
const [command, ...rest] = process.argv.slice(2)
|
|
44
49
|
const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
|
|
@@ -94,6 +99,60 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
|
|
|
94
99
|
return registry
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Every story and docs link in a catalogue, against the Storybook beside it
|
|
104
|
+
* (UIG-19). `storybook index` writes the ids Storybook itself would serve — no
|
|
105
|
+
* server, no browser, seconds — so a renamed story, or a page that no longer
|
|
106
|
+
* compiles, fails here rather than as a dead link somebody finds later.
|
|
107
|
+
* Reports, and says whether it passed. A folder with no Storybook has nothing
|
|
108
|
+
* to check.
|
|
109
|
+
*/
|
|
110
|
+
function checkLinks(registry: Registry, folder: string): boolean {
|
|
111
|
+
const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
|
|
112
|
+
if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
|
|
113
|
+
process.stdout.write('story links: no Storybook here, nothing to check\n')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
|
|
117
|
+
// A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
|
|
118
|
+
// rather than an argument list. Both paths are ours: the app's folder and a temp file.
|
|
119
|
+
const run =
|
|
120
|
+
process.platform === 'win32'
|
|
121
|
+
? spawnSync(`"${bin}" index -o "${out}" --quiet`, { cwd: folder, encoding: 'utf8', shell: true })
|
|
122
|
+
: spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
|
|
123
|
+
if (run.status !== 0 || !existsSync(out)) {
|
|
124
|
+
process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
const ids = new Set(Object.keys((JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, unknown> }).entries))
|
|
128
|
+
const dead = linkProblems(registry, ids)
|
|
129
|
+
const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
|
|
130
|
+
if (dead.length) {
|
|
131
|
+
process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a part draws only when a prop is passed, where no story passes it
|
|
140
|
+
* (UIG-19, `./gated`). Strict, by Katerina's ruling: a part's own stories show
|
|
141
|
+
* everything it can draw. In an app it reads the reusable parts; in the package,
|
|
142
|
+
* every component (`kinds` is the package's one class, `component`).
|
|
143
|
+
*/
|
|
144
|
+
async function checkGated(registry: Registry, folder: string, kinds?: string[]): Promise<boolean> {
|
|
145
|
+
const { gatedFindings } = await gatedScan()
|
|
146
|
+
const found = gatedFindings(registry, folder, { ...(kinds ? { kinds } : {}), pictured: 'own' })
|
|
147
|
+
if (found.length) {
|
|
148
|
+
const lines = found.map((f) => `${f.part} (${f.file}) draws something only when given \`${f.prop}\`, and no story that draws it passes \`${f.prop}\`: pass it in a story`)
|
|
149
|
+
process.stderr.write(`stories: ${found.length === 1 ? 'one thing a part draws is' : `${found.length} things parts draw are`} never shown:\n ${lines.join('\n ')}\n`)
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
process.stdout.write('stories: everything a part draws behind a prop is shown by a story\n')
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
97
156
|
/** One line per kind, in a fixed order, so two runs read the same. */
|
|
98
157
|
function summary(registry: Registry): string {
|
|
99
158
|
const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
|
|
@@ -165,8 +224,19 @@ async function main(): Promise<number> {
|
|
|
165
224
|
|
|
166
225
|
case 'check': {
|
|
167
226
|
if (!isPackage) {
|
|
168
|
-
|
|
169
|
-
|
|
227
|
+
const registry = await buildApp(root, value('repo'))
|
|
228
|
+
process.stdout.write(`${summary(registry)}\n`)
|
|
229
|
+
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
|
|
230
|
+
const broken = contractProblems(registry, root)
|
|
231
|
+
if (broken.length) {
|
|
232
|
+
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
|
|
233
|
+
} else {
|
|
234
|
+
const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
|
|
235
|
+
process.stdout.write(`usage pages: all ${owed} reusable parts keep the contract and are drawn somewhere\n`)
|
|
236
|
+
}
|
|
237
|
+
const linked = checkLinks(registry, root)
|
|
238
|
+
const shown = await checkGated(registry, root)
|
|
239
|
+
return broken.length || !linked || !shown ? 1 : 0
|
|
170
240
|
}
|
|
171
241
|
const { buildRegistry, serializeRegistry } = await packageBuilder()
|
|
172
242
|
const path = packageRegistryPath()
|
|
@@ -187,7 +257,9 @@ async function main(): Promise<number> {
|
|
|
187
257
|
return 1
|
|
188
258
|
}
|
|
189
259
|
process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
|
|
190
|
-
|
|
260
|
+
const linked = checkLinks(built, root)
|
|
261
|
+
const shown = await checkGated(built, root, ['component'])
|
|
262
|
+
return linked && shown ? 0 : 1
|
|
191
263
|
}
|
|
192
264
|
|
|
193
265
|
default:
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* UIG-19: the usage-page contract every app's `gate` job runs, and the story
|
|
4
|
+
* links. Each case is one the three repos actually have, or one that got past
|
|
5
|
+
* the copies this replaces:
|
|
6
|
+
*
|
|
7
|
+
* - a page that lost its `When not` (the ticket's own example);
|
|
8
|
+
* - Peek's pages, which never checked the order or the code under `How`;
|
|
9
|
+
* - a reusable part drawn only inside another story, whose page says so on a
|
|
10
|
+
* **Seen in** line (Katerina, 21 September);
|
|
11
|
+
* - a story file named after a part whose stories draw its `…View` half, in the
|
|
12
|
+
* same file — FilesPanel and TopicDetailsDialog in Peek, which the catalogue
|
|
13
|
+
* used to call drawn nowhere;
|
|
14
|
+
* - a Windows checkout, read with CRLF.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
|
+
import { buildAppRegistry } from './app'
|
|
21
|
+
import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
22
|
+
|
|
23
|
+
const made: string[] = []
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function app(files: Record<string, string>): string {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'uig19-'))
|
|
30
|
+
made.push(dir)
|
|
31
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
|
|
32
|
+
for (const [path, text] of Object.entries(files)) {
|
|
33
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
34
|
+
writeFileSync(join(dir, path), text)
|
|
35
|
+
}
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PAGE = [
|
|
40
|
+
"import { Meta } from '@storybook/addon-docs/blocks'",
|
|
41
|
+
'',
|
|
42
|
+
"<Meta title='Parts/Badge' />",
|
|
43
|
+
'',
|
|
44
|
+
'# Badge',
|
|
45
|
+
'',
|
|
46
|
+
'A small count beside a name.',
|
|
47
|
+
'',
|
|
48
|
+
'## When',
|
|
49
|
+
'',
|
|
50
|
+
'A count.',
|
|
51
|
+
'',
|
|
52
|
+
'## When not',
|
|
53
|
+
'',
|
|
54
|
+
'A word.',
|
|
55
|
+
'',
|
|
56
|
+
'## How',
|
|
57
|
+
'',
|
|
58
|
+
'```tsx',
|
|
59
|
+
'<Badge count={3} />',
|
|
60
|
+
'```',
|
|
61
|
+
'',
|
|
62
|
+
'## What it owns',
|
|
63
|
+
'',
|
|
64
|
+
'Nothing. It only draws.',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n')
|
|
67
|
+
|
|
68
|
+
const part = (name: string, cls = 'reusable') =>
|
|
69
|
+
`/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
|
|
70
|
+
|
|
71
|
+
describe('a usage page', () => {
|
|
72
|
+
it('keeps the contract with an opening line and the four sections in order', () => {
|
|
73
|
+
expect(pageProblem(PAGE)).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('fails without "When not"', () => {
|
|
77
|
+
expect(pageProblem(PAGE.replace('## When not', '## Not when'))).toBe('no "When not" section')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
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')
|
|
82
|
+
expect(pageProblem(swapped)).toMatch(/^sections out of order/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('fails with no opening line', () => {
|
|
86
|
+
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fails with no code under How', () => {
|
|
90
|
+
expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('reads a Windows checkout', () => {
|
|
94
|
+
expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('knows a Seen in line, and only at the start of a line', () => {
|
|
98
|
+
expect(hasSeenIn('**Seen in** — *Messages/ConversationCard*.')).toBe(true)
|
|
99
|
+
expect(hasSeenIn('It is **Seen in** two places.')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('an app against the contract', () => {
|
|
104
|
+
it('passes a reusable part with a page and a story, and asks nothing of a one-off', () => {
|
|
105
|
+
const dir = app({
|
|
106
|
+
'src/Badge.tsx': part('Badge'),
|
|
107
|
+
'src/Badge.mdx': PAGE,
|
|
108
|
+
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
|
|
109
|
+
'src/Once.tsx': part('Once', 'one-off'),
|
|
110
|
+
})
|
|
111
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('names a reusable part with no page, and the file to write', () => {
|
|
115
|
+
const dir = app({ 'src/Badge.tsx': part('Badge') })
|
|
116
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
117
|
+
'Badge (reusable, src/Badge.tsx) has no usage page: write src/Badge.mdx, with an opening line, then When, When not, How, What it owns',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('names a page that lost a section', () => {
|
|
122
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE.replace('## When not', '## Not when') })
|
|
123
|
+
const problems = contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)
|
|
124
|
+
expect(problems).toContain('Badge (reusable, src/Badge.tsx): src/Badge.mdx has no "When not" section')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('names a reusable part drawn nowhere, and passes it once its page says where it is seen', () => {
|
|
128
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE })
|
|
129
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
130
|
+
'Badge (reusable, src/Badge.tsx) is drawn nowhere: give it a story, or write a "**Seen in**" line on src/Badge.mdx naming the stories that draw it, or why none can',
|
|
131
|
+
])
|
|
132
|
+
writeFileSync(join(dir, 'src/Badge.mdx'), 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'))
|
|
133
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("counts a story file named after the part that draws its view, from the part's own file", () => {
|
|
137
|
+
const dir = app({
|
|
138
|
+
'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
|
|
139
|
+
'src/Panel.stories.tsx': "import { PanelView } from './Panel'\nconst meta = { title: 'Parts/Panel', component: PanelView }\nexport default meta\nexport const Open = {}\n",
|
|
140
|
+
})
|
|
141
|
+
const entry = buildAppRegistry({ root: dir, repo: 'fixture' }).entries.find((e) => e.name === 'Panel')
|
|
142
|
+
expect(entry?.storyId).toBe('parts-panel--open')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('story links', () => {
|
|
147
|
+
it('names a link its Storybook does not have, and passes the ones it has', () => {
|
|
148
|
+
const dir = app({
|
|
149
|
+
'src/Badge.tsx': part('Badge'),
|
|
150
|
+
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
|
|
151
|
+
})
|
|
152
|
+
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
|
|
153
|
+
expect(linkProblems(registry, new Set(['parts-badge--default', 'parts-badge--docs']))).toEqual([])
|
|
154
|
+
expect(linkProblems(registry, new Set(['parts-badge--docs']))).toEqual([
|
|
155
|
+
'Badge (src/Badge.tsx) links to a story its Storybook does not have: parts-badge--default',
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage-page contract, checked against an app's catalogue (UIG-19).
|
|
3
|
+
*
|
|
4
|
+
* Every part an app classifies **reusable** (or **promote-candidate**) has a
|
|
5
|
+
* page beside it — `<PartName>.mdx` in the component's own folder — that opens
|
|
6
|
+
* with a line saying what the part is, then `When`, `When not`, `How` (with
|
|
7
|
+
* code) and `What it owns`, in that order. And it is drawn somewhere: a story
|
|
8
|
+
* of its own, or a **Seen in** line on its page naming the stories that draw
|
|
9
|
+
* it, or saying why none can (Katerina, 21 and 23 September 2026). A one-off
|
|
10
|
+
* needs only its class and a purpose line, which the catalogue already refuses
|
|
11
|
+
* to build without.
|
|
12
|
+
*
|
|
13
|
+
* This is the one copy. Peek and Ship each used to carry their own, pasted into
|
|
14
|
+
* their `scripts/gates-checks.mjs` and run only by `gates:status`, so a page
|
|
15
|
+
* could lose its `When not` and still merge. `estiva-ui check` runs this in
|
|
16
|
+
* every app's `gate` job (docs/GATES.md §23: one copy, in the package).
|
|
17
|
+
*
|
|
18
|
+
* The package's own pages keep the fuller contract in `src/pages.test.ts`,
|
|
19
|
+
* which also reads `What it owns` back against the lint rule.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import type { Registry } from './schema'
|
|
24
|
+
|
|
25
|
+
/** The four headings, in the order a page carries them. */
|
|
26
|
+
export const PAGE_SECTIONS = ['When', 'When not', 'How', 'What it owns'] as const
|
|
27
|
+
|
|
28
|
+
/** The classes that owe a page and a picture. */
|
|
29
|
+
export const CONTRACT_KINDS = ['reusable', 'promote-candidate'] as const
|
|
30
|
+
|
|
31
|
+
/** The line that stands in for a story of the part's own. */
|
|
32
|
+
const SEEN_IN = /^\*\*Seen in\*\*/m
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a page breaks the contract, or null when it keeps it.
|
|
36
|
+
*
|
|
37
|
+
* Read on normalised text: the apps are checked out with CRLF on Windows.
|
|
38
|
+
*/
|
|
39
|
+
export function pageProblem(source: string): string | null {
|
|
40
|
+
const text = source.replace(/\r\n/g, '\n')
|
|
41
|
+
const heads = [...text.matchAll(/^##+\s+(.+?)\s*$/gm)].map((m) => m[1])
|
|
42
|
+
const at = PAGE_SECTIONS.map((s) => heads.indexOf(s))
|
|
43
|
+
const missing = PAGE_SECTIONS.filter((_, i) => at[i] < 0)
|
|
44
|
+
if (missing.length) return `no ${missing.map((s) => `"${s}"`).join(', ')} section`
|
|
45
|
+
if (at.some((i, n) => n > 0 && i < at[n - 1])) return `sections out of order: they go ${PAGE_SECTIONS.join(', ')}`
|
|
46
|
+
// The opening line: the first paragraph under the title that is not markup.
|
|
47
|
+
const opening = text
|
|
48
|
+
.split(/^# .*$/m)[1]
|
|
49
|
+
?.split(/^##\s/m)[0]
|
|
50
|
+
?.split(/\n\s*\n/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
|
|
53
|
+
if (!opening) return 'no opening line under the title saying what the part is'
|
|
54
|
+
if (!/^##\s+How\s*\n[\s\S]*?```tsx?\n/m.test(text)) return 'no code under "How"'
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a page records where the part is drawn, in place of a story of its own. */
|
|
59
|
+
export function hasSeenIn(source: string): boolean {
|
|
60
|
+
return SEEN_IN.test(source.replace(/\r\n/g, '\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every catalogue link that names a story or a docs page its Storybook does not
|
|
65
|
+
* have (UIG-19: a dead link is a build failure, not a dead end found six weeks
|
|
66
|
+
* later). `ids` is every entry id in the Storybook's own index — what
|
|
67
|
+
* `storybook index` writes — so this is Storybook's answer, not a guess at how
|
|
68
|
+
* it builds an id.
|
|
69
|
+
*/
|
|
70
|
+
export function linkProblems(registry: Registry, ids: ReadonlySet<string>): string[] {
|
|
71
|
+
const problems: string[] = []
|
|
72
|
+
for (const entry of registry.entries) {
|
|
73
|
+
for (const key of ['storyId', 'docsId'] as const) {
|
|
74
|
+
const id = entry[key]
|
|
75
|
+
if (id && !ids.has(id)) problems.push(`${entry.name} (${entry.sourceFile}) links to ${key === 'storyId' ? 'a story' : 'a docs page'} its Storybook does not have: ${id}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return problems
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every way an app's catalogue breaks the contract, one line each, naming the
|
|
83
|
+
* part, its file and what to do. Empty when it keeps it.
|
|
84
|
+
*
|
|
85
|
+
* `root` is the app's own folder: the one its catalogue's paths are relative to.
|
|
86
|
+
*/
|
|
87
|
+
export function contractProblems(registry: Registry, root: string): string[] {
|
|
88
|
+
const problems: string[] = []
|
|
89
|
+
for (const entry of registry.entries) {
|
|
90
|
+
const cls = entry.app?.class
|
|
91
|
+
if (!cls || !(CONTRACT_KINDS as readonly string[]).includes(cls)) continue
|
|
92
|
+
// Where the page must be: beside the component, named after the part —
|
|
93
|
+
// the same place the catalogue looks (src/registry/app.ts).
|
|
94
|
+
const page = join(entry.sourceFile.replace(/[^/\\]+$/, ''), `${entry.name}.mdx`).replace(/\\/g, '/')
|
|
95
|
+
const where = `${entry.name} (${cls}, ${entry.sourceFile})`
|
|
96
|
+
if (!existsSync(join(root, page))) {
|
|
97
|
+
problems.push(`${where} has no usage page: write ${page}, with an opening line, then ${PAGE_SECTIONS.join(', ')}`)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const text = readFileSync(join(root, page), 'utf8')
|
|
101
|
+
const problem = pageProblem(text)
|
|
102
|
+
if (problem) problems.push(`${where}: ${page} has ${problem}`)
|
|
103
|
+
if (entry.storyId === null && !hasSeenIn(text)) {
|
|
104
|
+
problems.push(`${where} is drawn nowhere: give it a story, or write a "**Seen in**" line on ${page} naming the stories that draw it, or why none can`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return problems
|
|
108
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* UIG-19: what a part draws only behind a prop, and no story passes. Each case
|
|
4
|
+
* is one the scan met in Peek or the package:
|
|
5
|
+
*
|
|
6
|
+
* - a gate on the part itself (SectionHeader's chevron);
|
|
7
|
+
* - a prop handed to a smaller part that gates on it (ConversationCard's
|
|
8
|
+
* `onReply`, which the strip draws — strict, by Katerina's ruling);
|
|
9
|
+
* - a prop handed to a dialog, shut in every story (skipped, her ruling);
|
|
10
|
+
* - a story that spreads another story's args (`...Topic.args`);
|
|
11
|
+
* - a spread it cannot open (a helper's `{...props}`), which keeps it quiet.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
17
|
+
import { buildAppRegistry } from './app'
|
|
18
|
+
import { gatedFindings } from './gated'
|
|
19
|
+
|
|
20
|
+
const made: string[] = []
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function app(files: Record<string, string>): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'uig19-gated-'))
|
|
27
|
+
made.push(dir)
|
|
28
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
|
|
29
|
+
for (const [path, text] of Object.entries(files)) {
|
|
30
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
31
|
+
writeFileSync(join(dir, path), text)
|
|
32
|
+
}
|
|
33
|
+
return dir
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const find = (dir: string) => gatedFindings(buildAppRegistry({ root: dir, repo: 'fixture' }), dir).map((f) => `${f.part}.${f.prop}`)
|
|
37
|
+
|
|
38
|
+
const card = `/**
|
|
39
|
+
* A card.
|
|
40
|
+
* @registry reusable: the fixture says so
|
|
41
|
+
*/
|
|
42
|
+
export function Card({ title, onReply, onShare }: { title: string; onReply?: () => void; onShare?: () => void }) {
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
{title}
|
|
46
|
+
{onReply && <button type="button">Reply</button>}
|
|
47
|
+
<Strip onShare={onShare} />
|
|
48
|
+
<ShareDialog onShare={onShare} />
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The strip under a card. */
|
|
54
|
+
export function Strip({ onShare }: { onShare?: () => void }) {
|
|
55
|
+
return <div>{onShare ? <button type="button">Share</button> : null}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A dialog, shut until opened. */
|
|
59
|
+
export function ShareDialog({ onShare }: { onShare?: () => void }) {
|
|
60
|
+
return <div>{onShare && <button type="button">Share now</button>}</div>
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
const story = (body: string) => `import { Card } from './Card'\nconst meta = { title: 'Parts/Card', component: Card, args: { title: 'Hello' } }\nexport default meta\n${body}\n`
|
|
65
|
+
|
|
66
|
+
describe('what a part draws behind a prop', () => {
|
|
67
|
+
it('names a gate on the part, and one it hands to a smaller part, when no story passes either', () => {
|
|
68
|
+
const dir = app({ 'src/Card.tsx': card, 'src/Card.stories.tsx': story('export const Default = {}') })
|
|
69
|
+
expect(find(dir).sort()).toEqual(['Card.onReply', 'Card.onShare'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is satisfied by a story that passes them, in args or through another story’s args', () => {
|
|
73
|
+
const dir = app({
|
|
74
|
+
'src/Card.tsx': card,
|
|
75
|
+
'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }\nexport const Shared = { args: { ...Default.args, onShare: () => {} } }"),
|
|
76
|
+
})
|
|
77
|
+
expect(find(dir)).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('skips a prop that reaches only a dialog', () => {
|
|
81
|
+
const only = card.replace(' <Strip onShare={onShare} />\n', '')
|
|
82
|
+
const dir = app({ 'src/Card.tsx': only, 'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }") })
|
|
83
|
+
expect(find(dir)).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('stays quiet when a story passes props it cannot read', () => {
|
|
87
|
+
const dir = app({
|
|
88
|
+
'src/Card.tsx': card,
|
|
89
|
+
'src/Card.stories.tsx': `import { Card } from './Card'\nimport { props } from './fixtures'\nconst meta = { title: 'Parts/Card', component: Card }\nexport default meta\nexport const Default = { render: () => <Card title="x" {...props} /> }\n`,
|
|
90
|
+
})
|
|
91
|
+
expect(find(dir)).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('asks nothing of a part no story draws: the contract says so instead', () => {
|
|
95
|
+
const dir = app({ 'src/Card.tsx': card })
|
|
96
|
+
expect(find(dir)).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
})
|