@bounded-systems/conformance-kit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ hardcodes `robertdelanghe.dev`, `bounded.tools`, an account, or an email.
14
14
  integrity/ verify-site · verify (sigstore) · gen-sitemanifest · gen-provenance · structure-audit · http-probe
15
15
  gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · vuln-gate (npm audit) · html-validator-gate (vnu) · baseline-gate (web-features) · jargon-gate (plain-language) · readability-gate · commonmark-runner · semantic (lone)
16
16
  gates/conformance/ conformance-report — lone's conformance() projection (Node port of jsr:@bounded-systems/lone@0.4) + a generic HTML renderer
17
- generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · gen-snapshots (reader/markdown) · openapi (static-API helper core)
17
+ generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · gen-snapshots (reader/markdown) · gen-print-snapshots (PDF) · openapi (static-API helper core)
18
18
  emitters/ reprDigest (RFC 9530) · securityTxt (RFC 9116) · webManifest · markdown-sibling headers
19
19
  lib/ schema-validate (zero-dep JSON Schema) · config (env/arg helpers)
20
20
  fixtures/ test/ isolated verification of the generic logic
@@ -77,6 +77,7 @@ in-process verifier). The Deno semantic runner pins its imports in
77
77
  | `vuln-gate.mjs` | `node …/vuln-gate.mjs [projectDir]` | `$VULN_ROOT` (lockfile lives here, default `.`). Optional `$VULN_OMIT_DEV` (`true`→production deps only, default `true`), `$VULN_THRESHOLD` (highest tolerated known critical/high, default `0`), `$VULN_REPORT` (write the JSON report). Runs **`npm audit`** and **fails closed** when the known critical/high count exceeds the threshold. The report's `vulns: { knownCriticalOrHighVulns }` envelope is what `conformance-report`'s `security.no-critical-vulns` criterion consumes. |
78
78
  | `html-validator-gate.mjs` | `node …/html-validator-gate.mjs [distDir]` | `$HTML_DIST`. Optional `$HTML_PAGES` (comma list, default: every `*.html`), `$HTML_THRESHOLD` (default `0`), `$HTML_REPORT`. Runs **vnu** (the Nu Html Checker, a self-contained Java jar — needs a JRE) `--errors-only` over the built pages and **fails closed** above the threshold. The report's `htmlValidator: { errors }` envelope is what `conformance-report`'s `html.validator-clean` criterion consumes. |
79
79
  | `baseline-gate.mjs` | `node …/baseline-gate.mjs [cssGlob]` | `$BASELINE_CSS` (default `dist/**/*.css`). Optional `$BASELINE_TARGET` (`widely`/`newly`, default `widely`), `$BASELINE_REPORT`. Maps the shipped CSS to **web-features Baseline** data (via `stylelint-plugin-use-baseline` — headless, no browser) and **fails closed** when the site-wide status is below target. A feature behind an `@supports` query is a tested fallback and doesn't count against it. The report's `baseline: { status, fallbackTested }` envelope is what `conformance-report`'s `compatibility.baseline` criterion consumes. |
80
+ | `palette-gate.mjs` | `node …/palette-gate.mjs <tokens.(json\|css)> <pairings.json>` | **Two inputs the consumer supplies**: a token map (a DTCG `tokens.json` — primitive→semantic aliases resolved — or a `tokens.css` of `--name: #hex` custom properties) and a `pairings.json` declaring the fg/bg pairs that actually co-occur (`{ "pairings":[{fg,bg,kind,size?,weight?,name?}], "categorical":[…], "thresholds":{…} }`; `kind` ∈ `text`\|`large-text`\|`ui`, `fg`/`bg` are token names or literal `#hex`). Runs **static colour-palette analysis** — zero-dep, every primitive computed by hand: (1) **CVD-safe contrast** — simulates each colour under deuteranopia/protanopia/tritanopia (**Machado-2009** matrices), recomputes the WCAG ratio per pair under each, and flags any pair dropping below AA, plus **categorical collapse** (CIEDE2000 ΔE below `$PALETTE_COLLAPSE_DELTAE`, default 10) post-transform; (2) **APCA** — implements **APCA-W3 ~0.1.9**, reports `Lc` per text pair against a font-size/weight-aware (or baseline `$PALETTE_MIN_LC_TEXT` 60 / `$PALETTE_MIN_LC_LARGE` 45) minimum, **alongside** the WCAG-2 ratio (complement, not replacement); (3) **non-text contrast** — `kind:'ui'` pairs require ≥3:1 (WCAG 2.2 **SC 1.4.11**). Thresholds are config-driven (`pairings.json` `thresholds` ⊕ `$PALETTE_MIN_RATIO_{TEXT,LARGE,UI}`) and it **fails closed** on any failure. `$PALETTE_REPORT` writes the per-pair JSON (WCAG ratio · APCA Lc · per-CVD ratios · pass/fail per check). The report's `palette: { cvdSafe, apcaBaseline, nonTextContrast }` envelope is what a future `palette.*` criterion consumes. |
80
81
  | `jargon-gate.mjs` | `node …/jargon-gate.mjs [distDir] [--strict]` | `$JARGON_DIST`. Optional `$JARGON_ALLOWLIST` (comma list of accepted terms), `$JARGON_MIN_LENGTH` (default `3`), `$JARGON_THRESHOLD` (default `0`, for `--strict`), `$JARGON_REPORT`. Flags **undefined jargon** in the prose: words not in a 275k-word English dictionary (compounds/possessives atomized first) that the page does not **define** via `<abbr title>`, `<dfn>`, or a `<dl>` glossary — for W3C COGA / WCAG 3.1.3 Unusual Words and for AI readers. WARN-only by default; `--strict` fails closed. Report carries a `plainLanguage: { undefinedJargon, glossaryPresent }` envelope (for a future `cognitive.plain-language` criterion). |
81
82
  | `readability-gate.mjs` | `node …/readability-gate.mjs <corpus.json> [--strict]` | **The corpus is an input** the site assembles from its copy: a JSON array of `{id,text}` or an `{id:text}` map. Optional `$READABILITY_THRESHOLDS`, `$READABILITY_MIN_WORDS`, `$READABILITY_KNOWN_ACRONYMS`. WARN-only unless `--strict`. |
82
83
  | `commonmark-runner.mjs` | `node …/commonmark-runner.mjs <renderer.mjs> [fixtures.json]` | **The site's markdown renderer module** (export `renderMarkdown`, or set `$COMMONMARK_RENDER_EXPORT`). Default fixtures pin a safe CommonMark subset + 4 hostile-HTML escapes; a site with a different renderer supplies its own `fixtures.json`. |
@@ -97,6 +98,7 @@ criteria are reported + summarised per area but never widen the headline claim.
97
98
  | `gen-cid.mjs` | `DIST=dist node …/gen-cid.mjs` | `$DIST`. Walks the `site.sha256` file set (or `dist`), computes the IPFS UnixFS dir CIDv1 with no daemon, records it into `$DIST/provenance.json`. |
