@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/gates/status.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { createRequire } from 'node:module'
|
|
|
31
31
|
import { join, relative, resolve } from 'node:path'
|
|
32
32
|
import { pathToFileURL } from 'node:url'
|
|
33
33
|
import type { ESLint as ESLintClass } from 'eslint'
|
|
34
|
+
import { escapeLines, listEscapes } from './escapes'
|
|
34
35
|
|
|
35
36
|
export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail: string }
|
|
36
37
|
type Maybe<T> = T | Promise<T>
|
|
@@ -141,6 +142,16 @@ const said = (e: unknown) => {
|
|
|
141
142
|
return { text: `${err.stdout ?? ''}${err.stderr ?? ''}`, message: String(err.message ?? e), code: err.code }
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** The scripts `estiva-gates ci` runs (R13): a job that runs it runs these. */
|
|
146
|
+
const GATE_CI_SCRIPTS = new Set(['lint:rules', 'lint:tokens', 'lint', 'registry:check'])
|
|
147
|
+
|
|
148
|
+
/** `estiva-gates ci`, as a workflow runs it, when it runs `script`; null otherwise. A comment line does not count. */
|
|
149
|
+
function runsGateCi(text: string, script: string | RegExp): string | null {
|
|
150
|
+
if (typeof script !== 'string' || !GATE_CI_SCRIPTS.has(script)) return null
|
|
151
|
+
const m = /^(?!\s*#).*?((?:npx )?estiva-gates ci\b|npm run gates:ci\b|cli\.js"? ci\b)/m.exec(text)
|
|
152
|
+
return m ? m[1] : null
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
/** The helpers, bound to one repository. */
|
|
145
156
|
export function helpers(ROOT: string): GateHelpers {
|
|
146
157
|
const abs = (rel: string) => resolve(ROOT, rel)
|
|
@@ -263,6 +274,8 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
263
274
|
for (const f of files) {
|
|
264
275
|
const m = read(f).match(step)
|
|
265
276
|
if (m) return PASS(`${f} runs npm run ${m[1]}`)
|
|
277
|
+
const all = runsGateCi(read(f), script)
|
|
278
|
+
if (all) return PASS(`${f} runs ${all}, which runs npm run ${script}`)
|
|
266
279
|
}
|
|
267
280
|
return FAIL(`no workflow runs npm run ${script}`)
|
|
268
281
|
},
|
|
@@ -285,7 +298,9 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
285
298
|
if (start === -1) continue
|
|
286
299
|
const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l))
|
|
287
300
|
const body = lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
|
|
288
|
-
|
|
301
|
+
if (step.test(body)) return PASS(`${f}: the job ${job} runs npm run ${script}`)
|
|
302
|
+
const all = runsGateCi(body, script)
|
|
303
|
+
return all ? PASS(`${f}: the job ${job} runs ${all}, which runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`)
|
|
289
304
|
}
|
|
290
305
|
return FAIL(`no workflow has a job ${job}`)
|
|
291
306
|
},
|
|
@@ -560,10 +575,35 @@ export interface StatusOptions {
|
|
|
560
575
|
app?: string
|
|
561
576
|
json?: boolean
|
|
562
577
|
detail?: boolean
|
|
578
|
+
/** List every escape, not only those due for review (B1). */
|
|
579
|
+
escapes?: boolean
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* What an app's catalogue leaves for Katerina: the parts nothing uses, by name
|
|
584
|
+
* (her UIG-13 ruling: an unused part is listed for her, never deleted by the
|
|
585
|
+
* catalogue; R20), and the parts with no link into Storybook (R22). A reusable
|
|
586
|
+
* part with none fails `estiva-ui check`; a one-off needs none (B10), so its
|
|
587
|
+
* number is shown and nothing more.
|
|
588
|
+
*/
|
|
589
|
+
async function catalogueLines(appDir: string): Promise<string[]> {
|
|
590
|
+
try {
|
|
591
|
+
const { buildAppRegistry } = await import('../registry/index')
|
|
592
|
+
const entries = buildAppRegistry({ root: appDir }).entries
|
|
593
|
+
const unused = entries.filter((e) => e.app?.class === 'unused')
|
|
594
|
+
const oneOffs = entries.filter((e) => e.app?.class === 'one-off')
|
|
595
|
+
const unlinked = oneOffs.filter((e) => !e.storyId && !e.docsId)
|
|
596
|
+
return [
|
|
597
|
+
`Unused parts: ${unused.length}${unused.length ? ` · Katerina rules on each: ${unused.map((e) => `${e.name} (${e.sourceFile})`).join(', ')}` : ''}`,
|
|
598
|
+
`Storybook links: every reusable part has one (estiva-ui check holds it) · one-offs with none: ${unlinked.length} of ${oneOffs.length}, which need none`,
|
|
599
|
+
]
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return [`Catalogue: could not be built here: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`]
|
|
602
|
+
}
|
|
563
603
|
}
|
|
564
604
|
|
|
565
605
|
/** Print where the project stands. Returns what was printed, or the JSON report. */
|
|
566
|
-
export async function runStatus({ root = process.cwd(), app = '.', json = false, detail = false }: StatusOptions = {}): Promise<string> {
|
|
606
|
+
export async function runStatus({ root = process.cwd(), app = '.', json = false, detail = false, escapes = false }: StatusOptions = {}): Promise<string> {
|
|
567
607
|
const ROOT = resolve(root)
|
|
568
608
|
const h = helpers(ROOT)
|
|
569
609
|
const shown = (dir: string) => (relative(ROOT, dir) || dir).replace(/\\/g, '/')
|
|
@@ -574,8 +614,11 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
|
|
|
574
614
|
const out: string[] = []
|
|
575
615
|
out.push('UI Guardrails · gates:status', 'Read from the code. Nothing here is a hand-ticked list.', '')
|
|
576
616
|
out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`)
|
|
617
|
+
// Every escape, listed and aged (Katerina's rulings B1, 25 September): the ones due for review always.
|
|
618
|
+
out.push('', ...escapeLines(listEscapes(join(ROOT, app)), { all: escapes }))
|
|
577
619
|
|
|
578
620
|
if (!spec.all) {
|
|
621
|
+
out.push('', ...(await catalogueLines(join(ROOT, app))))
|
|
579
622
|
// A sibling repo on its own: its own rows, then its parts of rows owned elsewhere.
|
|
580
623
|
const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }))
|
|
581
624
|
const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: '', status: statusOf(r.checks) }))
|
package/src/gates/token-lint.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { ESLint, Linter } from 'eslint'
|
|
|
39
39
|
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
|
40
40
|
import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
|
|
41
41
|
import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
|
|
42
|
+
import { INLINE_STYLE_MESSAGE, INLINE_STYLE_TOKEN_KEYS, tokenStylePlugin } from './token-style'
|
|
42
43
|
|
|
43
44
|
/** Who reads the messages. */
|
|
44
45
|
export type TokenAudience = 'app' | 'package'
|
|
@@ -162,8 +163,7 @@ const HAND_WRITTEN = {
|
|
|
162
163
|
width: `${V}(?:border(?:-[xytrblse])?|divide-[xy]|outline|ring|ring-offset)-\\[(?:length:)?-?[0-9.]+(?:px|rem|em)?\\]$`,
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
const INLINE_STYLE_TOKEN_PROPERTIES =
|
|
166
|
-
'/^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/'
|
|
166
|
+
const INLINE_STYLE_TOKEN_PROPERTIES = String(INLINE_STYLE_TOKEN_KEYS)
|
|
167
167
|
|
|
168
168
|
interface Preset {
|
|
169
169
|
theme: { extend: { fontSize: Record<string, string | [string, unknown]>; borderRadius: Record<string, string> } }
|
|
@@ -204,8 +204,10 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
|
|
|
204
204
|
return {
|
|
205
205
|
files: ['**/*.{ts,tsx}'],
|
|
206
206
|
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
|
207
|
-
plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss },
|
|
207
|
+
plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss, 'token-style': tokenStylePlugin },
|
|
208
208
|
rules: {
|
|
209
|
+
// A style kept in a const and passed by name (R9): the selector below cannot follow it.
|
|
210
|
+
'token-style/no-token-style': 'error',
|
|
209
211
|
'token-values/no-restricted-classes': [
|
|
210
212
|
'error',
|
|
211
213
|
{
|
|
@@ -252,7 +254,12 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
|
|
|
252
254
|
'error',
|
|
253
255
|
{
|
|
254
256
|
selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.name=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
|
|
255
|
-
message:
|
|
257
|
+
message: INLINE_STYLE_MESSAGE,
|
|
258
|
+
},
|
|
259
|
+
// The same key in quotes, `style={{ 'background': 'red' }}` (R9).
|
|
260
|
+
{
|
|
261
|
+
selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.value=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
|
|
262
|
+
message: INLINE_STYLE_MESSAGE,
|
|
256
263
|
},
|
|
257
264
|
],
|
|
258
265
|
},
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The token contract's inline-style check, where a selector cannot reach
|
|
4
|
+
* (the second review's R9, 25 September). `no-restricted-syntax` reads a
|
|
5
|
+
* `style={{ … }}` written in place; a style kept in a `const` and passed by name
|
|
6
|
+
* got through every lint in all three repos:
|
|
7
|
+
*
|
|
8
|
+
* const look = { background: 'red' }
|
|
9
|
+
* <div style={look} />
|
|
10
|
+
*
|
|
11
|
+
* This follows the name to its `const` in the same file and reads the keys
|
|
12
|
+
* there, quoted or not. It reports on the key, where the value is written, so
|
|
13
|
+
* an escape sits beside it. A style the code works out while it runs passes:
|
|
14
|
+
* nothing can read it.
|
|
15
|
+
*/
|
|
16
|
+
import type { ESLint, Rule, Scope } from 'eslint'
|
|
17
|
+
|
|
18
|
+
/** The style keys outside the token contract: colours, a font size, borders and shadows. */
|
|
19
|
+
export const INLINE_STYLE_TOKEN_KEYS = /^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/
|
|
20
|
+
|
|
21
|
+
export const INLINE_STYLE_MESSAGE =
|
|
22
|
+
'An inline style that sets a colour, a font size, a border or a shadow is outside the token contract: use a token class. If the value is computed (a palette, a size from a prop), say why in an eslint-disable comment. Width, height and transforms are fine.'
|
|
23
|
+
|
|
24
|
+
interface Node {
|
|
25
|
+
type: string
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const keyOf = (property: Node): string | undefined => {
|
|
30
|
+
if (property.computed) return undefined
|
|
31
|
+
const key = property.key as Node
|
|
32
|
+
return key.type === 'Identifier' ? (key.name as string) : key.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The object a `const` of this name holds, in this scope or one around it. */
|
|
36
|
+
function constObject(name: string, scope: Scope.Scope | null): Node | undefined {
|
|
37
|
+
for (let current = scope; current; current = current.upper) {
|
|
38
|
+
const variable = current.set.get(name)
|
|
39
|
+
if (!variable) continue
|
|
40
|
+
const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined
|
|
41
|
+
if (variable.defs.length !== 1 || definition?.type !== 'Variable' || definition.parent?.kind !== 'const') return undefined
|
|
42
|
+
let init = definition.node.init as Node | undefined
|
|
43
|
+
while (init && (init.type === 'TSAsExpression' || init.type === 'TSSatisfiesExpression')) init = init.expression as Node
|
|
44
|
+
return init?.type === 'ObjectExpression' ? init : undefined
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const noTokenStyle: Rule.RuleModule = {
|
|
50
|
+
meta: {
|
|
51
|
+
type: 'problem',
|
|
52
|
+
docs: { description: 'No colour, font size, border or shadow in a style kept in a const' },
|
|
53
|
+
schema: [],
|
|
54
|
+
messages: { token: INLINE_STYLE_MESSAGE },
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const reported = new Set<unknown>()
|
|
58
|
+
return {
|
|
59
|
+
JSXAttribute(ruleNode: Rule.Node) {
|
|
60
|
+
const attribute = ruleNode as unknown as Node
|
|
61
|
+
if ((attribute.name as Node).name !== 'style') return
|
|
62
|
+
const value = attribute.value as Node | null
|
|
63
|
+
const expression = value?.type === 'JSXExpressionContainer' ? (value.expression as Node) : undefined
|
|
64
|
+
if (expression?.type !== 'Identifier') return
|
|
65
|
+
const object = constObject(expression.name as string, context.sourceCode.getScope(ruleNode))
|
|
66
|
+
for (const property of (object?.properties as Node[] | undefined) ?? []) {
|
|
67
|
+
if (property.type !== 'Property' || reported.has(property)) continue
|
|
68
|
+
const key = keyOf(property)
|
|
69
|
+
if (!key || !INLINE_STYLE_TOKEN_KEYS.test(key)) continue
|
|
70
|
+
reported.add(property)
|
|
71
|
+
context.report({ node: property as unknown as Rule.Node, messageId: 'token' })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The plugin, as the token lint registers it: `token-style/no-token-style`. */
|
|
79
|
+
export const tokenStylePlugin: ESLint.Plugin = { rules: { 'no-token-style': noTokenStyle } }
|
package/src/how.test.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The code under every page's `## How` compiles against the package (UIG-14:
|
|
4
|
+
* "Every How compiles — checked, not assumed"; Katerina's ruling R18, 25
|
|
5
|
+
* September). It was compiled once, by hand, on 19 September, and never again.
|
|
6
|
+
*
|
|
7
|
+
* Each ```tsx block under `## How` becomes a module of its own:
|
|
8
|
+
*
|
|
9
|
+
* 1. Its own `import` lines, verbatim; `'@estiva-app/ui'` is this package's
|
|
10
|
+
* `src/index.ts`, so a wrong name in an import fails.
|
|
11
|
+
* 2. Every part it did not import, imported (a page shows a part's companions
|
|
12
|
+
* without their import line).
|
|
13
|
+
* 3. The rest as the body of a component: all JSX, or statements then JSX, or
|
|
14
|
+
* statements alone, whichever parses.
|
|
15
|
+
* 4. What the page leaves to the reader is a placeholder: a lower-case name or
|
|
16
|
+
* an ALL_CAPS one is `any`, a React hook comes from React, an `Icon…` from
|
|
17
|
+
* Tabler. A capitalised name the package does not have is an error — that is
|
|
18
|
+
* what a wrong part name looks like — except the reader's own stand-ins
|
|
19
|
+
* below, which the pages name on purpose.
|
|
20
|
+
*
|
|
21
|
+
* Then it is type-checked with the package's own settings. A part name, a prop
|
|
22
|
+
* name and a prop's literal value are checked; a prop given a placeholder is not
|
|
23
|
+
* (a placeholder is `any`).
|
|
24
|
+
*/
|
|
25
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import ts from 'typescript'
|
|
29
|
+
import { describe, expect, it } from 'vitest'
|
|
30
|
+
|
|
31
|
+
const REPO = process.cwd()
|
|
32
|
+
const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
|
|
33
|
+
|
|
34
|
+
/** The reader's own components a page draws around a part (Katerina's ruling R18: an allow-list). */
|
|
35
|
+
const STAND_INS = ['App', 'Conversation', 'ItemDetail', 'ItemRow', 'ListRow', 'Row', 'YourList']
|
|
36
|
+
/** Browser globals a page may mean for real; any other lower-case lib.dom name is a placeholder (`name`, `open`). */
|
|
37
|
+
const REAL_GLOBALS = new Set(['window', 'document', 'console', 'fetch', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'requestAnimationFrame', 'navigator', 'localStorage', 'sessionStorage', 'globalThis', 'queueMicrotask', 'structuredClone'])
|
|
38
|
+
|
|
39
|
+
interface Block {
|
|
40
|
+
page: string
|
|
41
|
+
/** The .mdx line of the block's first line of code. */
|
|
42
|
+
start: number
|
|
43
|
+
lines: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function howBlocks(page: string, text: string): Block[] {
|
|
47
|
+
const lines = text.split(/\r?\n/)
|
|
48
|
+
const blocks: Block[] = []
|
|
49
|
+
let inHow = false
|
|
50
|
+
let fence: string | null = null
|
|
51
|
+
let start = 0
|
|
52
|
+
let buf: string[] = []
|
|
53
|
+
lines.forEach((line, i) => {
|
|
54
|
+
if (fence !== null) {
|
|
55
|
+
if (/^\s*```\s*$/.test(line)) {
|
|
56
|
+
if (inHow && fence === 'tsx') blocks.push({ page, start, lines: buf })
|
|
57
|
+
fence = null
|
|
58
|
+
buf = []
|
|
59
|
+
} else buf.push(line)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const open = /^\s*```(\S*)/.exec(line)
|
|
63
|
+
if (open) {
|
|
64
|
+
fence = open[1] || '(none)'
|
|
65
|
+
start = i + 2
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (/^## /.test(line)) inHow = /^## How\b/.test(line)
|
|
69
|
+
})
|
|
70
|
+
return blocks
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function indexValueExports(): string[] {
|
|
74
|
+
const sf = ts.createSourceFile('index.ts', readFileSync(join(REPO, 'src/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
|
75
|
+
const names: string[] = []
|
|
76
|
+
for (const s of sf.statements) {
|
|
77
|
+
if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.exportClause && ts.isNamedExports(s.exportClause)) for (const e of s.exportClause.elements) if (!e.isTypeOnly) names.push(e.name.text)
|
|
78
|
+
}
|
|
79
|
+
return names
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const require = createRequire(join(REPO, 'package.json'))
|
|
83
|
+
const REACT = new Set(Object.keys(require('react') as object))
|
|
84
|
+
const TABLER = new Set(Object.keys(require('@tabler/icons-react') as object))
|
|
85
|
+
const INDEX = indexValueExports()
|
|
86
|
+
|
|
87
|
+
function splitImports(lines: string[]) {
|
|
88
|
+
const imports: { text: string; line: number }[] = []
|
|
89
|
+
const body = lines.slice()
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
if (!/^import\b/.test(lines[i])) continue
|
|
92
|
+
let j = i
|
|
93
|
+
while (j < lines.length && !/from\s+['"][^'"]+['"]\s*;?\s*$/.test(lines[j]) && !/^import\s+['"]/.test(lines[j])) j++
|
|
94
|
+
imports.push({ text: lines.slice(i, j + 1).join('\n'), line: i })
|
|
95
|
+
for (let k = i; k <= j; k++) body[k] = ''
|
|
96
|
+
i = j
|
|
97
|
+
}
|
|
98
|
+
return { imports, body }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function importedNames(imports: { text: string }[]): Set<string> {
|
|
102
|
+
const names = new Set<string>()
|
|
103
|
+
const sf = ts.createSourceFile('i.ts', imports.map((x) => x.text).join('\n'), ts.ScriptTarget.Latest, true)
|
|
104
|
+
for (const s of sf.statements) {
|
|
105
|
+
const c = ts.isImportDeclaration(s) ? s.importClause : undefined
|
|
106
|
+
if (!c) continue
|
|
107
|
+
if (c.name) names.add(c.name.text)
|
|
108
|
+
if (c.namedBindings && ts.isNamedImports(c.namedBindings)) for (const e of c.namedBindings.elements) names.add(e.name.text)
|
|
109
|
+
if (c.namedBindings && ts.isNamespaceImport(c.namedBindings)) names.add(c.namedBindings.name.text)
|
|
110
|
+
}
|
|
111
|
+
return names
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const jsxComments = (lines: string[]) => lines.map((l) => l.replace(/^\/\/(.*)$/, '{/*$1*/}'))
|
|
115
|
+
const parses = (text: string) => (ts.transpileModule(text, { fileName: 'x.tsx', reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.ReactJSX } }).diagnostics ?? []).length === 0
|
|
116
|
+
|
|
117
|
+
/** All JSX; statements then JSX; statements alone. The first that parses wins. */
|
|
118
|
+
function wrappings(body: string[]) {
|
|
119
|
+
const out: { pre: string[]; jsx: string[] | null }[] = [{ pre: [], jsx: body }]
|
|
120
|
+
body.forEach((l, k) => {
|
|
121
|
+
if (k > 0 && /^[<{]/.test(l) && body.slice(0, k).some((x) => x.trim() && !/^\s*\/\//.test(x))) out.push({ pre: body.slice(0, k), jsx: body.slice(k) })
|
|
122
|
+
})
|
|
123
|
+
out.push({ pre: body, jsx: null })
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Extra {
|
|
128
|
+
react: Set<string>
|
|
129
|
+
tabler: Set<string>
|
|
130
|
+
declared: Set<string>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function build(block: Block, extra: Extra): { text: string; map: (number | null)[] } {
|
|
134
|
+
const { imports, body } = splitImports(block.lines)
|
|
135
|
+
const have = importedNames(imports)
|
|
136
|
+
const lines: string[] = []
|
|
137
|
+
const map: (number | null)[] = []
|
|
138
|
+
const push = (text: string, mdx: number | null) => {
|
|
139
|
+
for (const t of text.split('\n')) {
|
|
140
|
+
lines.push(t)
|
|
141
|
+
map.push(mdx)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const imp of imports) imp.text.split('\n').forEach((t, k) => push(t, block.start + imp.line + k))
|
|
145
|
+
const auto = INDEX.filter((n) => !have.has(n))
|
|
146
|
+
if (auto.length) push(`import { ${auto.join(', ')} } from '@estiva-app/ui'`, null)
|
|
147
|
+
const react = [...extra.react].filter((n) => !have.has(n))
|
|
148
|
+
if (react.length) push(`import { ${react.join(', ')} } from 'react'`, null)
|
|
149
|
+
const tabler = [...extra.tabler].filter((n) => !have.has(n))
|
|
150
|
+
if (tabler.length) push(`import { ${tabler.join(', ')} } from '@tabler/icons-react'`, null)
|
|
151
|
+
for (const d of extra.declared) push(`declare const ${d}: any`, null)
|
|
152
|
+
for (const s of STAND_INS) if (!have.has(s)) push(`declare const ${s}: (props: any) => React.JSX.Element`, null)
|
|
153
|
+
|
|
154
|
+
for (const w of wrappings(body)) {
|
|
155
|
+
const L = lines.slice()
|
|
156
|
+
const M = map.slice()
|
|
157
|
+
const add = (t: string, mdx: number | null) => {
|
|
158
|
+
L.push(t)
|
|
159
|
+
M.push(mdx)
|
|
160
|
+
}
|
|
161
|
+
add('export function __How() {', null)
|
|
162
|
+
w.pre.forEach((t, k) => add(t, block.start + k))
|
|
163
|
+
if (w.jsx) {
|
|
164
|
+
add('return (<>', null)
|
|
165
|
+
jsxComments(w.jsx).forEach((t, k) => add(t, block.start + w.pre.length + k))
|
|
166
|
+
add('</>)', null)
|
|
167
|
+
}
|
|
168
|
+
add('}', null)
|
|
169
|
+
const text = L.join('\n')
|
|
170
|
+
if (parses(text)) return { text, map: M }
|
|
171
|
+
}
|
|
172
|
+
return { text: `${lines.join('\n')}\nexport function __How() {\n${body.join('\n')}\n}`, map: [...map, null, ...body.map((_, k) => block.start + k), null] }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function compilerOptions(): ts.CompilerOptions {
|
|
176
|
+
const raw = ts.parseConfigFileTextToJson('tsconfig.json', readFileSync(join(REPO, 'tsconfig.json'), 'utf8')).config
|
|
177
|
+
const parsed = ts.convertCompilerOptionsFromJson(raw.compilerOptions, REPO)
|
|
178
|
+
// A placeholder is `any`: `people.map((p) => …)` would fail noImplicitAny on `p`,
|
|
179
|
+
// the placeholder's fault, not the page's. A callback prop's parameter keeps the part's type.
|
|
180
|
+
return { ...parsed.options, noEmit: true, noImplicitAny: false, baseUrl: REPO, paths: { '@estiva-app/ui': ['./src/index.ts'] }, types: [] }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function makeHost(options: ts.CompilerOptions, virtual: Map<string, string>): ts.CompilerHost {
|
|
184
|
+
const host = ts.createCompilerHost(options, true)
|
|
185
|
+
const read = host.readFile.bind(host)
|
|
186
|
+
const exists = host.fileExists.bind(host)
|
|
187
|
+
host.readFile = (f) => virtual.get(norm(f)) ?? read(f)
|
|
188
|
+
host.fileExists = (f) => virtual.has(norm(f)) || exists(f)
|
|
189
|
+
host.getSourceFile = (f, lang) => {
|
|
190
|
+
const t = host.readFile(f)
|
|
191
|
+
return t === undefined ? undefined : ts.createSourceFile(f, t, lang, true)
|
|
192
|
+
}
|
|
193
|
+
host.getCurrentDirectory = () => REPO
|
|
194
|
+
return host
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface Result {
|
|
198
|
+
page: string
|
|
199
|
+
start: number
|
|
200
|
+
errors: { line: number | null; code: number; text: string }[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Compile every block, adding placeholders until nothing new is found. */
|
|
204
|
+
function compileAll(blocks: Block[]): Result[] {
|
|
205
|
+
const options = compilerOptions()
|
|
206
|
+
const state: Extra[] = blocks.map(() => ({ react: new Set(), tabler: new Set(), declared: new Set() }))
|
|
207
|
+
const fileOf = blocks.map((b) => `${REPO}/src/__how__${b.page.replace(/\W+/g, '_')}_${b.start}.tsx`)
|
|
208
|
+
let program: ts.Program | undefined
|
|
209
|
+
let built: ReturnType<typeof build>[] = []
|
|
210
|
+
for (let round = 0; round < 8; round++) {
|
|
211
|
+
built = blocks.map((b, i) => build(b, state[i]))
|
|
212
|
+
const virtual = new Map(fileOf.map((f, i) => [norm(f), built[i].text]))
|
|
213
|
+
program = ts.createProgram(fileOf, options, makeHost(options, virtual), program)
|
|
214
|
+
const checker = program.getTypeChecker()
|
|
215
|
+
let added = 0
|
|
216
|
+
fileOf.forEach((f, i) => {
|
|
217
|
+
const s = state[i]
|
|
218
|
+
const sf = program!.getSourceFile(f)!
|
|
219
|
+
for (const d of ts.getPreEmitDiagnostics(program!, sf)) {
|
|
220
|
+
if (d.code !== 2304 && d.code !== 2552) continue
|
|
221
|
+
const x = /Cannot find name '([^']+)'/.exec(ts.flattenDiagnosticMessageText(d.messageText, ' '))?.[1]
|
|
222
|
+
if (!x || s.react.has(x) || s.tabler.has(x) || s.declared.has(x)) continue
|
|
223
|
+
if (REACT.has(x)) s.react.add(x)
|
|
224
|
+
else if (/^Icon[A-Z]/.test(x) && TABLER.has(x)) s.tabler.add(x)
|
|
225
|
+
else if (/^[a-z_$]/.test(x) || /^[A-Z][A-Z0-9_]+$/.test(x)) s.declared.add(x)
|
|
226
|
+
else continue
|
|
227
|
+
added++
|
|
228
|
+
}
|
|
229
|
+
// A placeholder that is also a browser global (`name`, `open`) never reports
|
|
230
|
+
// "Cannot find name": it silently takes the global's type. Shadow those too.
|
|
231
|
+
const walk = (node: ts.Node) => {
|
|
232
|
+
if (ts.isIdentifier(node) && built[i].map[sf.getLineAndCharacterOfPosition(node.getStart(sf)).line] != null) {
|
|
233
|
+
const p = node.parent
|
|
234
|
+
const member = (ts.isPropertyAccessExpression(p) && p.name === node) || (ts.isPropertyAssignment(p) && p.name === node) || ts.isJsxAttribute(p)
|
|
235
|
+
const x = node.text
|
|
236
|
+
if (!member && /^[a-z]/.test(x) && !REAL_GLOBALS.has(x) && !s.declared.has(x)) {
|
|
237
|
+
const sym = checker.getSymbolAtLocation(node)
|
|
238
|
+
const decls = sym?.declarations ?? []
|
|
239
|
+
if (sym && decls.length && decls.every((d) => program!.isSourceFileDefaultLibrary(d.getSourceFile())) && sym.flags & (ts.SymbolFlags.Variable | ts.SymbolFlags.Function)) {
|
|
240
|
+
s.declared.add(x)
|
|
241
|
+
added++
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
ts.forEachChild(node, walk)
|
|
246
|
+
}
|
|
247
|
+
walk(sf)
|
|
248
|
+
})
|
|
249
|
+
if (!added) break
|
|
250
|
+
}
|
|
251
|
+
return blocks.map((b, i) => {
|
|
252
|
+
const sf = program!.getSourceFile(fileOf[i])!
|
|
253
|
+
const errors = ts.getPreEmitDiagnostics(program!, sf).filter((d) => d.file === sf).map((d) => ({
|
|
254
|
+
line: built[i].map[sf.getLineAndCharacterOfPosition(d.start ?? 0).line] ?? null,
|
|
255
|
+
code: d.code,
|
|
256
|
+
text: ts.flattenDiagnosticMessageText(d.messageText, ' '),
|
|
257
|
+
}))
|
|
258
|
+
return { page: b.page, start: b.start, errors }
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const pages = readdirSync(join(REPO, 'src')).filter((f) => f.endsWith('.mdx')).sort()
|
|
263
|
+
const blocks = pages.flatMap((f) => howBlocks(`src/${f}`, readFileSync(join(REPO, 'src', f), 'utf8')))
|
|
264
|
+
|
|
265
|
+
describe('the code under How', () => {
|
|
266
|
+
it('compiles on every page, and goes red on a wrong part, prop, value or import', { timeout: 120_000 }, () => {
|
|
267
|
+
expect(blocks.length).toBeGreaterThan(50)
|
|
268
|
+
const button = blocks.find((b) => b.page === 'src/Button.mdx')!
|
|
269
|
+
const broken = (name: string, change: (line: string) => string) => ({ ...button, page: `src/BROKEN-${name}.mdx`, lines: button.lines.map(change) })
|
|
270
|
+
const probes = [
|
|
271
|
+
broken('part', (l) => l.replace('<Button ', '<Buton ').replace('</Button>', '</Buton>')),
|
|
272
|
+
broken('prop', (l) => l.replace('variant="primary"', 'varient="primary"')),
|
|
273
|
+
broken('value', (l) => l.replace('variant="primary"', 'variant="big"')),
|
|
274
|
+
broken('import', (l) => l.replace('import { Button }', 'import { Buton }')),
|
|
275
|
+
]
|
|
276
|
+
const results = compileAll([...blocks, ...probes])
|
|
277
|
+
const shown = (r: Result) => r.errors.map((e) => `${r.page}:${e.line ?? '?'} TS${e.code} ${e.text}`)
|
|
278
|
+
expect(results.slice(0, blocks.length).flatMap(shown)).toEqual([])
|
|
279
|
+
// Each broken copy must fail, or the check proves nothing.
|
|
280
|
+
for (const r of results.slice(blocks.length)) expect(r.errors.length, r.page).toBeGreaterThan(0)
|
|
281
|
+
})
|
|
282
|
+
})
|
package/src/pages.test.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readdirSync, readFileSync } from 'node:fs'
|
|
17
17
|
import { describe, expect, it } from 'vitest'
|
|
18
18
|
import { OWNED_BEHAVIOURS } from './eslint/no-rebuilt-behaviour'
|
|
19
|
+
import { whenNotProblem } from './registry/contract'
|
|
19
20
|
|
|
20
21
|
/** How a page words each behaviour. `base-ui` is how a part is built, not what it does for a caller. */
|
|
21
22
|
const ROW: Record<string, RegExp> = {
|
|
@@ -119,6 +120,11 @@ describe('the page contract', () => {
|
|
|
119
120
|
expect(names.filter((n) => !exported.has(n) && !pages.includes(n))).toEqual([])
|
|
120
121
|
})
|
|
121
122
|
|
|
123
|
+
// B8 (25 September): When not names the part to use instead, or says there is none.
|
|
124
|
+
it('names another part under When not, or starts a line "No alternative:"', () => {
|
|
125
|
+
expect(whenNotProblem(t, partsOf(page), exported)).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
122
128
|
if (WAITING.includes(page)) return
|
|
123
129
|
|
|
124
130
|
it('says what it owns, as the checker does', () => {
|
|
@@ -140,3 +146,14 @@ describe('the page contract', () => {
|
|
|
140
146
|
})
|
|
141
147
|
}
|
|
142
148
|
})
|
|
149
|
+
|
|
150
|
+
// The re-review after the audit before UIG-26 (R17): "Choosing a component" is written by
|
|
151
|
+
// hand, and six parts with pages of their own had no row — ContainerHeader and ListColumn
|
|
152
|
+
// among them, the parts behind the skill's first two Gotchas.
|
|
153
|
+
describe('Choosing a component', () => {
|
|
154
|
+
it('names every part that has a page of its own', () => {
|
|
155
|
+
const choosing = readFileSync(new URL('../stories/Choosing.mdx', dir), 'utf8')
|
|
156
|
+
const named = new Set([...choosing.matchAll(/\*\*([A-Z][A-Za-z]+)\*\*/g)].map((m) => m[1]))
|
|
157
|
+
expect(pages.filter((page) => !named.has(page))).toEqual([])
|
|
158
|
+
})
|
|
159
|
+
})
|
package/src/registry/app.test.ts
CHANGED
|
@@ -173,6 +173,22 @@ describe("an app's catalogue", () => {
|
|
|
173
173
|
expect(registry.storybook.devUrl).toBe('http://localhost:6123')
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
+
// C6: UIG-19's "adding an unclassified component fails the build — proved", proved.
|
|
177
|
+
it('fails a part with no class, and gives every part the builder finds one', () => {
|
|
178
|
+
const unclassified = structuredClone(registry)
|
|
179
|
+
const one = unclassified.entries.find((e) => e.app?.class === 'one-off')!
|
|
180
|
+
delete (one.app as Partial<NonNullable<RegistryEntry['app']>>).class
|
|
181
|
+
expect(validateRegistry(unclassified)).toContain(`entries[${unclassified.entries.indexOf(one)}] (${one.name}).app.class is undefined`)
|
|
182
|
+
const bare = structuredClone(registry)
|
|
183
|
+
bare.entries[0].app = null
|
|
184
|
+
expect(validateRegistry(bare).join('\n')).toContain('app is missing on an app entry')
|
|
185
|
+
// A part added to the app is classified the moment it exists.
|
|
186
|
+
const dir = app({ ...FIXTURE, 'src/components/Added.tsx': '/** A part nobody has sorted. */\nexport function Added() {\n return <div />\n}\n' })
|
|
187
|
+
const added = buildAppRegistry({ root: dir, repo: 'fixture', packageRegistry })
|
|
188
|
+
expect(added.entries.find((e) => e.name === 'Added')?.app?.class).toBe('unused')
|
|
189
|
+
expect(added.entries.every((e) => ['re-export', 'reusable', 'one-off', 'promote-candidate', 'unused'].includes(e.app?.class ?? ''))).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
176
192
|
it('counts every .tsx outside stories and tests, and says why a file holds no part', () => {
|
|
177
193
|
// main.tsx, App.tsx, 2 pass-ons, Frame, Card, Timeline, Sync, Ctx, Lonely, Shown, Wide, EmptyState, Lazy, HomePage, Other, hooks.tsx
|
|
178
194
|
expect(registry.builtFrom.files).toBe(17)
|
|
@@ -230,6 +246,24 @@ describe("an app's catalogue", () => {
|
|
|
230
246
|
expect(entry('Shown').app).toMatchObject({ class: 'unused', reason: 'No file of the app uses it; only its stories do.' })
|
|
231
247
|
})
|
|
232
248
|
|
|
249
|
+
// B9: an app part owns what the part it is owns, followed through the app's own parts.
|
|
250
|
+
it('gives an app part what the part it returns owns, and a page nothing of its dialogs', () => {
|
|
251
|
+
const dir = app({
|
|
252
|
+
'src/ui/Floating.tsx': "/** Hands on the package's Popover. */\nexport { Popover } from '@estiva-app/ui'\n",
|
|
253
|
+
'src/DeleteDialog.tsx': "import { ConfirmDialog } from '@estiva-app/ui'\n/** Asks before deleting. */\nexport function DeleteDialog() {\n return <ConfirmDialog open title=\"Delete?\" onConfirm={() => {}} onOpenChange={() => {}} />\n}\n",
|
|
254
|
+
'src/Ask.tsx': "import { DeleteDialog } from './DeleteDialog'\n/** The app's own wrapper of it. */\nexport function Ask() {\n return open ? <DeleteDialog /> : null\n}\n",
|
|
255
|
+
'src/Page.tsx': "import { Ask } from './Ask'\n/** A page that opens it. */\nexport function Page() {\n return (\n <>\n <main>Page</main>\n <Ask />\n </>\n )\n}\n",
|
|
256
|
+
'src/main.tsx': "import { Page } from './Page'\nimport { Popover } from './ui/Floating'\nexport const all = [<Page key=\"p\" />, <Popover key=\"f\" />]\n",
|
|
257
|
+
})
|
|
258
|
+
const entries = buildAppRegistry({ root: dir, repo: 'x', packageRegistry }).entries
|
|
259
|
+
const ids = (name: string) => entries.find((e) => e.name === name)!.ownsBehaviours.map((owned) => owned.id)
|
|
260
|
+
expect(ids('DeleteDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock']))
|
|
261
|
+
expect(ids('DeleteDialog')).not.toContain('tab-stop')
|
|
262
|
+
expect(ids('Ask')).toEqual(ids('DeleteDialog'))
|
|
263
|
+
expect(ids('Page')).toEqual([])
|
|
264
|
+
expect(ids('Popover')).toEqual(packageRegistry.entries.find((e) => e.name === 'Popover')!.ownsBehaviours.map((owned) => owned.id))
|
|
265
|
+
})
|
|
266
|
+
|
|
233
267
|
it('takes a kind written beside the part, with its reason', () => {
|
|
234
268
|
expect(entry('Wide').app).toMatchObject({ class: 'reusable', written: true, reason: 'general; one screen uses it so far', usedIn: ['src/pages/HomePage.tsx'] })
|
|
235
269
|
expect(entry('Wide').purpose).toBe('A general strip of things.')
|