@bounded-systems/conformance-kit 0.4.0 → 0.6.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 +10 -1
- package/gates/ai-readability-gate.mjs +169 -0
- package/gates/conformance/conformance.mjs +47 -0
- package/gates/conformance/web-build.mjs +80 -0
- package/gates/likeness-gate.mjs +174 -0
- package/gates/opacity-contrast-gate.mjs +191 -0
- package/gates/pairing-extractor.mjs +267 -0
- package/gates/target-size-gate.mjs +166 -0
- package/gates/token-a11y.mjs +128 -0
- package/gates/typography-gate.mjs +396 -0
- package/generators/gen-print-snapshots.mjs +133 -0
- package/package.json +10 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Token Accessibility suite — the UNIFIED runner. One `token-a11y.json` config drives
|
|
3
|
+
// every member of the suite over a single token map, and the runner FAILS CLOSED if
|
|
4
|
+
// ANY member fails. This is the named "thing": token-level accessibility as one gate.
|
|
5
|
+
//
|
|
6
|
+
// Members (each a standalone gate, documented in TOKEN-A11Y.md):
|
|
7
|
+
// · palette — palette-gate.mjs CVD-safe / APCA / non-text contrast
|
|
8
|
+
// · pairing — pairing-extractor.mjs derive fg×bg pairings from CSS usage,
|
|
9
|
+
// then feed the palette check (coverage without hand-listing)
|
|
10
|
+
// · typography — typography-gate.mjs line-height / spacing / size / weight
|
|
11
|
+
// · targetSize — target-size-gate.mjs interactive target ≥ 24×24 (2.5.8)
|
|
12
|
+
// · opacity — opacity-contrast-gate effective contrast of translucent fg
|
|
13
|
+
// · likeness — likeness-gate.mjs near-duplicate + confusable categoricals
|
|
14
|
+
//
|
|
15
|
+
// node gates/token-a11y.mjs <token-a11y.json> # (or $TOKEN_A11Y_CONFIG)
|
|
16
|
+
//
|
|
17
|
+
// CONFIG (every member is OPTIONAL — only declared members run):
|
|
18
|
+
// { "tokens": "brand/tokens/tokens.css", // default token map (path or map)
|
|
19
|
+
// "palette": { "pairings":[…], "categorical":[…], "thresholds":{…} } | "pairings.json",
|
|
20
|
+
// "pairing": { "css":["a.css","b.css"], "declared":"pairings.json", "gate":true },
|
|
21
|
+
// "typography":{ "tokens":"…", "body":["body"], "thresholds":{…} } | "typo.json",
|
|
22
|
+
// "targetSize":{ "targets":[…], "thresholds":{…} } | "targets.json",
|
|
23
|
+
// "opacity": { "usages":[…], "opacityTokens":{…} } | "usages.json",
|
|
24
|
+
// "likeness": { "categorical":[…], "thresholds":{…} } | "likeness.json" }
|
|
25
|
+
// A member may set its own `tokens` to override the top-level map.
|
|
26
|
+
// $TOKEN_A11Y_REPORT writes the aggregate JSON report.
|
|
27
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
28
|
+
import { resolve, dirname, isAbsolute, join } from "node:path";
|
|
29
|
+
import { runPaletteGate } from "./palette-gate.mjs";
|
|
30
|
+
import { runTypographyGate } from "./typography-gate.mjs";
|
|
31
|
+
import { runTargetSizeGate } from "./target-size-gate.mjs";
|
|
32
|
+
import { runOpacityContrastGate } from "./opacity-contrast-gate.mjs";
|
|
33
|
+
import { runLikenessGate } from "./likeness-gate.mjs";
|
|
34
|
+
import { runPairingExtractor } from "./pairing-extractor.mjs";
|
|
35
|
+
|
|
36
|
+
const MEMBERS = ["palette", "pairing", "typography", "targetSize", "opacity", "likeness"];
|
|
37
|
+
|
|
38
|
+
/** Resolve a path/config value relative to the config file's directory. */
|
|
39
|
+
function rel(base, p) {
|
|
40
|
+
if (typeof p !== "string") return p;
|
|
41
|
+
return isAbsolute(p) ? p : join(base, p);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run the suite from a parsed config. `base` is the dir paths resolve against.
|
|
46
|
+
* Returns the aggregate report. Pure-ish (I/O only via the member runners).
|
|
47
|
+
*/
|
|
48
|
+
export async function runTokenA11y(config, base = ".") {
|
|
49
|
+
const tokens = (m) => rel(base, m?.tokens ?? config.tokens);
|
|
50
|
+
const members = {};
|
|
51
|
+
const run = async (name, fn) => { try { members[name] = { ...(await fn()), error: null }; } catch (e) { members[name] = { passed: false, error: String(e.message || e) }; } };
|
|
52
|
+
|
|
53
|
+
if (config.palette) {
|
|
54
|
+
const m = typeof config.palette === "string" ? rel(base, config.palette) : config.palette;
|
|
55
|
+
await run("palette", () => runPaletteGate({ tokens: tokens(typeof m === "object" ? m : {}), pairings: m }));
|
|
56
|
+
}
|
|
57
|
+
if (config.pairing) {
|
|
58
|
+
const m = config.pairing;
|
|
59
|
+
await run("pairing", async () => {
|
|
60
|
+
const r = await runPairingExtractor({
|
|
61
|
+
tokens: tokens(m), css: (m.css || []).map((c) => rel(base, c)),
|
|
62
|
+
declared: m.declared ? rel(base, m.declared) : null,
|
|
63
|
+
});
|
|
64
|
+
// Report-only unless `gate:true`.
|
|
65
|
+
return m.gate ? r : { ...r, passed: true, gated: false };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (config.typography) {
|
|
69
|
+
const m = typeof config.typography === "string" ? rel(base, config.typography) : config.typography;
|
|
70
|
+
await run("typography", () => runTypographyGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
|
|
71
|
+
}
|
|
72
|
+
if (config.targetSize) {
|
|
73
|
+
const m = typeof config.targetSize === "string" ? rel(base, config.targetSize) : config.targetSize;
|
|
74
|
+
await run("targetSize", () => runTargetSizeGate({ config: m }));
|
|
75
|
+
}
|
|
76
|
+
if (config.opacity) {
|
|
77
|
+
const m = typeof config.opacity === "string" ? rel(base, config.opacity) : config.opacity;
|
|
78
|
+
await run("opacity", () => runOpacityContrastGate({ tokens: tokens(typeof m === "object" ? m : {}), usages: m }));
|
|
79
|
+
}
|
|
80
|
+
if (config.likeness) {
|
|
81
|
+
const m = typeof config.likeness === "string" ? rel(base, config.likeness) : config.likeness;
|
|
82
|
+
await run("likeness", () => runLikenessGate({ tokens: tokens(typeof m === "object" ? m : {}), config: m }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ran = Object.keys(members);
|
|
86
|
+
const failing = ran.filter((k) => members[k].passed === false);
|
|
87
|
+
return {
|
|
88
|
+
passed: failing.length === 0,
|
|
89
|
+
members: Object.fromEntries(MEMBERS.filter((k) => k in members).map((k) => [k, members[k]])),
|
|
90
|
+
summary: { ran, passed: ran.filter((k) => members[k].passed !== false), failing },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One-line status per member, for the CLI. */
|
|
95
|
+
function memberLine(name, m) {
|
|
96
|
+
if (m.error) return ` ✗ ${name}: error — ${m.error}`;
|
|
97
|
+
const s = m.summary || {};
|
|
98
|
+
const tail =
|
|
99
|
+
name === "palette" ? `${s.pairs} pair(s), ${s.failingPairs} failing, ${s.categoricalCollapses} collapse(s)`
|
|
100
|
+
: name === "pairing" ? `${s.total} pair(s), ${s.failing} failing${m.gated === false ? " (report-only)" : ""}`
|
|
101
|
+
: name === "typography" ? `${s.styles} style(s), ${s.errors} error(s), ${s.warnings} warn(s)`
|
|
102
|
+
: name === "targetSize" ? `${s.targets} target(s), ${s.belowAA} below AA${m.coverage === "none" ? " (none declared)" : ""}`
|
|
103
|
+
: name === "opacity" ? `${s.usages} usage(s), ${s.failing} failing`
|
|
104
|
+
: name === "likeness" ? `${s.nearDuplicates} near-dup(s), ${s.categoricalCollapses} collapse(s)`
|
|
105
|
+
: "";
|
|
106
|
+
return ` ${m.passed ? "✓" : "✗"} ${name}: ${tail}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
111
|
+
const configPath = argv[0] || process.env.TOKEN_A11Y_CONFIG;
|
|
112
|
+
if (!configPath) {
|
|
113
|
+
console.error("✗ token-a11y: usage: token-a11y.mjs <token-a11y.json> (or set $TOKEN_A11Y_CONFIG)");
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
117
|
+
const report = await runTokenA11y(config, dirname(resolve(configPath)));
|
|
118
|
+
if (process.env.TOKEN_A11Y_REPORT) await writeFile(resolve(process.env.TOKEN_A11Y_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
119
|
+
|
|
120
|
+
const lines = Object.entries(report.members).map(([k, m]) => memberLine(k, m));
|
|
121
|
+
const head = `token-a11y: ${report.summary.ran.length} member(s) — ${report.summary.failing.length} failing`;
|
|
122
|
+
if (!report.passed) { console.error(`✗ ${head}`); for (const l of lines) console.error(l); process.exit(1); }
|
|
123
|
+
console.log(`✓ ${head}`); for (const l of lines) console.log(l);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
127
|
+
main().catch((e) => { console.error("✗ token-a11y: error —", e.stack || e.message); process.exit(1); });
|
|
128
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Typography gate — STATIC analysis over a site/brand's TYPE tokens (the
|
|
3
|
+
// font-size / line-height / weight / letter-&-word-&-paragraph-spacing recipes),
|
|
4
|
+
// member of the Token Accessibility suite. Like the palette gate it reasons about
|
|
5
|
+
// the TOKENS themselves — the values a design system ships — not a rendered page,
|
|
6
|
+
// so it answers a question axe cannot: "do these type tokens PERMIT accessible
|
|
7
|
+
// text, or do they bake in a barrier?"
|
|
8
|
+
//
|
|
9
|
+
// Four checks, each mapped to a WCAG success criterion:
|
|
10
|
+
//
|
|
11
|
+
// 1. BODY LINE-HEIGHT ≥ 1.5 (WCAG 2.2 SC 1.4.12 Text Spacing)
|
|
12
|
+
// Body/paragraph styles must author a line-height of at least 1.5× the font
|
|
13
|
+
// size. 1.4.12 is written as a USER-OVERRIDE criterion (no loss of content
|
|
14
|
+
// when the reader sets line-height to 1.5) — a static token check approximates
|
|
15
|
+
// the spirit by requiring the shipped body token not undercut that floor, and
|
|
16
|
+
// by requiring it be expressed in an OVERRIDABLE unit (see check 2).
|
|
17
|
+
//
|
|
18
|
+
// 2. TEXT-SPACING ACHIEVABILITY (WCAG 2.2 SC 1.4.12)
|
|
19
|
+
// The criterion guarantees the reader can set letter-spacing ≥ 0.12em, word-
|
|
20
|
+
// spacing ≥ 0.16em, paragraph-spacing ≥ 2em and line-height ≥ 1.5 with no loss
|
|
21
|
+
// of content. From tokens alone we cannot prove "no loss of content", but we
|
|
22
|
+
// CAN prove the tokens don't PRECLUDE those overrides: spacing/line-height must
|
|
23
|
+
// be expressed in RELATIVE, overridable units (unitless / em / rem / %), never
|
|
24
|
+
// pinned in px (which a user stylesheet without !important cannot scale past a
|
|
25
|
+
// fixed line-box). A px-pinned line-height or letter-spacing on body is flagged.
|
|
26
|
+
//
|
|
27
|
+
// 3. MINIMUM FONT SIZE (WCAG 1.4.4 Resize Text — supporting; readability)
|
|
28
|
+
// Body text should be ≥ ~16px (recommended) and MUST clear a hard floor of
|
|
29
|
+
// ~12px. Below the floor → error; between floor and recommended → warning. Plus
|
|
30
|
+
// a modular-scale sanity check: the size ramp should be monotonic and each step
|
|
31
|
+
// within a sane ratio band (no inversions, no absurd jumps, no exact dups).
|
|
32
|
+
//
|
|
33
|
+
// 4. WEIGHT × SIZE LEGIBILITY (APCA cross-check; WCAG 1.4.3/1.4.6 spirit)
|
|
34
|
+
// Hairline/thin weights (≤ 200) at small sizes render as low-contrast strokes.
|
|
35
|
+
// Flag thin weight below a size threshold. AND cross-link the palette gate:
|
|
36
|
+
// report the APCA Lc a style of this size/weight will REQUIRE wherever it is
|
|
37
|
+
// coloured (thin + small ⇒ higher Lc), via palette-gate's `apcaMinLc`.
|
|
38
|
+
//
|
|
39
|
+
// Zero-dependency. Colour-science / APCA primitives are imported from the sibling
|
|
40
|
+
// palette gate (no duplication). The pure functions are exported for unit testing;
|
|
41
|
+
// the CLI is a thin wrapper that FAILS CLOSED (exit 1) on any error-severity finding.
|
|
42
|
+
//
|
|
43
|
+
// node gates/typography-gate.mjs <type-tokens.(json|css)> [config.json]
|
|
44
|
+
//
|
|
45
|
+
// INPUTS the consumer supplies (nothing about any one brand is hard-coded):
|
|
46
|
+
// argv[2] / $TYPO_TOKENS a DTCG `tokens.json` (its `$type:"typography"` recipes,
|
|
47
|
+
// with `{size.*}` aliases resolved) OR a `tokens.css`
|
|
48
|
+
// (`.bs-text-*{ font-size; line-height; … }` recipe classes).
|
|
49
|
+
// argv[3] / $TYPO_CONFIG a `config.json` declaring which styles are BODY text
|
|
50
|
+
// (`{ "body":["body"], "thresholds":{…}, "styles":[…] }`).
|
|
51
|
+
// Only the consumer knows which token is the body recipe;
|
|
52
|
+
// line-height & min-size rules apply to those. `styles[]`
|
|
53
|
+
// can inline extra styles (for a CSS-less consumer) or
|
|
54
|
+
// override parsed ones.
|
|
55
|
+
// $TYPO_REPORT path to write the machine-readable JSON report.
|
|
56
|
+
//
|
|
57
|
+
// Thresholds are config-driven (`config.json` `thresholds` ⊕ env) and fail closed:
|
|
58
|
+
// $TYPO_MIN_BODY_LINE_HEIGHT (default 1.5) SC 1.4.12 body line-height floor
|
|
59
|
+
// $TYPO_REC_BODY_PX (default 16) recommended body min (warn below)
|
|
60
|
+
// $TYPO_MIN_BODY_PX (default 12) hard body min (error below)
|
|
61
|
+
// $TYPO_MIN_LETTER_EM (default 0.12) 1.4.12 letter-spacing override target
|
|
62
|
+
// $TYPO_MIN_WORD_EM (default 0.16) 1.4.12 word-spacing override target
|
|
63
|
+
// $TYPO_MIN_PARA_EM (default 2) 1.4.12 paragraph-spacing override target
|
|
64
|
+
// $TYPO_THIN_WEIGHT (default 200) weight ≤ this is "thin/hairline"
|
|
65
|
+
// $TYPO_THIN_MIN_PX (default 24) thin weight below this px → error
|
|
66
|
+
// $TYPO_SCALE_MIN_RATIO (default 1.05) min step ratio in the size ramp
|
|
67
|
+
// $TYPO_SCALE_MAX_RATIO (default 2.4) max step ratio in the size ramp
|
|
68
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
69
|
+
import { resolve } from "node:path";
|
|
70
|
+
import { apcaMinLc } from "./palette-gate.mjs";
|
|
71
|
+
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// 1. Value parsing — dimensions, line-heights, spacings (pure)
|
|
74
|
+
// Refs: CSS Values & Units (px/em/rem/%); a `rem`/`em` is resolved against the
|
|
75
|
+
// document/element font size (we assume a 16px root, the browser default).
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const ROOT_PX = 16; // CSS initial root font-size (browser default).
|
|
79
|
+
|
|
80
|
+
/** A length string → { px, unit, value } or null. Supports px / rem / em / pt / %. */
|
|
81
|
+
export function parseLength(value, contextPx = ROOT_PX) {
|
|
82
|
+
if (value == null) return null;
|
|
83
|
+
if (typeof value === "number") return { px: value, unit: "px", value };
|
|
84
|
+
const m = /^(-?[0-9]*\.?[0-9]+)\s*(px|rem|em|pt|%)?$/.exec(String(value).trim());
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
const n = parseFloat(m[1]);
|
|
87
|
+
const unit = m[2] || "px";
|
|
88
|
+
let px;
|
|
89
|
+
if (unit === "px") px = n;
|
|
90
|
+
else if (unit === "rem") px = n * ROOT_PX;
|
|
91
|
+
else if (unit === "em") px = n * contextPx;
|
|
92
|
+
else if (unit === "pt") px = (n * 96) / 72;
|
|
93
|
+
else if (unit === "%") px = (n / 100) * contextPx;
|
|
94
|
+
else px = n;
|
|
95
|
+
return { px, unit, value: n };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A line-height (unitless | px | em | rem | %) → its RATIO to the font size. */
|
|
99
|
+
export function parseLineHeight(value, fontSizePx) {
|
|
100
|
+
if (value == null) return null;
|
|
101
|
+
const s = String(value).trim();
|
|
102
|
+
if (/^-?[0-9]*\.?[0-9]+$/.test(s)) return { ratio: parseFloat(s), unit: "unitless", overridable: true };
|
|
103
|
+
const len = parseLength(s, fontSizePx);
|
|
104
|
+
if (!len) return null;
|
|
105
|
+
const overridable = len.unit !== "px" && len.unit !== "pt"; // px/pt pin a fixed box
|
|
106
|
+
return { ratio: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A letter/word/paragraph spacing → em relative to font size. */
|
|
110
|
+
export function parseSpacingEm(value, fontSizePx) {
|
|
111
|
+
if (value == null) return null;
|
|
112
|
+
const s = String(value).trim();
|
|
113
|
+
if (s === "normal" || s === "0") return { em: 0, unit: s === "0" ? "unitless" : "normal", overridable: true };
|
|
114
|
+
const len = parseLength(s, fontSizePx);
|
|
115
|
+
if (!len) return null;
|
|
116
|
+
if (len.unit === "em") return { em: len.value, unit: "em", overridable: true };
|
|
117
|
+
if (len.unit === "rem" || len.unit === "%") return { em: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable: true };
|
|
118
|
+
// px/pt: a fixed spacing the reader's relative override can't supersede cleanly.
|
|
119
|
+
return { em: fontSizePx ? len.px / fontSizePx : null, unit: len.unit, overridable: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
// 2. Token loading — DTCG `$type:"typography"` recipes, or `tokens.css` classes
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Flatten DTCG `size`/`dimension` tokens → { "size.text-body": "15px", … } (aliases kept). */
|
|
127
|
+
function flattenDimensions(json) {
|
|
128
|
+
const out = {};
|
|
129
|
+
const walk = (node, path) => {
|
|
130
|
+
if (node && typeof node === "object") {
|
|
131
|
+
if (typeof node.$value === "string" && (node.$type === "dimension" || /px|rem|em|pt|%$/.test(node.$value))) {
|
|
132
|
+
out[path.join(".")] = node.$value;
|
|
133
|
+
}
|
|
134
|
+
for (const [k, v] of Object.entries(node)) if (!k.startsWith("$")) walk(v, [...path, k]);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
walk(json, []);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Resolve a `{dotted.alias}` (or literal) against a flat map. */
|
|
142
|
+
function resolveAlias(v, flat, seen = new Set()) {
|
|
143
|
+
const m = /^\{(.+)\}$/.exec(String(v).trim());
|
|
144
|
+
if (!m) return v;
|
|
145
|
+
if (seen.has(m[1]) || !(m[1] in flat)) return v;
|
|
146
|
+
return resolveAlias(flat[m[1]], flat, new Set([...seen, m[1]]));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse a DTCG tokens.json into normalized type styles:
|
|
151
|
+
* { name → { fontSizePx, lineHeight{ratio,unit,overridable}, fontWeight,
|
|
152
|
+
* letterSpacing{em,unit,overridable}, wordSpacing?, paragraphSpacing?,
|
|
153
|
+
* fontFamily, raw } }
|
|
154
|
+
* `$type:"typography"` recipes are read from any tier (brand uses a `text` tier).
|
|
155
|
+
*/
|
|
156
|
+
export function typesFromDTCG(json) {
|
|
157
|
+
const dims = flattenDimensions(json);
|
|
158
|
+
const styles = {};
|
|
159
|
+
const walk = (node, path) => {
|
|
160
|
+
if (node && typeof node === "object") {
|
|
161
|
+
if (node.$type === "typography" && node.$value && typeof node.$value === "object") {
|
|
162
|
+
styles[path.join(".")] = normalizeStyle(node.$value, dims);
|
|
163
|
+
}
|
|
164
|
+
for (const [k, v] of Object.entries(node)) if (!k.startsWith("$")) walk(v, [...path, k]);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
walk(json, []);
|
|
168
|
+
return styles;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeStyle(v, dims = {}) {
|
|
172
|
+
const fsRaw = resolveAlias(v.fontSize, dims);
|
|
173
|
+
const fontSizePx = parseLength(fsRaw)?.px ?? null;
|
|
174
|
+
return {
|
|
175
|
+
fontSizePx,
|
|
176
|
+
fontSizeRaw: fsRaw,
|
|
177
|
+
lineHeight: v.lineHeight != null ? parseLineHeight(v.lineHeight, fontSizePx) : null,
|
|
178
|
+
fontWeight: v.fontWeight != null ? Number(v.fontWeight) : null,
|
|
179
|
+
letterSpacing: v.letterSpacing != null ? parseSpacingEm(v.letterSpacing, fontSizePx) : null,
|
|
180
|
+
wordSpacing: v.wordSpacing != null ? parseSpacingEm(v.wordSpacing, fontSizePx) : null,
|
|
181
|
+
paragraphSpacing: v.paragraphSpacing != null ? parseSpacingEm(v.paragraphSpacing, fontSizePx) : null,
|
|
182
|
+
fontFamily: v.fontFamily ?? null,
|
|
183
|
+
raw: v,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Parse a tokens.css of `.name { font-size; line-height; font-weight; letter-spacing }` recipes. */
|
|
188
|
+
export function typesFromCSS(css) {
|
|
189
|
+
const styles = {};
|
|
190
|
+
const re = /\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g;
|
|
191
|
+
let m;
|
|
192
|
+
while ((m = re.exec(css))) {
|
|
193
|
+
const name = m[1], body = m[2];
|
|
194
|
+
const decl = {};
|
|
195
|
+
for (const d of body.split(";")) {
|
|
196
|
+
const i = d.indexOf(":");
|
|
197
|
+
if (i < 0) continue;
|
|
198
|
+
decl[d.slice(0, i).trim().toLowerCase()] = d.slice(i + 1).trim();
|
|
199
|
+
}
|
|
200
|
+
if (!("font-size" in decl) && !("line-height" in decl)) continue;
|
|
201
|
+
styles[name] = normalizeStyle({
|
|
202
|
+
fontSize: decl["font-size"],
|
|
203
|
+
lineHeight: decl["line-height"],
|
|
204
|
+
fontWeight: decl["font-weight"],
|
|
205
|
+
letterSpacing: decl["letter-spacing"],
|
|
206
|
+
wordSpacing: decl["word-spacing"],
|
|
207
|
+
fontFamily: decl["font-family"],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return styles;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function loadTypeTokens(path) {
|
|
214
|
+
const raw = await readFile(path, "utf8");
|
|
215
|
+
if (path.endsWith(".json")) return typesFromDTCG(JSON.parse(raw));
|
|
216
|
+
if (path.endsWith(".css")) return typesFromCSS(raw);
|
|
217
|
+
try { return typesFromDTCG(JSON.parse(raw)); } catch { return typesFromCSS(raw); }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
// 3. Thresholds + per-style evaluation (pure)
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export const DEFAULT_THRESHOLDS = {
|
|
225
|
+
minBodyLineHeight: 1.5, // SC 1.4.12
|
|
226
|
+
recBodyPx: 16, // recommended body min (warn below)
|
|
227
|
+
minBodyPx: 12, // hard body min (error below)
|
|
228
|
+
minLetterEm: 0.12, // SC 1.4.12 target
|
|
229
|
+
minWordEm: 0.16, // SC 1.4.12 target
|
|
230
|
+
minParaEm: 2, // SC 1.4.12 target
|
|
231
|
+
thinWeight: 200, // ≤ this is hairline/thin
|
|
232
|
+
thinMinPx: 24, // thin weight below this px → error
|
|
233
|
+
scaleMinRatio: 1.05, // size-ramp step floor
|
|
234
|
+
scaleMaxRatio: 2.4, // size-ramp step ceiling
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const round2 = (n) => (n == null ? null : Math.round(n * 100) / 100);
|
|
238
|
+
|
|
239
|
+
/** Evaluate ONE type style. `isBody` drives which checks are hard. Pure. */
|
|
240
|
+
export function evaluateStyle(name, style, isBody, thresholds = DEFAULT_THRESHOLDS) {
|
|
241
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
242
|
+
const findings = [];
|
|
243
|
+
const add = (severity, sc, msg, data) => findings.push({ severity, sc, msg, ...data });
|
|
244
|
+
const px = style.fontSizePx;
|
|
245
|
+
|
|
246
|
+
// 1. Body line-height ≥ 1.5 (SC 1.4.12).
|
|
247
|
+
if (isBody && style.lineHeight) {
|
|
248
|
+
if (style.lineHeight.ratio != null && style.lineHeight.ratio + 1e-9 < t.minBodyLineHeight) {
|
|
249
|
+
add("error", "1.4.12", `body line-height ${round2(style.lineHeight.ratio)} < ${t.minBodyLineHeight}`, { lineHeight: round2(style.lineHeight.ratio) });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 2. Text-spacing achievability — relative, overridable units (SC 1.4.12).
|
|
254
|
+
if (style.lineHeight && style.lineHeight.overridable === false) {
|
|
255
|
+
add(isBody ? "error" : "warn", "1.4.12", `line-height pinned in ${style.lineHeight.unit} (not user-overridable)`, { unit: style.lineHeight.unit });
|
|
256
|
+
}
|
|
257
|
+
for (const [prop, sp] of [["letterSpacing", style.letterSpacing], ["wordSpacing", style.wordSpacing], ["paragraphSpacing", style.paragraphSpacing]]) {
|
|
258
|
+
if (sp && sp.overridable === false) {
|
|
259
|
+
add(isBody ? "error" : "warn", "1.4.12", `${prop} pinned in ${sp.unit} (not user-overridable, can clip 1.4.12 spacing)`, { prop, unit: sp.unit });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 3. Minimum font size (body) — hard floor + recommended (SC 1.4.4 / readability).
|
|
264
|
+
if (isBody && px != null) {
|
|
265
|
+
if (px + 1e-9 < t.minBodyPx) add("error", "1.4.4", `body font-size ${px}px < hard floor ${t.minBodyPx}px`, { px });
|
|
266
|
+
else if (px + 1e-9 < t.recBodyPx) add("warn", "1.4.4", `body font-size ${px}px < recommended ${t.recBodyPx}px`, { px });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 4. Weight × size legibility + APCA cross-check (SC 1.4.3/1.4.6 spirit; APCA).
|
|
270
|
+
if (px != null && style.fontWeight != null && style.fontWeight <= t.thinWeight && px < t.thinMinPx) {
|
|
271
|
+
add("error", "1.4.8", `thin weight ${style.fontWeight} at ${px}px (< ${t.thinMinPx}px) — hairline strokes lose legibility`, { weight: style.fontWeight, px });
|
|
272
|
+
}
|
|
273
|
+
const requiredLc = px != null ? apcaMinLc({ sizePx: px, weight: style.fontWeight ?? 400 }) : null;
|
|
274
|
+
|
|
275
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
276
|
+
return {
|
|
277
|
+
name,
|
|
278
|
+
isBody,
|
|
279
|
+
fontSizePx: px,
|
|
280
|
+
fontWeight: style.fontWeight ?? null,
|
|
281
|
+
lineHeight: style.lineHeight ? round2(style.lineHeight.ratio) : null,
|
|
282
|
+
letterSpacingEm: style.letterSpacing ? round2(style.letterSpacing.em) : null,
|
|
283
|
+
requiredApcaLc: requiredLc, // cross-link: wherever this style is coloured, palette gate must clear this Lc
|
|
284
|
+
findings,
|
|
285
|
+
passed: errors === 0,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Modular-scale sanity over the distinct font sizes used. Pure. */
|
|
290
|
+
export function evaluateScale(sizesPx, thresholds = DEFAULT_THRESHOLDS) {
|
|
291
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
292
|
+
const sizes = [...new Set(sizesPx.filter((n) => typeof n === "number"))].sort((a, b) => a - b);
|
|
293
|
+
const findings = [];
|
|
294
|
+
for (let i = 1; i < sizes.length; i++) {
|
|
295
|
+
const ratio = sizes[i] / sizes[i - 1];
|
|
296
|
+
if (ratio + 1e-9 < t.scaleMinRatio) findings.push({ severity: "warn", msg: `near-duplicate scale step ${sizes[i - 1]}px→${sizes[i]}px (ratio ${round2(ratio)} < ${t.scaleMinRatio})`, from: sizes[i - 1], to: sizes[i], ratio: round2(ratio) });
|
|
297
|
+
else if (ratio - 1e-9 > t.scaleMaxRatio) findings.push({ severity: "warn", msg: `large scale jump ${sizes[i - 1]}px→${sizes[i]}px (ratio ${round2(ratio)} > ${t.scaleMaxRatio})`, from: sizes[i - 1], to: sizes[i], ratio: round2(ratio) });
|
|
298
|
+
}
|
|
299
|
+
return { sizes, steps: Math.max(0, sizes.length - 1), findings };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Whole-typography evaluation → fail-closed report. Pure. */
|
|
303
|
+
export function evaluateTypography({ styles = {}, body = [], thresholds = DEFAULT_THRESHOLDS } = {}) {
|
|
304
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
305
|
+
const bodySet = new Set(body);
|
|
306
|
+
const results = Object.entries(styles).map(([name, s]) => evaluateStyle(name, s, bodySet.has(name) || bodySet.has(name.split(".").pop()), t));
|
|
307
|
+
const scale = evaluateScale(Object.values(styles).map((s) => s.fontSizePx), t);
|
|
308
|
+
|
|
309
|
+
const allFindings = [...results.flatMap((r) => r.findings.map((f) => ({ style: r.name, ...f }))), ...scale.findings.map((f) => ({ style: "<scale>", ...f }))];
|
|
310
|
+
const errors = allFindings.filter((f) => f.severity === "error");
|
|
311
|
+
const warns = allFindings.filter((f) => f.severity === "warn");
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
passed: errors.length === 0,
|
|
315
|
+
thresholds: t,
|
|
316
|
+
summary: {
|
|
317
|
+
styles: results.length,
|
|
318
|
+
bodyStyles: results.filter((r) => r.isBody).length,
|
|
319
|
+
errors: errors.length,
|
|
320
|
+
warnings: warns.length,
|
|
321
|
+
},
|
|
322
|
+
styles: results,
|
|
323
|
+
scale,
|
|
324
|
+
findings: allFindings,
|
|
325
|
+
// Envelope a future lone `typography.*` criterion can consume.
|
|
326
|
+
typography: {
|
|
327
|
+
bodyLineHeight: !errors.some((f) => f.sc === "1.4.12" && /line-height/.test(f.msg)),
|
|
328
|
+
textSpacingAchievable: !errors.some((f) => f.sc === "1.4.12" && /pinned/.test(f.msg)),
|
|
329
|
+
minFontSize: !errors.some((f) => f.sc === "1.4.4"),
|
|
330
|
+
weightLegibility: !errors.some((f) => f.sc === "1.4.8"),
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Full run: load tokens + config → evaluate. Exposed for tests. */
|
|
336
|
+
export async function runTypographyGate({ tokens, config = {}, thresholds = {} }) {
|
|
337
|
+
const parsed = typeof tokens === "string" ? await loadTypeTokens(tokens) : tokens;
|
|
338
|
+
const cfg = typeof config === "string" ? JSON.parse(await readFile(config, "utf8")) : config;
|
|
339
|
+
const styles = { ...parsed };
|
|
340
|
+
for (const s of cfg.styles || []) styles[s.name] = normalizeStyle(s);
|
|
341
|
+
return evaluateTypography({
|
|
342
|
+
styles,
|
|
343
|
+
body: cfg.body || [],
|
|
344
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}), ...thresholds },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
349
|
+
// 4. CLI
|
|
350
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function envThresholds() {
|
|
353
|
+
const t = {};
|
|
354
|
+
const num = (e) => (process.env[e] != null ? Number(process.env[e]) : undefined);
|
|
355
|
+
const set = (k, e) => { const v = num(e); if (v != null && !Number.isNaN(v)) t[k] = v; };
|
|
356
|
+
set("minBodyLineHeight", "TYPO_MIN_BODY_LINE_HEIGHT");
|
|
357
|
+
set("recBodyPx", "TYPO_REC_BODY_PX");
|
|
358
|
+
set("minBodyPx", "TYPO_MIN_BODY_PX");
|
|
359
|
+
set("minLetterEm", "TYPO_MIN_LETTER_EM");
|
|
360
|
+
set("minWordEm", "TYPO_MIN_WORD_EM");
|
|
361
|
+
set("minParaEm", "TYPO_MIN_PARA_EM");
|
|
362
|
+
set("thinWeight", "TYPO_THIN_WEIGHT");
|
|
363
|
+
set("thinMinPx", "TYPO_THIN_MIN_PX");
|
|
364
|
+
set("scaleMinRatio", "TYPO_SCALE_MIN_RATIO");
|
|
365
|
+
set("scaleMaxRatio", "TYPO_SCALE_MAX_RATIO");
|
|
366
|
+
return t;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function main() {
|
|
370
|
+
const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
371
|
+
const tokens = argv[0] || process.env.TYPO_TOKENS;
|
|
372
|
+
const config = argv[1] || process.env.TYPO_CONFIG;
|
|
373
|
+
if (!tokens) {
|
|
374
|
+
console.error("✗ typography-gate: usage: typography-gate.mjs <type-tokens.(json|css)> [config.json]");
|
|
375
|
+
console.error(" (or set $TYPO_TOKENS and $TYPO_CONFIG)");
|
|
376
|
+
process.exit(2);
|
|
377
|
+
}
|
|
378
|
+
const cfg = config ? JSON.parse(await readFile(config, "utf8")) : {};
|
|
379
|
+
const report = await runTypographyGate({ tokens, config: cfg, thresholds: envThresholds() });
|
|
380
|
+
if (process.env.TYPO_REPORT) await writeFile(resolve(process.env.TYPO_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
381
|
+
|
|
382
|
+
const s = report.summary;
|
|
383
|
+
const line = `typography-gate: ${s.styles} style(s) (${s.bodyStyles} body) — ${s.errors} error(s), ${s.warnings} warning(s)`;
|
|
384
|
+
const fmt = (f) => ` · [${f.severity}] ${f.style}${f.sc ? ` (SC ${f.sc})` : ""}: ${f.msg}`;
|
|
385
|
+
if (!report.passed) {
|
|
386
|
+
console.error(`✗ ${line}`);
|
|
387
|
+
for (const f of report.findings) console.error(fmt(f));
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
console.log(`✓ ${line}`);
|
|
391
|
+
for (const f of report.findings) console.log(fmt(f)); // warnings only when passing
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
395
|
+
main().catch((e) => { console.error("✗ typography-gate: error —", e.stack || e.message); process.exit(1); });
|
|
396
|
+
}
|
|
@@ -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
|
+
}
|