@estiva-app/ui 0.35.2 → 0.37.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/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +86 -12
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- 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 +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 +42 -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.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -49
- package/dist/index.js.map +4 -4
- 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-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -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/package.json +4 -1
- package/registry.json +369 -19
- 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/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +6 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- 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 +11 -2
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-native-title.ts +1 -1
- 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 +9 -6
- 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 +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- 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/index.ts +1 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +37 -1
- 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 +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +18 -4
- package/stories/Choosing.mdx +4 -3
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.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/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/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ export { Reaction, type ReactionProps } from './Reaction'
|
|
|
83
83
|
export { SearchInput, type SearchInputProps } from './SearchInput'
|
|
84
84
|
export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
|
|
85
85
|
export { ContainerHeader, type ContainerHeaderProps } from './ContainerHeader'
|
|
86
|
+
export { Panel, type PanelProps } from './Panel'
|
|
86
87
|
export { ListColumn, type ListColumnProps } from './ListColumn'
|
|
87
88
|
export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
|
|
88
89
|
export { SectionLabel } from './SectionLabel'
|
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', () => {
|
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.')
|
|
@@ -533,7 +567,9 @@ describe('one search over the package and the apps', () => {
|
|
|
533
567
|
repo: 'fixture',
|
|
534
568
|
})
|
|
535
569
|
const found = findInRegistries([packageRegistry, header], 'panel header', { limit: 5 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
|
|
536
|
-
|
|
570
|
+
// Since 0.37.0 an app's panel header is Panel's (26 September): the package answers first either way.
|
|
571
|
+
expect(found[0]).toBe('estiva-ui:Panel')
|
|
572
|
+
expect(found).toContain('estiva-ui:ContainerHeader')
|
|
537
573
|
expect(found).toContain('fixture:Header')
|
|
538
574
|
})
|
|
539
575
|
|
package/src/registry/app.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
37
37
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
38
38
|
import ts from 'typescript'
|
|
39
|
+
import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, isBaseUi, rootsIn, splitTag } from './behaviours'
|
|
39
40
|
import { firstSentence, pageOpening, readModule, sanitize, toId, PACKAGE_IMPORT, type Resolved } from './build'
|
|
40
41
|
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
41
42
|
import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema'
|
|
@@ -722,6 +723,50 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
722
723
|
}
|
|
723
724
|
const port = /(?:-p|--port)[\s=]+(\d+)/.exec(manifest.scripts?.storybook ?? '')?.[1] ?? '6006'
|
|
724
725
|
|
|
726
|
+
// What each part owns, from what it draws (B9): a pass-on owns what its package
|
|
727
|
+
// part owns; any other part what the whole of each part it draws does, followed
|
|
728
|
+
// through the app's own parts, and what the Base UI parts it draws do.
|
|
729
|
+
const packageOwns = new Map((theRegistry?.entries ?? []).map((e) => [e.name, e.ownsBehaviours.map((b) => b.id)]))
|
|
730
|
+
const drawnByFile = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>> }>()
|
|
731
|
+
const owned = new Map<string, Set<string>>()
|
|
732
|
+
const ownsOf = (p: Found, seen: Set<string>): Set<string> => {
|
|
733
|
+
const at = key(p.file, p.name)
|
|
734
|
+
const held = owned.get(at)
|
|
735
|
+
if (held) return held
|
|
736
|
+
const out = new Set<string>()
|
|
737
|
+
if (p.from) {
|
|
738
|
+
if (p.from.specifier === PACKAGE_IMPORT) for (const id of packageOwns.get(p.from.name) ?? []) out.add(id)
|
|
739
|
+
return out
|
|
740
|
+
}
|
|
741
|
+
const f = facts.get(p.file)
|
|
742
|
+
if (!f || !p.local || seen.has(at)) return out
|
|
743
|
+
seen.add(at)
|
|
744
|
+
if (!drawnByFile.has(p.file)) drawnByFile.set(p.file, { drawn: drawnIn(f.sf), roots: rootsIn(f.sf) })
|
|
745
|
+
const { drawn, roots } = drawnByFile.get(p.file)!
|
|
746
|
+
const imported = new Map(f.links.filter((l) => l.kind === 'import').flatMap((l) => l.names.map((n) => [n.local, { link: l, imported: n.imported }] as const)))
|
|
747
|
+
// The Base UI parts it draws, anywhere in it (rare in an app: the gate refuses most).
|
|
748
|
+
for (const tag of drawn.get(p.local) ?? []) {
|
|
749
|
+
const [local, piece] = splitTag(tag)
|
|
750
|
+
const from = imported.get(local)
|
|
751
|
+
if (from && isBaseUi(from.link.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
|
|
752
|
+
}
|
|
753
|
+
// The parts it is: what it hands back at its root, the package's or the app's own.
|
|
754
|
+
for (const tag of roots.get(p.local) ?? []) {
|
|
755
|
+
const from = imported.get(splitTag(tag)[0])
|
|
756
|
+
if (!from) continue
|
|
757
|
+
let inner: Iterable<string> = []
|
|
758
|
+
if (from.link.specifier === PACKAGE_IMPORT) inner = handedOn(from.imported, packageOwns.get(from.imported) ?? [])
|
|
759
|
+
else if (from.link.target && from.link.target !== '?') {
|
|
760
|
+
const part = origin(from.link.target, from.imported)
|
|
761
|
+
if (part) inner = handedOn(part.from?.name ?? part.name, ownsOf(part, seen))
|
|
762
|
+
}
|
|
763
|
+
for (const id of inner) out.add(id)
|
|
764
|
+
}
|
|
765
|
+
if (seen.size === 1) owned.set(at, out)
|
|
766
|
+
seen.delete(at)
|
|
767
|
+
return out
|
|
768
|
+
}
|
|
769
|
+
|
|
725
770
|
const entries: RegistryEntry[] = []
|
|
726
771
|
for (const p of found) {
|
|
727
772
|
const u = users.get(key(p.file, p.name))!
|
|
@@ -803,7 +848,12 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
803
848
|
packageNamesake: namesakes.has(p.name) && !(pass && pass.specifier === PACKAGE_IMPORT && pass.name === p.name) ? p.name : null,
|
|
804
849
|
defaultExport: p.defaultExport,
|
|
805
850
|
}
|
|
806
|
-
const
|
|
851
|
+
const found = pass ? { docsId: null, storyId: null } : storyOf(p)
|
|
852
|
+
// A part with a page of its own and no story file links to its page, by the
|
|
853
|
+
// page's own title (Katerina's ruling B10, 25 September): Peek's EditedMarker.mdx
|
|
854
|
+
// shows the part in other parts' stories, and nothing linked it.
|
|
855
|
+
const titled = !found.docsId && pageText ? /<Meta\s+title=["'`]([^"'`]+)["'`]/.exec(readFileSync(join(app, page!), 'utf8'))?.[1] : undefined
|
|
856
|
+
const { docsId, storyId } = titled ? { docsId: `${sanitize(titled)}--docs`, storyId: found.storyId } : found
|
|
807
857
|
entries.push({
|
|
808
858
|
name: p.name,
|
|
809
859
|
repo: repoName,
|
|
@@ -814,7 +864,7 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
814
864
|
purposeFrom,
|
|
815
865
|
props: shape.props,
|
|
816
866
|
variants: shape.variants,
|
|
817
|
-
ownsBehaviours:
|
|
867
|
+
ownsBehaviours: asBehaviours(ownsOf(p, new Set())),
|
|
818
868
|
status: declared?.deprecated ? 'deprecated' : 'stable',
|
|
819
869
|
migrationStage: null,
|
|
820
870
|
docsId,
|