98
99
  | `gen-identity.mjs` | `IDENTITY_DOMAIN=… IDENTITY_REPO=owner/repo node …/gen-identity.mjs` | `$IDENTITY_DOMAIN`, `$IDENTITY_REPO` (cert-identity regexp), `$IDENTITY_SUBJECT` (the credentialSubject JSON, default `$DIST/resume.json`), optional `$IDENTITY_SUBJECT_SCHEMA`, `$IDENTITY_VC_NAME/DESCRIPTION`, `$IDENTITY_VALID_FROM_PATH`. Emits `did.json` + a W3C VC 2.0. |
99
100
  | `gen-snapshots.mjs` | `node …/gen-snapshots.mjs [distDir]` | `$SNAPSHOT_DIST` (default `dist`). Optional `$SNAPSHOT_PAGES`, `$SNAPSHOT_BASE_URL` (recorded as `source` in the front-matter), `$SNAPSHOT_SUFFIX` (default `.reader`). For every built page, runs **@mozilla/readability** (the Firefox/Safari Reader engine, via `linkedom` — headless, no browser) and writes a clean reader **`<page>.reader.html`** + an analysis-friendly **`<page>.reader.md`** (YAML front-matter + Markdown via `turndown`). The Markdown is the durable, diffable twin of the page — far easier to run NLP/LLM analysis over than scraping live HTML — and doubles as the AI-readable Markdown sibling. (The printed/PDF view needs a print-CSS renderer and is a separate generator.) |
101
+ | `gen-print-snapshots.mjs` | `node …/gen-print-snapshots.mjs [distDir]` | `$PRINT_DIST` (default `dist`). Optional `$PRINT_PAGES`, `$PRINT_RENDERER` (default `tezcatl`, or a `"cmd {url} {out}"` template), `$PRINT_WAIT` (default `600`), `$PRINT_SUFFIX` (default `.print`). The print/PDF twin of `gen-snapshots`: serves `dist` over an ephemeral origin (so assets resolve) and renders each page's `@media print` view to **`<page>.print.pdf`** via **tezcatl** (macOS-native WebKit — no Chromium). A LOCAL / macOS-deploy artifact: on a host without the renderer (e.g. a Linux CI runner) it **SKIPS** with a note. |
100
102
  | `openapi.mjs` | `import { sortKeys, writeApiFile, embedSchema, jsonResponse, validateOpenapi }` | The **generic core** of a static-API generator. The per-endpoint projection of a site's contracts (profile/posts/corpus/VC, etc.) stays in the site's build; this module provides deterministic JSON output, schema embedding, and OpenAPI 3.1/3.2 well-formedness validation. Pair with `lib/schema-validate.mjs` to self-check emitted docs. |
101
103
 
102
104
  ### emitters/
@@ -122,7 +124,7 @@ deno run -A jsr:@bounded-systems/verify https://your-site
122
124
  ## Test
123
125
 
124
126
  ```
125
- npm install && npm test # 13 cases against fixtures/, in isolation
127
+ npm install && npm test # cases against fixtures/, in isolation
126
128
  ```
127
129
 
128
130
  The suite verifies the generic logic end-to-end: gen-sbom against a sample lockfile;
