@nanisoft/prism-ui 0.7.0 → 0.9.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 +30 -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/fonts/Inter-OFL.txt +93 -0
- package/dist/fonts/inter-latin-400.woff2 +0 -0
- package/dist/fonts/inter-latin-500.woff2 +0 -0
- package/dist/fonts/inter-latin-600.woff2 +0 -0
- package/dist/styles.css +52 -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 +16 -3
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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token-read law's runtime form: no token is read at runtime.
|
|
3
|
+
*
|
|
4
|
+
* The clause was written about canvas, because a canvas reads a computed style
|
|
5
|
+
* from one element and paints it into pixels. The pack is a runtime attribute on
|
|
6
|
+
* an ancestor, so a scoped boundary hands that read the pack's light values on a
|
|
7
|
+
* dark page, and a painted pixel does not move when the pack beneath it changes.
|
|
8
|
+
* A reader with dark mode stored and a reader without it are served different
|
|
9
|
+
* colours from the same markup, and nothing in the rendered page says so.
|
|
10
|
+
*
|
|
11
|
+
* It generalises from canvas to any runtime token read, and it holds its shape
|
|
12
|
+
* from the original clause: **a read with a hard-coded fallback is a read that
|
|
13
|
+
* cannot fail, and a read that cannot fail hides its own failure.** The token
|
|
14
|
+
* build publishes the replacement, which is a token-driven inline replacement
|
|
15
|
+
* painted in the HTML: it resolves through the cascade, holds every pack, and
|
|
16
|
+
* ships no client code. So the fix is deletion rather than repair, and this gate
|
|
17
|
+
* asserts the deletion.
|
|
18
|
+
*
|
|
19
|
+
* What this gate does not do is forbid client code. Three of the four consumer
|
|
20
|
+
* repositories ship none and one ships exactly one component for a reason its own
|
|
21
|
+
* gate explains; whether a given repository has a client boundary is that
|
|
22
|
+
* repository's business, and a gate that forbade one would be a law about one
|
|
23
|
+
* site's structure pretending to be a law about all of them. What is universal is
|
|
24
|
+
* the read, because a read resolves once and paints a value that never follows the
|
|
25
|
+
* cascade.
|
|
26
|
+
*
|
|
27
|
+
* Coverage is asserted: the declared roots must hold at least as many source files
|
|
28
|
+
* as the consumer declares as its floor, so a renamed directory cannot empty this
|
|
29
|
+
* run and report a repository that reads nothing.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
|
|
34
|
+
import { law } from './laws.mjs'
|
|
35
|
+
import { CoverageError, finding, floor } from './run.mjs'
|
|
36
|
+
|
|
37
|
+
/** Every way a program resolves a custom property from a live element. */
|
|
38
|
+
const RUNTIME_READ = /getPropertyValue|getComputedStyle|documentElement\.style\.|style\.setProperty/
|
|
39
|
+
|
|
40
|
+
/** The retired line's runtime read helpers, which a half-finished removal leaves behind. */
|
|
41
|
+
const RETIRED_READS = /prismCssVarKey|prismBrandPacks|PrismThemeModeProvider/
|
|
42
|
+
|
|
43
|
+
/** How a canvas or an inline replacement is painted, for the message that names the fix. */
|
|
44
|
+
const PAINTS = /getContext\(|<canvas|document\.createElement\(['"]canvas['"]\)/
|
|
45
|
+
|
|
46
|
+
export function run({ root, config }) {
|
|
47
|
+
const l = law('runtime-token-read')
|
|
48
|
+
const findings = []
|
|
49
|
+
const notes = []
|
|
50
|
+
const sourceRoots = config.sourceRoots ?? ['app', 'components', 'lib']
|
|
51
|
+
const minFiles = config.minFiles ?? 5
|
|
52
|
+
|
|
53
|
+
const missing = sourceRoots.filter((declared) => !existsSync(path.join(root, declared)))
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
throw new CoverageError(
|
|
56
|
+
`${missing.length} of ${sourceRoots.length} declared source roots do not resolve: ${missing.join(', ')}.\n` +
|
|
57
|
+
' A gate that read nothing reports a repository that reads nothing, which is the cleanest possible\n' +
|
|
58
|
+
' reading of this law and the one this gate must never give by accident.',
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const skip = new Set(['node_modules', '.next', 'out', '.git', '.wrangler', '.source'])
|
|
63
|
+
const files = []
|
|
64
|
+
const visit = (dir) => {
|
|
65
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
66
|
+
if (skip.has(entry.name)) continue
|
|
67
|
+
const full = path.join(dir, entry.name)
|
|
68
|
+
if (entry.isDirectory() || statSync(full).isDirectory()) visit(full)
|
|
69
|
+
else if (/\.(tsx?|mjs|jsx?)$/.test(entry.name)) files.push(full)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const declared of sourceRoots) visit(path.join(root, declared))
|
|
73
|
+
floor('source file(s)', files.length, minFiles)
|
|
74
|
+
|
|
75
|
+
const relative = (file) => path.relative(root, file).split(path.sep).join('/')
|
|
76
|
+
let clientModules = 0
|
|
77
|
+
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const source = readFileSync(file, 'utf8')
|
|
80
|
+
const where = relative(file)
|
|
81
|
+
if (/^\s*['"]use client['"]/m.test(source)) clientModules += 1
|
|
82
|
+
|
|
83
|
+
source.split('\n').forEach((line, index) => {
|
|
84
|
+
const read = line.match(RUNTIME_READ)
|
|
85
|
+
if (read) {
|
|
86
|
+
const paints = PAINTS.test(source)
|
|
87
|
+
findings.push(
|
|
88
|
+
finding(
|
|
89
|
+
`${where}:${index + 1}`,
|
|
90
|
+
'runtime-token-read',
|
|
91
|
+
`"${read[0]}" resolves a token from a live element. ${paints ? 'A canvas reads it once and paints\n' +
|
|
92
|
+
' pixels, so a scoped boundary hands it the light values on a dark page and a painted pixel does not\n' +
|
|
93
|
+
' move when the pack beneath it does. ' : ''}A read with a hard-coded fallback is a read that cannot\n` +
|
|
94
|
+
' fail, and a read that cannot fail hides its own failure.',
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const retired = line.match(RETIRED_READS)
|
|
99
|
+
if (retired) {
|
|
100
|
+
findings.push(
|
|
101
|
+
finding(
|
|
102
|
+
`${where}:${index + 1}`,
|
|
103
|
+
'retired-runtime-read',
|
|
104
|
+
`"${retired[0]}" is the retired line's runtime read helper. The theme is two attributes on the document\n` +
|
|
105
|
+
' element and a blocking script the design system ships.',
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
notes.push(
|
|
113
|
+
`${l.id}: ${findings.length} finding(s) across ${files.length} source file(s) read, floor ${minFiles}; roots: ${sourceRoots.join(', ')}`,
|
|
114
|
+
)
|
|
115
|
+
notes.push(
|
|
116
|
+
`${l.id}: ${clientModules} module(s) carry a client directive. Whether this repository has a client boundary at\n` +
|
|
117
|
+
' all is its own business; what is universal is the read, because a read resolves once and a resolved\n' +
|
|
118
|
+
' value does not follow the cascade.',
|
|
119
|
+
)
|
|
120
|
+
notes.push(
|
|
121
|
+
`${l.id}: every directory this run did not read, printed so an exclusion is arguable: ${[...skip].sort().join(', ')}`,
|
|
122
|
+
)
|
|
123
|
+
notes.push(
|
|
124
|
+
`${l.id}: the honest limit: this is a lexical scan. It cannot see a computed \`import()\` or a \`require\` assembled at\n` +
|
|
125
|
+
' runtime, and it does not attempt to decide whether a read it found is load-bearing.',
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return { law: l, findings, notes }
|
|
129
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two laws about one sheet: what it may not own, and what it may not read.
|
|
3
|
+
*
|
|
4
|
+
* **Ownership.** Prism's base rules sit in `@layer base` and a consumer's sheet
|
|
5
|
+
* is unlayered, so an unlayered declaration outranks a layered one at any
|
|
6
|
+
* specificity regardless of import order, and a consumer that imported its sheet
|
|
7
|
+
* first would have exactly the same outcome. The layer is not the cause of these
|
|
8
|
+
* failures; it is the reason they were invisible, because nobody can tell from a
|
|
9
|
+
* screenshot that the site won.
|
|
10
|
+
*
|
|
11
|
+
* A site declaration **competes** when it can match the same element, for a
|
|
12
|
+
* property the design system's base declares, with shorthands expanded. Two looser
|
|
13
|
+
* definitions were tried and rejected: "any declaration of a property the base
|
|
14
|
+
* declares anywhere" is 1,478 rows across this family and says that every
|
|
15
|
+
* declaration in CSS touches something a reset already sets; "any declaration of a
|
|
16
|
+
* property the base declares" is 98 rows of which 55 match no element on the
|
|
17
|
+
* current line. The list below is the narrow one, and it needs no cascade to
|
|
18
|
+
* decide: an unlayered bare-element declaration for a property the base declares
|
|
19
|
+
* **is** the defect, whatever the cascade then does with it.
|
|
20
|
+
*
|
|
21
|
+
* A focus rule is a finding whatever it declares. The design system draws its
|
|
22
|
+
* ring on the component as a `box-shadow`, so a site `outline` does not compete
|
|
23
|
+
* with it; it draws a second band over the real one, and on anything that is not
|
|
24
|
+
* a Prism Component it is the only thing standing between a keyboard reader and
|
|
25
|
+
* no indicator at all.
|
|
26
|
+
*
|
|
27
|
+
* **The token read.** A custom property that resolves to nothing does not paint a
|
|
28
|
+
* wrong colour. The declaration it appears in is invalid at computed-value time,
|
|
29
|
+
* so a shorthand erases itself and a longhand reverts to its initial value: a page
|
|
30
|
+
* ground reverts to transparent, an inherited ink reverts to the browser default,
|
|
31
|
+
* and a hairline reverts to `border-style: none`, which is a box with no edge
|
|
32
|
+
* rather than a box with a wrong-coloured one. Ninety-eight of the 219 dead reads
|
|
33
|
+
* in the four repositories were shorthands. The reference is the design system's
|
|
34
|
+
* emitted stylesheet, read through its own export map, so a renamed token is a
|
|
35
|
+
* finding rather than a silent erasure.
|
|
36
|
+
*
|
|
37
|
+
* **The runtime form is the same law.** A read in JavaScript with a hard-coded
|
|
38
|
+
* fallback is a read that cannot fail, which is why the original clause was
|
|
39
|
+
* written about canvas: the pack is a runtime attribute on an ancestor, a canvas
|
|
40
|
+
* reads a computed style from one element, and a scoped boundary hands it the
|
|
41
|
+
* pack's light values on a dark page. The shipped form of that law is the gate
|
|
42
|
+
* that forbids the client read, and a repository that has no client code has
|
|
43
|
+
* nothing left to check.
|
|
44
|
+
*
|
|
45
|
+
* Coverage: the declared sheets must resolve and must hold at least as many
|
|
46
|
+
* declarations as the site declares as its floor. A floor on *findings* would fail
|
|
47
|
+
* a correct sheet, and a floor on nothing would pass an unread one, so the floor
|
|
48
|
+
* is on declarations read.
|
|
49
|
+
*/
|
|
50
|
+
import { law } from './laws.mjs'
|
|
51
|
+
import {
|
|
52
|
+
CoverageError,
|
|
53
|
+
blankComments,
|
|
54
|
+
cssBlocks,
|
|
55
|
+
declaredProperties,
|
|
56
|
+
designSystem,
|
|
57
|
+
finding,
|
|
58
|
+
floor,
|
|
59
|
+
read,
|
|
60
|
+
} from './run.mjs'
|
|
61
|
+
|
|
62
|
+
/** The properties the design system's own base layer declares, so a bare-element rule competes. */
|
|
63
|
+
const COMPETING_PROPERTIES = [
|
|
64
|
+
'background',
|
|
65
|
+
'background-color',
|
|
66
|
+
'color',
|
|
67
|
+
'font-family',
|
|
68
|
+
'outline',
|
|
69
|
+
'outline-style',
|
|
70
|
+
'border-color',
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/** Any focus selector. The design system owns the indicator; a consumer drawing one is a second one. */
|
|
74
|
+
const FOCUS_SELECTOR = /:focus(-visible)?\b/
|
|
75
|
+
|
|
76
|
+
/** A `color-mix()` that takes a custom property as an operand erases itself rather than repainting. */
|
|
77
|
+
const DEAD_OPERAND = /color-mix\([^)]*var\(/g
|
|
78
|
+
|
|
79
|
+
export function run({ root, config }) {
|
|
80
|
+
const l = law('stylesheet-ownership')
|
|
81
|
+
const readLaw = law('token-read')
|
|
82
|
+
const findings = []
|
|
83
|
+
const notes = []
|
|
84
|
+
const sheets = config.sheets ?? ['app/globals.css']
|
|
85
|
+
const supplied = config.supplied ?? {}
|
|
86
|
+
const minDeclarations = config.minDeclarations ?? 20
|
|
87
|
+
|
|
88
|
+
const missing = sheets.filter((sheet) => read(root, sheet) === null)
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
throw new CoverageError(
|
|
91
|
+
`${missing.length} of ${sheets.length} configured stylesheets do not resolve: ${missing.join(', ')}.\n` +
|
|
92
|
+
' A gate that read nothing reports a clean sheet, so an unresolved root fails the run.',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const system = designSystem(root)
|
|
97
|
+
const emitted = system.styles()
|
|
98
|
+
const emittedProperties = declaredProperties(emitted)
|
|
99
|
+
|
|
100
|
+
let rules = 0
|
|
101
|
+
let declarations = 0
|
|
102
|
+
let competing = 0
|
|
103
|
+
let deadReads = 0
|
|
104
|
+
|
|
105
|
+
for (const sheet of sheets) {
|
|
106
|
+
const source = blankComments(read(root, sheet))
|
|
107
|
+
for (const block of cssBlocks(source)) {
|
|
108
|
+
rules += 1
|
|
109
|
+
for (const declaration of block.body.split(';')) {
|
|
110
|
+
if (/^[a-z-]+\s*:/.test(declaration.trim())) declarations += 1
|
|
111
|
+
}
|
|
112
|
+
for (const selector of block.selectors) {
|
|
113
|
+
if (FOCUS_SELECTOR.test(selector)) {
|
|
114
|
+
competing += 1
|
|
115
|
+
findings.push(
|
|
116
|
+
finding(
|
|
117
|
+
`${sheet}:${block.line}`,
|
|
118
|
+
'focus-indicator',
|
|
119
|
+
`${selector} draws a focus indicator in this repository's own sheet. The design system draws its\n` +
|
|
120
|
+
' ring on the component, and a plain anchor keeps the browser\'s own. A site rule is a second\n' +
|
|
121
|
+
" band over the first or the suppression of the second, and a shorthand whose colour token\n" +
|
|
122
|
+
' stops resolving suppresses it rather than failing to draw one.',
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
// A bare-element selector: `body`, `a`, `*`, `html`. A class in the selector makes
|
|
128
|
+
// the rule this repository's own surface, which is a different question.
|
|
129
|
+
const hasClass = selector.includes('.') || selector.includes('[') || selector.includes(':')
|
|
130
|
+
if (hasClass) continue
|
|
131
|
+
for (const property of COMPETING_PROPERTIES) {
|
|
132
|
+
if (!new RegExp(`(?:^|[;{\\s])${property}\\s*:`, 'm').test(block.body)) continue
|
|
133
|
+
competing += 1
|
|
134
|
+
findings.push(
|
|
135
|
+
finding(
|
|
136
|
+
`${sheet}:${block.line}`,
|
|
137
|
+
'competes-with-base',
|
|
138
|
+
`${selector} declares ${property}, which the design system's own base layer declares. The base is\n` +
|
|
139
|
+
" layered and this sheet is not, so this rule wins the cascade at any specificity and silently\n" +
|
|
140
|
+
" replaces the design system's.",
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const dead = [...block.body.matchAll(DEAD_OPERAND)]
|
|
146
|
+
competing += dead.length
|
|
147
|
+
if (dead.length > 0) {
|
|
148
|
+
findings.push(
|
|
149
|
+
finding(
|
|
150
|
+
`${sheet}:${block.line}`,
|
|
151
|
+
'dead-operand',
|
|
152
|
+
'color-mix() takes a var() as an operand. A custom property that resolves to nothing does not paint a\n' +
|
|
153
|
+
' wrong colour: a shorthand with one dead operand erases the whole declaration, so the rule stops\n' +
|
|
154
|
+
' existing.',
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
floor('declarations read', declarations, minDeclarations)
|
|
162
|
+
|
|
163
|
+
for (const sheet of sheets) {
|
|
164
|
+
const source = blankComments(read(root, sheet))
|
|
165
|
+
const own = declaredProperties(source)
|
|
166
|
+
for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
|
|
167
|
+
const property = match[1]
|
|
168
|
+
if (own.has(property) || emittedProperties.has(property) || property in supplied) continue
|
|
169
|
+
deadReads += 1
|
|
170
|
+
const line = source.slice(0, match.index).split('\n').length
|
|
171
|
+
findings.push(
|
|
172
|
+
finding(
|
|
173
|
+
`${sheet}:${line}`,
|
|
174
|
+
'dead-read',
|
|
175
|
+
`var(${property}) names a custom property nothing declares. The declaration is invalid at\n` +
|
|
176
|
+
' computed-value time, so a shorthand erases itself and a longhand reverts to its initial value.\n' +
|
|
177
|
+
' This is not a wrong colour; it is no declaration at all.',
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
notes.push(
|
|
184
|
+
`${l.id}: ${sheets.length} sheet(s), ${rules} rule(s) and ${declarations} declaration(s) read, floor ${minDeclarations}`,
|
|
185
|
+
)
|
|
186
|
+
notes.push(`${l.id}: sheets read: ${sheets.join(', ')}`)
|
|
187
|
+
notes.push(
|
|
188
|
+
`${l.id}: ${competing} competing declaration(s); properties the design system's base declares: ${COMPETING_PROPERTIES.join(', ')}`,
|
|
189
|
+
)
|
|
190
|
+
notes.push(
|
|
191
|
+
`${readLaw.id}: ${deadReads} dead read(s). Every var() in this repository's own sheet names a property the\n` +
|
|
192
|
+
` emitted stylesheet declares (${emittedProperties.size} properties), the sheet itself declares, or one of\n` +
|
|
193
|
+
` ${Object.keys(supplied).length} the build supplies.`,
|
|
194
|
+
)
|
|
195
|
+
for (const [property, why] of Object.entries(supplied)) notes.push(`${readLaw.id}: supplied: ${property} is ${why}`)
|
|
196
|
+
notes.push(
|
|
197
|
+
`${l.id}: the reference is ${'`@nanisoft/prism-ui/styles.css`'}, resolved through the installed package's own export map,\n` +
|
|
198
|
+
' so a renamed subpath is an error here rather than a run that reports every property as undeclared.',
|
|
199
|
+
)
|
|
200
|
+
notes.push(
|
|
201
|
+
`${l.id}: the honest limit, printed on every run: this is a text scan over selectors and declarations, not a\n` +
|
|
202
|
+
' cascade resolution. It cannot see a class-scoped rule that competes, an inline style prop, a sheet it\n' +
|
|
203
|
+
" was not pointed at, or a change to the design system's base layer.",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return { law: l, findings, notes, also: [readLaw] }
|
|
207
|
+
}
|