@nanisoft/prism-ui 0.7.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.
Files changed (43) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +30 -0
  4. package/dist/components/index.d.ts +6 -0
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +3 -0
  7. package/dist/components/ui/breadcrumb.d.ts +6 -2
  8. package/dist/components/ui/breadcrumb.d.ts.map +1 -1
  9. package/dist/components/ui/breadcrumb.js +5 -3
  10. package/dist/components/ui/dialog.d.ts +12 -1
  11. package/dist/components/ui/dialog.d.ts.map +1 -1
  12. package/dist/components/ui/dialog.js +2 -2
  13. package/dist/components/ui/live-region.d.ts +78 -0
  14. package/dist/components/ui/live-region.d.ts.map +1 -0
  15. package/dist/components/ui/live-region.js +48 -0
  16. package/dist/components/ui/mark.d.ts +44 -0
  17. package/dist/components/ui/mark.d.ts.map +1 -0
  18. package/dist/components/ui/mark.js +79 -0
  19. package/dist/components/ui/pagination.d.ts +24 -6
  20. package/dist/components/ui/pagination.d.ts.map +1 -1
  21. package/dist/components/ui/pagination.js +23 -9
  22. package/dist/components/ui/product-switcher.d.ts +7 -0
  23. package/dist/components/ui/product-switcher.d.ts.map +1 -1
  24. package/dist/components/ui/product-switcher.js +2 -2
  25. package/dist/components/ui/slider.d.ts +1 -1
  26. package/dist/components/ui/slider.d.ts.map +1 -1
  27. package/dist/components/ui/tree.d.ts +115 -0
  28. package/dist/components/ui/tree.d.ts.map +1 -0
  29. package/dist/components/ui/tree.js +137 -0
  30. package/dist/styles.css +31 -0
  31. package/gates/README.md +98 -0
  32. package/gates/cli.mjs +133 -0
  33. package/gates/hidden-state.mjs +243 -0
  34. package/gates/index.mjs +81 -0
  35. package/gates/laws.mjs +170 -0
  36. package/gates/links.mjs +187 -0
  37. package/gates/pack-boundary.mjs +421 -0
  38. package/gates/pin.mjs +130 -0
  39. package/gates/retired-line.mjs +314 -0
  40. package/gates/run.mjs +320 -0
  41. package/gates/runtime-token-read.mjs +129 -0
  42. package/gates/stylesheet-ownership.mjs +207 -0
  43. 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
+ }
@@ -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
+ }
@@ -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
+ }