@estiva-app/ui 0.35.1 → 0.35.2
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/{chunk-GI6M4P7H.js → chunk-DBMUQAR6.js} +4 -3
- package/dist/gates/chunk-DBMUQAR6.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.js +5 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-5K2TRR4X.js} +30 -2
- package/dist/registry/chunk-5K2TRR4X.js.map +7 -0
- package/dist/registry/cli.js +89 -31
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +14 -0
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- 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 +1 -1
- package/registry.json +1 -1
- package/src/gates/gate-config.ts +4 -2
- package/src/gates/gates.test.ts +7 -0
- package/src/gates/hook.ts +6 -2
- package/src/pages.test.ts +11 -0
- package/src/registry/cli.ts +34 -17
- package/src/registry/contract.test.ts +37 -2
- package/src/registry/contract.ts +49 -1
- 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-GI6M4P7H.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
|
@@ -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 } from './contract'
|
|
22
22
|
|
|
23
23
|
const made: string[] = []
|
|
24
24
|
afterAll(() => {
|
|
@@ -86,8 +86,11 @@ describe('a usage page', () => {
|
|
|
86
86
|
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// R18: the code must be under How itself; one in a later section passed.
|
|
90
|
+
it('fails with no code under How, even with code further down', () => {
|
|
90
91
|
expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
|
|
92
|
+
const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
|
|
93
|
+
expect(pageProblem(later)).toBe('no code under "How"')
|
|
91
94
|
})
|
|
92
95
|
|
|
93
96
|
it('reads a Windows checkout', () => {
|
|
@@ -156,3 +159,35 @@ describe('story links', () => {
|
|
|
156
159
|
])
|
|
157
160
|
})
|
|
158
161
|
})
|
|
162
|
+
|
|
163
|
+
// R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
|
|
164
|
+
// StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
|
|
165
|
+
// deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
|
|
166
|
+
// named the package's story by a title it had lost.
|
|
167
|
+
describe('nameProblems', () => {
|
|
168
|
+
const page = (whenNot: string, seenIn = '') =>
|
|
169
|
+
PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
|
|
170
|
+
const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
|
|
171
|
+
const known = new Set(['Badge', 'Chip'])
|
|
172
|
+
|
|
173
|
+
it('names a part under When not that no catalogue has, and passes one that exists', () => {
|
|
174
|
+
const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
|
|
175
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
|
|
179
|
+
const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
|
|
180
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
|
|
181
|
+
const titles = new Set(['Parts/Badge'])
|
|
182
|
+
expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
|
|
183
|
+
'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
|
|
184
|
+
'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
|
|
185
|
+
])
|
|
186
|
+
expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('leaves the Seen in line alone when there is no Storybook index to read', () => {
|
|
190
|
+
const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
|
|
191
|
+
expect(nameProblems(registry(dir), dir, { known })).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
})
|
package/src/registry/contract.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function pageProblem(source: string): string | null {
|
|
|
51
51
|
.map((s) => s.trim())
|
|
52
52
|
.find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
|
|
53
53
|
if (!opening) return 'no opening line under the title saying what the part is'
|
|
54
|
-
|
|
54
|
+
// Only the How section: the code block must sit under How, not in a later section
|
|
55
|
+
// (a page with none under How and one under What it owns passed, R18).
|
|
56
|
+
const how = text.split(/^##\s+How\s*$/m)[1]?.split(/^##\s/m)[0] ?? ''
|
|
57
|
+
if (!/```tsx?\n/.test(how)) return 'no code under "How"'
|
|
55
58
|
return null
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -106,3 +109,48 @@ export function contractProblems(registry: Registry, root: string): string[] {
|
|
|
106
109
|
}
|
|
107
110
|
return problems
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
/** Storybook's id for a title: lower case, every run of other characters one hyphen. */
|
|
114
|
+
const toStoryId = (title: string) =>
|
|
115
|
+
title
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
118
|
+
.replace(/^-+|-+$/g, '')
|
|
119
|
+
|
|
120
|
+
/** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
|
|
121
|
+
function namedParts(section: string): string[] {
|
|
122
|
+
return [...section.matchAll(/\*\*([A-Z][A-Za-z0-9]+)\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What an app's usage pages name that is not there (the re-review after the audit
|
|
127
|
+
* before UIG-26, R14): a part named under When or When not that no catalogue has —
|
|
128
|
+
* Peek's StartTopicDialog sent readers to CreateTopicDialog, deleted by FOL-23 —
|
|
129
|
+
* and a story a **Seen in** line names that no Storybook has. `known` is every part
|
|
130
|
+
* of the package and the app; `titles` is the app's Storybook titles, when there
|
|
131
|
+
* is an index to read them from; `packageIds` the package's story and docs ids, so
|
|
132
|
+
* a page may send readers to the package's Storybook too.
|
|
133
|
+
*/
|
|
134
|
+
export function nameProblems(registry: Registry, root: string, { known, titles, packageIds = [] }: { known: Set<string>; titles?: Set<string>; packageIds?: string[] }): string[] {
|
|
135
|
+
const problems: string[] = []
|
|
136
|
+
for (const entry of registry.entries) {
|
|
137
|
+
if (!entry.docPage || !existsSync(join(root, entry.docPage))) continue
|
|
138
|
+
const text = readFileSync(join(root, entry.docPage), 'utf8').replace(/\r\n/g, '\n')
|
|
139
|
+
for (const heading of ['When', 'When not']) {
|
|
140
|
+
const section = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))[1]?.split(/^##\s/m)[0] ?? ''
|
|
141
|
+
for (const name of new Set(namedParts(section).filter((n) => !known.has(n)))) {
|
|
142
|
+
problems.push(`${entry.docPage}: "${heading}" names ${name}, which is no part of the package or this app`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!titles) continue
|
|
146
|
+
const seenIn = text.match(/^\*\*Seen in\*\*[^\n]*(?:\n(?!\n)[^\n]*)*/m)?.[0] ?? ''
|
|
147
|
+
for (const [, raw] of seenIn.matchAll(/\*([A-Z][^*/]*\/[^*]+)\*/g)) {
|
|
148
|
+
// A title may wrap across lines, and a line may break after its slash.
|
|
149
|
+
const title = raw.replace(/\s+/g, ' ').replace(/\s*\/\s*/g, '/').trim()
|
|
150
|
+
const id = toStoryId(title)
|
|
151
|
+
if (titles.has(title) || packageIds.some((p) => p.startsWith(`${id}--`))) continue
|
|
152
|
+
problems.push(`${entry.docPage}: "Seen in" names ${title}, which no Storybook has`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return problems
|
|
156
|
+
}
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
|
|
|
11
11
|
import { dirname, join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
14
|
-
import { appName, findSiblings, workspaceOf } from './siblings'
|
|
14
|
+
import { appName, behindBy, findSiblings, workspaceOf } from './siblings'
|
|
15
15
|
|
|
16
16
|
const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
|
|
17
17
|
afterAll(() => rmSync(workspace, { recursive: true, force: true }))
|
|
@@ -83,3 +83,26 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
|
|
|
83
83
|
expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
// The re-review after the audit before UIG-26: a search from one repository read a
|
|
88
|
+
// neighbour's checkout eleven commits behind, and answered with a part it had deleted.
|
|
89
|
+
describe('behindBy', () => {
|
|
90
|
+
it('says how far a checkout is behind its main as last fetched, and null for a folder that is none', () => {
|
|
91
|
+
const base = mkdtempSync(join(tmpdir(), 'behind-'))
|
|
92
|
+
const git = (cwd: string, ...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, stdio: 'pipe' })
|
|
93
|
+
git(base, 'init', '-q', '--bare', '-b', 'main', 'origin.git')
|
|
94
|
+
git(base, 'clone', '-q', 'origin.git', 'writer')
|
|
95
|
+
git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'one')
|
|
96
|
+
git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
|
|
97
|
+
git(base, 'clone', '-q', 'origin.git', 'reader')
|
|
98
|
+
const reader = join(base, 'reader')
|
|
99
|
+
expect(behindBy(reader)).toBe(0)
|
|
100
|
+
git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'two')
|
|
101
|
+
git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
|
|
102
|
+
expect(behindBy(reader)).toBe(0)
|
|
103
|
+
git(reader, 'fetch', '-q')
|
|
104
|
+
expect(behindBy(reader)).toBe(1)
|
|
105
|
+
expect(behindBy(tmpdir())).toBeNull()
|
|
106
|
+
rmSync(base, { recursive: true, force: true })
|
|
107
|
+
})
|
|
108
|
+
})
|
package/src/registry/siblings.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* somebody's copy) carries the same package name; the app the search runs in
|
|
17
17
|
* wins, then the one whose top folder has the shortest name.
|
|
18
18
|
*/
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
19
20
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
20
21
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
21
22
|
|
|
@@ -94,3 +95,25 @@ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[]
|
|
|
94
95
|
}
|
|
95
96
|
return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How many commits the checkout that holds `folder` is behind its remote's main,
|
|
101
|
+
* as last fetched: nothing is fetched, so a search stays quick. Null when it is
|
|
102
|
+
* not a git checkout or has no remote main. A search that reads a stale copy
|
|
103
|
+
* answers with parts that are gone (the re-review after the audit before UIG-26:
|
|
104
|
+
* "files panel" found Peek's deleted FilesPanel in an old checkout), so `find`
|
|
105
|
+
* says so beside its answer.
|
|
106
|
+
*/
|
|
107
|
+
export function behindBy(folder: string): number | null {
|
|
108
|
+
const git = (...a: string[]) => {
|
|
109
|
+
try {
|
|
110
|
+
return execFileSync('git', ['-C', folder, ...a], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim()
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const main = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? (git('rev-parse', '--verify', '--quiet', 'origin/main') ? 'origin/main' : null)
|
|
116
|
+
if (!main) return null
|
|
117
|
+
const count = Number(git('rev-list', '--count', `HEAD..${main}`))
|
|
118
|
+
return Number.isFinite(count) ? count : null
|
|
119
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
4
|
-
*
|
|
5
|
-
* the stories,
|
|
6
|
-
*
|
|
3
|
+
* The hand-written map of a Storybook (audit A3, widened by R16). Every case is a
|
|
4
|
+
* real one: Peek's Huddles heading, kept in its Introduction and `HEADINGS` after
|
|
5
|
+
* FOL-23 deleted the stories, and its "49 parts worth reusing" when the catalogue
|
|
6
|
+
* built 41; Ship's `storySort.order` naming a Feedback heading it does not have and
|
|
7
|
+
* leaving out Pages, and its Introduction's bullets; the package's order leaving
|
|
8
|
+
* out Layout; and a Storybook with no map at all, which has nothing to rot.
|
|
7
9
|
*/
|
|
8
10
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
11
|
import { tmpdir } from 'node:os'
|
|
10
12
|
import { dirname, join } from 'node:path'
|
|
11
13
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
12
14
|
import type { Registry } from './schema'
|
|
13
|
-
import { introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
|
|
15
|
+
import { indexHeadings, introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
|
|
14
16
|
|
|
15
17
|
const made: string[] = []
|
|
16
18
|
afterAll(() => {
|
|
@@ -31,66 +33,85 @@ function app(files: Record<string, string>): string {
|
|
|
31
33
|
const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
|
|
32
34
|
|
|
33
35
|
const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
|
|
34
|
-
const
|
|
36
|
+
const HEADINGS_PREVIEW = (headings: string[]) =>
|
|
35
37
|
`export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
|
|
36
|
-
const
|
|
38
|
+
const ORDER_PREVIEW = (headings: string[]) =>
|
|
39
|
+
`export default {\n parameters: {\n options: {\n // Docs first; the rest follows\n storySort: {\n order: [${headings.map((h) => (h === 'Docs' ? "'Docs', ['Introduction']" : `'${h}'`)).join(', ')}],\n },\n },\n },\n}\n`
|
|
40
|
+
const TABLE_INTRO = (count: number, headings: string[]) =>
|
|
37
41
|
`import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title="Docs/Introduction" />\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
|
|
42
|
+
const BULLET_INTRO = (groups: string[][]) =>
|
|
43
|
+
`<Meta title="Docs/Introduction" />\n\n## How the sidebar is organised\n\n${groups.map((g) => `- **${g.join(' · ')}** — about them.`).join('\n')}\n\n## Conventions\n\n- **Stories first.** A component exists in Storybook first.\n`
|
|
38
44
|
|
|
39
45
|
const PEEK_TODAY = {
|
|
40
|
-
'src/stories/Introduction.mdx':
|
|
46
|
+
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives', 'Huddles']),
|
|
41
47
|
'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
|
|
42
48
|
'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title='Primitives/Chip' />\n\n# Chip\n`,
|
|
43
|
-
'.storybook/preview.tsx':
|
|
49
|
+
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives', 'Huddles']),
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
describe('reading the map', () => {
|
|
47
|
-
it('reads the sidebar from story titles and
|
|
48
|
-
expect([...sidebarHeadings(app(PEEK_TODAY))].sort()).toEqual(['Docs', 'Primitives'])
|
|
53
|
+
it('reads the sidebar from story titles and page titles in either quote, in src and stories', () => {
|
|
54
|
+
expect([...sidebarHeadings(app({ ...PEEK_TODAY, 'stories/Tokens.mdx': "<Meta title='Tokens/All' />\n" }))].sort()).toEqual(['Docs', 'Primitives', 'Tokens'])
|
|
49
55
|
})
|
|
50
56
|
|
|
51
|
-
it(
|
|
52
|
-
expect(
|
|
57
|
+
it("reads Storybook's own index, whatever way a title was written", () => {
|
|
58
|
+
expect([...indexHeadings({ a: { title: 'Pages/Desk' }, b: { title: 'Docs/Introduction' }, c: {} })].sort()).toEqual(['Docs', 'Pages'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reads a HEADINGS list and a storySort order, skipping comments and nested lists', () => {
|
|
62
|
+
expect(orderedHeadings(HEADINGS_PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
|
|
63
|
+
expect(orderedHeadings(ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']))).toEqual(['Docs', 'Primitives', 'Feedback'])
|
|
53
64
|
expect(orderedHeadings('export default {}')).toBeNull()
|
|
54
65
|
})
|
|
55
66
|
|
|
56
|
-
it(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(
|
|
67
|
+
it("reads the Introduction's table rows and its bullets, and its stated count", () => {
|
|
68
|
+
expect([...introductionHeadings(TABLE_INTRO(49, ['Docs', 'Huddles']))]).toEqual(['Docs', 'Huddles'])
|
|
69
|
+
// A bullet with no dash after its bold words is prose, not a heading.
|
|
70
|
+
expect([...introductionHeadings(BULLET_INTRO([['Docs'], ['Primitives', 'Feedback', 'Inputs']]))]).toEqual(['Docs', 'Primitives', 'Feedback', 'Inputs'])
|
|
71
|
+
expect(introductionCount(TABLE_INTRO(49, ['Docs']))).toBe(49)
|
|
60
72
|
expect(introductionCount('# No number here')).toBeNull()
|
|
61
73
|
})
|
|
62
74
|
})
|
|
63
75
|
|
|
64
76
|
describe('storyMapProblems', () => {
|
|
65
77
|
it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
|
|
78
|
+
expect(storyMapProblems(app(PEEK_TODAY), { registry: catalogue('reusable', 'reusable', 'reusable', 'local') })).toEqual([
|
|
79
|
+
'.storybook/preview.tsx: in the heading order but no story uses them: Huddles',
|
|
69
80
|
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
|
|
70
81
|
'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
|
|
71
82
|
])
|
|
72
83
|
})
|
|
73
84
|
|
|
74
|
-
it(
|
|
75
|
-
const dir = app({
|
|
76
|
-
|
|
77
|
-
'
|
|
78
|
-
|
|
85
|
+
it("finds Ship's: an order and bullets naming Feedback, which it has not got, and leaving out Pages", () => {
|
|
86
|
+
const dir = app({
|
|
87
|
+
'.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']),
|
|
88
|
+
'src/stories/Introduction.mdx': BULLET_INTRO([['Docs'], ['Primitives', 'Feedback']]),
|
|
89
|
+
})
|
|
90
|
+
expect(storyMapProblems(dir, { real: new Set(['Docs', 'Primitives', 'Pages']) })).toEqual([
|
|
91
|
+
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Pages',
|
|
92
|
+
'.storybook/preview.tsx: in the heading order but no story uses them: Feedback',
|
|
79
93
|
'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
|
|
80
|
-
'src/stories/Introduction.mdx: the page names, the sidebar does not have:
|
|
94
|
+
'src/stories/Introduction.mdx: the page names, the sidebar does not have: Feedback',
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("finds the package's order leaving out Layout, from a Storybook index", () => {
|
|
99
|
+
const dir = app({ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives']) })
|
|
100
|
+
expect(storyMapProblems(dir, { real: indexHeadings({ a: { title: 'Layout/ListColumn' }, b: { title: 'Primitives/Button' }, c: { title: 'Docs/Introduction' } }) })).toEqual([
|
|
101
|
+
'.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Layout',
|
|
81
102
|
])
|
|
82
103
|
})
|
|
83
104
|
|
|
84
105
|
it('passes when both maps match the Storybook and the count matches the catalogue', () => {
|
|
85
106
|
const dir = app({
|
|
86
107
|
...PEEK_TODAY,
|
|
87
|
-
'src/stories/Introduction.mdx':
|
|
88
|
-
'.storybook/preview.tsx':
|
|
108
|
+
'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives']),
|
|
109
|
+
'.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives']),
|
|
89
110
|
})
|
|
90
|
-
expect(storyMapProblems(catalogue('reusable', 'promote-candidate', 'local')
|
|
111
|
+
expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
|
|
91
112
|
})
|
|
92
113
|
|
|
93
|
-
it('has nothing to check in
|
|
94
|
-
expect(storyMapProblems(
|
|
114
|
+
it('has nothing to check in a repository that keeps no map', () => {
|
|
115
|
+
expect(storyMapProblems(app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }), { registry: catalogue('reusable') })).toEqual([])
|
|
95
116
|
})
|
|
96
117
|
})
|
package/src/registry/storymap.ts
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/**
|
|
3
|
-
* The hand-written map of
|
|
4
|
-
*
|
|
3
|
+
* The hand-written map of a Storybook, held against the Storybook it describes
|
|
4
|
+
* (moved here from Peek's own status checks, audit A3; widened by the re-review
|
|
5
|
+
* after it, R16).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* A repository may keep two maps of its sidebar: the order of its top-level
|
|
8
|
+
* headings in `.storybook/preview.tsx` — a `const HEADINGS = [...]` list read by
|
|
9
|
+
* a `storySort` function (Peek), or `storySort: { order: [...] }` (Ship, this
|
|
10
|
+
* package) — and the Introduction page, which names each heading, in a table row
|
|
11
|
+
* (`| **Folders** | … |`, Peek) or a bullet (`- **Primitives · Inputs** — …`,
|
|
12
|
+
* Ship), and in an app states how many parts are worth reusing. Both are written
|
|
13
|
+
* by hand and both describe something that moves, so both rot: Peek's named a
|
|
14
|
+
* Huddles heading for a day after its stories were deleted and said 49 reusable
|
|
15
|
+
* parts when the catalogue built 41; Ship's named a Feedback heading it does not
|
|
16
|
+
* have and left out four it does.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* The real headings are read from Storybook's own index when the command has
|
|
19
|
+
* one, so a title written any way at all counts; otherwise from the story files.
|
|
20
|
+
* Every check runs only when the repository states the thing it checks.
|
|
17
21
|
*/
|
|
18
22
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
19
23
|
import { join } from 'node:path'
|
|
20
24
|
import type { Registry } from './schema'
|
|
21
25
|
|
|
22
26
|
const PREVIEW = '.storybook/preview.tsx'
|
|
23
|
-
const
|
|
27
|
+
const INTRODUCTIONS = ['src/stories/Introduction.mdx', 'stories/Introduction.mdx']
|
|
24
28
|
/** The classes an app's Introduction counts as "worth reusing". */
|
|
25
29
|
const WORTH_REUSING = ['reusable', 'promote-candidate']
|
|
26
30
|
|
|
@@ -36,10 +40,11 @@ function storyFiles(root: string): string[] {
|
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
walk(join(root, 'src'))
|
|
43
|
+
walk(join(root, 'stories'))
|
|
39
44
|
return out
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/** Every top-level heading the sidebar has: the first segment of each
|
|
47
|
+
/** Every top-level heading the sidebar has, read from the story files: the first segment of each title. */
|
|
43
48
|
export function sidebarHeadings(root: string): Set<string> {
|
|
44
49
|
const real = new Set<string>()
|
|
45
50
|
for (const file of storyFiles(root)) {
|
|
@@ -52,19 +57,50 @@ export function sidebarHeadings(root: string): Set<string> {
|
|
|
52
57
|
return real
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
/** The headings `
|
|
60
|
+
/** The top-level headings of Storybook's own index (`storybook index`): the first segment of every entry's title. */
|
|
61
|
+
export function indexHeadings(entries: Record<string, { title?: string }>): Set<string> {
|
|
62
|
+
return new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title.split('/')[0].trim()] : [])))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The strings at the top level of the array that starts at `open` (the index of its `[`), skipping nested arrays. */
|
|
66
|
+
function topLevelStrings(text: string, open: number): string[] {
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
let depth = 0
|
|
69
|
+
for (let i = open; i < text.length; i++) {
|
|
70
|
+
const c = text[i]
|
|
71
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
72
|
+
const end = text.indexOf('*/', i + 2)
|
|
73
|
+
if (end < 0) break
|
|
74
|
+
i = end + 1
|
|
75
|
+
} else if (c === '/' && text[i + 1] === '/') i = text.indexOf('\n', i)
|
|
76
|
+
else if (c === '[') depth++
|
|
77
|
+
else if (c === ']') {
|
|
78
|
+
if (--depth === 0) return out
|
|
79
|
+
} else if (c === "'" || c === '"') {
|
|
80
|
+
const end = text.indexOf(c, i + 1)
|
|
81
|
+
if (depth === 1) out.push(text.slice(i + 1, end))
|
|
82
|
+
i = end
|
|
83
|
+
}
|
|
84
|
+
if (i < 0) break
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The headings the preview orders — a `HEADINGS` list or `storySort: { order: [...] }` — or null when it keeps no order. */
|
|
56
90
|
export function orderedHeadings(previewText: string): string[] | null {
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
59
|
-
|
|
60
|
-
// comment would open a string that swallows the rest.
|
|
61
|
-
const listed = block[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
62
|
-
return [...listed.matchAll(/'([^']+)'/g)].map((m) => m[1])
|
|
91
|
+
const list = /const HEADINGS = \[/.exec(previewText) ?? /\border:\s*\[/.exec(previewText)
|
|
92
|
+
if (!list) return null
|
|
93
|
+
return topLevelStrings(previewText, list.index + list[0].length - 1)
|
|
63
94
|
}
|
|
64
95
|
|
|
65
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The headings the Introduction names: a table row that opens with a bold name,
|
|
98
|
+
* or a bullet that opens with bold names joined by " · ".
|
|
99
|
+
*/
|
|
66
100
|
export function introductionHeadings(introText: string): Set<string> {
|
|
67
|
-
|
|
101
|
+
const rows = [...introText.matchAll(/^\|\s*\*\*([^*]+)\*\*\s*\|/gm)].map((m) => m[1])
|
|
102
|
+
const bullets = [...introText.matchAll(/^[-*]\s+\*\*([^*]+)\*\*\s*[—–-]/gm)].flatMap((m) => m[1].split('·'))
|
|
103
|
+
return new Set([...rows, ...bullets].map((h) => h.trim()).filter(Boolean))
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
/** The number the Introduction states, or null when it states none. */
|
|
@@ -73,34 +109,37 @@ export function introductionCount(introText: string): number | null {
|
|
|
73
109
|
return said ? Number(said[1]) : null
|
|
74
110
|
}
|
|
75
111
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
112
|
+
/**
|
|
113
|
+
* What is wrong with the repository's hand-written map of its Storybook; empty
|
|
114
|
+
* when nothing is. `real` is the sidebar's top-level headings from Storybook's
|
|
115
|
+
* index, when the caller has it; `registry` is needed only for an app's count.
|
|
116
|
+
*/
|
|
117
|
+
export function storyMapProblems(root: string, { registry, real = sidebarHeadings(root) }: { registry?: Registry; real?: Set<string> } = {}): string[] {
|
|
78
118
|
const problems: string[] = []
|
|
79
|
-
const real = sidebarHeadings(root)
|
|
80
119
|
const previewPath = join(root, PREVIEW)
|
|
81
120
|
if (existsSync(previewPath)) {
|
|
82
121
|
const ordered = orderedHeadings(readFileSync(previewPath, 'utf8'))
|
|
83
122
|
if (ordered) {
|
|
84
123
|
const unordered = [...real].filter((r) => !ordered.includes(r))
|
|
85
124
|
const empty = ordered.filter((o) => !real.has(o))
|
|
86
|
-
if (unordered.length) problems.push(`${PREVIEW}: not
|
|
87
|
-
if (empty.length) problems.push(`${PREVIEW}:
|
|
125
|
+
if (unordered.length) problems.push(`${PREVIEW}: not in the heading order, so they fall to the bottom of the sidebar: ${unordered.join(', ')}`)
|
|
126
|
+
if (empty.length) problems.push(`${PREVIEW}: in the heading order but no story uses them: ${empty.join(', ')}`)
|
|
88
127
|
}
|
|
89
128
|
}
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
const
|
|
93
|
-
const named = introductionHeadings(
|
|
129
|
+
const intro = INTRODUCTIONS.find((p) => existsSync(join(root, p)))
|
|
130
|
+
if (intro) {
|
|
131
|
+
const text = readFileSync(join(root, intro), 'utf8')
|
|
132
|
+
const named = introductionHeadings(text)
|
|
94
133
|
if (named.size) {
|
|
95
134
|
const missing = [...real].filter((r) => !named.has(r))
|
|
96
135
|
const stale = [...named].filter((n) => !real.has(n))
|
|
97
|
-
if (missing.length) problems.push(`${
|
|
98
|
-
if (stale.length) problems.push(`${
|
|
136
|
+
if (missing.length) problems.push(`${intro}: the sidebar has, the page does not name: ${missing.join(', ')}`)
|
|
137
|
+
if (stale.length) problems.push(`${intro}: the page names, the sidebar does not have: ${stale.join(', ')}`)
|
|
99
138
|
}
|
|
100
|
-
const said = introductionCount(
|
|
101
|
-
if (said !== null) {
|
|
139
|
+
const said = introductionCount(text)
|
|
140
|
+
if (said !== null && registry) {
|
|
102
141
|
const reusable = registry.entries.filter((entry) => WORTH_REUSING.includes(entry.app?.class ?? '')).length
|
|
103
|
-
if (said !== reusable) problems.push(`${
|
|
142
|
+
if (said !== reusable) problems.push(`${intro}: says ${said} parts worth reusing; the catalogue builds ${reusable}`)
|
|
104
143
|
}
|
|
105
144
|
}
|
|
106
145
|
return problems
|
package/stories/Choosing.mdx
CHANGED
|
@@ -68,12 +68,16 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
68
68
|
| One window to find something and act on it, opened by a shortcut | **CommandPalette** — rows, forms and written answers, one level at a time |
|
|
69
69
|
| Anything with a title, body and footer | **DialogShell** |
|
|
70
70
|
| "Are you sure?" | **ConfirmDialog** |
|
|
71
|
+
| A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |
|
|
71
72
|
|
|
72
73
|
## Structure and wayfinding
|
|
73
74
|
|
|
74
75
|
| You need | Reach for |
|
|
75
76
|
|---|---|
|
|
76
77
|
| Label–value rows (a details rail) | **Property** — `row` and `stacked` |
|
|
78
|
+
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — one per column; the actions as IconButtons in `actions` |
|
|
79
|
+
| A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
|
|
80
|
+
| A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
|
|
77
81
|
| One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
|
|
78
82
|
| A section heading, chevron, hover actions | **SectionHeader** |
|
|
79
83
|
| A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
|
|
@@ -95,6 +99,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
95
99
|
| It happened, briefly | **Toast**, via `useToast` inside a `ToastProvider` |
|
|
96
100
|
| It happened, and must not be missed | **Toast** with `durationMs: 0` and an action — it floats and stays until dismissed (D21) |
|
|
97
101
|
| The whole surface has something to say | **Banner** — the strip under the top bar, full width; `onDismiss` if the reader closes it rather than the app |
|
|
102
|
+
| A part that breaks without taking the page with it | **ErrorBoundary** — around each column, panel or conversation, and once around the app; `label` says what it holds |
|
|
98
103
|
|
|
99
104
|
## The frame
|
|
100
105
|
|
|
@@ -109,11 +114,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
109
114
|
| A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
|
|
110
115
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
111
116
|
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
117
|
+
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|
|
112
118
|
|
|
113
119
|
## Deliberately not in the package
|
|
114
120
|
|
|
115
121
|
- Anything that knows your **product's objects** — its documents, threads,
|
|
116
122
|
files, tickets. Build it in your app, out of these parts, and keep its
|
|
117
123
|
stories in your app's own Storybook.
|
|
118
|
-
- Page layouts
|
|
119
|
-
|
|
124
|
+
- Page layouts beyond the list column (**ListColumn**) and a column's bar
|
|
125
|
+
(**ContainerHeader**) — split views, side rails — not here yet.
|