@estiva-app/ui 0.33.0 → 0.34.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/README.md +8 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Card.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Lightbox.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts +9 -2
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +392 -14
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/looks-of.d.ts +54 -0
- package/dist/eslint/looks-of.d.ts.map +1 -0
- package/dist/eslint/no-copied-look.d.ts +3 -0
- package/dist/eslint/no-copied-look.d.ts.map +1 -0
- package/dist/gates/index.js +1 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +171 -133
- package/dist/index.js.map +3 -3
- package/dist/looks.d.ts +34 -0
- package/dist/looks.d.ts.map +1 -0
- package/dist/registry/app-3ALZE6XO.js +10 -0
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{build-LGFCCOLR.js → build-NDXMVFQ5.js} +3 -3
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-XCBN2EKW.js → chunk-3WJ7ZZ7Z.js} +8 -4
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +7 -0
- package/dist/registry/{chunk-E4JNV7PC.js → chunk-PZO4BVV3.js} +2 -1
- package/dist/registry/chunk-PZO4BVV3.js.map +7 -0
- package/dist/registry/chunk-XVII7P6G.js +595 -0
- package/dist/registry/chunk-XVII7P6G.js.map +7 -0
- package/dist/registry/cli.js +3 -3
- package/dist/registry/index.js +3 -3
- package/dist/registry/schema.d.ts +8 -0
- package/dist/registry/schema.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +347 -90
- package/src/AttachmentCard.tsx +2 -0
- package/src/Button.tsx +1 -0
- package/src/Card.stories.tsx +2 -2
- package/src/Card.tsx +1 -0
- package/src/Chip.tsx +2 -1
- package/src/ChipInput.tsx +4 -2
- package/src/CommandPalette.tsx +2 -0
- package/src/ContainerHeader.stories.tsx +2 -1
- package/src/DialogShell.tsx +1 -0
- package/src/Divider.stories.tsx +12 -10
- package/src/Lightbox.tsx +1 -0
- package/src/Link.stories.tsx +5 -4
- package/src/Menu.tsx +3 -1
- package/src/NavItem.tsx +1 -0
- package/src/PersonTrigger.tsx +1 -0
- package/src/Reaction.tsx +3 -1
- package/src/ReactionPicker.stories.tsx +3 -2
- package/src/ScrollArea.stories.tsx +13 -12
- package/src/SearchInput.tsx +5 -4
- package/src/SectionLabel.mdx +4 -2
- package/src/SectionLabel.stories.tsx +13 -0
- package/src/SectionLabel.tsx +19 -2
- package/src/Select.tsx +6 -4
- package/src/TextInput.tsx +6 -6
- package/src/Textarea.tsx +6 -4
- package/src/Tooltip.stories.tsx +3 -2
- package/src/Tooltip.tsx +2 -1
- package/src/eslint/index.test.ts +12 -8
- package/src/eslint/index.ts +13 -5
- package/src/eslint/looks-of.ts +302 -0
- package/src/eslint/no-copied-look.test.ts +155 -0
- package/src/eslint/no-copied-look.ts +203 -0
- package/src/eslint/no-restyled-part.ts +1 -1
- package/src/gates/app-checks.ts +1 -1
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
- package/src/looks.ts +42 -0
- package/src/registry/app.ts +3 -0
- package/src/registry/build.ts +8 -0
- package/src/registry/registry.test.ts +1 -1
- package/src/registry/schema.ts +9 -0
- package/dist/registry/app-FXN4EI34.js +0 -10
- package/dist/registry/chunk-E4JNV7PC.js.map +0 -7
- package/dist/registry/chunk-NJN4MQAM.js +0 -397
- package/dist/registry/chunk-NJN4MQAM.js.map +0 -7
- package/dist/registry/chunk-XCBN2EKW.js.map +0 -7
- /package/dist/registry/{app-FXN4EI34.js.map → app-3ALZE6XO.js.map} +0 -0
- /package/dist/registry/{build-LGFCCOLR.js.map → build-NDXMVFQ5.js.map} +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import type { Rule } from 'eslint'
|
|
4
|
+
import type ts from 'typescript'
|
|
5
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
6
|
+
import { classUnits, componentsOf, copies, looksOf, parseForLooks, sharedLooksOf, type ClassUnit } from './looks-of'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* UIG-25: a component's look typed again by hand. **A warning, never an error**
|
|
10
|
+
* (Katerina, 13 September): a copy is found by how alike two lists are, and
|
|
11
|
+
* likeness is a judgement — a warning gets read, a guess that blocks gets
|
|
12
|
+
* switched off.
|
|
13
|
+
*
|
|
14
|
+
* Every class list in the file (see `looks-of.ts` for where one is read) is
|
|
15
|
+
* compared, by its look words alone, against the looks of every catalogued
|
|
16
|
+
* part in another file. A list is a copy when it shares `MIN_SHARED` look words
|
|
17
|
+
* with one of a part's looks, covering `MIN_COVER` of the shorter of the two.
|
|
18
|
+
*
|
|
19
|
+
* - **In an app**, the parts are the package's — read from the catalogue the
|
|
20
|
+
* installed package ships (`@estiva-app/ui/registry.json`, its `looks`) — and
|
|
21
|
+
* the app's own exported components, read from its `src`. So a part added to
|
|
22
|
+
* either is covered the day it is added, with no list kept here. The fix:
|
|
23
|
+
* use the part.
|
|
24
|
+
* - **In the package**, the parts are its own. The fix: write the look once in
|
|
25
|
+
* `src/looks.ts` and use it in both.
|
|
26
|
+
*
|
|
27
|
+
* Left alone: a part's look in its own file, and a component named like the
|
|
28
|
+
* part it wraps (Peek's `Avatar` over the package's). A constant imported from
|
|
29
|
+
* another file is a look written once, not a copy. Peek and Ship copying each
|
|
30
|
+
* other is not seen here — each app knows only itself and the package — and
|
|
31
|
+
* is counted by `gates:status`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface Look {
|
|
35
|
+
part: string
|
|
36
|
+
file: string
|
|
37
|
+
look: string[]
|
|
38
|
+
/** The installed package's part, this repo's own part, or a shared look (`…_CLASSES`) this repo writes once. */
|
|
39
|
+
from: 'package' | 'own' | 'shared'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const packageDirCache = new Map<string, string | null>()
|
|
43
|
+
function packageDirOf(file: string): string | null {
|
|
44
|
+
const start = dirname(resolve(file))
|
|
45
|
+
if (packageDirCache.has(start)) return packageDirCache.get(start) ?? null
|
|
46
|
+
let dir = start
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (existsSync(join(dir, 'package.json'))) break
|
|
49
|
+
const up = dirname(dir)
|
|
50
|
+
if (up === dir) {
|
|
51
|
+
dir = ''
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
dir = up
|
|
55
|
+
}
|
|
56
|
+
packageDirCache.set(start, dir || null)
|
|
57
|
+
return dir || null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PACKAGE = '@estiva-app/ui'
|
|
61
|
+
const nameOf = (dir: string) => {
|
|
62
|
+
try {
|
|
63
|
+
return (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string }).name
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The installed package's parts and their looks, from the catalogue it ships. */
|
|
70
|
+
const shippedCache = new Map<string, { mtime: number; looks: Look[] }>()
|
|
71
|
+
function shippedLooks(appDir: string): Look[] {
|
|
72
|
+
for (let dir = appDir; ; dir = dirname(dir)) {
|
|
73
|
+
const file = join(dir, 'node_modules', PACKAGE, 'registry.json')
|
|
74
|
+
if (existsSync(file)) {
|
|
75
|
+
const mtime = statSync(file).mtimeMs
|
|
76
|
+
const cached = shippedCache.get(file)
|
|
77
|
+
if (cached && cached.mtime === mtime) return cached.looks
|
|
78
|
+
let looks: Look[] = []
|
|
79
|
+
try {
|
|
80
|
+
const registry = JSON.parse(readFileSync(file, 'utf8')) as { entries: { name: string; sourceFile: string; looks?: string[] }[] }
|
|
81
|
+
looks = registry.entries.flatMap((e) => (e.looks ?? []).map((l) => ({ part: e.name, file: e.sourceFile, look: l.split(' '), from: 'package' as const })))
|
|
82
|
+
} catch {
|
|
83
|
+
// A catalogue that does not parse is its own check's business.
|
|
84
|
+
}
|
|
85
|
+
shippedCache.set(file, { mtime, looks })
|
|
86
|
+
return looks
|
|
87
|
+
}
|
|
88
|
+
if (dirname(dir) === dir) return []
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The names a file exports. */
|
|
93
|
+
function exportedNames(source: ts.SourceFile): Set<string> {
|
|
94
|
+
const names = new Set<string>()
|
|
95
|
+
const text = source.text
|
|
96
|
+
for (const m of text.matchAll(/export\s+(?:default\s+)?(?:function|const)\s+([A-Z]\w*)/g)) names.add(m[1])
|
|
97
|
+
for (const m of text.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of m[1].split(',')) {
|
|
98
|
+
const name = part.trim().split(/\s+as\s+/).pop()
|
|
99
|
+
if (name && /^[A-Z]/.test(name)) names.add(name)
|
|
100
|
+
}
|
|
101
|
+
return names
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A repo's own exported components and their looks, from its `src`. */
|
|
105
|
+
const ownCache = new Map<string, { mtime: number; looks: Look[] }>()
|
|
106
|
+
function ownLooks(repoDir: string): Look[] {
|
|
107
|
+
const out: Look[] = []
|
|
108
|
+
const walk = (dir: string) => {
|
|
109
|
+
let names: string[] = []
|
|
110
|
+
try {
|
|
111
|
+
names = readdirSync(dir)
|
|
112
|
+
} catch {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
if (name === 'node_modules' || name === 'dist') continue
|
|
117
|
+
const path = join(dir, name)
|
|
118
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
119
|
+
else if (/\.tsx?$/.test(name) && !/\.(?:stories|test)\.tsx?$/.test(name) && !name.endsWith('.d.ts')) {
|
|
120
|
+
const mtime = statSync(path).mtimeMs
|
|
121
|
+
let cached = ownCache.get(path)
|
|
122
|
+
if (!cached || cached.mtime !== mtime) {
|
|
123
|
+
const text = readFileSync(path, 'utf8')
|
|
124
|
+
const source = parseForLooks(path, text)
|
|
125
|
+
const exported = name.endsWith('.tsx') ? [...componentsOf(source).keys()].filter((n) => exportedNames(source).has(n)) : []
|
|
126
|
+
const file = relative(repoDir, path).replace(/\\/g, '/')
|
|
127
|
+
const looks: Look[] = [...looksOf(source, exported)].flatMap(([part, lists]) => lists.map((l) => ({ part, file, look: l.split(' '), from: 'own' as const })))
|
|
128
|
+
// A shared look written once (`…_CLASSES`): a copy of it is told to use it.
|
|
129
|
+
if (text.includes('_CLASSES')) for (const [part, lists] of sharedLooksOf(source)) for (const l of lists) looks.push({ part, file, look: l.split(' '), from: 'shared' })
|
|
130
|
+
cached = { mtime, looks }
|
|
131
|
+
ownCache.set(path, cached)
|
|
132
|
+
}
|
|
133
|
+
out.push(...cached.looks)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
walk(join(repoDir, 'src'))
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const noCopiedLook: Rule.RuleModule = {
|
|
142
|
+
meta: {
|
|
143
|
+
type: 'suggestion',
|
|
144
|
+
docs: { description: "A part's look typed again by hand, where the part (or a shared look) should be used" },
|
|
145
|
+
schema: [],
|
|
146
|
+
messages: {
|
|
147
|
+
usePart:
|
|
148
|
+
"This class list is `{{part}}`'s look, typed again ({{count}} words: {{words}}). Use `{{part}}` from {{from}}: a copy looks right today and stays behind the day `{{part}}` changes.",
|
|
149
|
+
shareIt:
|
|
150
|
+
'This look is also written in `{{part}}` ({{file}}): {{words}}. Write it once in src/looks.ts and use it in both, so the two cannot drift apart.',
|
|
151
|
+
useShared:
|
|
152
|
+
'This class list is `{{part}}` ({{file}}), typed again: {{words}}. Use `{{part}}`: a look written once cannot drift apart.',
|
|
153
|
+
...ESCAPE_MESSAGES,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
create(context) {
|
|
157
|
+
const file = context.filename
|
|
158
|
+
const repoDir = packageDirOf(file)
|
|
159
|
+
if (!repoDir) return {}
|
|
160
|
+
const inPackage = nameOf(repoDir) === PACKAGE
|
|
161
|
+
return {
|
|
162
|
+
Program() {
|
|
163
|
+
const here = relative(repoDir, file).replace(/\\/g, '/')
|
|
164
|
+
const source = parseForLooks(file, context.sourceCode.text)
|
|
165
|
+
const declared = new Set(componentsOf(source).keys())
|
|
166
|
+
const looks = [...(inPackage ? [] : shippedLooks(repoDir)), ...ownLooks(repoDir)].filter((l) => l.file !== here && !declared.has(l.part))
|
|
167
|
+
if (!looks.length) return
|
|
168
|
+
const units = classUnits(source)
|
|
169
|
+
// A string inside a className is read with it; report the className once.
|
|
170
|
+
const within = (u: ClassUnit) => units.some((o) => o !== u && o.via === 'attribute' && u.via === 'literal' && u.start >= o.start && u.end <= o.end)
|
|
171
|
+
const reported = new Set<number>()
|
|
172
|
+
for (const unit of units) {
|
|
173
|
+
if (within(unit) || reported.has(unit.start)) continue
|
|
174
|
+
let best: { look: Look; shared: string[] } | null = null
|
|
175
|
+
for (const look of looks) {
|
|
176
|
+
// A shared look is compared with the package's parts only: the parts here that use it are not its copies.
|
|
177
|
+
if (unit.shared && look.from !== 'package') continue
|
|
178
|
+
const shared = copies(unit.look, look.look)
|
|
179
|
+
// On a tie, the shared look: using it is the whole fix.
|
|
180
|
+
if (shared && (!best || shared.length > best.shared.length || (shared.length === best.shared.length && look.from === 'shared'))) best = { look, shared }
|
|
181
|
+
}
|
|
182
|
+
if (!best) continue
|
|
183
|
+
// A className built from a constant of this file is reported once, at the constant.
|
|
184
|
+
const found = best
|
|
185
|
+
if (unit.via === 'attribute' && units.some((o) => o.via === 'literal' && !within(o) && found.shared.every((w) => o.look.includes(w)) && looks.some((l) => copies(o.look, l.look)))) continue
|
|
186
|
+
const loc = { start: context.sourceCode.getLocFromIndex(unit.start), end: context.sourceCode.getLocFromIndex(unit.end) }
|
|
187
|
+
const at = { start: context.sourceCode.getLocFromIndex(unit.anchor), end: loc.end }
|
|
188
|
+
if (isEscaped(context, { loc: at, range: [unit.anchor, unit.end] })) continue
|
|
189
|
+
reported.add(unit.start)
|
|
190
|
+
const words = best.shared.map((w) => `\`${w}\``).join(' ')
|
|
191
|
+
if (best.look.from === 'shared') context.report({ loc, messageId: 'useShared', data: { part: best.look.part, file: best.look.file, words } })
|
|
192
|
+
else if (inPackage) context.report({ loc, messageId: 'shareIt', data: { part: best.look.part, file: best.look.file, words } })
|
|
193
|
+
else
|
|
194
|
+
context.report({
|
|
195
|
+
loc,
|
|
196
|
+
messageId: 'usePart',
|
|
197
|
+
data: { part: best.look.part, count: String(best.shared.length), words, from: best.look.from === 'package' ? PACKAGE : `\`${best.look.file}\`` },
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}
|
|
@@ -119,7 +119,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
119
119
|
Reaction: ['pressed'],
|
|
120
120
|
ReactionPicker: ['surface'],
|
|
121
121
|
SectionHeader: ['hover'],
|
|
122
|
-
SectionLabel: ['tone'],
|
|
122
|
+
SectionLabel: ['tone', 'truncate'],
|
|
123
123
|
Select: ['size'],
|
|
124
124
|
Tabs: ['size'],
|
|
125
125
|
TextInput: ['size'],
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -230,7 +230,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
230
230
|
{ what: 'a title= is an error naming WithTooltip', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button title="Delete">x</Button>')}`, 'error', 'WithTooltip') },
|
|
231
231
|
]),
|
|
232
232
|
ticket('UIG-25', [
|
|
233
|
-
{ what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-
|
|
233
|
+
{ what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary">Label</span>'), 'warning', 'SectionLabel') },
|
|
234
234
|
]),
|
|
235
235
|
ticket('UIG-27', [
|
|
236
236
|
{ what: 'the installed package has Link', run: () => h.contains(installed, /\bLink\b/, 'the installed @estiva-app/ui exports Link') },
|
|
@@ -59,7 +59,7 @@ describe('create-estiva-app', () => {
|
|
|
59
59
|
|
|
60
60
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
61
61
|
const count = JSON.parse(files['.gates-count.json'])
|
|
62
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
62
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
63
63
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
|
|
64
64
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
65
65
|
})
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -153,7 +153,7 @@ describe('writeGateCount', () => {
|
|
|
153
153
|
const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
|
|
154
154
|
expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
|
|
155
155
|
expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
|
|
156
|
-
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
|
|
156
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
|
|
157
157
|
|
|
158
158
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
159
159
|
|
package/src/looks.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Looks two or more parts share, written once (UIG-25).
|
|
3
|
+
*
|
|
4
|
+
* A look typed again by hand draws the same today and drifts the day one of
|
|
5
|
+
* them changes: nothing fails, one screen just looks slightly off. So a look
|
|
6
|
+
* more than one part wears lives here, and each part takes it from here. The
|
|
7
|
+
* lint that finds a copied look reads a class list wherever it is written; a
|
|
8
|
+
* constant of classes ends in `_CLASSES`, so it is read as one.
|
|
9
|
+
*
|
|
10
|
+
* Only looks — colour, text, border, corner, shadow. Where a part sits and how
|
|
11
|
+
* much room it takes stay in the part.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A field's box: TextInput and Textarea. The hairline strengthens on hover; focus comes after it, so a focused field keeps its colour under the pointer. */
|
|
15
|
+
export const FIELD_BOX_CLASSES = 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg'
|
|
16
|
+
|
|
17
|
+
/** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
|
|
18
|
+
export const FIELD_TEXT_CLASSES = 'text-text-primary placeholder:text-text-muted'
|
|
19
|
+
|
|
20
|
+
/** A field that cannot be used: TextInput, Textarea and Select. */
|
|
21
|
+
export const FIELD_DISABLED_CLASSES = 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled'
|
|
22
|
+
|
|
23
|
+
/** The ring a focused field wears in Signal: TextInput and Textarea. */
|
|
24
|
+
export const FIELD_FOCUS_RING_CLASSES = 'signal:transition-shadow signal:focus:shadow-focus-ring'
|
|
25
|
+
|
|
26
|
+
/** A field's two sizes, its padding and its words: TextInput, Textarea (default only) and Select. The small one is the small Select's trigger. */
|
|
27
|
+
export const FIELD_SIZE_CLASSES = {
|
|
28
|
+
default: 'px-3 py-2 text-input-value',
|
|
29
|
+
small: 'h-6 min-h-6 px-2 text-caption',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
/** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
|
|
33
|
+
export const FIELD_SHELL_CLASSES = 'bg-bg-inset border border-border-default hover:border-border-strong rounded-lg transition-colors'
|
|
34
|
+
|
|
35
|
+
/** A panel that floats over the page: Menu's panel and Tooltip. */
|
|
36
|
+
export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
|
|
37
|
+
|
|
38
|
+
/** A chip's words: Chip's label and Reaction's count. */
|
|
39
|
+
export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
|
|
40
|
+
|
|
41
|
+
/** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
|
|
42
|
+
export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:text-text-muted outline-none'
|
package/src/registry/app.ts
CHANGED
|
@@ -37,6 +37,7 @@ 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
39
|
import { firstSentence, pageOpening, readModule, sanitize, toId, PACKAGE_IMPORT, type Resolved } from './build'
|
|
40
|
+
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
40
41
|
import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema'
|
|
41
42
|
|
|
42
43
|
export interface AppBuildOptions {
|
|
@@ -820,6 +821,8 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
|
|
|
820
821
|
storyId,
|
|
821
822
|
docPage: pageText ? page : null,
|
|
822
823
|
app: facts_,
|
|
824
|
+
// A part handed on from the package looks like the package's (UIG-25).
|
|
825
|
+
looks: pass ? [] : (looksOf(parseForLooks(join(root, p.file), facts.get(p.file)!.source), [p.name]).get(p.name) ?? []),
|
|
823
826
|
})
|
|
824
827
|
}
|
|
825
828
|
|
package/src/registry/build.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import ts from 'typescript'
|
|
21
21
|
import { OWNED_BEHAVIOURS } from '../eslint/index'
|
|
22
|
+
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
22
23
|
import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
|
|
23
24
|
|
|
24
25
|
export interface BuildOptions {
|
|
@@ -594,6 +595,12 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
594
595
|
|
|
595
596
|
const problems: string[] = []
|
|
596
597
|
const entries: RegistryEntry[] = []
|
|
598
|
+
// What each part looks like (UIG-25), read once per file.
|
|
599
|
+
const looksCache = new Map<string, ReturnType<typeof parseForLooks>>()
|
|
600
|
+
const looksFor = (file: string, name: string) => {
|
|
601
|
+
if (!looksCache.has(file)) looksCache.set(file, parseForLooks(join(src, file.replace(/^src\//, '')), readFileSync(join(src, file.replace(/^src\//, '')), 'utf8')))
|
|
602
|
+
return looksOf(looksCache.get(file)!, [name]).get(name) ?? []
|
|
603
|
+
}
|
|
597
604
|
|
|
598
605
|
for (const value of values) {
|
|
599
606
|
const module = moduleOf(value.module)
|
|
@@ -641,6 +648,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
641
648
|
storyId: title && story ? toId(title, story) : null,
|
|
642
649
|
docPage: pageFile,
|
|
643
650
|
app: null,
|
|
651
|
+
looks: kindOf(value.name) === 'component' ? looksFor(module.file, value.name) : [],
|
|
644
652
|
})
|
|
645
653
|
}
|
|
646
654
|
|
|
@@ -176,7 +176,7 @@ describe('the registry builds', () => {
|
|
|
176
176
|
|
|
177
177
|
it('reads them however the component was written', () => {
|
|
178
178
|
// Written out at the parameter, with no named type at all.
|
|
179
|
-
expect(entry('SectionLabel').variants).toEqual([{ prop: 'tone', values: ['primary', 'secondary'] }])
|
|
179
|
+
expect(entry('SectionLabel').variants).toEqual([{ prop: 'tone', values: ['primary', 'secondary', 'muted'] }])
|
|
180
180
|
// forwardRef: the props are its second type argument, and the inner
|
|
181
181
|
// function's parameter is bare.
|
|
182
182
|
expect(entry('TextInput').variants).toEqual([{ prop: 'size', values: ['default', 'small'] }])
|
package/src/registry/schema.ts
CHANGED
|
@@ -177,6 +177,14 @@ export interface RegistryEntry {
|
|
|
177
177
|
docPage: string | null
|
|
178
178
|
/** An app's part: its class and the evidence for it (UIG-13). `null` on the package's own entries. */
|
|
179
179
|
app: AppFacts | null
|
|
180
|
+
/**
|
|
181
|
+
* What it looks like: each class list it writes, as its look words
|
|
182
|
+
* (colour, text, border, corner, shadow — never placement), those with four
|
|
183
|
+
* or more (UIG-25). What `estiva/no-copied-look` compares a hand-typed list
|
|
184
|
+
* against. Empty for a part that only places others; absent in a catalogue
|
|
185
|
+
* built before 0.34.0.
|
|
186
|
+
*/
|
|
187
|
+
looks?: string[]
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
/** A name that is exported and deliberately not an entry. Empty today; the count still has to reconcile. */
|
|
@@ -329,6 +337,7 @@ export function validateRegistry(value: unknown): string[] {
|
|
|
329
337
|
else for (const behaviour of entry.ownsBehaviours) {
|
|
330
338
|
if (!isFilledString(behaviour?.id) || !isFilledString(behaviour?.behaviour)) fail(at('ownsBehaviours') + ' has a malformed entry')
|
|
331
339
|
}
|
|
340
|
+
if (entry.looks !== undefined && !(Array.isArray(entry.looks) && entry.looks.every(isFilledString))) fail(at('looks') + ' is not a list of class lists')
|
|
332
341
|
for (const key of ['docsId', 'storyId', 'docPage'] as const) {
|
|
333
342
|
const held = entry[key]
|
|
334
343
|
if (!(held === null || isFilledString(held))) fail(at(key) + ' is neither a string nor null')
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../src/registry/schema.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The catalogue's shape (UIG-12, seam S2 in docs/GATES.md \u00A716).\n *\n * A **data source read by machines first** and rendered second \u2014 not a\n * documentation generator. The skill (UIG-20), the copy detector (UIG-25) and\n * the merged search (UIG-19) all read this and nothing else, so the field\n * names are the contract and `schemaVersion` is what makes a change to them\n * detectable.\n *\n * Names are plain and ours. Open-sourcing is far off; when it comes, a\n * renaming pass is one version bump, and guessing at a public vocabulary now\n * would cost every reader between here and there.\n */\n\n/**\n * Raise this whenever a field is added, removed or given a new meaning. A\n * reader that knows version N and is handed N+1 must be able to say so rather\n * than silently read a field that moved.\n */\nexport const SCHEMA_VERSION = 2\n\n/** What a name is, which decides how a reader offers it. */\nexport type EntryKind =\n /** Draws something. Used in JSX. */\n | 'component'\n /** A React hook: `use\u2026`, callable only inside a component. */\n | 'hook'\n /** A function or a constant. Neither of the above. */\n | 'helper'\n\n/** Where an entry's one-line purpose was read from. */\nexport type PurposeSource =\n /** The `.mdx` page's opening paragraph \u2014 the name has a page of its own. */\n | 'page'\n /** The doc comment above the export \u2014 the name is documented on a sibling's page. */\n | 'comment'\n /** An app's file that holds this one part: the comment at the top of the file (UIG-13). */\n | 'file'\n /** An app's pass-on: the part is the package's, and says so (UIG-13). */\n | 'package'\n\n/**\n * Which of four kinds an app's part is (UIG-13), or that nothing uses it.\n *\n * Worked out from the code, never from the folder a file sits in: Peek's\n * `components/ui` holds one-line pass-ons beside 187-line components.\n */\nexport type EntryClass =\n /** Its file only hands a package part on, so the app keeps its own import path. */\n | 're-export'\n /** Used in two or more places in the app. Gets a usage page (UIG-17, UIG-18). */\n | 'reusable'\n /** Used in one place. Needs only its purpose line. */\n | 'one-off'\n /** Reusable, and everything it uses is already in the package: it could move there. Katerina rules. */\n | 'promote-candidate'\n /** No file of the app uses it: only its stories or tests, or nothing. Listed, never deleted by the catalogue. */\n | 'unused'\n\n/**\n * What the catalogue knows about a part that lives in an app (UIG-13). `null`\n * on the package's own entries.\n */\nexport interface AppFacts {\n class: EntryClass\n /** Why it has that class, in words: the evidence, or the written reason. */\n reason: string\n /**\n * `true` when the class is written beside the part (`@registry <class>: <reason>`\n * in its comment) rather than worked out \u2014 \"general, though one screen uses it yet\".\n */\n written: boolean\n /** A pass-on's part, as `Name` for the package's and `Name from <package>` for another's. `null` otherwise. */\n handsOn: string | null\n /**\n * The app's files that use it, relative to the app: every file that imports it,\n * and its own file when something else there draws it. Stories and tests are not\n * uses \u2014 a part only they reach is unused.\n */\n usedIn: string[]\n /**\n * What ties it to this app: each import that is neither in the package's own\n * dependencies nor an app file that is itself untied. Empty means it could\n * live in the package as it is.\n */\n tiedTo: string[]\n /** The package part with the same name, when there is one: the reader should see both. */\n packageNamesake: string | null\n /** Imported as `import X from \u2026` rather than `import { X } from \u2026`. */\n defaultExport: boolean\n}\n\n/** A file in an app's target set that holds no part, and why, in words. */\nexport interface FileWithoutPart {\n file: string\n reason: string\n}\n\nexport type EntryStatus = 'stable' | 'migrating' | 'deprecated'\n\n/** A prop that takes one of a known set of words, and the set. */\nexport interface EntryVariant {\n prop: string\n values: string[]\n}\n\n/**\n * One prop a component declares itself.\n *\n * **Itself**, not what it inherits: a component that extends\n * `ComponentPropsWithRef<'div'>` would otherwise drag in every DOM attribute,\n * which is noise in a catalogue whose whole point is a short answer.\n */\nexport interface EntryProp {\n name: string\n /**\n * What it takes, in a word a person reads: `true/false`, `number`, `text`,\n * `anything`, `a handler`, the words themselves for a set (`left | right`),\n * or the type as written where none of those fit.\n */\n takes: string\n required: boolean\n /** The first sentence of the prop's own comment, where it has one. */\n note: string | null\n}\n\n/** A behaviour this component owns, from UIG-8's enumeration. */\nexport interface EntryBehaviour {\n /** `OwnedBehaviour.id` \u2014 `portal`, `walking`, `scroll`\u2026 */\n id: string\n /** What a person notices it doing, in words. */\n behaviour: string\n}\n\nexport interface RegistryEntry {\n /** The exported name, exactly as `index.ts` exports it. */\n name: string\n /** Which library it came from: `estiva-ui`, or the app that owns it. */\n repo: string\n kind: EntryKind\n /** What a caller actually types. */\n importPath: string\n /** The file the name is declared in, relative to the repo. Several names share one file. */\n sourceFile: string\n /** One line: what it is for. Never empty \u2014 the build fails instead. */\n purpose: string\n purposeFrom: PurposeSource\n /**\n * Every prop it declares itself \u2014 what it can do. This is the field a reader\n * asks \"does it already do X?\" of, before writing X by hand.\n */\n props: EntryProp[]\n /**\n * The subset of `props` that takes one of a known set of words, structured.\n * Kept beside `props` because it is what a search ranks well on, and what a\n * caller most often needs named back at them.\n */\n variants: EntryVariant[]\n /** What it owns, so a caller never rebuilds it (UIG-8). Empty for most. */\n ownsBehaviours: EntryBehaviour[]\n status: EntryStatus\n /**\n * Which migration stage plans to change it.\n *\n * Always `null` here. The migration's plan lives in `K:/Estiva/migration\n * docs`, which is not in this repo and is not in CI \u2014 and a copy kept here by\n * hand is the drifting list this whole project exists to remove. The field\n * stays in the schema because a later ticket may have a source a build can\n * read; until then a reader gets an honest `null` rather than a stale number.\n */\n migrationStage: number | null\n /** The `.mdx` page's Storybook id, or `null` where the name has no page of its own. */\n docsId: string | null\n /** A story that shows it, by Storybook id. `null` where nothing renders it alone. */\n storyId: string | null\n /** The `.mdx` path, relative to the repo, or `null`. */\n docPage: string | null\n /** An app's part: its class and the evidence for it (UIG-13). `null` on the package's own entries. */\n app: AppFacts | null\n}\n\n/** A name that is exported and deliberately not an entry. Empty today; the count still has to reconcile. */\nexport interface RegistryExclusion {\n name: string\n reason: string\n}\n\nexport interface Registry {\n schemaVersion: number\n builtFrom: {\n /**\n * `package`: the package's catalogue, committed and shipped. `app`: one app's,\n * built fresh from its code each time and never committed \u2014 Peek and Ship are\n * private, the package is public (Katerina, 18 September 2026).\n */\n kind: 'package' | 'app'\n repo: string\n /** The package the entries are imported from, or the app's own name. */\n package: string\n packageVersion: string\n /**\n * The package: value exports counted in `index.ts`. An app: parts found in its\n * files. `entries + excluded` must equal it.\n */\n exports: number\n /** Type-only exports, which are not entries. Recorded so the count reconciles. `0` for an app. */\n typeExports: number\n /**\n * An app only: its `.tsx` files outside stories and tests. Each one either holds\n * an entry or is in `filesWithoutParts`. `null` for the package.\n */\n files: number | null\n }\n /**\n * How to turn an id into a link. There is no Storybook on the internet yet\n * (UIG-19), so an absolute URL committed here would resolve nowhere: the\n * reader joins its own base to these.\n */\n storybook: {\n docsPath: string\n storyPath: string\n devUrl: string\n }\n entries: RegistryEntry[]\n excluded: RegistryExclusion[]\n /** An app's `.tsx` files that hold no part, each with its reason. Empty for the package. */\n filesWithoutParts: FileWithoutPart[]\n}\n\nconst KINDS: EntryKind[] = ['component', 'hook', 'helper']\nconst STATUSES: EntryStatus[] = ['stable', 'migrating', 'deprecated']\nconst SOURCES: PurposeSource[] = ['page', 'comment', 'file', 'package']\nexport const CLASSES: EntryClass[] = ['re-export', 'reusable', 'one-off', 'promote-candidate', 'unused']\n\nconst isString = (v: unknown): v is string => typeof v === 'string'\nconst isFilledString = (v: unknown): v is string => isString(v) && v.trim().length > 0\n\n/**\n * Check a registry against this schema. Returns every problem, not the first:\n * a build that reports one missing purpose per run costs one run per entry.\n *\n * An empty list is the only pass. CI runs this on the committed file.\n */\nexport function validateRegistry(value: unknown): string[] {\n const problems: string[] = []\n const fail = (message: string) => problems.push(message)\n\n if (typeof value !== 'object' || value === null) return ['the registry is not an object']\n const registry = value as Partial<Registry>\n\n if (registry.schemaVersion !== SCHEMA_VERSION) fail(`schemaVersion is ${String(registry.schemaVersion)}, expected ${SCHEMA_VERSION}`)\n\n const built = registry.builtFrom\n let isApp = false\n if (typeof built !== 'object' || built === null) fail('builtFrom is missing')\n else {\n if (built.kind !== 'package' && built.kind !== 'app') fail(`builtFrom.kind is ${String(built.kind)}, expected package or app`)\n isApp = built.kind === 'app'\n if (!isFilledString(built.repo)) fail('builtFrom.repo is missing')\n if (!isFilledString(built.package)) fail('builtFrom.package is missing')\n if (!isFilledString(built.packageVersion)) fail('builtFrom.packageVersion is missing')\n if (typeof built.exports !== 'number') fail('builtFrom.exports is not a number')\n if (typeof built.typeExports !== 'number') fail('builtFrom.typeExports is not a number')\n if (isApp ? typeof built.files !== 'number' : built.files !== null) fail(`builtFrom.files is ${String(built.files)}: a number for an app, null for the package`)\n }\n\n const book = registry.storybook\n if (typeof book !== 'object' || book === null) fail('storybook is missing')\n else {\n for (const key of ['docsPath', 'storyPath', 'devUrl'] as const) {\n if (!isFilledString(book[key])) fail(`storybook.${key} is missing`)\n }\n if (isString(book.docsPath) && !book.docsPath.includes('{docsId}')) fail('storybook.docsPath does not carry {docsId}')\n if (isString(book.storyPath) && !book.storyPath.includes('{storyId}')) fail('storybook.storyPath does not carry {storyId}')\n }\n\n const entries = registry.entries\n const excluded = registry.excluded\n const withoutParts = registry.filesWithoutParts\n if (!Array.isArray(entries)) return [...problems, 'entries is not an array']\n if (!Array.isArray(excluded)) return [...problems, 'excluded is not an array']\n if (!Array.isArray(withoutParts)) return [...problems, 'filesWithoutParts is not an array']\n // An app with no part of its own yet is still an app: only the package must have entries.\n if (entries.length === 0 && !isApp) fail('entries is empty')\n\n // The package's names are unique; an app may have two parts of one name in two\n // files, and the catalogue says which is which rather than refusing the app.\n const seen = new Set<string>()\n const seenNames = new Set<string>()\n for (const [i, raw] of entries.entries()) {\n const at = (field: string) => `entries[${i}] (${isString((raw as RegistryEntry)?.name) ? (raw as RegistryEntry).name : '?'}).${field}`\n if (typeof raw !== 'object' || raw === null) {\n fail(`entries[${i}] is not an object`)\n continue\n }\n const entry = raw as Partial<RegistryEntry>\n const key = isApp ? `${String(entry.sourceFile)}#${String(entry.name)}` : String(entry.name)\n if (!isFilledString(entry.name)) fail(at('name') + ' is missing')\n else if (seen.has(key)) fail(`${entry.name} appears twice${isApp ? ` in ${String(entry.sourceFile)}` : ''}`)\n else {\n seen.add(key)\n seenNames.add(entry.name)\n }\n\n if (!isFilledString(entry.repo)) fail(at('repo') + ' is missing')\n if (!isFilledString(entry.importPath)) fail(at('importPath') + ' is missing')\n if (!isFilledString(entry.sourceFile)) fail(at('sourceFile') + ' is missing')\n // The reason this schema exists: an entry with no purpose is an entry the\n // search cannot find and the skill cannot quote.\n if (!isFilledString(entry.purpose)) fail(at('purpose') + ' is empty')\n if (!KINDS.includes(entry.kind as EntryKind)) fail(at('kind') + ` is ${String(entry.kind)}`)\n if (!SOURCES.includes(entry.purposeFrom as PurposeSource)) fail(at('purposeFrom') + ` is ${String(entry.purposeFrom)}`)\n if (!STATUSES.includes(entry.status as EntryStatus)) fail(at('status') + ` is ${String(entry.status)}`)\n if (!(entry.migrationStage === null || typeof entry.migrationStage === 'number')) fail(at('migrationStage') + ' is neither a number nor null')\n if (!Array.isArray(entry.props)) fail(at('props') + ' is not an array')\n else\n for (const prop of entry.props) {\n if (!isFilledString(prop?.name) || !isFilledString(prop?.takes) || typeof prop?.required !== 'boolean') fail(at('props') + ' has a malformed entry')\n else if (!(prop.note === null || isFilledString(prop.note))) fail(at('props') + `.${prop.name}.note is neither a string nor null`)\n }\n if (!Array.isArray(entry.variants)) fail(at('variants') + ' is not an array')\n else\n for (const variant of entry.variants) {\n if (!isFilledString(variant?.prop) || !Array.isArray(variant?.values) || variant.values.length === 0) fail(at('variants') + ' has a malformed entry')\n // variants is a view of props, never a second source that can disagree.\n else if (Array.isArray(entry.props) && !entry.props.some((prop) => prop?.name === variant.prop)) fail(at('variants') + `.${variant.prop} is not one of its props`)\n }\n if (!Array.isArray(entry.ownsBehaviours)) fail(at('ownsBehaviours') + ' is not an array')\n else for (const behaviour of entry.ownsBehaviours) {\n if (!isFilledString(behaviour?.id) || !isFilledString(behaviour?.behaviour)) fail(at('ownsBehaviours') + ' has a malformed entry')\n }\n for (const key of ['docsId', 'storyId', 'docPage'] as const) {\n const held = entry[key]\n if (!(held === null || isFilledString(held))) fail(at(key) + ' is neither a string nor null')\n }\n // \"Every storyUrl resolves, or is recorded as absent with a reason\" \u2014 the\n // reason is enforced rather than written down: only something that draws\n // nothing may have nowhere to be looked at. A component with no story is\n // already an error of UIG-5's component-has-a-story; this stops the\n // catalogue quietly carrying one.\n //\n // The package only. An app's parts are described before they are drawn:\n // absence of a story says nothing about whether a part is reused, and UIG-19\n // is where a reusable one must have one.\n if (!isApp && entry.kind === 'component' && (entry.docsId === null || entry.storyId === null)) {\n fail(at('docsId') + ' is null, and only a helper or a hook may have no page or story')\n }\n\n if (!isApp) {\n if (entry.app !== null) fail(at('app') + ' is set on a package entry')\n } else {\n problems.push(...validateAppFacts(entry, at))\n }\n }\n\n for (const [i, raw] of excluded.entries()) {\n const gap = raw as Partial<RegistryExclusion>\n if (!isFilledString(gap?.name)) fail(`excluded[${i}].name is missing`)\n if (!isFilledString(gap?.reason)) fail(`excluded[${i}].reason is missing`)\n if (!isApp && isString(gap?.name) && seenNames.has(gap.name)) fail(`${gap.name} is both an entry and excluded`)\n }\n\n // Completeness (the ticket's acceptance): every value export is either an\n // entry or an exclusion with a reason. Averaging the difference away is the\n // failure this check exists to make impossible.\n const counted = entries.length + excluded.length\n if (typeof built === 'object' && built !== null && typeof built.exports === 'number' && counted !== built.exports) {\n fail(`${entries.length} entries + ${excluded.length} excluded = ${counted}, but ${isApp ? 'the app has' : 'index.ts exports'} ${built.exports} ${isApp ? 'parts' : 'values'}`)\n }\n\n // And an app's files: every one either holds a part or says why it does not.\n const fileProblems: string[] = []\n const withParts = new Set(entries.map((entry) => (entry as RegistryEntry).sourceFile))\n for (const [i, raw] of withoutParts.entries()) {\n const gap = raw as Partial<FileWithoutPart>\n if (!isFilledString(gap?.file)) fileProblems.push(`filesWithoutParts[${i}].file is missing`)\n else if (withParts.has(gap.file)) fileProblems.push(`${gap.file} holds a part and is listed as holding none`)\n if (!isFilledString(gap?.reason)) fileProblems.push(`filesWithoutParts[${i}].reason is missing`)\n }\n if (!isApp && withoutParts.length) fileProblems.push('filesWithoutParts is for an app; the package lists its exports instead')\n if (isApp && typeof built === 'object' && built !== null && typeof built.files === 'number' && withParts.size + withoutParts.length !== built.files) {\n fileProblems.push(`${withParts.size} files hold a part + ${withoutParts.length} hold none = ${withParts.size + withoutParts.length}, but the app has ${built.files} files`)\n }\n problems.push(...fileProblems)\n\n return problems\n}\n\n/**\n * An app entry's class must be what its own evidence says, unless it is written\n * beside the part with a reason. A pass-on is a fact about the file, so it can\n * never be written, and only a pass-on hands a part on.\n */\nfunction validateAppFacts(entry: Partial<RegistryEntry>, at: (field: string) => string): string[] {\n const problems: string[] = []\n const fail = (message: string) => problems.push(message)\n const facts = entry.app\n if (typeof facts !== 'object' || facts === null) return [at('app') + ' is missing on an app entry']\n if (!CLASSES.includes(facts.class)) fail(at('app.class') + ` is ${String(facts.class)}`)\n if (!isFilledString(facts.reason)) fail(at('app.reason') + ' is empty')\n if (typeof facts.written !== 'boolean') fail(at('app.written') + ' is not true/false')\n if (typeof facts.defaultExport !== 'boolean') fail(at('app.defaultExport') + ' is not true/false')\n if (!Array.isArray(facts.usedIn) || !facts.usedIn.every(isFilledString)) fail(at('app.usedIn') + ' is not a list of files')\n if (!Array.isArray(facts.tiedTo) || !facts.tiedTo.every(isFilledString)) fail(at('app.tiedTo') + ' is not a list')\n if (!(facts.handsOn === null || isFilledString(facts.handsOn))) fail(at('app.handsOn') + ' is neither a name nor null')\n if (!(facts.packageNamesake === null || isFilledString(facts.packageNamesake))) fail(at('app.packageNamesake') + ' is neither a name nor null')\n if (problems.length) return problems\n\n const passOn = facts.class === 're-export'\n if (passOn !== (facts.handsOn !== null)) fail(at('app.handsOn') + (passOn ? ' is missing on a pass-on' : ' is set on a part that is not a pass-on'))\n if (passOn !== (entry.purposeFrom === 'package')) fail(at('purposeFrom') + (passOn ? ' must be package on a pass-on' : ' is package on a part that is not a pass-on'))\n if (passOn && facts.written) fail(at('app.written') + ': a pass-on is read from the file, never written')\n if (!facts.written && !passOn) {\n const uses = facts.usedIn.length\n const expected: EntryClass = uses === 0 ? 'unused' : uses === 1 ? 'one-off' : facts.tiedTo.length ? 'reusable' : 'promote-candidate'\n if (facts.class !== expected) fail(at('app.class') + ` is ${facts.class}, but its evidence (${uses} uses, ${facts.tiedTo.length} ties) says ${expected}`)\n }\n return problems\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": ["key"]
|
|
7
|
-
}
|