@@ -0,0 +1,601 @@
1
+ #!/usr/bin/env node
2
+ // Palette gate — STATIC colour-palette analysis over a site/brand's design tokens.
3
+ // Three high-value checks that a per-page a11y scan (axe) cannot do, because they
4
+ // reason about the PALETTE itself — the colour math, not the rendered DOM:
5
+ //
6
+ // 1. CVD-SAFE CONTRAST — simulate every used colour under deuteranopia /
7
+ // protanopia / tritanopia (Machado-2009 matrices), recompute WCAG contrast
8
+ // for each declared pair under each CVD type, and FAIL any pair that drops
9
+ // below AA for someone with that colour-vision deficiency. Also flag
10
+ // CATEGORICAL colours that COLLAPSE (become indistinguishable, CIEDE2000 ΔE
11
+ // below a threshold) once a CVD transform is applied.
12
+ // 2. APCA — the perceptual contrast metric (APCA-W3 ~0.1.9) the next WCAG (WCAG 3
13
+ // / "Silver") is built around. Compute Lc per text pair, check against a
14
+ // font-size/weight-aware minimum (or the documented baseline floor), and
15
+ // report BOTH the APCA Lc AND the WCAG-2 ratio — complement, not replacement.
16
+ // 3. NON-TEXT CONTRAST (WCAG 2.2 SC 1.4.11) — UI pairs (borders, focus rings,
17
+ // icon glyphs, control boundaries) require ≥ 3:1 against what they sit on.
18
+ //
19
+ // Zero-dependency: every colour-science primitive (sRGB→linear, relative
20
+ // luminance, WCAG ratio, CIE Lab, CIEDE2000, APCA-W3, the CVD matrices) is
21
+ // computed here by hand and CITED in-line, exactly like the kit's other gates.
22
+ //
23
+ // node gates/palette-gate.mjs [tokens] [pairings] # build gate (exit 1 on any failure)
24
+ //
25
+ // INPUTS the consumer supplies (nothing about any one brand is hard-coded):
26
+ // argv[2] / $PALETTE_TOKENS a token map: a DTCG `tokens.json` (primitive →
27
+ // semantic aliases resolved) OR a `tokens.css`
28
+ // (`--name: #hex;` custom properties).
29
+ // argv[3] / $PALETTE_PAIRINGS a `pairings.json` the consumer authors, declaring
30
+ // the fg/bg pairs that actually CO-OCCUR in the UI:
31
+ // { "thresholds": { … optional overrides … },
32
+ // "pairings": [ { "fg","bg","kind","size?","weight?","name?" } ],
33
+ // "categorical": [ "tokenA","tokenB", … ] }
34
+ // `kind` ∈ text | large-text | ui. `fg`/`bg` are a
35
+ // token name (resolved from the map) or a literal #hex.
36
+ // `categorical` = colours that must stay mutually
37
+ // distinguishable (chart series, status colours, …).
38
+ // $PALETTE_REPORT path to write the machine-readable JSON report.
39
+ //
40
+ // Thresholds are config-driven (pairings.json `thresholds` ⊕ env) and FAIL CLOSED:
41
+ // $PALETTE_MIN_RATIO_TEXT (default 4.5) WCAG AA, normal text (SC 1.4.3)
42
+ // $PALETTE_MIN_RATIO_LARGE (default 3.0) WCAG AA, large text (SC 1.4.3)
43
+ // $PALETTE_MIN_RATIO_UI (default 3.0) non-text contrast (SC 1.4.11)
44
+ // $PALETTE_MIN_LC_TEXT (default 60) APCA baseline, body
45
+ // $PALETTE_MIN_LC_LARGE (default 45) APCA baseline, large/headline
46
+ // $PALETTE_COLLAPSE_DELTAE (default 10) min CIEDE2000 ΔE between categorical
47
+ // colours after a CVD transform
48
+ //
49
+ // The pure functions are exported for unit testing; the CLI is a thin wrapper.
50
+ import { readFile, writeFile } from "node:fs/promises";
51
+ import { resolve } from "node:path";
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // 1. sRGB ↔ linear, relative luminance, WCAG-2 contrast ratio
55
+ // Refs: WCAG 2.2 — "relative luminance" + "contrast ratio" definitions
56
+ // https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
57
+ // https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio
58
+ // IEC 61966-2-1:1999 (sRGB) transfer function.
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+
61
+ /** Parse a #rgb / #rrggbb (or bare-hex) string → [r,g,b] in 0..255. */
62
+ export function parseHex(s) {
63
+ let h = String(s).trim().replace(/^#/, "");
64
+ if (/^[0-9a-fA-F]{3}$/.test(h)) h = h.split("").map((c) => c + c).join("");
65
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`not a hex colour: "${s}"`);
66
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
67
+ }
68
+
69
+ /** [r,g,b] 0..255 → "#rrggbb" (clamped + rounded). */
70
+ export function toHex(rgb) {
71
+ return "#" + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
72
+ }
73
+
74
+ /** sRGB channel 0..255 → linear-light 0..1. WCAG/IEC 61966-2-1 transfer fn. */
75
+ export function srgbToLinear(c) {
76
+ const cs = c / 255;
77
+ return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
78
+ }
79
+
80
+ /** linear-light 0..1 → sRGB channel 0..255 (inverse transfer fn). */
81
+ export function linearToSrgb(c) {
82
+ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
83
+ return Math.max(0, Math.min(255, v * 255));
84
+ }
85
+
86
+ /** WCAG relative luminance of an sRGB [r,g,b]. L = 0.2126R+0.7152G+0.0722B (linear). */
87
+ export function relativeLuminance(rgb) {
88
+ const [r, g, b] = rgb.map(srgbToLinear);
89
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
90
+ }
91
+
92
+ /** WCAG-2 contrast ratio of two sRGB colours: (L_light+0.05)/(L_dark+0.05), 1..21. */
93
+ export function wcagContrast(a, b) {
94
+ const la = relativeLuminance(a), lb = relativeLuminance(b);
95
+ const hi = Math.max(la, lb), lo = Math.min(la, lb);
96
+ return (hi + 0.05) / (lo + 0.05);
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // 2. CIE Lab + CIEDE2000 colour difference
101
+ // Refs: sRGB→XYZ (D65) matrix, IEC 61966-2-1 / Bruce Lindbloom.
102
+ // CIEDE2000: Sharma, Wu, Dalal (2005), "The CIEDE2000 Color-Difference
103
+ // Formula: Implementation Notes, Supplementary Test Data, and Mathematical
104
+ // Observations", Color Res. Appl. 30(1). http://www.ece.rochester.edu/~gsharma/ciede2000/
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+
107
+ /** sRGB [r,g,b] 0..255 → CIE L*a*b* (D65 reference white). */
108
+ export function rgbToLab(rgb) {
109
+ const [r, g, b] = rgb.map(srgbToLinear);
110
+ // linear sRGB → XYZ (D65), IEC 61966-2-1 / sRGB→XYZ matrix.
111
+ const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
112
+ const Y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
113
+ const Z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b;
114
+ // D65 reference white.
115
+ const Xn = 0.95047, Yn = 1.0, Zn = 1.08883;
116
+ const e = 216 / 24389, k = 24389 / 27; // CIE standard ε, κ
117
+ const f = (t) => (t > e ? Math.cbrt(t) : (k * t + 16) / 116);
118
+ const fx = f(X / Xn), fy = f(Y / Yn), fz = f(Z / Zn);
119
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
120
+ }
121
+
122
+ /** CIEDE2000 ΔE between two CIE Lab colours (Sharma et al. 2005 reference impl). */
123
+ export function ciede2000(lab1, lab2) {
124
+ const [L1, a1, b1] = lab1, [L2, a2, b2] = lab2;
125
+ const rad = Math.PI / 180, deg = 180 / Math.PI;
126
+ const kL = 1, kC = 1, kH = 1;
127
+ const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2);
128
+ const Cbar = (C1 + C2) / 2;
129
+ const Cbar7 = Math.pow(Cbar, 7);
130
+ const G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + Math.pow(25, 7))));
131
+ const a1p = (1 + G) * a1, a2p = (1 + G) * a2;
132
+ const C1p = Math.hypot(a1p, b1), C2p = Math.hypot(a2p, b2);
133
+ const hp = (b, ap) => {
134
+ if (b === 0 && ap === 0) return 0;
135
+ let h = Math.atan2(b, ap) * deg;
136
+ return h < 0 ? h + 360 : h;
137
+ };
138
+ const h1p = hp(b1, a1p), h2p = hp(b2, a2p);
139
+ const dLp = L2 - L1;
140
+ const dCp = C2p - C1p;
141
+ let dhp;
142
+ if (C1p * C2p === 0) dhp = 0;
143
+ else {
144
+ dhp = h2p - h1p;
145
+ if (dhp > 180) dhp -= 360;
146
+ else if (dhp < -180) dhp += 360;
147
+ }
148
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * rad) / 2);
149
+ const Lbarp = (L1 + L2) / 2;
150
+ const Cbarp = (C1p + C2p) / 2;
151
+ let hbarp;
152
+ if (C1p * C2p === 0) hbarp = h1p + h2p;
153
+ else if (Math.abs(h1p - h2p) <= 180) hbarp = (h1p + h2p) / 2;
154
+ else hbarp = h1p + h2p < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
155
+ const T =
156
+ 1 -
157
+ 0.17 * Math.cos((hbarp - 30) * rad) +
158
+ 0.24 * Math.cos(2 * hbarp * rad) +
159
+ 0.32 * Math.cos((3 * hbarp + 6) * rad) -
160
+ 0.20 * Math.cos((4 * hbarp - 63) * rad);
161
+ const dTheta = 30 * Math.exp(-Math.pow((hbarp - 275) / 25, 2));
162
+ const Cbarp7 = Math.pow(Cbarp, 7);
163
+ const RC = 2 * Math.sqrt(Cbarp7 / (Cbarp7 + Math.pow(25, 7)));
164
+ const SL = 1 + (0.015 * Math.pow(Lbarp - 50, 2)) / Math.sqrt(20 + Math.pow(Lbarp - 50, 2));
165
+ const SC = 1 + 0.045 * Cbarp;
166
+ const SH = 1 + 0.015 * Cbarp * T;
167
+ const RT = -Math.sin(2 * dTheta * rad) * RC;
168
+ return Math.sqrt(
169
+ Math.pow(dLp / (kL * SL), 2) +
170
+ Math.pow(dCp / (kC * SC), 2) +
171
+ Math.pow(dHp / (kH * SH), 2) +
172
+ RT * (dCp / (kC * SC)) * (dHp / (kH * SH)),
173
+ );
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+ // 3. APCA-W3 (Accessible Perceptual Contrast Algorithm), constants ~0.1.9 / 0.98G-4g.
178
+ // Ref: Andrew Somers (Myndex), APCA-W3 / SAPC — https://github.com/Myndex/apca-w3
179
+ // and https://github.com/Myndex/SAPC-APCA . Output `Lc` is a polarity-signed
180
+ // "lightness contrast" roughly in [-108, 106]; conformance uses |Lc|.
181
+ // APCA is a CANDIDATE method (WCAG 3 / "Silver"), reported ALONGSIDE — not in
182
+ // place of — the WCAG-2 ratio.
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+
185
+ const SA98G = {
186
+ mainTRC: 2.4,
187
+ sRco: 0.2126729, sGco: 0.7151522, sBco: 0.0721750,
188
+ normBG: 0.56, normTXT: 0.57, revTXT: 0.62, revBG: 0.65,
189
+ blkThrs: 0.022, blkClmp: 1.414,
190
+ scaleBoW: 1.14, scaleWoB: 1.14,
191
+ loBoWoffset: 0.027, loWoBoffset: 0.027,
192
+ deltaYmin: 0.0005, loClip: 0.1,
193
+ };
194
+
195
+ /** sRGB [r,g,b] 0..255 → APCA screen luminance Ys (simple power-curve, not WCAG luminance). */
196
+ export function apcaY(rgb) {
197
+ const s = SA98G;
198
+ return (
199
+ s.sRco * Math.pow(rgb[0] / 255, s.mainTRC) +
200
+ s.sGco * Math.pow(rgb[1] / 255, s.mainTRC) +
201
+ s.sBco * Math.pow(rgb[2] / 255, s.mainTRC)
202
+ );
203
+ }
204
+
205
+ /** APCA-W3 contrast Lc for text-luminance over background-luminance. */
206
+ export function apcaContrastY(txtY, bgY) {
207
+ const s = SA98G;
208
+ if (Math.min(txtY, bgY) < 0) return 0.0;
209
+ // Soft-clamp near-black (low-luminance offset).
210
+ if (txtY < s.blkThrs) txtY += Math.pow(s.blkThrs - txtY, s.blkClmp);
211
+ if (bgY < s.blkThrs) bgY += Math.pow(s.blkThrs - bgY, s.blkClmp);
212
+ if (Math.abs(bgY - txtY) < s.deltaYmin) return 0.0;
213
+ let out;
214
+ if (bgY > txtY) {
215
+ // normal polarity: dark text on light bg
216
+ const SAPC = (Math.pow(bgY, s.normBG) - Math.pow(txtY, s.normTXT)) * s.scaleBoW;
217
+ out = SAPC < s.loClip ? 0.0 : SAPC - s.loBoWoffset;
218
+ } else {
219
+ // reverse polarity: light text on dark bg
220
+ const SAPC = (Math.pow(bgY, s.revBG) - Math.pow(txtY, s.revTXT)) * s.scaleWoB;
221
+ out = SAPC > -s.loClip ? 0.0 : SAPC + s.loWoBoffset;
222
+ }
223
+ return out * 100;
224
+ }
225
+
226
+ /** APCA Lc for a text fg over a bg, both sRGB [r,g,b]. Signed (polarity-aware). */
227
+ export function apcaContrast(fg, bg) {
228
+ return apcaContrastY(apcaY(fg), apcaY(bg));
229
+ }
230
+
231
+ /**
232
+ * Minimum |Lc| a text pair must clear. If size (px) / weight are given, use a
233
+ * conservative font-aware tier derived from the APCA readability guidance ("Bronze"
234
+ * use-case levels: Lc 90 ideal body, 75 min body column, 60 large/content, 45
235
+ * large+bold/headline, 30 spot). Otherwise fall back to the baseline floor by kind.
236
+ * Cited: APCA "Font Size & Weight" guidance, https://git.myndex.com/ (use-cases).
237
+ * This is intentionally conservative (rounds UP a requirement, never down); a
238
+ * consumer can override exactly via a per-pairing `minLc`.
239
+ */
240
+ export function apcaMinLc({ kind, sizePx, weight, baseText = 60, baseLarge = 45 }) {
241
+ if (sizePx != null) {
242
+ const w = weight ?? 400;
243
+ if (sizePx >= 36 || (sizePx >= 24 && w >= 700)) return baseLarge; // big display
244
+ if (sizePx >= 24 || (sizePx >= 18.5 && w >= 600)) return baseText; // large text
245
+ if (sizePx >= 18) return 75; // body
246
+ if (sizePx >= 16) return 90; // small body
247
+ return 100; // < 16px: max
248
+ }
249
+ return kind === "large-text" ? baseLarge : baseText;
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // 4. Colour-vision-deficiency simulation — Machado 2009, severity 1.0 (dichromacy).
254
+ // Ref: G. M. Machado, M. M. Oliveira, L. A. F. Fernandes (2009), "A
255
+ // Physiologically-based Model for Simulation of Color Vision Deficiency",
256
+ // IEEE Trans. Vis. Comput. Graph. 15(6). Matrices operate on LINEAR-light sRGB.
257
+ // http://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+
260
+ export const CVD_MATRICES = {
261
+ // protanopia (severity 1.0)
262
+ protanopia: [
263
+ [0.152286, 1.052583, -0.204868],
264
+ [0.114503, 0.786281, 0.099216],
265
+ [-0.003882, -0.048116, 1.051998],
266
+ ],
267
+ // deuteranopia (severity 1.0)
268
+ deuteranopia: [
269
+ [0.367322, 0.860646, -0.227968],
270
+ [0.280085, 0.672501, 0.047413],
271
+ [-0.011820, 0.042940, 0.968881],
272
+ ],
273
+ // tritanopia (severity 1.0)
274
+ tritanopia: [
275
+ [1.255528, -0.076749, -0.178779],
276
+ [-0.078411, 0.930809, 0.147602],
277
+ [0.004733, 0.691367, 0.303900],
278
+ ],
279
+ };
280
+
281
+ export const CVD_TYPES = Object.keys(CVD_MATRICES);
282
+
283
+ /** Simulate how an sRGB [r,g,b] colour appears to someone with the given CVD type. */
284
+ export function simulateCVD(rgb, type) {
285
+ const M = CVD_MATRICES[type];
286
+ if (!M) throw new Error(`unknown CVD type: ${type}`);
287
+ const lin = rgb.map(srgbToLinear); // Machado matrices act on linear light
288
+ const out = M.map((row) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]);
289
+ return out.map((c) => Math.round(linearToSrgb(Math.max(0, Math.min(1, c)))));
290
+ }
291
+
292
+ // ─────────────────────────────────────────────────────────────────────────────
293
+ // 5. Threshold model + per-pair evaluation (pure)
294
+ // ─────────────────────────────────────────────────────────────────────────────
295
+
296
+ export const DEFAULT_THRESHOLDS = {
297
+ minRatioText: 4.5, // WCAG 2.2 SC 1.4.3 (AA, normal text)
298
+ minRatioLarge: 3.0, // WCAG 2.2 SC 1.4.3 (AA, large text)
299
+ minRatioUi: 3.0, // WCAG 2.2 SC 1.4.11 (non-text contrast)
300
+ minLcText: 60, // APCA baseline floor, body
301
+ minLcLarge: 45, // APCA baseline floor, large
302
+ collapseDeltaE: 10, // categorical CIEDE2000 collapse floor
303
+ };
304
+
305
+ /** The WCAG-AA ratio floor for a pairing kind. */
306
+ export function ratioFloor(kind, t) {
307
+ if (kind === "ui") return t.minRatioUi;
308
+ if (kind === "large-text") return t.minRatioLarge;
309
+ return t.minRatioText; // text (default)
310
+ }
311
+
312
+ /**
313
+ * Evaluate ONE resolved pairing (fg/bg already hex) against all three checks.
314
+ * Pure: (pairing, thresholds) → per-pair report. `kind` drives which checks apply:
315
+ * text / large-text → WCAG AA ratio, APCA Lc, and CVD-safe AA under every CVD.
316
+ * ui → WCAG 1.4.11 (≥3:1), and CVD-safe ≥3:1 under every CVD.
317
+ */
318
+ export function evaluatePair(pairing, thresholds = DEFAULT_THRESHOLDS) {
319
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
320
+ const kind = pairing.kind || "text";
321
+ const fgRgb = parseHex(pairing.fgHex), bgRgb = parseHex(pairing.bgHex);
322
+ const floor = ratioFloor(kind, t);
323
+
324
+ const ratio = wcagContrast(fgRgb, bgRgb);
325
+ const wcagPass = ratio + 1e-9 >= floor;
326
+
327
+ // CVD-safe contrast: recompute the WCAG ratio with BOTH colours simulated.
328
+ const cvd = { normalRatio: round2(ratio) };
329
+ let cvdPass = true;
330
+ for (const type of CVD_TYPES) {
331
+ const r = wcagContrast(simulateCVD(fgRgb, type), simulateCVD(bgRgb, type));
332
+ const pass = r + 1e-9 >= floor;
333
+ cvd[type] = { ratio: round2(r), floor, pass };
334
+ if (!pass) cvdPass = false;
335
+ }
336
+ cvd.pass = cvdPass;
337
+
338
+ const checks = {};
339
+ const isText = kind === "text" || kind === "large-text";
340
+
341
+ checks.wcagAA = wcagPass;
342
+
343
+ let apca = null;
344
+ if (isText) {
345
+ const Lc = apcaContrast(fgRgb, bgRgb);
346
+ const minLc = pairing.minLc ?? apcaMinLc({
347
+ kind, sizePx: pairing.size, weight: pairing.weight,
348
+ baseText: t.minLcText, baseLarge: t.minLcLarge,
349
+ });
350
+ const apcaPass = Math.abs(Lc) + 1e-9 >= minLc;
351
+ apca = { Lc: round1(Lc), absLc: round1(Math.abs(Lc)), min: minLc, pass: apcaPass };
352
+ checks.apca = apcaPass;
353
+ } else {
354
+ checks.apca = null;
355
+ }
356
+
357
+ checks.nonText = kind === "ui" ? wcagPass : null;
358
+ checks.cvdSafe = cvdPass;
359
+
360
+ const passed =
361
+ checks.wcagAA &&
362
+ (checks.apca !== false) &&
363
+ (checks.nonText !== false) &&
364
+ cvdPass;
365
+
366
+ return {
367
+ name: pairing.name || `${pairing.fg} on ${pairing.bg}`,
368
+ fg: { ref: pairing.fg, hex: toHex(fgRgb) },
369
+ bg: { ref: pairing.bg, hex: toHex(bgRgb) },
370
+ kind,
371
+ size: pairing.size ?? null,
372
+ weight: pairing.weight ?? null,
373
+ wcag: { ratio: round2(ratio), min: floor, pass: wcagPass },
374
+ apca,
375
+ cvd,
376
+ checks,
377
+ passed,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Detect CATEGORICAL collapse: every unordered pair of categorical colours must
383
+ * stay ≥ collapseDeltaE apart (CIEDE2000) under NORMAL vision AND under every CVD
384
+ * transform. A pair that collapses post-transform is reported. Pure.
385
+ */
386
+ export function evaluateCategorical(colors, thresholds = DEFAULT_THRESHOLDS) {
387
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
388
+ const collapses = [];
389
+ const list = colors.map((c) => ({ ref: c.ref, rgb: parseHex(c.hex), hex: toHex(parseHex(c.hex)) }));
390
+ for (let i = 0; i < list.length; i++) {
391
+ for (let j = i + 1; j < list.length; j++) {
392
+ const A = list[i], B = list[j];
393
+ const conditions = { normal: ciede2000(rgbToLab(A.rgb), rgbToLab(B.rgb)) };
394
+ for (const type of CVD_TYPES) {
395
+ conditions[type] = ciede2000(rgbToLab(simulateCVD(A.rgb, type)), rgbToLab(simulateCVD(B.rgb, type)));
396
+ }
397
+ for (const [cond, dE] of Object.entries(conditions)) {
398
+ if (dE + 1e-9 < t.collapseDeltaE) {
399
+ collapses.push({
400
+ a: A.ref, b: B.ref, aHex: A.hex, bHex: B.hex,
401
+ condition: cond, deltaE: round2(dE), min: t.collapseDeltaE,
402
+ });
403
+ }
404
+ }
405
+ }
406
+ }
407
+ return { threshold: t.collapseDeltaE, count: collapses.length, collapses };
408
+ }
409
+
410
+ /** Whole-palette evaluation: pairs + categorical collapse → fail-closed report. Pure. */
411
+ export function evaluatePalette({ pairings = [], categorical = [], thresholds = DEFAULT_THRESHOLDS } = {}) {
412
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
413
+ const pairs = pairings.map((p) => evaluatePair(p, t));
414
+ const cat = evaluateCategorical(categorical, t);
415
+
416
+ const summary = {
417
+ pairs: pairs.length,
418
+ failingPairs: pairs.filter((p) => !p.passed).length,
419
+ wcagFailures: pairs.filter((p) => !p.checks.wcagAA).length,
420
+ cvdFailures: pairs.filter((p) => !p.cvd.pass).length,
421
+ apcaFailures: pairs.filter((p) => p.checks.apca === false).length,
422
+ nonTextFailures: pairs.filter((p) => p.checks.nonText === false).length,
423
+ categoricalCollapses: cat.count,
424
+ };
425
+ const passed = summary.failingPairs === 0 && cat.count === 0;
426
+
427
+ return {
428
+ passed,
429
+ thresholds: t,
430
+ summary,
431
+ pairs,
432
+ categorical: cat,
433
+ // Envelope a future lone `palette.cvd-safe` / `palette.apca` criterion can consume.
434
+ palette: {
435
+ cvdSafe: summary.cvdFailures === 0 && cat.count === 0,
436
+ apcaBaseline: summary.apcaFailures === 0,
437
+ nonTextContrast: summary.nonTextFailures === 0,
438
+ },
439
+ };
440
+ }
441
+
442
+ const round1 = (n) => Math.round(n * 10) / 10;
443
+ const round2 = (n) => Math.round(n * 100) / 100;
444
+
445
+ // ─────────────────────────────────────────────────────────────────────────────
446
+ // 6. Token / pairing loading (impure; deterministic, no network/browser)
447
+ // ─────────────────────────────────────────────────────────────────────────────
448
+
449
+ /** Flatten a DTCG-ish tokens.json (primitive → semantic aliases) to { dotted.name → #hex }. */
450
+ export function tokensFromDTCG(json) {
451
+ const flat = {}; // dotted path → raw $value (may be an alias or hex)
452
+ const walk = (node, path) => {
453
+ if (node && typeof node === "object") {
454
+ if (typeof node.$value === "string" && (node.$type === "color" || /^#|^\{/.test(node.$value))) {
455
+ flat[path.join(".")] = node.$value;
456
+ }
457
+ for (const [k, v] of Object.entries(node)) {
458
+ if (k.startsWith("$")) continue;
459
+ walk(v, [...path, k]);
460
+ }
461
+ }
462
+ };
463
+ walk(json, []);
464
+ const resolve1 = (v, seen = new Set()) => {
465
+ const m = /^\{(.+)\}$/.exec(String(v).trim());
466
+ if (!m) return v;
467
+ if (seen.has(m[1])) throw new Error(`alias cycle at {${m[1]}}`);
468
+ if (!(m[1] in flat)) throw new Error(`unresolved alias {${m[1]}}`);
469
+ return resolve1(flat[m[1]], new Set([...seen, m[1]]));
470
+ };
471
+ const out = {};
472
+ for (const [k, v] of Object.entries(flat)) {
473
+ let resolved;
474
+ try { resolved = resolve1(v); } catch { continue; }
475
+ if (/^#?[0-9a-fA-F]{3}$|^#?[0-9a-fA-F]{6}$/.test(String(resolved).replace(/^#/, "#"))) {
476
+ out[k] = String(resolved).startsWith("#") ? resolved : "#" + resolved;
477
+ }
478
+ }
479
+ return out;
480
+ }
481
+
482
+ /** Parse a tokens.css for `--name: #hex;` custom properties → { name(no --) → #hex }. */
483
+ export function tokensFromCSS(css) {
484
+ const out = {};
485
+ const re = /--([a-zA-Z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g;
486
+ let m;
487
+ while ((m = re.exec(css))) out[m[1]] = m[2];
488
+ return out;
489
+ }
490
+
491
+ /** Load a token map from a .json (DTCG) or .css path. */
492
+ export async function loadTokens(path) {
493
+ const raw = await readFile(path, "utf8");
494
+ if (path.endsWith(".json")) return tokensFromDTCG(JSON.parse(raw));
495
+ if (path.endsWith(".css")) return tokensFromCSS(raw);
496
+ // Best-effort: try JSON, else CSS.
497
+ try { return tokensFromDTCG(JSON.parse(raw)); } catch { return tokensFromCSS(raw); }
498
+ }
499
+
500
+ /**
501
+ * Resolve a colour REFERENCE (a token name or a literal #hex) against the map.
502
+ * Tolerant of the brand's short names: tries the exact key, then common prefixes
503
+ * (`bs-color-`, `bs-grade-`, `color.`, `bs-`), then a case-insensitive match.
504
+ */
505
+ export function resolveColor(map, ref) {
506
+ const r = String(ref).trim();
507
+ if (/^#[0-9a-fA-F]{3,8}$/.test(r)) return r;
508
+ if (r in map) return map[r];
509
+ for (const p of ["bs-color-", "bs-grade-", "color.", "primitive.", "bs-"]) {
510
+ if (p + r in map) return map[p + r];
511
+ }
512
+ const lc = r.toLowerCase();
513
+ for (const [k, v] of Object.entries(map)) {
514
+ if (k.toLowerCase() === lc || k.toLowerCase().endsWith("-" + lc) || k.toLowerCase().endsWith("." + lc)) return v;
515
+ }
516
+ throw new Error(`unresolved colour reference: "${ref}"`);
517
+ }
518
+
519
+ /** Resolve a pairings.json spec (token names → hex) against a token map. */
520
+ export function resolvePairings(spec, map) {
521
+ const pairings = (spec.pairings || []).map((p) => ({
522
+ ...p,
523
+ fgHex: resolveColor(map, p.fg),
524
+ bgHex: resolveColor(map, p.bg),
525
+ }));
526
+ const categorical = (spec.categorical || []).map((ref) => ({ ref, hex: resolveColor(map, ref) }));
527
+ return { pairings, categorical, thresholds: spec.thresholds || {} };
528
+ }
529
+
530
+ /** Full run: load tokens + pairings → resolve → evaluate. Exposed for tests. */
531
+ export async function runPaletteGate({ tokens, pairings, thresholds = {} }) {
532
+ const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
533
+ const spec = typeof pairings === "string" ? JSON.parse(await readFile(pairings, "utf8")) : pairings;
534
+ const resolved = resolvePairings(spec, map);
535
+ return evaluatePalette({
536
+ pairings: resolved.pairings,
537
+ categorical: resolved.categorical,
538
+ thresholds: { ...DEFAULT_THRESHOLDS, ...resolved.thresholds, ...thresholds },
539
+ });
540
+ }
541
+
542
+ // ─────────────────────────────────────────────────────────────────────────────
543
+ // 7. CLI
544
+ // ─────────────────────────────────────────────────────────────────────────────
545
+
546
+ function envThresholds() {
547
+ const t = {};
548
+ const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
549
+ const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
550
+ set("minRatioText", "PALETTE_MIN_RATIO_TEXT");
551
+ set("minRatioLarge", "PALETTE_MIN_RATIO_LARGE");
552
+ set("minRatioUi", "PALETTE_MIN_RATIO_UI");
553
+ set("minLcText", "PALETTE_MIN_LC_TEXT");
554
+ set("minLcLarge", "PALETTE_MIN_LC_LARGE");
555
+ set("collapseDeltaE", "PALETTE_COLLAPSE_DELTAE");
556
+ return t;
557
+ }
558
+
559
+ async function main() {
560
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
561
+ const tokens = argv[0] || process.env.PALETTE_TOKENS;
562
+ const pairings = argv[1] || process.env.PALETTE_PAIRINGS;
563
+ if (!tokens || !pairings) {
564
+ console.error("✗ palette-gate: usage: palette-gate.mjs <tokens.(json|css)> <pairings.json>");
565
+ console.error(" (or set $PALETTE_TOKENS and $PALETTE_PAIRINGS)");
566
+ process.exit(2);
567
+ }
568
+
569
+ const report = await runPaletteGate({ tokens, pairings, thresholds: envThresholds() });
570
+ if (process.env.PALETTE_REPORT) {
571
+ await writeFile(resolve(process.env.PALETTE_REPORT), JSON.stringify(report, null, 2) + "\n");
572
+ }
573
+
574
+ const s = report.summary;
575
+ const line =
576
+ `palette-gate: ${s.pairs} pair(s) — ${s.failingPairs} failing ` +
577
+ `(WCAG ${s.wcagFailures}, CVD ${s.cvdFailures}, APCA ${s.apcaFailures}, non-text ${s.nonTextFailures}) · ` +
578
+ `${s.categoricalCollapses} categorical collapse(s)`;
579
+
580
+ const detail = (p) => {
581
+ const bits = [`WCAG ${p.wcag.ratio}:1 (min ${p.wcag.min})`];
582
+ if (p.apca) bits.push(`APCA |Lc| ${p.apca.absLc} (min ${p.apca.min})`);
583
+ const cvdBad = CVD_TYPES.filter((c) => !p.cvd[c].pass).map((c) => `${c} ${p.cvd[c].ratio}:1`);
584
+ if (cvdBad.length) bits.push(`CVD-fail: ${cvdBad.join(", ")}`);
585
+ return ` · ${p.name} [${p.kind}] ${p.fg.hex}/${p.bg.hex} — ${bits.join("; ")}`;
586
+ };
587
+
588
+ if (!report.passed) {
589
+ console.error(`✗ ${line}`);
590
+ for (const p of report.pairs) if (!p.passed) console.error(detail(p));
591
+ for (const c of report.categorical.collapses) {
592
+ console.error(` · collapse: ${c.a} vs ${c.b} (${c.aHex}/${c.bHex}) under ${c.condition} — ΔE ${c.deltaE} < ${c.min}`);
593
+ }
594
+ process.exit(1);
595
+ }
596
+ console.log(`✓ ${line}`);
597
+ }
598
+
599
+ if (import.meta.url === `file://${process.argv[1]}`) {
600
+ main().catch((e) => { console.error("✗ palette-gate: error —", e.stack || e.message); process.exit(1); });
601
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // Printed-view snapshot generator — the print/PDF twin of gen-snapshots (which does
3
+ // the headless reader/Markdown view). For every built page it renders the page's
4
+ // `@media print` view to a durable `<page>.print.pdf`, so the printed artifact is
5
+ // archived + content-addressable alongside the served bytes.
6
+ //
7
+ // Unlike the reader view, a PDF needs a real print-CSS renderer. The default is
8
+ // `tezcatl` (macOS-native WebKit, no Chromium download) — the same engine the kit's
9
+ // axe gate uses locally. So this generator is a LOCAL / macOS-deploy artifact: on a
10
+ // host without the renderer (e.g. a Linux CI runner) it SKIPS with a clear note.
11
+ //
12
+ // node generators/gen-print-snapshots.mjs [distDir]
13
+ //
14
+ // Config-driven; NOTHING about any one site is hard-coded:
15
+ // argv / $PRINT_DIST built output dir (default: "dist")
16
+ // $PRINT_PAGES comma list of page paths under dist (default: every *.html)
17
+ // $PRINT_RENDERER renderer command (default: "tezcatl")
18
+ // $PRINT_WAIT ms to let JS/layout settle (default: 600)
19
+ // $PRINT_SUFFIX output basename suffix (default: ".print")
20
+ //
21
+ // The pure path/arg functions are exported for unit testing without a renderer.
22
+ import { readdir, access, stat } from "node:fs/promises";
23
+ import { readFileSync } from "node:fs";
24
+ import { createServer } from "node:http";
25
+ import { spawn, spawnSync } from "node:child_process";
26
+ import { resolve, join, relative, dirname, basename, extname } from "node:path";
27
+
28
+ // ── Pure core (renderer-free; unit-testable) ─────────────────────────────────
29
+
30
+ const MIME = {
31
+ ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
32
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
33
+ ".jpg": "image/jpeg", ".webp": "image/webp", ".woff2": "font/woff2",
34
+ ".woff": "font/woff", ".ico": "image/x-icon", ".txt": "text/plain",
35
+ };
36
+ export const mimeFor = (p) => MIME[extname(p).toLowerCase()] || "application/octet-stream";
37
+
38
+ /** Output PDF path for a built page: dist/blog/x.html → dist/blog/x.print.pdf. */
39
+ export function pdfOutPath(file, suffix = ".print") {
40
+ return join(dirname(file), basename(file, extname(file)) + suffix + ".pdf");
41
+ }
42
+
43
+ /** The renderer command + args. Pure: (renderer, url, out, wait) → [cmd, args]. */
44
+ export function rendererCommand(renderer, url, out, wait = 600) {
45
+ if (renderer === "tezcatl") return ["tezcatl", [url, `--pdf=${out}`, `--wait=${wait}`]];
46
+ // A custom $PRINT_RENDERER is a command template: "cmd {url} {out}".
47
+ const parts = renderer.split(/\s+/).map((t) => t.replace("{url}", url).replace("{out}", out).replace("{wait}", String(wait)));
48
+ return [parts[0], parts.slice(1)];
49
+ }
50
+
51
+ // ── Impure: static origin + renderer ─────────────────────────────────────────
52
+
53
+ /** Serve `root` over an ephemeral localhost origin so absolute asset paths resolve. */
54
+ function startServer(root) {
55
+ return new Promise((res) => {
56
+ const server = createServer(async (req, res2) => {
57
+ try {
58
+ let p = join(root, decodeURIComponent((req.url || "/").split("?")[0]));
59
+ try { if ((await stat(p)).isDirectory()) p = join(p, "index.html"); } catch { p = join(root, "404.html"); }
60
+ res2.writeHead(200, { "content-type": mimeFor(p) });
61
+ res2.end(readFileSync(p));
62
+ } catch { res2.writeHead(404); res2.end(); }
63
+ });
64
+ server.listen(0, "127.0.0.1", () => res({ origin: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }));
65
+ });
66
+ }
67
+
68
+ function render(renderer, url, out, wait) {
69
+ const [cmd, args] = rendererCommand(renderer, url, out, wait);
70
+ return new Promise((res, rej) => {
71
+ const ch = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
72
+ let err = "";
73
+ ch.stderr.on("data", (d) => (err += d));
74
+ ch.on("error", (e) => rej(new Error(`renderer "${cmd}" not runnable (on PATH?): ${e.message}`)));
75
+ ch.on("close", (code) => (code === 0 ? res() : rej(new Error(`renderer "${cmd}" exit ${code}: ${err.trim().slice(0, 200)}`))));
76
+ });
77
+ }
78
+
79
+ async function walkHtml(dir) {
80
+ const out = [];
81
+ for (const e of await readdir(dir, { withFileTypes: true })) {
82
+ const p = join(dir, e.name);
83
+ if (e.isDirectory()) out.push(...await walkHtml(p));
84
+ else if (e.name.endsWith(".html")) out.push(p);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Render each page → PDF. Exposed for programmatic use. */
90
+ export async function genPrintSnapshots({ dist, pages, renderer = "tezcatl", wait = 600, suffix = ".print" }) {
91
+ const distAbs = resolve(dist);
92
+ const files = pages && pages.length ? pages.map((p) => resolve(distAbs, p)) : (await walkHtml(distAbs)).sort();
93
+ const { origin, close } = await startServer(distAbs);
94
+ const written = [];
95
+ try {
96
+ for (const file of files) {
97
+ const rel = relative(distAbs, file);
98
+ const out = pdfOutPath(file, suffix);
99
+ await render(renderer, `${origin}/${rel}`, out, wait);
100
+ written.push(relative(distAbs, out));
101
+ }
102
+ } finally { close(); }
103
+ return written;
104
+ }
105
+
106
+ // ── CLI ──────────────────────────────────────────────────────────────────────
107
+
108
+ async function main() {
109
+ const distArg = process.argv.slice(2).find((a) => !a.startsWith("--"));
110
+ const dist = resolve(distArg || process.env.PRINT_DIST || "dist");
111
+ const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
112
+ if (!(await exists(dist))) { console.error(`✗ gen-print-snapshots: ${dist} not found — build first.`); process.exit(2); }
113
+
114
+ const renderer = (process.env.PRINT_RENDERER || "tezcatl").trim();
115
+ const wait = Number.parseInt(process.env.PRINT_WAIT ?? "600", 10);
116
+ const suffix = process.env.PRINT_SUFFIX || ".print";
117
+ const pages = (process.env.PRINT_PAGES || "").split(",").map((s) => s.trim().replace(/^\//, "")).filter(Boolean);
118
+
119
+ // Renderer present? If not, SKIP (this is a local/macOS-deploy artifact, not CI).
120
+ const cmd0 = rendererCommand(renderer, "", "", wait)[0];
121
+ if (spawnSync(cmd0, ["--help"], { stdio: "ignore" }).error) {
122
+ console.log(`✓ gen-print-snapshots: renderer "${cmd0}" not on PATH — SKIPPED (run on a host with it, e.g. macOS + tezcatl).`);
123
+ return;
124
+ }
125
+
126
+ const written = await genPrintSnapshots({ dist, pages, renderer, wait, suffix });
127
+ for (const w of written) console.log(` ✓ ${w}`);
128
+ console.log(`✓ gen-print-snapshots: ${written.length} printed PDF snapshot(s) via ${cmd0}.`);
129
+ }
130
+
131
+ if (import.meta.url === `file://${process.argv[1]}`) {
132
+ main().catch((e) => { console.error("✗ gen-print-snapshots: error —", e.stack || e.message); process.exit(1); });
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bounded-systems/conformance-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone, site-agnostic web-conformance toolkit: integrity tooling + build gates + provenance generators, all parameterized so a site vendors one kit instead of duplicating scripts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,25 +9,27 @@
9
9
  "url": "git+https://github.com/bounded-systems/conformance-kit.git"
10
10
  },
11
11
  "bin": {
12
- "ck-gen-sitemanifest": "./integrity/gen-sitemanifest.mjs",
13
- "ck-gen-provenance": "./integrity/gen-provenance.mjs",
14
- "ck-verify-site": "./integrity/verify-site.mjs",
15
- "ck-http-probe": "./integrity/http-probe.mjs",
16
- "ck-structure-audit": "./integrity/structure-audit/audit.mjs",
17
- "ck-gen-sbom": "./gates/sbom/gen-sbom.mjs",
18
- "ck-check-sbom": "./gates/sbom/check-sbom.mjs",
19
- "ck-shacl-runner": "./gates/shacl-runner.mjs",
20
- "ck-seo-gate": "./gates/seo-gate.mjs",
21
- "ck-axe-gate": "./gates/axe-gate.mjs",
22
- "ck-vuln-gate": "./gates/vuln-gate.mjs",
23
- "ck-html-validator-gate": "./gates/html-validator-gate.mjs",
24
- "ck-baseline-gate": "./gates/baseline-gate.mjs",
25
- "ck-jargon-gate": "./gates/jargon-gate.mjs",
26
- "ck-readability-gate": "./gates/readability-gate.mjs",
27
- "ck-commonmark-runner": "./gates/commonmark-runner.mjs",
28
- "ck-gen-cid": "./generators/gen-cid.mjs",
29
- "ck-gen-identity": "./generators/gen-identity.mjs",
30
- "ck-gen-snapshots": "./generators/gen-snapshots.mjs"
12
+ "ck-gen-sitemanifest": "integrity/gen-sitemanifest.mjs",
13
+ "ck-gen-provenance": "integrity/gen-provenance.mjs",
14
+ "ck-verify-site": "integrity/verify-site.mjs",
15
+ "ck-http-probe": "integrity/http-probe.mjs",
16
+ "ck-structure-audit": "integrity/structure-audit/audit.mjs",
17
+ "ck-gen-sbom": "gates/sbom/gen-sbom.mjs",
18
+ "ck-check-sbom": "gates/sbom/check-sbom.mjs",
19
+ "ck-shacl-runner": "gates/shacl-runner.mjs",
20
+ "ck-seo-gate": "gates/seo-gate.mjs",
21
+ "ck-axe-gate": "gates/axe-gate.mjs",
22
+ "ck-vuln-gate": "gates/vuln-gate.mjs",
23
+ "ck-html-validator-gate": "gates/html-validator-gate.mjs",
24
+ "ck-baseline-gate": "gates/baseline-gate.mjs",
25
+ "ck-palette-gate": "gates/palette-gate.mjs",
26
+ "ck-jargon-gate": "gates/jargon-gate.mjs",
27
+ "ck-readability-gate": "gates/readability-gate.mjs",
28
+ "ck-commonmark-runner": "gates/commonmark-runner.mjs",
29
+ "ck-gen-cid": "generators/gen-cid.mjs",
30
+ "ck-gen-identity": "generators/gen-identity.mjs",
31
+ "ck-gen-snapshots": "generators/gen-snapshots.mjs",
32
+ "ck-gen-print-snapshots": "generators/gen-print-snapshots.mjs"
31
33
  },
32
34
  "scripts": {
33
35
  "test": "node test/run.mjs"