@nanisoft/prism-ui 0.6.0 → 0.8.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/.tsbuildinfo +1 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +40 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/ui/breadcrumb.d.ts +6 -2
- package/dist/components/ui/breadcrumb.d.ts.map +1 -1
- package/dist/components/ui/breadcrumb.js +5 -3
- package/dist/components/ui/dialog.d.ts +12 -1
- package/dist/components/ui/dialog.d.ts.map +1 -1
- package/dist/components/ui/dialog.js +2 -2
- package/dist/components/ui/live-region.d.ts +78 -0
- package/dist/components/ui/live-region.d.ts.map +1 -0
- package/dist/components/ui/live-region.js +48 -0
- package/dist/components/ui/mark.d.ts +44 -0
- package/dist/components/ui/mark.d.ts.map +1 -0
- package/dist/components/ui/mark.js +79 -0
- package/dist/components/ui/pagination.d.ts +24 -6
- package/dist/components/ui/pagination.d.ts.map +1 -1
- package/dist/components/ui/pagination.js +23 -9
- package/dist/components/ui/product-switcher.d.ts +7 -0
- package/dist/components/ui/product-switcher.d.ts.map +1 -1
- package/dist/components/ui/product-switcher.js +2 -2
- package/dist/components/ui/slider.d.ts +1 -1
- package/dist/components/ui/slider.d.ts.map +1 -1
- package/dist/components/ui/tree.d.ts +115 -0
- package/dist/components/ui/tree.d.ts.map +1 -0
- package/dist/components/ui/tree.js +137 -0
- package/dist/pages/docs-shell/docs-shell.d.ts +250 -0
- package/dist/pages/docs-shell/docs-shell.d.ts.map +1 -0
- package/dist/pages/docs-shell/docs-shell.js +197 -0
- package/dist/pages/docs-shell/index.d.ts +3 -0
- package/dist/pages/docs-shell/index.d.ts.map +1 -0
- package/dist/pages/docs-shell/index.js +1 -0
- package/dist/pages/index.d.ts +9 -2
- package/dist/pages/index.d.ts.map +1 -1
- package/dist/pages/index.js +8 -2
- package/dist/styles.css +93 -0
- package/gates/README.md +98 -0
- package/gates/cli.mjs +133 -0
- package/gates/hidden-state.mjs +243 -0
- package/gates/index.mjs +81 -0
- package/gates/laws.mjs +170 -0
- package/gates/links.mjs +187 -0
- package/gates/pack-boundary.mjs +421 -0
- package/gates/pin.mjs +130 -0
- package/gates/retired-line.mjs +314 -0
- package/gates/run.mjs +320 -0
- package/gates/runtime-token-read.mjs +129 -0
- package/gates/stylesheet-ownership.mjs +207 -0
- package/package.json +14 -2
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The retired-line law: no trace of the old component library survives.
|
|
3
|
+
*
|
|
4
|
+
* Four repositories shipped the retired line together, each discovered it had to
|
|
5
|
+
* come off separately, and the rule that governed the removal lived in prose in
|
|
6
|
+
* four files. A rule in a document decays; this one already had. One repository's
|
|
7
|
+
* own instructions said never import the library directly while three files did,
|
|
8
|
+
* the build baked 126 KB of generated variables from it on every run, and that
|
|
9
|
+
* generated file was the only definition site for every custom property the
|
|
10
|
+
* repository's own stylesheet read.
|
|
11
|
+
*
|
|
12
|
+
* **The lockfile is read as a graph and never grepped.** Two failures make that
|
|
13
|
+
* non-negotiable. A base64 integrity hash contains the characters a
|
|
14
|
+
* package-specifier pattern admits, so a grep reports hits that are not packages.
|
|
15
|
+
* And deleting a dependency line does not empty a lockfile while another package
|
|
16
|
+
* declares the library, which is exactly what happened here: the retired line
|
|
17
|
+
* declared it, so ten packages stayed reachable after the line was dropped and
|
|
18
|
+
* only the move of the pin emptied the graph. The parser reads `importers:` and
|
|
19
|
+
* `snapshots:` and never reads a `resolution:` value, which is where the hashes
|
|
20
|
+
* are.
|
|
21
|
+
*
|
|
22
|
+
* **There is no carve-out for this gate's own prose.** The four copies each
|
|
23
|
+
* excluded one file by path, because each named the library it was keeping out.
|
|
24
|
+
* A rule that has to except itself is a rule whose exception is a file a later
|
|
25
|
+
* commit renames, and the file it excepted was inside a consumer's repository
|
|
26
|
+
* where a reader would come to change a gate. The gate now lives in the package,
|
|
27
|
+
* so the text that names the library is not in the tree being scanned.
|
|
28
|
+
*
|
|
29
|
+
* Coverage is asserted: the run reads the whole repository below the declared
|
|
30
|
+
* roots, every skipped directory is printed, and a run that read fewer files than
|
|
31
|
+
* its floor fails rather than reporting a clean tree.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync } from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
|
|
36
|
+
import { law } from './laws.mjs'
|
|
37
|
+
import { SKIP_DIRECTORIES, blankComments, finding, floor, read, walk } from './run.mjs'
|
|
38
|
+
|
|
39
|
+
/** The old line was built on Ant Design. */
|
|
40
|
+
const isVendorPackage = (name) => name === 'antd' || name.startsWith('@ant-design/')
|
|
41
|
+
|
|
42
|
+
/** Text files a trace could be in. The lockfile is a graph input, not a text input. */
|
|
43
|
+
const TEXT = /\.(ts|tsx|js|jsx|mjs|cjs|css|json|md|mdx|svg|ya?ml|toml)$/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A module specifier naming the old line's packages, in every position a
|
|
47
|
+
* specifier can take. The bare root matters: `from 'antd'` is reachable with no
|
|
48
|
+
* subpath at all, and a pattern anchored on `from 'antd/` misses it.
|
|
49
|
+
*/
|
|
50
|
+
const MODULE_SPECIFIER =
|
|
51
|
+
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"](?:antd(?:\/[^'"]*)?|@ant-design\/[^'"]*)['"]/
|
|
52
|
+
|
|
53
|
+
/** The files the old line shipped, by name. A `.gitignore` line naming one is a finding. */
|
|
54
|
+
const ARTEFACTS = ['antd-vars.css', 'bake-antd-css.mjs']
|
|
55
|
+
|
|
56
|
+
/** The old line's own theming symbols, which a half-finished removal leaves behind. */
|
|
57
|
+
const THEMING_SYMBOLS = /prismCssVarKey|prismBrandPacks|PrismThemeModeProvider/
|
|
58
|
+
|
|
59
|
+
/** The old line's custom-property namespace. The current contract is unprefixed. */
|
|
60
|
+
const OLD_NAMESPACE = /--prism-[a-z0-9-]*/
|
|
61
|
+
|
|
62
|
+
/** The old line's ruleset class. A pack and a mode are `[data-pack]` and `.dark` today. */
|
|
63
|
+
const OLD_RULESET = /\bprism-[a-z0-9]+(?:-[a-z0-9]+)*-(?:light|dark)\b/
|
|
64
|
+
|
|
65
|
+
export function run({ root, config }) {
|
|
66
|
+
const l = law('retired-line')
|
|
67
|
+
const findings = []
|
|
68
|
+
const notes = []
|
|
69
|
+
const manifestPath = config.manifest ?? 'package.json'
|
|
70
|
+
const lockfilePath = config.lockfile ?? 'pnpm-lock.yaml'
|
|
71
|
+
const documents = new Set(config.documents ?? ['README.md', 'AGENTS.md', 'CLAUDE.md'])
|
|
72
|
+
const minFiles = config.minFiles ?? 12
|
|
73
|
+
|
|
74
|
+
const files = walk(root, { skip: SKIP_DIRECTORIES, extensions: [TEXT] })
|
|
75
|
+
floor('this run read', files.length, minFiles)
|
|
76
|
+
|
|
77
|
+
let bytes = 0
|
|
78
|
+
let commentOnly = 0
|
|
79
|
+
for (const relative of files) {
|
|
80
|
+
const source = read(root, relative)
|
|
81
|
+
bytes += source.length
|
|
82
|
+
const lines = source.split('\n')
|
|
83
|
+
/*
|
|
84
|
+
* Comments are blanked before the two families that name things rather than
|
|
85
|
+
* use them: the custom-property namespace and the theming symbols. A comment
|
|
86
|
+
* that says which property the old sheet declared is a historical record, and
|
|
87
|
+
* the design system's own gate excludes its rule table for the same reason.
|
|
88
|
+
* A *declaration* or a *read* of one is not excused, and blanking rather than
|
|
89
|
+
* skipping the file is what keeps that distinction: a stylesheet whose rule is
|
|
90
|
+
* commented out stops being a read and starts being a note, which is correct.
|
|
91
|
+
*
|
|
92
|
+
* The module specifier, the artefact name and the ruleset class are NOT
|
|
93
|
+
* comment-blanked. A comment that tells a later implementer to import the old
|
|
94
|
+
* package is an instruction rather than a record, and that is the family the
|
|
95
|
+
* whole programme lost a reader to.
|
|
96
|
+
*/
|
|
97
|
+
const codeLines = blankComments(source).split('\n')
|
|
98
|
+
|
|
99
|
+
lines.forEach((line, index) => {
|
|
100
|
+
const at = `${relative}:${index + 1}`
|
|
101
|
+
const code = codeLines[index] ?? ''
|
|
102
|
+
if (code.trim() === '' && line.trim() !== '') commentOnly += 1
|
|
103
|
+
|
|
104
|
+
const specifier = line.match(MODULE_SPECIFIER)
|
|
105
|
+
if (specifier) {
|
|
106
|
+
findings.push(
|
|
107
|
+
finding(at, 'module-imported', `"${specifier[0].trim()}" imports the retired line. A consumer installs ${'`@nanisoft/prism-ui`'}, which needs no part of it.`),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
for (const artefact of ARTEFACTS) {
|
|
111
|
+
if (line.includes(artefact)) {
|
|
112
|
+
findings.push(
|
|
113
|
+
finding(at, 'generated-artefact', `this line names the retired line's artefact \`${artefact}\`, and the artefact and its generator are the same removal.`),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (THEMING_SYMBOLS.test(code)) {
|
|
118
|
+
findings.push(
|
|
119
|
+
finding(at, 'theming-symbol', `this line names the retired line's theming symbol. The theme is two attributes on the document element and a blocking script the design system ships.`),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
const namespace = code.match(OLD_NAMESPACE)
|
|
123
|
+
if (namespace) {
|
|
124
|
+
findings.push(
|
|
125
|
+
finding(at, 'custom-property', `\`${namespace[0]}\` is the retired line's custom-property namespace. The current contract is unprefixed (\`--background\`), so this read resolves to nothing.`),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
const ruleset = code.match(OLD_RULESET)
|
|
129
|
+
if (ruleset) {
|
|
130
|
+
findings.push(
|
|
131
|
+
finding(at, 'ruleset-class', `\`${ruleset[0]}\` is the retired line's ruleset class. A pack and a mode are \`[data-pack]\` and \`.dark\` today.`),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
// The lockfile is read as a graph below and never as text, because a base64
|
|
135
|
+
// integrity hash contains the characters every specifier pattern admits.
|
|
136
|
+
if (relative === lockfilePath) {
|
|
137
|
+
const parsed = parseLockfile(source)
|
|
138
|
+
for (const [name, chain] of reachableVendors(parsed)) {
|
|
139
|
+
findings.push(
|
|
140
|
+
finding(
|
|
141
|
+
lockfilePath,
|
|
142
|
+
'reachable-package',
|
|
143
|
+
`\`${name}\` is still reachable from importer \`${chain[0]}\`: ${chain.join(' -> ')}. Deleting a dependency line does not empty a lockfile while another package declares it.`,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (relative === manifestPath) {
|
|
150
|
+
for (const name of vendorDependencies(JSON.parse(source))) {
|
|
151
|
+
findings.push(
|
|
152
|
+
finding(manifestPath, 'dependency-declared', `\`${name}\` is a live dependency. A consumer installs the design system, which brings its own foundation.`),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (documents.has(relative) || relative.startsWith('docs/')) {
|
|
157
|
+
const prose = line.match(/\bant[\s-]design\b/i) ?? line.match(/\bantd\b/)
|
|
158
|
+
if (prose) {
|
|
159
|
+
findings.push(
|
|
160
|
+
finding(at, 'living-instruction', `"${prose[0]}" names the retired line in an instruction a later implementer will follow. A document that needs to say what the old line was belongs in a historical record.`),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!existsSync(path.join(root, lockfilePath))) {
|
|
168
|
+
findings.push(
|
|
169
|
+
finding(lockfilePath, 'missing', 'the lockfile does not resolve, so reachability cannot be read at all and the graph half of this law is unrun.'),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
notes.push(`${l.id}: ${findings.length} finding(s) across ${files.length} file(s) read, ${bytes} byte(s)`)
|
|
174
|
+
notes.push(`${l.id}: ${commentOnly} comment-only line(s) were exempt from the namespace and theming-symbol families, and were not exempt from the specifier, artefact or ruleset families. A comment naming a property the old sheet declared is a record; a comment telling a later implementer which package to import is an instruction.`)
|
|
175
|
+
notes.push(`${l.id}: ${lockfilePath} was read as a dependency graph and never text-scanned, because a base64 integrity hash contains the characters a specifier pattern admits.`)
|
|
176
|
+
notes.push(`${l.id}: every directory this run did not read, printed so an exclusion is arguable:`)
|
|
177
|
+
for (const directory of SKIP_DIRECTORIES) notes.push(`${l.id}: ${directory}/`)
|
|
178
|
+
notes.push(
|
|
179
|
+
`${l.id}: this gate does not except itself, because it does not live in the tree it scans. The four copies each excluded one file by path for exactly that reason.`,
|
|
180
|
+
)
|
|
181
|
+
notes.push(
|
|
182
|
+
`${l.id}: the honest limit: this is a text and dependency-graph scan. It cannot see a competitor reached through a package that renames it, and it cannot see a runtime that resolves one by string.`,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return { law: l, findings, notes }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every dependency key naming the retired line, in any block that declares one. */
|
|
189
|
+
export function vendorDependencies(manifest) {
|
|
190
|
+
const found = []
|
|
191
|
+
for (const block of [
|
|
192
|
+
'dependencies',
|
|
193
|
+
'devDependencies',
|
|
194
|
+
'peerDependencies',
|
|
195
|
+
'optionalDependencies',
|
|
196
|
+
'overrides',
|
|
197
|
+
'resolutions',
|
|
198
|
+
'pnpm.overrides',
|
|
199
|
+
'pnpm.resolutions',
|
|
200
|
+
]) {
|
|
201
|
+
const declared = manifest?.[block]
|
|
202
|
+
if (!declared || typeof declared !== 'object') continue
|
|
203
|
+
for (const name of Object.keys(declared)) if (isVendorPackage(name)) found.push(name)
|
|
204
|
+
}
|
|
205
|
+
return found
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const indentOf = (line) => line.length - line.trimStart().length
|
|
209
|
+
const unquote = (value) => value.trim().replace(/^'(.*)'$/, '$1').trim()
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse the lockfile into the two things reachability needs and nothing else.
|
|
213
|
+
*
|
|
214
|
+
* The line ending is normalised first, because a CRLF checkout leaves a carriage
|
|
215
|
+
* return on the end of every line, so trimming a section name yields a name with
|
|
216
|
+
* a character at the end of it. No section then matches, the graph comes back
|
|
217
|
+
* empty, and an empty graph finds nothing and reports a clean pass. That is the
|
|
218
|
+
* difference between a gate and a gate that is switched off.
|
|
219
|
+
*/
|
|
220
|
+
export function parseLockfile(text) {
|
|
221
|
+
const importers = new Map()
|
|
222
|
+
const snapshots = new Map()
|
|
223
|
+
let section = null
|
|
224
|
+
let importer = null
|
|
225
|
+
let snapshot = null
|
|
226
|
+
let depBlock = null
|
|
227
|
+
let pending = null
|
|
228
|
+
|
|
229
|
+
for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
|
|
230
|
+
if (line.trim() === '') continue
|
|
231
|
+
const indent = indentOf(line)
|
|
232
|
+
|
|
233
|
+
if (indent === 0) {
|
|
234
|
+
section = line.replace(/:.*/, '')
|
|
235
|
+
importer = null
|
|
236
|
+
snapshot = null
|
|
237
|
+
depBlock = null
|
|
238
|
+
pending = null
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (section === 'importers') {
|
|
243
|
+
if (indent === 2) {
|
|
244
|
+
importer = unquote(line.trim().replace(/:$/, ''))
|
|
245
|
+
importers.set(importer, new Map())
|
|
246
|
+
} else if (indent === 4) {
|
|
247
|
+
depBlock = line.trim().replace(/:$/, '')
|
|
248
|
+
} else if (indent === 6) {
|
|
249
|
+
pending = unquote(line.trim().replace(/:$/, ''))
|
|
250
|
+
} else if (indent === 8) {
|
|
251
|
+
// The resolved version is the seed of the walk. A `link:` version is a
|
|
252
|
+
// workspace edge with no snapshot, so it seeds nothing.
|
|
253
|
+
if (!line.trim().startsWith('version:') || pending == null) continue
|
|
254
|
+
const version = unquote(line.trim().slice('version:'.length))
|
|
255
|
+
if (version.startsWith('link:')) continue
|
|
256
|
+
importers.get(importer)?.set(`${depBlock ?? 'dependencies'}/${pending}`, `${pending}@${version}`)
|
|
257
|
+
}
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (section === 'snapshots') {
|
|
262
|
+
if (indent === 2) {
|
|
263
|
+
snapshot = unquote(line.trim().replace(/:(\s*\{\})?$/, ''))
|
|
264
|
+
if (!snapshots.has(snapshot)) snapshots.set(snapshot, new Map())
|
|
265
|
+
} else if (indent === 4) {
|
|
266
|
+
depBlock = line.trim().replace(/:$/, '')
|
|
267
|
+
} else if (indent === 6) {
|
|
268
|
+
const colon = line.indexOf(':')
|
|
269
|
+
const name = unquote(line.slice(0, colon))
|
|
270
|
+
const version = unquote(line.slice(colon + 1))
|
|
271
|
+
snapshots.get(snapshot)?.set(`${depBlock ?? 'dependencies'}/${name}`, `${name}@${version}`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { importers, snapshots }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Split a `name@version(peer@1)` key at the `@` outside every parenthesised
|
|
281
|
+
* suffix. `lastIndexOf` does not work: the suffixes are full of `@`.
|
|
282
|
+
*/
|
|
283
|
+
export function splitPackageKey(key) {
|
|
284
|
+
let depth = 0
|
|
285
|
+
for (let i = 1; i < key.length; i += 1) {
|
|
286
|
+
const char = key[i]
|
|
287
|
+
if (char === '(') depth += 1
|
|
288
|
+
else if (char === ')') depth -= 1
|
|
289
|
+
else if (char === '@' && depth === 0) return [key.slice(0, i), key.slice(i + 1)]
|
|
290
|
+
}
|
|
291
|
+
return [key, '']
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Every retired-line package reachable from an importer, with the chain that reaches it. */
|
|
295
|
+
export function reachableVendors(lock) {
|
|
296
|
+
const found = new Map()
|
|
297
|
+
for (const [importer, direct] of lock.importers) {
|
|
298
|
+
const queue = [...direct.values()].map((key) => ({ key, parent: null }))
|
|
299
|
+
const seen = new Set()
|
|
300
|
+
while (queue.length > 0) {
|
|
301
|
+
const node = queue.shift()
|
|
302
|
+
if (seen.has(node.key)) continue
|
|
303
|
+
seen.add(node.key)
|
|
304
|
+
const [name] = splitPackageKey(node.key)
|
|
305
|
+
if (isVendorPackage(name) && !found.has(name)) {
|
|
306
|
+
const chain = [importer]
|
|
307
|
+
for (let cursor = node; cursor; cursor = cursor.parent) chain.push(splitPackageKey(cursor.key)[0])
|
|
308
|
+
found.set(name, chain.reverse())
|
|
309
|
+
}
|
|
310
|
+
for (const target of lock.snapshots.get(node.key)?.values() ?? []) queue.push({ key: target, parent: node })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return found
|
|
314
|
+
}
|
package/gates/run.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's shared machinery, and the one place a consumer's design system is
|
|
3
|
+
* resolved.
|
|
4
|
+
*
|
|
5
|
+
* Three things live here because all six gates need them and a consumer must
|
|
6
|
+
* only write them once:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Resolution of the design system.** A gate reads the token contract and
|
|
9
|
+
* the emitted base rules, and it reads them *through the component
|
|
10
|
+
* package's own dependency*, not through the consumer's. That is the whole
|
|
11
|
+
* reason a consumer no longer declares `@nanisoft/prism-tokens`: the file
|
|
12
|
+
* belongs to the package that owns it, and the package that owns it is the
|
|
13
|
+
* one the consumer pinned exactly. A second declaration of the token
|
|
14
|
+
* version in a consumer is a second fact to keep in step with a release.
|
|
15
|
+
* 2. **The CSS scanner**, shared by the ownership, token-read, hidden-state
|
|
16
|
+
* and pack-boundary gates, so four gates cannot disagree about what a
|
|
17
|
+
* declaration is. Comments are blanked rather than removed, so a finding
|
|
18
|
+
* names the line a reader has to edit.
|
|
19
|
+
* 3. **The report shape**, so every gate says what it read, names every
|
|
20
|
+
* exclusion, and fails when it read less than its floor.
|
|
21
|
+
*
|
|
22
|
+
* A gate that can read nothing must fail. `floor()` is that: it is called by
|
|
23
|
+
* every gate on the number it is about to be judged on, and it throws rather
|
|
24
|
+
* than warning, because a run that scanned an empty export directory and
|
|
25
|
+
* reported zero findings is indistinguishable from a clean repository.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
|
+
import { createRequire } from 'node:module'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
|
|
31
|
+
/** The component package's name. The one package a consumer of the kit pins. */
|
|
32
|
+
export const PACKAGE = '@nanisoft/prism-ui'
|
|
33
|
+
|
|
34
|
+
/** The token package's name, and the fact that it is the component package's dependency. */
|
|
35
|
+
export const TOKEN_PACKAGE = '@nanisoft/prism-tokens'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A gate read less than it needed to read.
|
|
39
|
+
*
|
|
40
|
+
* Its own class rather than a generic `Error`, so the report can name it as a
|
|
41
|
+
* coverage failure instead of a crash: the difference matters, because a crash
|
|
42
|
+
* reads as "the gate is broken" and a coverage failure reads as "the gate saw
|
|
43
|
+
* nothing", and only the second is true.
|
|
44
|
+
*/
|
|
45
|
+
export class CoverageError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message)
|
|
48
|
+
this.name = 'CoverageError'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fail the run when `actual` is below `floor`.
|
|
54
|
+
*
|
|
55
|
+
* `what` is the unit, because "read 0" is meaningless and "read 0 routes" is a
|
|
56
|
+
* diagnosis. The message says what a reader has to do, which is the whole
|
|
57
|
+
* reason this is a function and not a number in each gate.
|
|
58
|
+
*/
|
|
59
|
+
export function floor(what, actual, minimum) {
|
|
60
|
+
if (actual >= minimum) return actual
|
|
61
|
+
throw new CoverageError(
|
|
62
|
+
`${what} ${actual} and this gate needs at least ${minimum}. A gate that read nothing reports\n` +
|
|
63
|
+
` zero findings, and a zero-finding report is indistinguishable from a clean repository.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------ reading */
|
|
68
|
+
|
|
69
|
+
/** Directories no gate ever reads. Printed on every run, because an exclusion is arguable. */
|
|
70
|
+
export const SKIP_DIRECTORIES = [
|
|
71
|
+
'.git',
|
|
72
|
+
'.next',
|
|
73
|
+
'.source',
|
|
74
|
+
'.wrangler',
|
|
75
|
+
'coverage',
|
|
76
|
+
'node_modules',
|
|
77
|
+
'out',
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every file under `root`, as forward-slashed relative paths, depth first and
|
|
82
|
+
* sorted, with `skip` pruned rather than filtered afterwards.
|
|
83
|
+
*
|
|
84
|
+
* Sorted because a gate's output order is a reader's reading order and a
|
|
85
|
+
* filesystem's order is not one.
|
|
86
|
+
*/
|
|
87
|
+
export function walk(root, { skip = SKIP_DIRECTORIES, extensions = null } = {}) {
|
|
88
|
+
const pruned = new Set(skip)
|
|
89
|
+
const found = []
|
|
90
|
+
const visit = (dir) => {
|
|
91
|
+
let entries
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
94
|
+
} catch {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
98
|
+
if (pruned.has(entry.name)) continue
|
|
99
|
+
const full = path.join(dir, entry.name)
|
|
100
|
+
if (entry.isDirectory()) visit(full)
|
|
101
|
+
else if (extensions === null || extensions.some((pattern) => pattern.test(entry.name))) {
|
|
102
|
+
found.push(path.relative(root, full).split(path.sep).join('/'))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
visit(root)
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a file under `root`, or return null when it is absent. */
|
|
111
|
+
export function read(root, relative) {
|
|
112
|
+
try {
|
|
113
|
+
return readFileSync(path.join(root, relative), 'utf8')
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read a JSON file under `root`, or return null when it is absent or malformed. */
|
|
120
|
+
export function readJson(root, relative) {
|
|
121
|
+
const text = read(root, relative)
|
|
122
|
+
if (text === null) return null
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text)
|
|
125
|
+
} catch (cause) {
|
|
126
|
+
throw new Error(`${relative} is not valid JSON: ${cause.message}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fail the run when a declared root does not resolve, naming the file. */
|
|
131
|
+
export function requireRoot(root, relative) {
|
|
132
|
+
const full = path.join(root, relative)
|
|
133
|
+
if (!existsSync(full) || !statSync(full).isFile()) {
|
|
134
|
+
throw new CoverageError(
|
|
135
|
+
`${relative} does not resolve, so this gate cannot read what it was pointed at. ` +
|
|
136
|
+
`A renamed file empties a run and an emptied run reports a clean repository.`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
return full
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ------------------------------------------------- the design system, resolved */
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The consumer's installed design system, reached through its own export map.
|
|
146
|
+
*
|
|
147
|
+
* Everything a gate knows about tokens comes from here, so a gate never has to
|
|
148
|
+
* be told where the token package is and a consumer never has to declare it.
|
|
149
|
+
* `createRequire` is seeded with the *component* package's `package.json`, which
|
|
150
|
+
* is itself a published export, so the token package is resolved as a dependency
|
|
151
|
+
* of the thing that owns it rather than as a top-level lookup in the consumer.
|
|
152
|
+
*/
|
|
153
|
+
export function designSystem(root) {
|
|
154
|
+
const fromConsumer = createRequire(path.join(root, 'package.json'))
|
|
155
|
+
let manifestPath
|
|
156
|
+
try {
|
|
157
|
+
manifestPath = fromConsumer.resolve(`${PACKAGE}/package.json`)
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
throw new CoverageError(
|
|
160
|
+
`the pinned design system does not resolve from ${PACKAGE}/package.json (${cause.message}).\n` +
|
|
161
|
+
` Every judgement about tokens and base rules is a guess without it. Run pnpm install first.`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
const packageRoot = path.dirname(manifestPath)
|
|
165
|
+
const require = createRequire(manifestPath)
|
|
166
|
+
const version = JSON.parse(readFileSync(manifestPath, 'utf8')).version
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
version,
|
|
170
|
+
packageRoot,
|
|
171
|
+
/** The one emitted stylesheet, which carries the token blocks and the base layer. */
|
|
172
|
+
styles() {
|
|
173
|
+
try {
|
|
174
|
+
return readFileSync(require.resolve(`${PACKAGE}/styles.css`), 'utf8')
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
throw new CoverageError(
|
|
177
|
+
`${PACKAGE}/styles.css does not resolve (${cause.message}), so the token contract and the\n` +
|
|
178
|
+
' base rules cannot be read and every judgement about them would be a guess.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
/**
|
|
183
|
+
* The published per-pack block for one pack and mode, as the token package
|
|
184
|
+
* emits it. Resolved through the component package's dependency, which is
|
|
185
|
+
* why a consumer does not declare the token package itself.
|
|
186
|
+
*/
|
|
187
|
+
tokenTheme(pack, mode) {
|
|
188
|
+
try {
|
|
189
|
+
const file = require.resolve(`${TOKEN_PACKAGE}/dist/themes/${pack}/${mode}.css`)
|
|
190
|
+
return properties(readFileSync(file, 'utf8'))
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
throw new CoverageError(
|
|
193
|
+
`the token package's ${pack} ${mode} block does not resolve through ${PACKAGE} ` +
|
|
194
|
+
`(${cause.message}).\n A boundary resolves this file's values in both modes, so without it the\n` +
|
|
195
|
+
' both-modes check cannot run at all.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ------------------------------------------------------------------- the css */
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Blank comments rather than removing them, so every line number a finding
|
|
206
|
+
* prints is still the line a reader has to edit.
|
|
207
|
+
*/
|
|
208
|
+
export function blankComments(source) {
|
|
209
|
+
const out = source.split('')
|
|
210
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
211
|
+
if (out[i] === '/' && out[i + 1] === '*') {
|
|
212
|
+
const close = source.indexOf('*/', i + 2)
|
|
213
|
+
const end = close === -1 ? out.length : close + 2
|
|
214
|
+
for (let k = i; k < end; k += 1) if (out[k] !== '\n') out[k] = ' '
|
|
215
|
+
i = end - 1
|
|
216
|
+
} else if (out[i] === '/' && out[i + 1] === '/') {
|
|
217
|
+
let end = source.indexOf('\n', i)
|
|
218
|
+
if (end === -1) end = source.length
|
|
219
|
+
for (let k = i; k < end; k += 1) out[k] = ' '
|
|
220
|
+
i = end - 1
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out.join('')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Split a stylesheet into `selector { declarations }`, keeping the line each
|
|
228
|
+
* block starts on. A declaration parser rather than a cascade resolution, and
|
|
229
|
+
* the difference is printed on every run: this cannot see a class-scoped rule
|
|
230
|
+
* that competes, an inline style prop, or a sheet it was not pointed at.
|
|
231
|
+
*/
|
|
232
|
+
export function cssBlocks(source) {
|
|
233
|
+
const blanked = blankComments(source)
|
|
234
|
+
const blocks = []
|
|
235
|
+
for (const match of blanked.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
236
|
+
blocks.push({
|
|
237
|
+
selectors: splitSelectors(match[1]),
|
|
238
|
+
body: match[2],
|
|
239
|
+
line: blanked.slice(0, match.index).split('\n').length,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
return blocks
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A selector list, split on the commas that separate selectors.
|
|
247
|
+
*
|
|
248
|
+
* Not `split(',')`, because a comma inside `:where(a, b)` or `:is()` separates
|
|
249
|
+
* arguments rather than selectors, and splitting there makes the tail look like a
|
|
250
|
+
* bare-element selector: `.site :where(a, b)` would report `b)` as an unclassed
|
|
251
|
+
* element rule that declares a property the design system owns. A gate that
|
|
252
|
+
* reports a false positive on ordinary modern CSS is a gate that gets switched
|
|
253
|
+
* off, so the parentheses are tracked and the split only happens at depth zero.
|
|
254
|
+
*/
|
|
255
|
+
export function splitSelectors(text) {
|
|
256
|
+
const parts = []
|
|
257
|
+
let depth = 0
|
|
258
|
+
let current = ''
|
|
259
|
+
for (const character of text) {
|
|
260
|
+
if (character === '(') depth += 1
|
|
261
|
+
else if (character === ')') depth = Math.max(0, depth - 1)
|
|
262
|
+
if (character === ',' && depth === 0) {
|
|
263
|
+
parts.push(current)
|
|
264
|
+
current = ''
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
current += character
|
|
268
|
+
}
|
|
269
|
+
parts.push(current)
|
|
270
|
+
return parts.map((part) => part.trim()).filter(Boolean)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The custom properties a declaration list declares, `--name` included. */
|
|
274
|
+
export function properties(body) {
|
|
275
|
+
return new Map([...body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every custom property name a stylesheet declares. */
|
|
279
|
+
export function declaredProperties(css) {
|
|
280
|
+
return new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Two colours are the same colour however they are written.
|
|
285
|
+
*
|
|
286
|
+
* A minifier shortens `#ffffff` to `#fff`, and a value read from an emitted
|
|
287
|
+
* sheet compared byte for byte against the contract reports a difference that is
|
|
288
|
+
* not one. Normalising is what lets a gate compare values rather than spellings.
|
|
289
|
+
*/
|
|
290
|
+
export function sameColour(left, right) {
|
|
291
|
+
const expand = (value) => {
|
|
292
|
+
const hex = /^#([0-9a-f]{3,8})$/i.exec(String(value).trim())
|
|
293
|
+
if (!hex) return String(value).trim().toLowerCase()
|
|
294
|
+
const digits = hex[1]
|
|
295
|
+
return digits.length === 3 || digits.length === 4
|
|
296
|
+
? `#${[...digits].map((digit) => digit + digit).join('')}`.toLowerCase()
|
|
297
|
+
: `#${digits}`.toLowerCase()
|
|
298
|
+
}
|
|
299
|
+
return expand(left) === expand(right)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ---------------------------------------------------------------- reporting */
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One finding, formatted the way every gate formats one, so a reader who has
|
|
306
|
+
* seen one finding has seen every finding.
|
|
307
|
+
*
|
|
308
|
+
* `where` is the file and line a reader has to edit, `tag` is the class of the
|
|
309
|
+
* defect, and `body` is what it is. The law is not repeated per finding: it is
|
|
310
|
+
* printed once at the end, because a message repeated per finding is a message
|
|
311
|
+
* a reader learns to skip.
|
|
312
|
+
*/
|
|
313
|
+
export function finding(where, tag, body) {
|
|
314
|
+
return `error ${where} [${tag}] ${body}`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A note the run prints, so coverage and exclusions are on every run. */
|
|
318
|
+
export function note(text) {
|
|
319
|
+
return text
|
|
320
|
+
}
|