@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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hidden-state law: a CSS-authored hidden state can dismiss itself, and its
|
|
3
|
+
* exit is not a clock.
|
|
4
|
+
*
|
|
5
|
+
* The defect this replaces is not a bug report; it is a page nobody can read. A
|
|
6
|
+
* sheet carried
|
|
7
|
+
*
|
|
8
|
+
* .site [data-reveal] { opacity: 0; transform: translateY(12px); }
|
|
9
|
+
* .site [data-reveal].is-in { opacity: 1; transform: none; }
|
|
10
|
+
*
|
|
11
|
+
* and `is-in` was added by an IntersectionObserver in a client effect. That is the
|
|
12
|
+
* only exit. There is no timeout, so there is nothing to wait for and nothing to
|
|
13
|
+
* give up on: a reader with scripting disabled, a reader whose browser has no
|
|
14
|
+
* IntersectionObserver, a reader whose JavaScript failed to parse, and a crawler
|
|
15
|
+
* that never executes any of it all received `opacity: 0` and nothing else. The
|
|
16
|
+
* whole body of the page, below the header, was invisible.
|
|
17
|
+
*
|
|
18
|
+
* Exactly two shapes are allowed. An escapable condition, which is a media query
|
|
19
|
+
* or a `:not()` that the reader's own capability satisfies. Or a script-armed
|
|
20
|
+
* ancestor attribute, where the arming is written by exactly one script and by
|
|
21
|
+
* nothing else, so a reader whose scripting is off never receives it and the
|
|
22
|
+
* hidden state does not exist for them. A reader whose scripting fails midway
|
|
23
|
+
* might, and the `(scripting: none)` block is the guard for that case: it is a
|
|
24
|
+
* statement about capability rather than about health, and it does not care why
|
|
25
|
+
* the script did not finish. The two are complementary and neither is a timeout.
|
|
26
|
+
*
|
|
27
|
+
* **The clock is banned rather than shortened.** A fallback animation's clock
|
|
28
|
+
* starts at first style resolution rather than at scroll, so by the time the
|
|
29
|
+
* class lands there is nothing left to cancel, and a reader on a slow connection
|
|
30
|
+
* looks at a blank page for three seconds of a timer measuring the wrong
|
|
31
|
+
* interval. Cancellability was never the load-bearing property; having an exit
|
|
32
|
+
* that does not depend on a clock was.
|
|
33
|
+
*
|
|
34
|
+
* **A gate that only read the stylesheet would pass a site that renders nothing.**
|
|
35
|
+
* That is why a consumer that ships a hidden state also has a test that renders
|
|
36
|
+
* with scripting off, and this gate's job is to make the stylesheet half true
|
|
37
|
+
* enough for that test to be the only half left. A consumer with no hidden state
|
|
38
|
+
* runs this gate too and passes vacuously, and the run prints the rule count it
|
|
39
|
+
* read so the reader can tell the difference between a vacuous pass and a scan of
|
|
40
|
+
* nothing.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
43
|
+
import path from 'node:path'
|
|
44
|
+
|
|
45
|
+
import { law } from './laws.mjs'
|
|
46
|
+
import { CoverageError, blankComments, cssBlocks, finding, floor, read } from './run.mjs'
|
|
47
|
+
|
|
48
|
+
/** A declaration that makes an element not painted. */
|
|
49
|
+
const HIDES = /^\s*(?:opacity\s*:\s*(?:0|0%)\b|visibility\s*:\s*hidden\b|display\s*:\s*none\b)/
|
|
50
|
+
|
|
51
|
+
/** A clock. Any of the three is a mechanism with its own failure mode. */
|
|
52
|
+
const CLOCK = /(?:^|[;\s])animation(?:-[a-z-]+)?\s*:|@keyframes/
|
|
53
|
+
|
|
54
|
+
export function run({ root, config }) {
|
|
55
|
+
const l = law('hidden-state')
|
|
56
|
+
const findings = []
|
|
57
|
+
const notes = []
|
|
58
|
+
const sheets = config.sheets ?? ['app/globals.css']
|
|
59
|
+
const minRules = config.minRules ?? 25
|
|
60
|
+
const marker = config.marker ?? 'data-reveal'
|
|
61
|
+
const armed = config.armed ?? 'data-reveal-armed'
|
|
62
|
+
const sourceRoots = config.sourceRoots ?? ['app', 'components', 'lib']
|
|
63
|
+
|
|
64
|
+
const missing = sheets.filter((sheet) => read(root, sheet) === null)
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
throw new CoverageError(
|
|
67
|
+
`${missing.length} of ${sheets.length} configured stylesheets do not resolve: ${missing.join(', ')}.\n` +
|
|
68
|
+
' A gate that read nothing reports a clean sheet, so an unresolved root fails the run.',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const markerSelector = new RegExp(`\\[${marker}\\b`)
|
|
73
|
+
const armedSelector = new RegExp(`\\[${armed}\\]`)
|
|
74
|
+
|
|
75
|
+
let rules = 0
|
|
76
|
+
let declarations = 0
|
|
77
|
+
let hiding = 0
|
|
78
|
+
|
|
79
|
+
for (const sheet of sheets) {
|
|
80
|
+
for (const block of cssBlocks(read(root, sheet))) {
|
|
81
|
+
rules += 1
|
|
82
|
+
for (const declaration of block.body.split(';')) {
|
|
83
|
+
if (/^[a-z-]+\s*:/.test(declaration.trim())) declarations += 1
|
|
84
|
+
}
|
|
85
|
+
const selectors = block.selectors.join(', ')
|
|
86
|
+
|
|
87
|
+
if (CLOCK.test(block.body)) {
|
|
88
|
+
findings.push(
|
|
89
|
+
finding(
|
|
90
|
+
`${sheet}:${block.line}`,
|
|
91
|
+
'clock',
|
|
92
|
+
`${selectors} carries an animation clock. A fallback animation's clock starts at first style\n` +
|
|
93
|
+
' resolution rather than at scroll, so by the time the class lands there is nothing left to\n' +
|
|
94
|
+
' cancel, and a slow connection sees a blank page for a timer that measures the wrong interval.',
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!block.body.split(';').some((declaration) => HIDES.test(declaration))) continue
|
|
100
|
+
if (!markerSelector.test(selectors)) {
|
|
101
|
+
// A rule that hides nothing through the marked selector is not the law's
|
|
102
|
+
// subject. It is still a rule this gate read, and it is still counted.
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
hiding += 1
|
|
106
|
+
|
|
107
|
+
if (!armedSelector.test(selectors)) {
|
|
108
|
+
findings.push(
|
|
109
|
+
finding(
|
|
110
|
+
`${sheet}:${block.line}`,
|
|
111
|
+
'unarmed',
|
|
112
|
+
`${selectors} hides content with no armed ancestor. A reader whose scripting is switched off never\n` +
|
|
113
|
+
` receives \`${armed}\`, so this declaration is the last thing they are shown.`,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
if (!/\(scripting\s*:\s*none\)/.test(read(root, sheet))) {
|
|
118
|
+
findings.push(
|
|
119
|
+
finding(
|
|
120
|
+
sheet,
|
|
121
|
+
'no-scripting-guard',
|
|
122
|
+
`the sheet hides content and declares no \`(scripting: none)\` guard. A reader whose scripting\n` +
|
|
123
|
+
' started and stopped would receive the arming attribute and nothing that withdraws it.',
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
floor('rule(s) read', rules, minRules)
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* A second writer of the arming attribute is a second owner of a state whose exit
|
|
134
|
+
* depends on every path withdrawing it, so the writer is counted.
|
|
135
|
+
*
|
|
136
|
+
* Counted by the WRITE, not by the name. A module that explains the mechanism in a
|
|
137
|
+
* comment names the attribute and writes nothing, and a gate that counted names
|
|
138
|
+
* would read its own subject's documentation as a second writer. The write is
|
|
139
|
+
* matched on a code line with the comments blanked, so a comment that shows the
|
|
140
|
+
* call as an example is a record and a call is a call.
|
|
141
|
+
*
|
|
142
|
+
* The withdrawal is counted separately and separately required: an arming with no
|
|
143
|
+
* withdrawal anywhere is a hidden state whose only exit is the scripting-media
|
|
144
|
+
* query, which does not see the reader whose scripting started and stopped.
|
|
145
|
+
*/
|
|
146
|
+
const sources = sourceFiles(root, sourceRoots)
|
|
147
|
+
const writer = new RegExp(`setAttribute\\(\\s*['"\`]${armed}['"\`]`)
|
|
148
|
+
const withdrawer = new RegExp(`removeAttribute\\(\\s*['"\`]${armed}['"\`]`)
|
|
149
|
+
const writers = sources.filter((file) =>
|
|
150
|
+
writer.test(blankComments(readFileSync(file, 'utf8'))),
|
|
151
|
+
)
|
|
152
|
+
const withdrawers = sources.filter((file) =>
|
|
153
|
+
withdrawer.test(blankComments(readFileSync(file, 'utf8'))),
|
|
154
|
+
)
|
|
155
|
+
const clientModules = sources.filter((file) =>
|
|
156
|
+
/^\s*['"]use client['"]/m.test(readFileSync(file, 'utf8')),
|
|
157
|
+
)
|
|
158
|
+
const relative = (file) => path.relative(root, file).split(path.sep).join('/')
|
|
159
|
+
if (writers.length === 0 && hiding > 0) {
|
|
160
|
+
findings.push(
|
|
161
|
+
finding(
|
|
162
|
+
sheets.join(', '),
|
|
163
|
+
'unwritten',
|
|
164
|
+
`a rule hides content through \`[${marker}]\` and no module writes \`${armed}\`. The attribute is the\n` +
|
|
165
|
+
' whole of the exit for a reader whose scripting works, so a sheet that hides with nothing to\n' +
|
|
166
|
+
' arm it hides for everyone.',
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (writers.length > 1) {
|
|
171
|
+
findings.push(
|
|
172
|
+
finding(
|
|
173
|
+
writers.slice(1).map(relative).join(', '),
|
|
174
|
+
'second-writer',
|
|
175
|
+
`more than one module writes \`${armed}\`, at ${writers.map(relative).join(' and ')}. The attribute has one\n` +
|
|
176
|
+
' writer, and a second writer is a second owner of a state whose exit depends on every path\n' +
|
|
177
|
+
' withdrawing it.',
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (withdrawers.length === 0 && writers.length > 0) {
|
|
182
|
+
findings.push(
|
|
183
|
+
finding(
|
|
184
|
+
writers.map(relative).join(', '),
|
|
185
|
+
'no-withdrawal',
|
|
186
|
+
`\`${armed}\` is written and never removed, so a reader whose scripting started and then\n` +
|
|
187
|
+
' stopped has the attribute and no path that takes it away. The (scripting: none) guard\n' +
|
|
188
|
+
" cannot see them, because their scripting is enabled.",
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
notes.push(
|
|
194
|
+
`${l.id}: ${findings.length} finding(s) across ${sheets.length} sheet(s), ${rules} rule(s) and ${declarations} declaration(s) read; floor ${minRules} rule(s)`,
|
|
195
|
+
)
|
|
196
|
+
notes.push(`${l.id}: sheets read: ${sheets.join(', ')}`)
|
|
197
|
+
notes.push(
|
|
198
|
+
`${l.id}: ${hiding} rule(s) hide content through \`[${marker}]\`; the escape shapes are an escapable condition\n` +
|
|
199
|
+
` or an armed ancestor \`[${armed}]\` written by exactly one script.`,
|
|
200
|
+
)
|
|
201
|
+
notes.push(
|
|
202
|
+
`${l.id}: ${sources.length} source file(s) read; ${writers.length} write the arming attribute` +
|
|
203
|
+
`${writers.length === 0 ? '' : ` (${writers.map(relative).join(', ')})`} and ${withdrawers.length} withdraw it` +
|
|
204
|
+
`${withdrawers.length === 0 ? '' : ` (${withdrawers.map(relative).join(', ')})`}. Counted by the call, not by the name, so a comment explaining the mechanism is not a writer.`,
|
|
205
|
+
)
|
|
206
|
+
if (hiding === 0) {
|
|
207
|
+
notes.push(
|
|
208
|
+
`${l.id}: this run passed vacuously. No rule in ${sheets.join(', ')} hides content through the marker, so this\n` +
|
|
209
|
+
' repository has nothing to exit and no runtime to exit it with. That is a real answer rather than a\n' +
|
|
210
|
+
' scan of nothing: the rule count above says how much was read, and a repository that acquired a\n' +
|
|
211
|
+
' hidden state would produce a finding here rather than a pass.',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
notes.push(`${l.id}: ${clientModules.length} module(s) carry a client directive.`)
|
|
215
|
+
notes.push(
|
|
216
|
+
`${l.id}: a stylesheet half is not enough. A consumer that ships a hidden state needs a test that renders\n` +
|
|
217
|
+
' with scripting switched off and asserts the content is present and the arming attribute is absent.',
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return { law: l, findings, notes }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every source file the declared roots hold, so a second writer is findable. */
|
|
224
|
+
function sourceFiles(root, roots) {
|
|
225
|
+
const found = []
|
|
226
|
+
const skip = new Set(['node_modules', '.next', 'out', '.git', '.wrangler', '.source'])
|
|
227
|
+
const visit = (dir) => {
|
|
228
|
+
let entries
|
|
229
|
+
try {
|
|
230
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
231
|
+
} catch {
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (skip.has(entry.name)) continue
|
|
236
|
+
const full = path.join(dir, entry.name)
|
|
237
|
+
if (entry.isDirectory() || statSync(full).isDirectory()) visit(full)
|
|
238
|
+
else if (/\.(tsx?|mjs)$/.test(entry.name)) found.push(full)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const declared of roots) visit(path.join(root, declared))
|
|
242
|
+
return found
|
|
243
|
+
}
|
package/gates/index.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate kit's registry: which law is which program, and which version of this
|
|
3
|
+
* kit a consumer is running.
|
|
4
|
+
*
|
|
5
|
+
* The registry exists so that "the laws" is a list rather than a folder. A gate
|
|
6
|
+
* that is not in `GATES` is a program nobody runs, and a law with no gate is a
|
|
7
|
+
* sentence, which is the thing this kit was written to remove. `check-gate-kit.mjs`
|
|
8
|
+
* asserts the two are the same set in both directions, and the assertion runs in
|
|
9
|
+
* this repository's own `check`, so the registry cannot rot without the build
|
|
10
|
+
* failing.
|
|
11
|
+
*
|
|
12
|
+
* `GATE_KIT_VERSION` is the version of the *contract*, not of the package. A
|
|
13
|
+
* consumer names it in its configuration so that a consumer's own gate chain can
|
|
14
|
+
* say which generation of the law it is holding itself to, and so that a law
|
|
15
|
+
* rewritten in a later release is visible as a version the consumer has not moved
|
|
16
|
+
* to rather than as a silent change of wording underneath it.
|
|
17
|
+
*/
|
|
18
|
+
import { LAW_IDS, law } from './laws.mjs'
|
|
19
|
+
|
|
20
|
+
/** The contract generation. Bump it when a law's wording or its gate's surface changes. */
|
|
21
|
+
export const GATE_KIT_VERSION = 1
|
|
22
|
+
|
|
23
|
+
/** Every gate, keyed by the id a consumer names. The value is the module's `run`. */
|
|
24
|
+
export const GATES = {
|
|
25
|
+
'retired-line': () => import('./retired-line.mjs'),
|
|
26
|
+
'stylesheet-ownership': () => import('./stylesheet-ownership.mjs'),
|
|
27
|
+
links: () => import('./links.mjs'),
|
|
28
|
+
'pack-boundary': () => import('./pack-boundary.mjs'),
|
|
29
|
+
'hidden-state': () => import('./hidden-state.mjs'),
|
|
30
|
+
'runtime-token-read': () => import('./runtime-token-read.mjs'),
|
|
31
|
+
pin: () => import('./pin.mjs'),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which gate holds which law, in both directions.
|
|
36
|
+
*
|
|
37
|
+
* The token-read law is held by the stylesheet-ownership gate because it reads the
|
|
38
|
+
* same sheet with the same parser and the same reference; splitting it would mean
|
|
39
|
+
* parsing a stylesheet twice to assert one property of it, and the two runs could
|
|
40
|
+
* disagree about what a declaration is. It is a separate law because its failure is
|
|
41
|
+
* a different failure: an unlayered declaration that wins the cascade is not the
|
|
42
|
+
* same defect as a read that erases itself.
|
|
43
|
+
*/
|
|
44
|
+
export const GATE_LAWS = {
|
|
45
|
+
'retired-line': ['retired-line'],
|
|
46
|
+
'stylesheet-ownership': ['stylesheet-ownership', 'token-read'],
|
|
47
|
+
links: ['links'],
|
|
48
|
+
'pack-boundary': ['pack-boundary'],
|
|
49
|
+
'hidden-state': ['hidden-state'],
|
|
50
|
+
'runtime-token-read': ['runtime-token-read'],
|
|
51
|
+
pin: ['pin'],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The gate ids, in the order a report prints them. */
|
|
55
|
+
export const GATE_IDS = Object.keys(GATES)
|
|
56
|
+
|
|
57
|
+
/** One gate by id, loaded. A throw naming the ones that exist, because a misspell must not pass. */
|
|
58
|
+
export async function gate(id) {
|
|
59
|
+
const load = GATES[id]
|
|
60
|
+
if (!load) {
|
|
61
|
+
throw new Error(`prism-gates: unknown gate "${id}". The kit ships: ${GATE_IDS.join(', ')}.`)
|
|
62
|
+
}
|
|
63
|
+
const module = await load()
|
|
64
|
+
if (typeof module.run !== 'function') {
|
|
65
|
+
throw new Error(`prism-gates: the gate "${id}" exports no run(), so it cannot be run rather than skipped.`)
|
|
66
|
+
}
|
|
67
|
+
return module
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every law the kit enforces, resolved, so a consumer can print them without
|
|
72
|
+
* knowing which program holds which.
|
|
73
|
+
*/
|
|
74
|
+
export function laws() {
|
|
75
|
+
return LAW_IDS.map(law)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The laws a named gate holds. */
|
|
79
|
+
export function lawsOf(gateId) {
|
|
80
|
+
return (GATE_LAWS[gateId] ?? []).map(law)
|
|
81
|
+
}
|
package/gates/laws.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The laws, defined once.
|
|
3
|
+
*
|
|
4
|
+
* For four years the cross-repository contract between the five NaniSoft
|
|
5
|
+
* repositories was a prose document mirrored in four files, and it decayed in
|
|
6
|
+
* the only way a mirror can: the two sites that mattered drifted into three
|
|
7
|
+
* implementations of one rule and one of the three rules was false. A prose law
|
|
8
|
+
* cannot fail, so it stays true until the day it is not, and nobody is told.
|
|
9
|
+
*
|
|
10
|
+
* So a law here is not a sentence a reader is asked to believe. It is the text a
|
|
11
|
+
* gate prints when the build fails, and it lives in the package every consumer
|
|
12
|
+
* pins exactly. A consumer's own repository holds data: its roots, its sheet, its
|
|
13
|
+
* pack map, its coverage floors. It does not hold the wording of a rule, because
|
|
14
|
+
* a wording held in four places is four rules that will disagree.
|
|
15
|
+
*
|
|
16
|
+
* **The test for what belongs here.** A clause earns a law when its violation is
|
|
17
|
+
* silent. Every entry below is silent by construction: an unlayered bare-element
|
|
18
|
+
* declaration of the page ground looks like a design decision in a screenshot, a
|
|
19
|
+
* `var()` that resolves to nothing erases the declaration rather than painting a
|
|
20
|
+
* wrong colour, a link that goes nowhere renders exactly like a link that works,
|
|
21
|
+
* and a boundary that resolves the light block on a dark page is pack-correct and
|
|
22
|
+
* looks right. None of them throws. That is the whole reason they are programs.
|
|
23
|
+
*
|
|
24
|
+
* **Why `stops` is here and not in a document.** A gate's failure message is read
|
|
25
|
+
* at the moment somebody's build is red, which is the only moment a rule about
|
|
26
|
+
* cross-repository conduct is ever going to be read by the person it is for. The
|
|
27
|
+
* consequence therefore belongs in the same string as the assertion, or it is
|
|
28
|
+
* prose again.
|
|
29
|
+
*
|
|
30
|
+
* Published at `@nanisoft/prism-ui/gates`. Not imported from `src/`, because a
|
|
31
|
+
* gate is a build-time program for a consumer's repository and must never enter a
|
|
32
|
+
* consumer's module graph or its bundle.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every law the kit can enforce, keyed by the id a consumer names in its
|
|
37
|
+
* configuration. Order is the order a reader meets them in, which is the order
|
|
38
|
+
* they were written in the contract this file replaces.
|
|
39
|
+
*/
|
|
40
|
+
export const LAWS = {
|
|
41
|
+
'retired-line': {
|
|
42
|
+
title: 'No trace of the retired component library survives.',
|
|
43
|
+
message:
|
|
44
|
+
'No trace of the retired line survives in a consumer: not a dependency, not an import, not a\n' +
|
|
45
|
+
' generated stylesheet, not a build step, and not a living instruction. A dependency line is the\n' +
|
|
46
|
+
' easy half; the lockfile is read as a graph because deleting a line does not empty a tree while\n' +
|
|
47
|
+
' another package still declares the library.',
|
|
48
|
+
why:
|
|
49
|
+
'The four repositories shipped the retired line together and each of them discovered it had to\n' +
|
|
50
|
+
' remove it separately. The rule survived the removal as prose in four files and nothing failed\n' +
|
|
51
|
+
' when one of them brought it back.',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
'stylesheet-ownership': {
|
|
55
|
+
title: "A site stylesheet does not own a surface the design system owns.",
|
|
56
|
+
message:
|
|
57
|
+
'A site stylesheet must not own a surface the design system already owns. The cascade layer is\n' +
|
|
58
|
+
' not the cause of the failures this replaces; it is the reason they were invisible. Prism\'s base\n' +
|
|
59
|
+
' is layered and a consumer\'s sheet is not, so an unlayered declaration wins at any specificity\n' +
|
|
60
|
+
' whatever the cascade then does with it.',
|
|
61
|
+
why:
|
|
62
|
+
'An unlayered bare-element declaration of the page ground, the body ink, a focus outline or a\n' +
|
|
63
|
+
' hairline colour was live in all four repositories at once, and every one of them was a box\n' +
|
|
64
|
+
' with no edge, a white page in dark mode, or an element with no keyboard indicator, each of\n' +
|
|
65
|
+
' which is a plausible design in a screenshot.',
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
'token-read': {
|
|
69
|
+
title: 'A custom property that nothing declares is not a wrong colour; it is no declaration at all.',
|
|
70
|
+
message:
|
|
71
|
+
'Every custom property this sheet reads is declared, and that is what a shorthand with one dead\n' +
|
|
72
|
+
' operand does to itself: a declaration that is invalid at computed-value time is not a wrong\n' +
|
|
73
|
+
' colour, it is no declaration, so a page ground reverts to transparent and a hairline reverts to\n' +
|
|
74
|
+
' border-style: none. A read that cannot fail hides its own failure, which is the whole reason the\n' +
|
|
75
|
+
' runtime form of this law was written first.',
|
|
76
|
+
why:
|
|
77
|
+
'Roughly two hundred and twenty custom-property reads across the four repositories resolved to\n' +
|
|
78
|
+
' nothing on the new line, about half of them shorthands. The page ground, the body ink, the\n' +
|
|
79
|
+
' hairline and the focus outline all reverted, and a screenshot of unstyled body text is a\n' +
|
|
80
|
+
' plausible design.',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
links: {
|
|
84
|
+
title: 'Every internal destination resolves, and every fragment names an element that exists.',
|
|
85
|
+
message:
|
|
86
|
+
'A reader followed a link on this site and arrived nowhere. Every address a reader has ever used\n' +
|
|
87
|
+
' has to keep working, and a broken internal link renders exactly like a working one, so nothing\n' +
|
|
88
|
+
' else in the build can see it.',
|
|
89
|
+
why:
|
|
90
|
+
'A migration rewrites the rendering layer and is forbidden from changing a word, so a route that\n' +
|
|
91
|
+
' quietly stopped being published is the one defect class the whole programme was at risk of\n' +
|
|
92
|
+
' shipping. This is the gate that outlived the parity baseline.',
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
'pack-boundary': {
|
|
96
|
+
title: 'A pack boundary lands on a mark, and a declared region set is the whole of what may carry a second pack.',
|
|
97
|
+
message:
|
|
98
|
+
'A pack boundary is a promise with two axes. It re-points the corner radius as well as the colour,\n' +
|
|
99
|
+
' so a boundary on anything but a fully rounded mark changes that shape, and a boundary with no\n' +
|
|
100
|
+
' mode class of its own resolves its pack in the wrong mode on half the pages a reader sees. A\n' +
|
|
101
|
+
' screenshot in one mode is not evidence for either.',
|
|
102
|
+
why:
|
|
103
|
+
'A boundary that carries no descendant form resolves the light block on a dark page, which is\n' +
|
|
104
|
+
' pack-correct, mode-inverted and indistinguishable from a design decision. And because radius\n' +
|
|
105
|
+
' is the one non-colour member of a pack block, a section ground would put the section index\n' +
|
|
106
|
+
' into that section\'s corner radius, which is an encoding nobody chose.',
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'runtime-token-read': {
|
|
110
|
+
title: 'No token is read at runtime, and a read with a hard-coded fallback is a read that cannot fail.',
|
|
111
|
+
message:
|
|
112
|
+
'A token is resolved once at runtime and the resolved value does not follow the cascade, so a reader with\n' +
|
|
113
|
+
' a stored theme and a reader without one are served different colours from the same markup. The design\n' +
|
|
114
|
+
' system publishes the replacement, which is a token-driven inline replacement painted in the HTML: it\n' +
|
|
115
|
+
' resolves through the cascade, holds every pack, and ships no client code. The fix is deletion, not a\n' +
|
|
116
|
+
' better value.',
|
|
117
|
+
why:
|
|
118
|
+
'One site shipped a curve of numbers under a label reading as a live market feed, and the gate that should\n' +
|
|
119
|
+
' have seen it read prose while the dishonesty was arithmetic. A runtime token read is the same shape:\n' +
|
|
120
|
+
' the disagreement is between what is painted and what the cascade says, and only a program that reads\n' +
|
|
121
|
+
' the read can see it.',
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
'hidden-state': {
|
|
125
|
+
title: 'A CSS-authored hidden state is escapable, and its exit is not a clock.',
|
|
126
|
+
message:
|
|
127
|
+
'A reader with scripting switched off received opacity: 0 and no way out of it. A hidden state is\n' +
|
|
128
|
+
' allowed in exactly two shapes: an escapable condition, or a script-armed ancestor attribute. The\n' +
|
|
129
|
+
' arming attribute has one writer, the module that writes it withdraws it on every path including\n' +
|
|
130
|
+
' load, and an inlined listener covers the reader whose scripting started and stopped.',
|
|
131
|
+
why:
|
|
132
|
+
'A fallback animation\'s clock starts at first style resolution rather than at scroll, so by the\n' +
|
|
133
|
+
' time the class lands there is nothing left to cancel. Cancellability was never the\n' +
|
|
134
|
+
' load-bearing property; having an exit that does not depend on a clock was.',
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
pin: {
|
|
138
|
+
title: 'The design system is pinned exactly, and the token package is the component package\'s dependency rather than the site\'s.',
|
|
139
|
+
message:
|
|
140
|
+
'The pinned package is the whole cross-repository contract, so the pin is an exact version and the\n' +
|
|
141
|
+
' token package is not a dependency of this repository: `@nanisoft/prism-ui` declares it at an\n' +
|
|
142
|
+
' exact version, so a consumer cannot be handed a mismatched pair, and a second declaration of\n' +
|
|
143
|
+
' that number in a consumer is a second fact to keep in step with a release.',
|
|
144
|
+
why:
|
|
145
|
+
'The four repositories each declared the token version beside the component version, in a\n' +
|
|
146
|
+
' workspace configuration file as well as a manifest, so there were five places holding the\n' +
|
|
147
|
+
' same pair and no gate that could read all five. One site spent a release on the wrong line\n' +
|
|
148
|
+
' because nothing failed.',
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The ids, in the order they are declared. The order a report prints them in. */
|
|
153
|
+
export const LAW_IDS = Object.keys(LAWS)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One law by id, or a throw naming the ones that exist.
|
|
157
|
+
*
|
|
158
|
+
* A `throw` rather than a default, because a caller that misspells an id and
|
|
159
|
+
* receives an empty law gets a gate that runs and prints nothing, which is the
|
|
160
|
+
* failure this file exists to remove.
|
|
161
|
+
*/
|
|
162
|
+
export function law(id) {
|
|
163
|
+
const found = LAWS[id]
|
|
164
|
+
if (!found) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`prism-gates: unknown law "${id}". The kit enforces: ${LAW_IDS.join(', ')}.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
return { id, ...found }
|
|
170
|
+
}
|
package/gates/links.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links law, and the one gate that outlived the migration.
|
|
3
|
+
*
|
|
4
|
+
* Four kinds of destination, four different failures, and only two of them are
|
|
5
|
+
* things this gate can decide:
|
|
6
|
+
*
|
|
7
|
+
* 1. An `href` beginning with `/` must equal an emitted route. This is the one
|
|
8
|
+
* that breaks quietly, because a broken internal link renders exactly like a
|
|
9
|
+
* working one and a crawler finds it a month later.
|
|
10
|
+
* 2. An `href` beginning with `#` must name an `id` the same document emits, so
|
|
11
|
+
* a rebuilt section cannot orphan a deep link.
|
|
12
|
+
* 3. An `href` beginning with `mailto:` or `tel:` is a destination the reader's
|
|
13
|
+
* own client handles, and is listed rather than resolved.
|
|
14
|
+
* 4. An absolute `href` to another origin is a destination off this site, and is
|
|
15
|
+
* listed rather than resolved. This gate cannot know that a sibling site
|
|
16
|
+
* exists, and a check that guessed would be a check that reported a network
|
|
17
|
+
* failure as a content failure.
|
|
18
|
+
*
|
|
19
|
+
* Extraction goes through a document parser rather than a regular expression.
|
|
20
|
+
* Two titles on one of these sites serialise an ampersand, and a regex would
|
|
21
|
+
* store the escaped form and then report it as a permanent difference against
|
|
22
|
+
* itself.
|
|
23
|
+
*
|
|
24
|
+
* **An exemption list is a finding when it matches nothing.** A consumer that
|
|
25
|
+
* declares a destination it knows is broken has to say why, and the declaration
|
|
26
|
+
* is site data rather than a law: it is one site's content defect. What is a law
|
|
27
|
+
* is that the declaration must match a real destination on this build, because an
|
|
28
|
+
* exemption list that outlives its cause is a lie and a gate that accepts a new
|
|
29
|
+
* link because the list is long enough to seem plausible is not a gate.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
import { law } from './laws.mjs'
|
|
36
|
+
import { CoverageError, finding, floor } from './run.mjs'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The document parser, resolved from the consumer's own dependency.
|
|
40
|
+
*
|
|
41
|
+
* The consumer owns its parser because the consumer's tests already do; a gate
|
|
42
|
+
* that carried its own would add a copy of jsdom to four repositories so that
|
|
43
|
+
* four gates could read four documents.
|
|
44
|
+
*/
|
|
45
|
+
function loadParser(root) {
|
|
46
|
+
try {
|
|
47
|
+
return createRequire(path.join(root, 'package.json'))('jsdom').JSDOM
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new CoverageError(
|
|
50
|
+
`jsdom does not resolve from this repository (${cause.message}), so no document can be parsed\n` +
|
|
51
|
+
' and every destination in this run would be unanswerable.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every emitted HTML file under `outDir`, as `[absolute path, route]` pairs, sorted. */
|
|
57
|
+
export function emittedRoutes(root, outDir) {
|
|
58
|
+
const out = path.join(root, outDir)
|
|
59
|
+
if (!existsSync(out)) {
|
|
60
|
+
throw new CoverageError(
|
|
61
|
+
`${outDir} does not resolve, so there is no export to read and a reader's destinations cannot be\n` +
|
|
62
|
+
' checked at all. Run the build first.',
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
const files = []
|
|
66
|
+
const walk = (dir) => {
|
|
67
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
68
|
+
const full = path.join(dir, entry.name)
|
|
69
|
+
if (entry.isDirectory()) walk(full)
|
|
70
|
+
else if (entry.name.endsWith('.html')) {
|
|
71
|
+
const relative = path.relative(out, full).split(path.sep).join('/')
|
|
72
|
+
files.push([full, relative === 'index.html' ? '/' : `/${relative.replace(/\.html$/, '')}`])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
walk(out)
|
|
77
|
+
return files
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function run({ root, config }) {
|
|
81
|
+
const l = law('links')
|
|
82
|
+
const outDir = config.outDir ?? 'out'
|
|
83
|
+
const minRoutes = config.minRoutes ?? 6
|
|
84
|
+
const minAnchors = config.minAnchors ?? 0
|
|
85
|
+
const knownBroken = config.knownBroken ?? {}
|
|
86
|
+
|
|
87
|
+
const JSDOM = loadParser(root)
|
|
88
|
+
const files = emittedRoutes(root, outDir)
|
|
89
|
+
floor('route(s) read', files.length, minRoutes)
|
|
90
|
+
|
|
91
|
+
const emitted = new Set(files.map(([, route]) => route))
|
|
92
|
+
const resolves = (target) => {
|
|
93
|
+
const wanted = target.replace(/\/$/, '') || '/'
|
|
94
|
+
return emitted.has(wanted) || emitted.has(`${wanted}/`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const findings = []
|
|
98
|
+
const internal = new Set()
|
|
99
|
+
const fragments = new Set()
|
|
100
|
+
const external = new Set()
|
|
101
|
+
const schemes = new Set()
|
|
102
|
+
const discharged = new Set()
|
|
103
|
+
let anchors = 0
|
|
104
|
+
|
|
105
|
+
for (const [file, route] of files) {
|
|
106
|
+
const document = new JSDOM(readFileSync(file, 'utf8')).window.document
|
|
107
|
+
const ids = new Set([...document.querySelectorAll('[id]')].map((element) => element.id))
|
|
108
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
109
|
+
anchors += 1
|
|
110
|
+
const href = anchor.getAttribute('href') ?? ''
|
|
111
|
+
if (href === '') {
|
|
112
|
+
findings.push(
|
|
113
|
+
finding(route, 'empty-destination', 'an anchor with no href is a shape a reader has to guess at.'),
|
|
114
|
+
)
|
|
115
|
+
} else if (href.startsWith('/')) {
|
|
116
|
+
internal.add(href)
|
|
117
|
+
const target = href.split('#')[0] || route
|
|
118
|
+
const known = knownBroken[target]
|
|
119
|
+
if (known) {
|
|
120
|
+
discharged.add(target)
|
|
121
|
+
} else if (!resolves(target)) {
|
|
122
|
+
findings.push(
|
|
123
|
+
finding(
|
|
124
|
+
route,
|
|
125
|
+
'internal-destination',
|
|
126
|
+
`"${href}" is not an emitted route. Every address a reader has ever used has to keep\n` +
|
|
127
|
+
' working, and a link that renders is a link nobody notices is broken.',
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (href.includes('#')) fragments.add(`${target}${href.slice(href.indexOf('#'))}`)
|
|
132
|
+
} else if (href.startsWith('#')) {
|
|
133
|
+
fragments.add(`${route}${href}`)
|
|
134
|
+
if (!ids.has(href.slice(1))) {
|
|
135
|
+
findings.push(
|
|
136
|
+
finding(route, 'fragment', `"${href}" names an id this document does not emit.`),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
} else if (/^(mailto:|tel):/.test(href)) {
|
|
140
|
+
schemes.add(`${href.split(':')[0]}:`)
|
|
141
|
+
} else if (/^https?:\/\//.test(href)) {
|
|
142
|
+
external.add(new URL(href).host)
|
|
143
|
+
} else {
|
|
144
|
+
findings.push(
|
|
145
|
+
finding(route, 'unclassified-destination', `"${href}" is neither internal, a fragment nor absolute.`),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
floor('anchor(s) read', anchors, minAnchors)
|
|
152
|
+
|
|
153
|
+
for (const [destination] of Object.entries(knownBroken)) {
|
|
154
|
+
if (discharged.has(destination)) continue
|
|
155
|
+
findings.push(
|
|
156
|
+
finding(
|
|
157
|
+
destination,
|
|
158
|
+
'stale-exemption',
|
|
159
|
+
`this destination is declared as known-broken and this build emits no link to it. An exemption\n` +
|
|
160
|
+
' that fires on nothing is indistinguishable from a rule that found nothing, so close it and\n' +
|
|
161
|
+
' delete the entry in the same commit.',
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const notes = [
|
|
167
|
+
`${l.id}: ${findings.length} finding(s) across ${files.length} route(s) and ${anchors} anchor(s) read;` +
|
|
168
|
+
` floors ${minRoutes} route(s), ${minAnchors} anchor(s)`,
|
|
169
|
+
`${l.id}: ${emitted.size} emitted route(s): ${[...emitted].sort().join(', ')}`,
|
|
170
|
+
`${l.id}: destinations resolved on this site: ${[...internal].sort().join(', ') || 'none'}`,
|
|
171
|
+
`${l.id}: fragments resolved: ${[...fragments].sort().join(', ') || 'none'}`,
|
|
172
|
+
`${l.id}: destinations off this site, listed and not resolved: ${[...external].sort().join(', ') || 'none'}`,
|
|
173
|
+
]
|
|
174
|
+
if (schemes.size > 0) {
|
|
175
|
+
notes.push(`${l.id}: reader-handled schemes, listed and not resolved: ${[...schemes].sort().join(', ')}`)
|
|
176
|
+
}
|
|
177
|
+
notes.push(
|
|
178
|
+
`${l.id}: an off-site host is listed, not resolved. This gate cannot know that another origin exists, and a\n` +
|
|
179
|
+
' network failure reported as a content failure is a check that teaches people to ignore it.',
|
|
180
|
+
)
|
|
181
|
+
for (const [destination] of Object.entries(knownBroken)) {
|
|
182
|
+
const state = discharged.has(destination) ? 'and still linked' : 'AND NO LONGER LINKED'
|
|
183
|
+
notes.push(`${l.id}: declared broken, ${state}: ${destination}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { law: l, findings, notes }
|
|
187
|
+
}
|