@getvitops/utils 8.2.0-alpha.0 → 8.3.0-alpha.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/CHANGELOG.md +399 -23
- package/dist/{adc-D6aw2qgq.mjs → adc-BiW9BzqA.mjs} +2 -3
- package/dist/{adc-D6aw2qgq.mjs.map → adc-BiW9BzqA.mjs.map} +1 -1
- package/dist/{adc-BoEoogyo.d.mts → adc-DvX3-P7a.d.mts} +2 -4
- package/dist/adc-DvX3-P7a.d.mts.map +1 -0
- package/dist/ads/index.d.mts +37 -57
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +21 -10
- package/dist/ads/index.mjs.map +1 -1
- package/dist/audit/index.d.mts +180 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +509 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/color/index.d.mts +7 -5
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +10 -5
- package/dist/color/index.mjs.map +1 -1
- package/dist/content.d.mts +78 -0
- package/dist/content.d.mts.map +1 -0
- package/dist/content.mjs +45 -0
- package/dist/content.mjs.map +1 -0
- package/dist/crm/index.d.mts +5 -87
- package/dist/crm/index.d.mts.map +1 -1
- package/dist/crm/index.mjs +10 -19
- package/dist/crm/index.mjs.map +1 -1
- package/dist/email/index.d.mts +1 -11
- package/dist/email/index.d.mts.map +1 -1
- package/dist/email/index.mjs +6 -10
- package/dist/email/index.mjs.map +1 -1
- package/dist/favicon.d.mts +0 -2
- package/dist/favicon.d.mts.map +1 -1
- package/dist/favicon.mjs +1 -2
- package/dist/git/index.d.mts +0 -2
- package/dist/git/index.d.mts.map +1 -1
- package/dist/git/index.mjs +1 -2
- package/dist/hosting/index.d.mts +0 -8
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +4 -7
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/{identity-BoRddTx3.mjs → identity-C4yInICW.mjs} +2 -3
- package/dist/{identity-BoRddTx3.mjs.map → identity-C4yInICW.mjs.map} +1 -1
- package/dist/index.d.mts +2 -88
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +36 -52
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +3 -42
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +8 -13
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/{json-store-30CFCbZZ.mjs → json-store-VJKEaoVX.mjs} +2 -3
- package/dist/{json-store-30CFCbZZ.mjs.map → json-store-VJKEaoVX.mjs.map} +1 -1
- package/dist/media/index.d.mts +0 -12
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +7 -13
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +64 -44
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +138 -11
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +26 -6
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +92 -5
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/{plan-BLKikoOG.mjs → plan-B9USuc17.mjs} +3 -4
- package/dist/{plan-BLKikoOG.mjs.map → plan-B9USuc17.mjs.map} +1 -1
- package/dist/{plan-Hq8Pb0Uw.d.mts → plan-CH3Wt5ZV.d.mts} +8 -30
- package/dist/plan-CH3Wt5ZV.d.mts.map +1 -0
- package/dist/plan-CddqsmJJ.d.mts +82 -0
- package/dist/plan-CddqsmJJ.d.mts.map +1 -0
- package/dist/plan-DpHv6NO8.mjs +93 -0
- package/dist/plan-DpHv6NO8.mjs.map +1 -0
- package/dist/{plan-format-A18SvJ7C.mjs → plan-format-CIz6Feg2.mjs} +2 -3
- package/dist/{plan-format-A18SvJ7C.mjs.map → plan-format-CIz6Feg2.mjs.map} +1 -1
- package/dist/{platform-0nlOIy8h.mjs → platform-Cb_RtsF1.mjs} +3 -5
- package/dist/{platform-0nlOIy8h.mjs.map → platform-Cb_RtsF1.mjs.map} +1 -1
- package/dist/reviews/index.d.mts +4 -120
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/reviews/index.mjs +6 -99
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/reviews/select.d.mts +2 -0
- package/dist/reviews/select.mjs +3 -0
- package/dist/schema/index.d.mts +0 -12
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/schema/index.mjs +6 -12
- package/dist/schema/index.mjs.map +1 -1
- package/dist/security/index.d.mts +0 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/security/index.mjs +1 -2
- package/dist/{token-DmmoQ7um.d.mts → token-CHR1O4w5.d.mts} +3 -3
- package/dist/token-CHR1O4w5.d.mts.map +1 -0
- package/dist/{token-DkKC5m23.mjs → token-CJSl9eIS.mjs} +33 -14
- package/dist/token-CJSl9eIS.mjs.map +1 -0
- package/dist/tracking/index.d.mts +1 -9
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +3 -4
- package/dist/turnstile/index.d.mts +105 -0
- package/dist/turnstile/index.d.mts.map +1 -0
- package/dist/turnstile/index.mjs +281 -0
- package/dist/turnstile/index.mjs.map +1 -0
- package/dist/{types-7ELci8RQ.d.mts → types-oac2P5or.d.mts} +3 -23
- package/dist/types-oac2P5or.d.mts.map +1 -0
- package/dist/url/index.d.mts +18 -0
- package/dist/url/index.d.mts.map +1 -0
- package/dist/url/index.mjs +34 -0
- package/dist/url/index.mjs.map +1 -0
- package/package.json +43 -2
- package/dist/adc-BoEoogyo.d.mts.map +0 -1
- package/dist/cloudflare-BiK61Oq4.d.mts +0 -27
- package/dist/cloudflare-BiK61Oq4.d.mts.map +0 -1
- package/dist/cloudflare-CD8vwrgj.mjs +0 -92
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/plan-Hq8Pb0Uw.d.mts.map +0 -1
- package/dist/token-DkKC5m23.mjs.map +0 -1
- package/dist/token-DmmoQ7um.d.mts.map +0 -1
- package/dist/types-7ELci8RQ.d.mts.map +0 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
|
|
4
|
+
// src/audit/plan.ts
|
|
5
|
+
const DEFAULT_BUDGETS = {
|
|
6
|
+
lcpMs: 2500,
|
|
7
|
+
cls: .1,
|
|
8
|
+
tbtMs: 200,
|
|
9
|
+
blockingImpacts: ["serious", "critical"]
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* `wcag2a`/`wcag2aa` are what AODA (WCAG 2.0 AA) asks for; `wcag21a`/`wcag21aa` add the
|
|
13
|
+
* 2.1 criteria we target. `wcag21aa` alone covers only the *new AA* criteria, not the
|
|
14
|
+
* new level-A ones, so both 2.1 tags are listed.
|
|
15
|
+
*/
|
|
16
|
+
const AXE_TAGS = [
|
|
17
|
+
"wcag2a",
|
|
18
|
+
"wcag2aa",
|
|
19
|
+
"wcag21a",
|
|
20
|
+
"wcag21aa"
|
|
21
|
+
];
|
|
22
|
+
/** Pages tried on every run. `/` is required; the rest are skipped when they 404. */
|
|
23
|
+
const DEFAULT_PAGES = [
|
|
24
|
+
"/",
|
|
25
|
+
"/privacy",
|
|
26
|
+
"/terms",
|
|
27
|
+
"/cookies",
|
|
28
|
+
"/contact"
|
|
29
|
+
];
|
|
30
|
+
const DISCLAIMER = "A pass means no automated violations were found by axe-core and the performance budgets held. It is NOT an AODA / WCAG conformance certification: automated tools detect only a minority of WCAG failures. The manual checks below are still required.";
|
|
31
|
+
const MANUAL_CHECKS = [
|
|
32
|
+
"Keyboard order: tab through every page; order matches the visual and reading order, nothing is skipped or trapped.",
|
|
33
|
+
"Focus visibility: a clear focus indicator on every interactive element, including in forced-colors / high-contrast mode.",
|
|
34
|
+
"Screen reader pass: headings, landmarks, forms, dropdowns and the drawer menu read sensibly (NVDA or JAWS on Windows, VoiceOver on macOS/iOS).",
|
|
35
|
+
"Reduced motion: with prefers-reduced-motion on, no essential information depends on animation and nothing auto-plays.",
|
|
36
|
+
"200% zoom (and 400% reflow): no loss of content or function, no horizontal scrolling for text.",
|
|
37
|
+
"Colour and contrast of imagery, gradients and text over images (axe cannot evaluate these).",
|
|
38
|
+
"Meaningful alternative text, captions and transcripts for media."
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Normalise to a site-relative path, or `undefined` for anything that is not an
|
|
42
|
+
* auditable page of this site: external URLs, hash-only links, `mailto:`/`tel:`,
|
|
43
|
+
* protocol-relative URLs, file downloads (anything with an extension) and the CMS
|
|
44
|
+
* admin. Query and hash are dropped; a trailing slash is too (except the root).
|
|
45
|
+
*/
|
|
46
|
+
function normalizePath(url) {
|
|
47
|
+
if (!url) return void 0;
|
|
48
|
+
const u = url.trim();
|
|
49
|
+
if (!u.startsWith("/") || u.startsWith("//")) return void 0;
|
|
50
|
+
const bare = u.split(/[?#]/)[0] ?? "";
|
|
51
|
+
const path = bare.length > 1 ? bare.replace(/\/+$/, "") : bare;
|
|
52
|
+
if (!path) return void 0;
|
|
53
|
+
if (/\.[a-z0-9]{1,6}$/i.test(path)) return void 0;
|
|
54
|
+
if (path === "/_emdash" || path.startsWith("/_emdash/")) return void 0;
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
function walk(items, out) {
|
|
58
|
+
for (const item of items ?? []) {
|
|
59
|
+
const p = normalizePath(item.url);
|
|
60
|
+
if (p) out.push(p);
|
|
61
|
+
walk(item.children, out);
|
|
62
|
+
walk(item.items, out);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Is this one of the menus whose links are pages worth auditing? */
|
|
66
|
+
function isAuditMenu(name) {
|
|
67
|
+
return !!name && (name.startsWith("primary") || name.startsWith("footer"));
|
|
68
|
+
}
|
|
69
|
+
/** Paths from the `primary*` / `footer*` menus (`footer-legal`, `primary-mobile`, …). */
|
|
70
|
+
function pathsFromMenus(menus) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const m of menus) if (isAuditMenu(m.name)) walk(m.items, out);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Same, from an EmDash seed file's parsed JSON (`{ menus: [...] }`). */
|
|
76
|
+
function pathsFromSeed(seed) {
|
|
77
|
+
const menus = seed?.menus;
|
|
78
|
+
return Array.isArray(menus) ? pathsFromMenus(menus) : [];
|
|
79
|
+
}
|
|
80
|
+
/** The pages to try, `/` always first, unique, in a stable order. */
|
|
81
|
+
function planPages(input = {}) {
|
|
82
|
+
const seen = /* @__PURE__ */ new Set();
|
|
83
|
+
const out = [];
|
|
84
|
+
const add = (p) => {
|
|
85
|
+
if (p && !seen.has(p)) {
|
|
86
|
+
seen.add(p);
|
|
87
|
+
out.push(p);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
add("/");
|
|
91
|
+
if (input.includeDefaults !== false) for (const p of DEFAULT_PAGES) add(p);
|
|
92
|
+
for (const p of input.menuPaths ?? []) add(p);
|
|
93
|
+
for (const p of input.extra ?? []) add(normalizePath(p));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* What an HTTP status means for a candidate page. 404/410 → skipped (a single-page
|
|
98
|
+
* site has no `/contact`, and that is fine) — except `/`, which must exist. Any other
|
|
99
|
+
* non-2xx is an error: a 500 on `/privacy` is a defect, not an absent page.
|
|
100
|
+
*/
|
|
101
|
+
function classifyStatus(path, httpStatus) {
|
|
102
|
+
if (httpStatus === void 0) return {
|
|
103
|
+
status: "error",
|
|
104
|
+
reason: "no response"
|
|
105
|
+
};
|
|
106
|
+
if (httpStatus >= 200 && httpStatus < 300) return { status: "audited" };
|
|
107
|
+
if (httpStatus === 404 || httpStatus === 410) return path === "/" ? {
|
|
108
|
+
status: "error",
|
|
109
|
+
reason: `/ returned ${httpStatus} — the home page must exist`
|
|
110
|
+
} : {
|
|
111
|
+
status: "skipped",
|
|
112
|
+
reason: `${httpStatus} — page does not exist on this site`
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
status: "error",
|
|
116
|
+
reason: `HTTP ${httpStatus}`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const IMPACT_RANK = {
|
|
120
|
+
minor: 0,
|
|
121
|
+
moderate: 1,
|
|
122
|
+
serious: 2,
|
|
123
|
+
critical: 3
|
|
124
|
+
};
|
|
125
|
+
/** axe can report a null impact; treat it as `moderate`, never silently drop it. */
|
|
126
|
+
function normalizeImpact(impact) {
|
|
127
|
+
return impact && impact in IMPACT_RANK ? impact : "moderate";
|
|
128
|
+
}
|
|
129
|
+
/** `serious,critical` → impacts, rejecting typos so a budget cannot silently be empty. */
|
|
130
|
+
function parseImpacts(list) {
|
|
131
|
+
const parts = list.split(",").map((s) => s.trim()).filter(Boolean);
|
|
132
|
+
for (const p of parts) if (!(p in IMPACT_RANK)) throw new Error(`unknown axe impact "${p}" (expected: minor | moderate | serious | critical)`);
|
|
133
|
+
if (parts.length === 0) throw new Error("at least one axe impact is required");
|
|
134
|
+
return parts;
|
|
135
|
+
}
|
|
136
|
+
/** Everything that makes one audited page fail. Empty = the page passes. */
|
|
137
|
+
function evaluatePage(metrics, violations, budgets) {
|
|
138
|
+
const failures = [];
|
|
139
|
+
const check = (kind, value, limit, label, unit) => {
|
|
140
|
+
if (value === void 0 || Number.isNaN(value)) failures.push({
|
|
141
|
+
kind: "metric-missing",
|
|
142
|
+
message: `${label} could not be measured`
|
|
143
|
+
});
|
|
144
|
+
else if (value > limit) failures.push({
|
|
145
|
+
kind,
|
|
146
|
+
message: `${label} ${fmt(value, unit)} exceeds the budget of ${fmt(limit, unit)}`
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
check("lcp", metrics?.lcpMs, budgets.lcpMs, "LCP", "ms");
|
|
150
|
+
check("cls", metrics?.cls, budgets.cls, "CLS", "");
|
|
151
|
+
check("tbt", metrics?.tbtMs, budgets.tbtMs, "TBT", "ms");
|
|
152
|
+
for (const v of violations ?? []) if (budgets.blockingImpacts.includes(v.impact)) failures.push({
|
|
153
|
+
kind: "axe",
|
|
154
|
+
message: `axe ${v.impact}: ${v.id} — ${v.help} (${v.nodeCount} node${v.nodeCount === 1 ? "" : "s"})`
|
|
155
|
+
});
|
|
156
|
+
return failures;
|
|
157
|
+
}
|
|
158
|
+
function fmt(n, unit) {
|
|
159
|
+
const v = unit === "ms" ? Math.round(n) : Number(n.toFixed(3));
|
|
160
|
+
return unit ? `${v} ${unit}` : String(v);
|
|
161
|
+
}
|
|
162
|
+
/** Build a `PageResult` for a page that was not audited (skipped or error). */
|
|
163
|
+
function unauditedPage(path, url, httpStatus, verdict) {
|
|
164
|
+
return {
|
|
165
|
+
path,
|
|
166
|
+
url,
|
|
167
|
+
status: verdict.status,
|
|
168
|
+
...httpStatus !== void 0 ? { httpStatus } : {},
|
|
169
|
+
...verdict.reason ? { reason: verdict.reason } : {},
|
|
170
|
+
failures: verdict.status === "error" ? [{
|
|
171
|
+
kind: path === "/" ? "required-page-missing" : "page-error",
|
|
172
|
+
message: verdict.reason ?? "page error"
|
|
173
|
+
}] : []
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function auditedPage(path, url, httpStatus, metrics, violations, budgets) {
|
|
177
|
+
return {
|
|
178
|
+
path,
|
|
179
|
+
url,
|
|
180
|
+
status: "audited",
|
|
181
|
+
httpStatus,
|
|
182
|
+
metrics,
|
|
183
|
+
violations,
|
|
184
|
+
failures: evaluatePage(metrics, violations, budgets)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function buildReport(input) {
|
|
188
|
+
return {
|
|
189
|
+
generatedAt: input.generatedAt,
|
|
190
|
+
baseUrl: input.baseUrl,
|
|
191
|
+
formFactor: input.formFactor,
|
|
192
|
+
budgets: input.budgets,
|
|
193
|
+
axeTags: AXE_TAGS,
|
|
194
|
+
pages: input.pages,
|
|
195
|
+
passed: input.pages.every((p) => p.failures.length === 0),
|
|
196
|
+
disclaimer: DISCLAIMER,
|
|
197
|
+
manualChecks: MANUAL_CHECKS
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** `/` → `home`, `/a/b` → `a-b`: a safe per-page filename. */
|
|
201
|
+
function pageSlug(path) {
|
|
202
|
+
return path.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-") || "home";
|
|
203
|
+
}
|
|
204
|
+
const dash = (v, f) => v === void 0 ? "—" : f(v);
|
|
205
|
+
function renderPageMarkdown(page, budgets) {
|
|
206
|
+
const lines = [];
|
|
207
|
+
const verdict = page.status === "skipped" ? "SKIPPED" : page.failures.length === 0 ? "PASS" : "FAIL";
|
|
208
|
+
lines.push(`### \`${page.path}\` — ${verdict}`);
|
|
209
|
+
lines.push("");
|
|
210
|
+
if (page.status !== "audited") {
|
|
211
|
+
lines.push(page.reason ?? page.status);
|
|
212
|
+
for (const f of page.failures) lines.push(`- ✖ ${f.message}`);
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
const m = page.metrics;
|
|
216
|
+
lines.push("| Metric | Value | Budget |", "| --- | --- | --- |");
|
|
217
|
+
lines.push(`| LCP | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${budgets.lcpMs} ms |`, `| CLS | ${dash(m?.cls, (n) => n.toFixed(3))} | ${budgets.cls} |`, `| TBT | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${budgets.tbtMs} ms |`, `| Performance | ${dash(m?.scores.performance, String)} | — |`, `| SEO | ${dash(m?.scores.seo, String)} | — |`, `| Best practices | ${dash(m?.scores.bestPractices, String)} | — |`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
const v = page.violations ?? [];
|
|
220
|
+
if (v.length === 0) lines.push("axe: no violations.");
|
|
221
|
+
else {
|
|
222
|
+
lines.push(`axe: ${v.length} violated rule${v.length === 1 ? "" : "s"}.`, "");
|
|
223
|
+
for (const x of [...v].sort((a, b) => IMPACT_RANK[b.impact] - IMPACT_RANK[a.impact])) {
|
|
224
|
+
const blocking = budgets.blockingImpacts.includes(x.impact) ? " (blocking)" : "";
|
|
225
|
+
lines.push(`- **${x.impact}**${blocking} \`${x.id}\` — ${x.help} · ${x.nodeCount} node${x.nodeCount === 1 ? "" : "s"} · [rule](${x.helpUrl})`);
|
|
226
|
+
for (const t of x.targets) lines.push(` - \`${t}\``);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (page.failures.length) {
|
|
230
|
+
lines.push("", "Failures:");
|
|
231
|
+
for (const f of page.failures) lines.push(`- ✖ ${f.message}`);
|
|
232
|
+
}
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
function renderMarkdown(report) {
|
|
236
|
+
const lines = [];
|
|
237
|
+
lines.push("# Performance and accessibility audit", "");
|
|
238
|
+
lines.push(`**${report.passed ? "PASS" : "FAIL"}** · ${report.baseUrl} · ${report.formFactor} · ${report.generatedAt}`, "");
|
|
239
|
+
lines.push(`> ${report.disclaimer}`, "");
|
|
240
|
+
lines.push(`Budgets: LCP < ${report.budgets.lcpMs} ms, CLS < ${report.budgets.cls}, TBT < ${report.budgets.tbtMs} ms, no axe ${report.budgets.blockingImpacts.join(" or ")} violations. axe tags: ${report.axeTags.join(", ")}.`, "");
|
|
241
|
+
lines.push("| Page | Result | LCP | CLS | TBT | Perf | SEO | BP |", "| --- | --- | --- | --- | --- | --- | --- | --- |");
|
|
242
|
+
for (const p of report.pages) {
|
|
243
|
+
const m = p.metrics;
|
|
244
|
+
const verdict = p.status === "skipped" ? "skipped" : p.failures.length ? "FAIL" : "pass";
|
|
245
|
+
lines.push(`| \`${p.path}\` | ${verdict} | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.cls, (n) => n.toFixed(3))} | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.scores.performance, String)} | ${dash(m?.scores.seo, String)} | ${dash(m?.scores.bestPractices, String)} |`);
|
|
246
|
+
}
|
|
247
|
+
lines.push("");
|
|
248
|
+
for (const p of report.pages) lines.push(renderPageMarkdown(p, report.budgets), "");
|
|
249
|
+
lines.push("## Manual checks still required", "");
|
|
250
|
+
for (const c of report.manualChecks) lines.push(`- [ ] ${c}`);
|
|
251
|
+
lines.push("");
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
/** Files to write, relative to the output dir: JSON + markdown overall and per page. */
|
|
255
|
+
function reportFiles(report) {
|
|
256
|
+
const files = {
|
|
257
|
+
"report.json": `${JSON.stringify(report, null, 2)}\n`,
|
|
258
|
+
"report.md": renderMarkdown(report)
|
|
259
|
+
};
|
|
260
|
+
for (const p of report.pages) {
|
|
261
|
+
if (p.status === "skipped") continue;
|
|
262
|
+
const slug = pageSlug(p.path);
|
|
263
|
+
files[`pages/${slug}.json`] = `${JSON.stringify(p, null, 2)}\n`;
|
|
264
|
+
files[`pages/${slug}.md`] = `${renderPageMarkdown(p, report.budgets)}\n\n> ${report.disclaimer}\n`;
|
|
265
|
+
}
|
|
266
|
+
return files;
|
|
267
|
+
}
|
|
268
|
+
/** One-line-per-failure console summary. */
|
|
269
|
+
function formatSummary(report) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
for (const p of report.pages) if (p.status === "skipped") lines.push(` – ${p.path} skipped (${p.reason})`);
|
|
272
|
+
else if (p.failures.length === 0) lines.push(` ✓ ${p.path}`);
|
|
273
|
+
else {
|
|
274
|
+
lines.push(` ✖ ${p.path}`);
|
|
275
|
+
for (const f of p.failures) lines.push(` ${f.message}`);
|
|
276
|
+
}
|
|
277
|
+
lines.push("", report.passed ? "✓ no automated violations" : "✖ audit failed");
|
|
278
|
+
lines.push("Note: a pass is NOT AODA certification — see the manual checks in report.md.");
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/audit/run.ts
|
|
283
|
+
/**
|
|
284
|
+
* `vitops audit` — the I/O half. Starts (or attaches to) a server, drives Chromium,
|
|
285
|
+
* runs axe and Lighthouse, and hands the raw numbers to `plan.ts`. It decides nothing:
|
|
286
|
+
* which pages, what a status means and whether a number breaches a budget are all
|
|
287
|
+
* `plan.ts`'s.
|
|
288
|
+
*
|
|
289
|
+
* `playwright`, `@axe-core/playwright` and `lighthouse` are OPTIONAL peer dependencies,
|
|
290
|
+
* loaded lazily. They are heavy (a browser driver and ~100 MB of Lighthouse) and a
|
|
291
|
+
* consumer who never runs `vitops audit` must not pay for them. A missing one fails
|
|
292
|
+
* with the install command, not a bare `Cannot find package`.
|
|
293
|
+
*
|
|
294
|
+
* Lighthouse runs on the SAME Chromium Playwright launched — Playwright opens it with
|
|
295
|
+
* `--remote-debugging-port`, axe drives it through Playwright, and Lighthouse attaches
|
|
296
|
+
* over that port — so one browser binary serves both and nothing here needs Chrome
|
|
297
|
+
* installed system-wide.
|
|
298
|
+
*/
|
|
299
|
+
const INSTALL = "npm i -D playwright @axe-core/playwright lighthouse && npx playwright install chromium";
|
|
300
|
+
/** Load an optional dependency or throw an error that says how to get it. */
|
|
301
|
+
async function loadOptional(name) {
|
|
302
|
+
try {
|
|
303
|
+
return await import(
|
|
304
|
+
/* @vite-ignore */
|
|
305
|
+
name
|
|
306
|
+
);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
const code = err.code;
|
|
309
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") throw new Error(`vitops audit needs "${name}", which is an optional dependency and is not installed.\n Install: ${INSTALL}`, { cause: err });
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** A free TCP port, or `true` for "this specific one is free". */
|
|
314
|
+
function isPortFree(port) {
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
const s = createServer();
|
|
317
|
+
s.once("error", () => resolve(false));
|
|
318
|
+
s.listen(port, "127.0.0.1", () => s.close(() => resolve(true)));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function freePort() {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const s = createServer();
|
|
324
|
+
s.once("error", reject);
|
|
325
|
+
s.listen(0, "127.0.0.1", () => {
|
|
326
|
+
const { port } = s.address();
|
|
327
|
+
s.close(() => resolve(port));
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/** Run a command to completion, inheriting stdio. Rejects on a non-zero exit. */
|
|
332
|
+
function runCommand(cmd, args, cwd) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
const child = spawn(cmd, args, {
|
|
335
|
+
cwd,
|
|
336
|
+
stdio: "inherit",
|
|
337
|
+
shell: process.platform === "win32"
|
|
338
|
+
});
|
|
339
|
+
child.once("error", reject);
|
|
340
|
+
child.once("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`${cmd} ${args.join(" ")} exited with ${code}`)));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* `astro preview` on a dedicated port.
|
|
345
|
+
*
|
|
346
|
+
* Astro's preview server silently moves to the next port when its own is taken, and
|
|
347
|
+
* a readiness probe would then happily attach to whatever owns the original one — the
|
|
348
|
+
* failure `site/playwright.config.ts` documents. So the port is checked free first and
|
|
349
|
+
* the run aborts if it is not.
|
|
350
|
+
*/
|
|
351
|
+
async function startPreview(opts) {
|
|
352
|
+
if (!await isPortFree(opts.port)) throw new Error(`port ${opts.port} is already in use — refusing to audit whatever is listening there. Pass --port <free port>, or --url to audit a server you started yourself.`);
|
|
353
|
+
const baseUrl = `http://127.0.0.1:${opts.port}`;
|
|
354
|
+
const child = spawn("npx", [
|
|
355
|
+
"--no-install",
|
|
356
|
+
"astro",
|
|
357
|
+
"preview",
|
|
358
|
+
"--host",
|
|
359
|
+
"127.0.0.1",
|
|
360
|
+
"--port",
|
|
361
|
+
String(opts.port)
|
|
362
|
+
], {
|
|
363
|
+
cwd: opts.cwd,
|
|
364
|
+
stdio: [
|
|
365
|
+
"ignore",
|
|
366
|
+
"pipe",
|
|
367
|
+
"pipe"
|
|
368
|
+
],
|
|
369
|
+
detached: process.platform !== "win32"
|
|
370
|
+
});
|
|
371
|
+
let log = "";
|
|
372
|
+
child.stdout?.on("data", (d) => log += String(d));
|
|
373
|
+
child.stderr?.on("data", (d) => log += String(d));
|
|
374
|
+
let exited;
|
|
375
|
+
child.once("exit", (code) => exited = code);
|
|
376
|
+
const stop = () => {
|
|
377
|
+
try {
|
|
378
|
+
if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGTERM");
|
|
379
|
+
else child.kill("SIGTERM");
|
|
380
|
+
} catch {}
|
|
381
|
+
};
|
|
382
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
383
|
+
while (Date.now() < deadline) {
|
|
384
|
+
if (exited !== void 0) throw new Error(`astro preview exited early (code ${exited}). Did you build first? Pass --build.\n${log.trim()}`);
|
|
385
|
+
try {
|
|
386
|
+
await fetch(baseUrl, { redirect: "manual" });
|
|
387
|
+
return {
|
|
388
|
+
baseUrl,
|
|
389
|
+
stop
|
|
390
|
+
};
|
|
391
|
+
} catch {
|
|
392
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
stop();
|
|
396
|
+
throw new Error(`astro preview did not answer on ${baseUrl} within the timeout.\n${log.trim()}`);
|
|
397
|
+
}
|
|
398
|
+
function toViolations(res) {
|
|
399
|
+
return res.violations.map((v) => ({
|
|
400
|
+
id: v.id,
|
|
401
|
+
impact: normalizeImpact(v.impact),
|
|
402
|
+
help: v.help,
|
|
403
|
+
helpUrl: v.helpUrl,
|
|
404
|
+
nodeCount: v.nodes.length,
|
|
405
|
+
targets: v.nodes.slice(0, 3).map((n) => String(n.target.flat().join(" ")))
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
function toMetrics(run) {
|
|
409
|
+
const a = run.lhr.audits;
|
|
410
|
+
const score = (id) => {
|
|
411
|
+
const s = run.lhr.categories[id]?.score;
|
|
412
|
+
return s == null ? void 0 : Math.round(s * 100);
|
|
413
|
+
};
|
|
414
|
+
return {
|
|
415
|
+
lcpMs: a["largest-contentful-paint"]?.numericValue,
|
|
416
|
+
cls: a["cumulative-layout-shift"]?.numericValue,
|
|
417
|
+
tbtMs: a["total-blocking-time"]?.numericValue,
|
|
418
|
+
scores: {
|
|
419
|
+
performance: score("performance"),
|
|
420
|
+
seo: score("seo"),
|
|
421
|
+
bestPractices: score("best-practices")
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Audit each path. Never throws for a bad PAGE — only for missing tooling/browser. */
|
|
426
|
+
async function runAudit(opts) {
|
|
427
|
+
const log = opts.onProgress ?? (() => {});
|
|
428
|
+
const [pw, axe, lh] = await Promise.all([
|
|
429
|
+
loadOptional("playwright"),
|
|
430
|
+
loadOptional("@axe-core/playwright"),
|
|
431
|
+
loadOptional("lighthouse")
|
|
432
|
+
]);
|
|
433
|
+
const debugPort = await freePort();
|
|
434
|
+
let browser;
|
|
435
|
+
try {
|
|
436
|
+
browser = await pw.chromium.launch({
|
|
437
|
+
headless: true,
|
|
438
|
+
args: [`--remote-debugging-port=${debugPort}`]
|
|
439
|
+
});
|
|
440
|
+
} catch (err) {
|
|
441
|
+
throw new Error(`could not launch Chromium: ${err.message}\n Install a browser: npx playwright install chromium`, { cause: err });
|
|
442
|
+
}
|
|
443
|
+
const results = [];
|
|
444
|
+
try {
|
|
445
|
+
for (const path of opts.paths) {
|
|
446
|
+
const url = new URL(path, opts.baseUrl).href;
|
|
447
|
+
let httpStatus;
|
|
448
|
+
try {
|
|
449
|
+
httpStatus = (await fetch(url, { redirect: "follow" })).status;
|
|
450
|
+
} catch {
|
|
451
|
+
httpStatus = void 0;
|
|
452
|
+
}
|
|
453
|
+
const verdict = classifyStatus(path, httpStatus);
|
|
454
|
+
if (verdict.status !== "audited") {
|
|
455
|
+
log(`${path}: ${verdict.status} (${verdict.reason})`);
|
|
456
|
+
results.push(unauditedPage(path, url, httpStatus, verdict));
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
log(`${path}: axe`);
|
|
460
|
+
const context = await browser.newContext();
|
|
461
|
+
const page = await context.newPage();
|
|
462
|
+
let violations;
|
|
463
|
+
try {
|
|
464
|
+
await page.goto(url, { waitUntil: "load" });
|
|
465
|
+
violations = toViolations(await new axe.AxeBuilder({ page }).withTags(AXE_TAGS).analyze());
|
|
466
|
+
} finally {
|
|
467
|
+
await context.close();
|
|
468
|
+
}
|
|
469
|
+
log(`${path}: lighthouse`);
|
|
470
|
+
const flags = {
|
|
471
|
+
port: debugPort,
|
|
472
|
+
output: "json",
|
|
473
|
+
logLevel: "error",
|
|
474
|
+
onlyCategories: [
|
|
475
|
+
"performance",
|
|
476
|
+
"seo",
|
|
477
|
+
"best-practices"
|
|
478
|
+
],
|
|
479
|
+
formFactor: opts.formFactor,
|
|
480
|
+
...opts.formFactor === "desktop" ? {
|
|
481
|
+
screenEmulation: {
|
|
482
|
+
mobile: false,
|
|
483
|
+
width: 1350,
|
|
484
|
+
height: 940,
|
|
485
|
+
deviceScaleFactor: 1,
|
|
486
|
+
disabled: false
|
|
487
|
+
},
|
|
488
|
+
throttling: {
|
|
489
|
+
rttMs: 40,
|
|
490
|
+
throughputKbps: 10240,
|
|
491
|
+
cpuSlowdownMultiplier: 1,
|
|
492
|
+
requestLatencyMs: 0,
|
|
493
|
+
downloadThroughputKbps: 0,
|
|
494
|
+
uploadThroughputKbps: 0
|
|
495
|
+
}
|
|
496
|
+
} : {}
|
|
497
|
+
};
|
|
498
|
+
const run = await lh.default(url, flags);
|
|
499
|
+
const metrics = run ? toMetrics(run) : { scores: {} };
|
|
500
|
+
results.push(auditedPage(path, url, httpStatus, metrics, violations, opts.budgets));
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
await browser.close();
|
|
504
|
+
}
|
|
505
|
+
return results;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export { AXE_TAGS, DEFAULT_BUDGETS, DEFAULT_PAGES, DISCLAIMER, MANUAL_CHECKS, auditedPage, buildReport, classifyStatus, evaluatePage, formatSummary, isAuditMenu, isPortFree, normalizeImpact, normalizePath, pageSlug, parseImpacts, pathsFromMenus, pathsFromSeed, planPages, renderMarkdown, renderPageMarkdown, reportFiles, runAudit, runCommand, startPreview, unauditedPage };
|
|
509
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/audit/plan.ts","../../src/audit/run.ts"],"sourcesContent":["/**\n * `vitops audit` — the pure half. Decides which pages to audit, whether a response\n * status means \"skip\" or \"broken\", whether a result breaches a budget, and how it\n * reads. Touches no network, no browser, no filesystem and no clock (the caller\n * passes `generatedAt`) — the same split `indexing/plan.ts` and `ads/plan.ts` make,\n * for the same reason: `plan.test.ts` can assert every consequential case, and the\n * executor (`run.ts`) decides nothing.\n */\nimport type {\n AuditBudgets,\n AuditReport,\n AxeImpact,\n AxeViolation,\n BudgetFailure,\n FormFactor,\n PageMetrics,\n PageResult,\n} from './types.ts';\n\nexport const DEFAULT_BUDGETS: AuditBudgets = {\n lcpMs: 2500,\n cls: 0.1,\n tbtMs: 200,\n blockingImpacts: ['serious', 'critical'],\n};\n\n/**\n * `wcag2a`/`wcag2aa` are what AODA (WCAG 2.0 AA) asks for; `wcag21a`/`wcag21aa` add the\n * 2.1 criteria we target. `wcag21aa` alone covers only the *new AA* criteria, not the\n * new level-A ones, so both 2.1 tags are listed.\n */\nexport const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];\n\n/** Pages tried on every run. `/` is required; the rest are skipped when they 404. */\nexport const DEFAULT_PAGES = ['/', '/privacy', '/terms', '/cookies', '/contact'];\n\nexport const DISCLAIMER =\n 'A pass means no automated violations were found by axe-core and the performance budgets held. ' +\n 'It is NOT an AODA / WCAG conformance certification: automated tools detect only a minority of ' +\n 'WCAG failures. The manual checks below are still required.';\n\nexport const MANUAL_CHECKS = [\n 'Keyboard order: tab through every page; order matches the visual and reading order, nothing is skipped or trapped.',\n 'Focus visibility: a clear focus indicator on every interactive element, including in forced-colors / high-contrast mode.',\n 'Screen reader pass: headings, landmarks, forms, dropdowns and the drawer menu read sensibly (NVDA or JAWS on Windows, VoiceOver on macOS/iOS).',\n 'Reduced motion: with prefers-reduced-motion on, no essential information depends on animation and nothing auto-plays.',\n '200% zoom (and 400% reflow): no loss of content or function, no horizontal scrolling for text.',\n 'Colour and contrast of imagery, gradients and text over images (axe cannot evaluate these).',\n 'Meaningful alternative text, captions and transcripts for media.',\n];\n\n// ── Pages ───────────────────────────────────────────────────────────────────\n\n/**\n * A menu-ish thing: EmDash seed (`items`), `getMenu()` (`items`, items with `children`),\n * or either shape nested. Only `name`, `url` and children are read.\n */\nexport interface MenuLike {\n name?: string;\n items?: MenuItemLike[];\n}\nexport interface MenuItemLike {\n url?: string | null;\n children?: MenuItemLike[];\n items?: MenuItemLike[];\n}\n\n/**\n * Normalise to a site-relative path, or `undefined` for anything that is not an\n * auditable page of this site: external URLs, hash-only links, `mailto:`/`tel:`,\n * protocol-relative URLs, file downloads (anything with an extension) and the CMS\n * admin. Query and hash are dropped; a trailing slash is too (except the root).\n */\nexport function normalizePath(url: string | null | undefined): string | undefined {\n if (!url) return undefined;\n const u = url.trim();\n if (!u.startsWith('/') || u.startsWith('//')) return undefined;\n const bare = u.split(/[?#]/)[0] ?? '';\n const path = bare.length > 1 ? bare.replace(/\\/+$/, '') : bare;\n if (!path) return undefined;\n if (/\\.[a-z0-9]{1,6}$/i.test(path)) return undefined;\n if (path === '/_emdash' || path.startsWith('/_emdash/')) return undefined;\n return path;\n}\n\nfunction walk(items: MenuItemLike[] | undefined, out: string[]): void {\n for (const item of items ?? []) {\n const p = normalizePath(item.url);\n if (p) out.push(p);\n walk(item.children, out);\n walk(item.items, out);\n }\n}\n\n/** Is this one of the menus whose links are pages worth auditing? */\nexport function isAuditMenu(name: string | undefined): boolean {\n return !!name && (name.startsWith('primary') || name.startsWith('footer'));\n}\n\n/** Paths from the `primary*` / `footer*` menus (`footer-legal`, `primary-mobile`, …). */\nexport function pathsFromMenus(menus: MenuLike[]): string[] {\n const out: string[] = [];\n for (const m of menus) if (isAuditMenu(m.name)) walk(m.items, out);\n return out;\n}\n\n/** Same, from an EmDash seed file's parsed JSON (`{ menus: [...] }`). */\nexport function pathsFromSeed(seed: unknown): string[] {\n const menus = (seed as { menus?: unknown } | null)?.menus;\n return Array.isArray(menus) ? pathsFromMenus(menus as MenuLike[]) : [];\n}\n\nexport interface PagePlanInput {\n /** Paths found in menus. */\n menuPaths?: string[];\n /** Paths from `--pages`. */\n extra?: string[];\n /** Default true. `--no-defaults` for a site that wants an exact list. */\n includeDefaults?: boolean;\n}\n\n/** The pages to try, `/` always first, unique, in a stable order. */\nexport function planPages(input: PagePlanInput = {}): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n const add = (p: string | undefined) => {\n if (p && !seen.has(p)) {\n seen.add(p);\n out.push(p);\n }\n };\n add('/');\n if (input.includeDefaults !== false) for (const p of DEFAULT_PAGES) add(p);\n for (const p of input.menuPaths ?? []) add(p);\n for (const p of input.extra ?? []) add(normalizePath(p));\n return out;\n}\n\n/**\n * What an HTTP status means for a candidate page. 404/410 → skipped (a single-page\n * site has no `/contact`, and that is fine) — except `/`, which must exist. Any other\n * non-2xx is an error: a 500 on `/privacy` is a defect, not an absent page.\n */\nexport function classifyStatus(\n path: string,\n httpStatus: number | undefined,\n): { status: 'audited' | 'skipped' | 'error'; reason?: string } {\n if (httpStatus === undefined) return { status: 'error', reason: 'no response' };\n if (httpStatus >= 200 && httpStatus < 300) return { status: 'audited' };\n if (httpStatus === 404 || httpStatus === 410) {\n return path === '/'\n ? { status: 'error', reason: `/ returned ${httpStatus} — the home page must exist` }\n : { status: 'skipped', reason: `${httpStatus} — page does not exist on this site` };\n }\n return { status: 'error', reason: `HTTP ${httpStatus}` };\n}\n\n// ── Budgets ─────────────────────────────────────────────────────────────────\n\nconst IMPACT_RANK: Record<AxeImpact, number> = { minor: 0, moderate: 1, serious: 2, critical: 3 };\n\n/** axe can report a null impact; treat it as `moderate`, never silently drop it. */\nexport function normalizeImpact(impact: string | null | undefined): AxeImpact {\n return impact && impact in IMPACT_RANK ? (impact as AxeImpact) : 'moderate';\n}\n\n/** `serious,critical` → impacts, rejecting typos so a budget cannot silently be empty. */\nexport function parseImpacts(list: string): AxeImpact[] {\n const parts = list\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n for (const p of parts)\n if (!(p in IMPACT_RANK))\n throw new Error(\n `unknown axe impact \"${p}\" (expected: minor | moderate | serious | critical)`,\n );\n if (parts.length === 0) throw new Error('at least one axe impact is required');\n return parts as AxeImpact[];\n}\n\n/** Everything that makes one audited page fail. Empty = the page passes. */\nexport function evaluatePage(\n metrics: PageMetrics | undefined,\n violations: AxeViolation[] | undefined,\n budgets: AuditBudgets,\n): BudgetFailure[] {\n const failures: BudgetFailure[] = [];\n\n const check = (\n kind: 'lcp' | 'cls' | 'tbt',\n value: number | undefined,\n limit: number,\n label: string,\n unit: string,\n ) => {\n if (value === undefined || Number.isNaN(value)) {\n // A metric Lighthouse could not compute must not pass by default.\n failures.push({ kind: 'metric-missing', message: `${label} could not be measured` });\n } else if (value > limit) {\n failures.push({\n kind,\n message: `${label} ${fmt(value, unit)} exceeds the budget of ${fmt(limit, unit)}`,\n });\n }\n };\n check('lcp', metrics?.lcpMs, budgets.lcpMs, 'LCP', 'ms');\n check('cls', metrics?.cls, budgets.cls, 'CLS', '');\n check('tbt', metrics?.tbtMs, budgets.tbtMs, 'TBT', 'ms');\n\n for (const v of violations ?? []) {\n if (budgets.blockingImpacts.includes(v.impact)) {\n failures.push({\n kind: 'axe',\n message: `axe ${v.impact}: ${v.id} — ${v.help} (${v.nodeCount} node${v.nodeCount === 1 ? '' : 's'})`,\n });\n }\n }\n return failures;\n}\n\nfunction fmt(n: number, unit: string): string {\n const v = unit === 'ms' ? Math.round(n) : Number(n.toFixed(3));\n return unit ? `${v} ${unit}` : String(v);\n}\n\n/** Build a `PageResult` for a page that was not audited (skipped or error). */\nexport function unauditedPage(\n path: string,\n url: string,\n httpStatus: number | undefined,\n verdict: { status: 'skipped' | 'error'; reason?: string },\n): PageResult {\n return {\n path,\n url,\n status: verdict.status,\n ...(httpStatus !== undefined ? { httpStatus } : {}),\n ...(verdict.reason ? { reason: verdict.reason } : {}),\n failures:\n verdict.status === 'error'\n ? [\n {\n kind: path === '/' ? 'required-page-missing' : 'page-error',\n message: verdict.reason ?? 'page error',\n },\n ]\n : [],\n };\n}\n\nexport function auditedPage(\n path: string,\n url: string,\n httpStatus: number,\n metrics: PageMetrics,\n violations: AxeViolation[],\n budgets: AuditBudgets,\n): PageResult {\n return {\n path,\n url,\n status: 'audited',\n httpStatus,\n metrics,\n violations,\n failures: evaluatePage(metrics, violations, budgets),\n };\n}\n\nexport function buildReport(input: {\n generatedAt: string;\n baseUrl: string;\n formFactor: FormFactor;\n budgets: AuditBudgets;\n pages: PageResult[];\n}): AuditReport {\n return {\n generatedAt: input.generatedAt,\n baseUrl: input.baseUrl,\n formFactor: input.formFactor,\n budgets: input.budgets,\n axeTags: AXE_TAGS,\n pages: input.pages,\n passed: input.pages.every((p) => p.failures.length === 0),\n disclaimer: DISCLAIMER,\n manualChecks: MANUAL_CHECKS,\n };\n}\n\n// ── Rendering ───────────────────────────────────────────────────────────────\n\n/** `/` → `home`, `/a/b` → `a-b`: a safe per-page filename. */\nexport function pageSlug(path: string): string {\n const s = path.replace(/^\\/+|\\/+$/g, '').replace(/[^a-zA-Z0-9]+/g, '-');\n return s || 'home';\n}\n\nconst dash = (v: number | undefined, f: (n: number) => string) => (v === undefined ? '—' : f(v));\n\nexport function renderPageMarkdown(page: PageResult, budgets: AuditBudgets): string {\n const lines: string[] = [];\n const verdict =\n page.status === 'skipped' ? 'SKIPPED' : page.failures.length === 0 ? 'PASS' : 'FAIL';\n lines.push(`### \\`${page.path}\\` — ${verdict}`);\n lines.push('');\n if (page.status !== 'audited') {\n lines.push(page.reason ?? page.status);\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n return lines.join('\\n');\n }\n const m = page.metrics;\n lines.push('| Metric | Value | Budget |', '| --- | --- | --- |');\n lines.push(\n `| LCP | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${budgets.lcpMs} ms |`,\n `| CLS | ${dash(m?.cls, (n) => n.toFixed(3))} | ${budgets.cls} |`,\n `| TBT | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${budgets.tbtMs} ms |`,\n `| Performance | ${dash(m?.scores.performance, String)} | — |`,\n `| SEO | ${dash(m?.scores.seo, String)} | — |`,\n `| Best practices | ${dash(m?.scores.bestPractices, String)} | — |`,\n );\n lines.push('');\n const v = page.violations ?? [];\n if (v.length === 0) lines.push('axe: no violations.');\n else {\n lines.push(`axe: ${v.length} violated rule${v.length === 1 ? '' : 's'}.`, '');\n for (const x of [...v].sort((a, b) => IMPACT_RANK[b.impact] - IMPACT_RANK[a.impact])) {\n const blocking = budgets.blockingImpacts.includes(x.impact) ? ' (blocking)' : '';\n lines.push(\n `- **${x.impact}**${blocking} \\`${x.id}\\` — ${x.help} · ${x.nodeCount} node${x.nodeCount === 1 ? '' : 's'} · [rule](${x.helpUrl})`,\n );\n for (const t of x.targets) lines.push(` - \\`${t}\\``);\n }\n }\n if (page.failures.length) {\n lines.push('', 'Failures:');\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n }\n return lines.join('\\n');\n}\n\nexport function renderMarkdown(report: AuditReport): string {\n const lines: string[] = [];\n lines.push('# Performance and accessibility audit', '');\n lines.push(\n `**${report.passed ? 'PASS' : 'FAIL'}** · ${report.baseUrl} · ${report.formFactor} · ${report.generatedAt}`,\n '',\n );\n lines.push(`> ${report.disclaimer}`, '');\n lines.push(\n `Budgets: LCP < ${report.budgets.lcpMs} ms, CLS < ${report.budgets.cls}, TBT < ${report.budgets.tbtMs} ms, ` +\n `no axe ${report.budgets.blockingImpacts.join(' or ')} violations. axe tags: ${report.axeTags.join(', ')}.`,\n '',\n );\n lines.push(\n '| Page | Result | LCP | CLS | TBT | Perf | SEO | BP |',\n '| --- | --- | --- | --- | --- | --- | --- | --- |',\n );\n for (const p of report.pages) {\n const m = p.metrics;\n const verdict = p.status === 'skipped' ? 'skipped' : p.failures.length ? 'FAIL' : 'pass';\n lines.push(\n `| \\`${p.path}\\` | ${verdict} | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.cls, (n) => n.toFixed(3))} | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.scores.performance, String)} | ${dash(m?.scores.seo, String)} | ${dash(m?.scores.bestPractices, String)} |`,\n );\n }\n lines.push('');\n for (const p of report.pages) lines.push(renderPageMarkdown(p, report.budgets), '');\n lines.push('## Manual checks still required', '');\n for (const c of report.manualChecks) lines.push(`- [ ] ${c}`);\n lines.push('');\n return lines.join('\\n');\n}\n\n/** Files to write, relative to the output dir: JSON + markdown overall and per page. */\nexport function reportFiles(report: AuditReport): Record<string, string> {\n const files: Record<string, string> = {\n 'report.json': `${JSON.stringify(report, null, 2)}\\n`,\n 'report.md': renderMarkdown(report),\n };\n for (const p of report.pages) {\n if (p.status === 'skipped') continue;\n const slug = pageSlug(p.path);\n files[`pages/${slug}.json`] = `${JSON.stringify(p, null, 2)}\\n`;\n files[`pages/${slug}.md`] =\n `${renderPageMarkdown(p, report.budgets)}\\n\\n> ${report.disclaimer}\\n`;\n }\n return files;\n}\n\n/** One-line-per-failure console summary. */\nexport function formatSummary(report: AuditReport): string {\n const lines: string[] = [];\n for (const p of report.pages) {\n if (p.status === 'skipped') lines.push(` – ${p.path} skipped (${p.reason})`);\n else if (p.failures.length === 0) lines.push(` ✓ ${p.path}`);\n else {\n lines.push(` ✖ ${p.path}`);\n for (const f of p.failures) lines.push(` ${f.message}`);\n }\n }\n lines.push('', report.passed ? '✓ no automated violations' : '✖ audit failed');\n lines.push('Note: a pass is NOT AODA certification — see the manual checks in report.md.');\n return lines.join('\\n');\n}\n","/**\n * `vitops audit` — the I/O half. Starts (or attaches to) a server, drives Chromium,\n * runs axe and Lighthouse, and hands the raw numbers to `plan.ts`. It decides nothing:\n * which pages, what a status means and whether a number breaches a budget are all\n * `plan.ts`'s.\n *\n * `playwright`, `@axe-core/playwright` and `lighthouse` are OPTIONAL peer dependencies,\n * loaded lazily. They are heavy (a browser driver and ~100 MB of Lighthouse) and a\n * consumer who never runs `vitops audit` must not pay for them. A missing one fails\n * with the install command, not a bare `Cannot find package`.\n *\n * Lighthouse runs on the SAME Chromium Playwright launched — Playwright opens it with\n * `--remote-debugging-port`, axe drives it through Playwright, and Lighthouse attaches\n * over that port — so one browser binary serves both and nothing here needs Chrome\n * installed system-wide.\n */\nimport { spawn, type ChildProcess } from 'node:child_process';\nimport { createServer } from 'node:net';\nimport { AXE_TAGS, auditedPage, classifyStatus, normalizeImpact, unauditedPage } from './plan.ts';\nimport type { AuditBudgets, AxeViolation, FormFactor, PageMetrics, PageResult } from './types.ts';\n\nconst INSTALL =\n 'npm i -D playwright @axe-core/playwright lighthouse && npx playwright install chromium';\n\n/** Load an optional dependency or throw an error that says how to get it. */\nasync function loadOptional<T>(name: string): Promise<T> {\n try {\n // A variable specifier keeps bundlers and the type checker from resolving it —\n // that is the whole point of \"optional\".\n return (await import(/* @vite-ignore */ name)) as T;\n } catch (err) {\n const code = (err as { code?: string }).code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND')\n throw new Error(\n `vitops audit needs \"${name}\", which is an optional dependency and is not installed.\\n` +\n ` Install: ${INSTALL}`,\n { cause: err },\n );\n throw err;\n }\n}\n\n/** A free TCP port, or `true` for \"this specific one is free\". */\nexport function isPortFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once('error', () => resolve(false));\n s.listen(port, '127.0.0.1', () => s.close(() => resolve(true)));\n });\n}\n\nfunction freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const s = createServer();\n s.once('error', reject);\n s.listen(0, '127.0.0.1', () => {\n const { port } = s.address() as { port: number };\n s.close(() => resolve(port));\n });\n });\n}\n\n/** Run a command to completion, inheriting stdio. Rejects on a non-zero exit. */\nexport function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });\n child.once('error', reject);\n child.once('exit', (code) =>\n code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`)),\n );\n });\n}\n\nexport interface PreviewServer {\n baseUrl: string;\n stop(): void;\n}\n\n/**\n * `astro preview` on a dedicated port.\n *\n * Astro's preview server silently moves to the next port when its own is taken, and\n * a readiness probe would then happily attach to whatever owns the original one — the\n * failure `site/playwright.config.ts` documents. So the port is checked free first and\n * the run aborts if it is not.\n */\nexport async function startPreview(opts: {\n cwd: string;\n port: number;\n timeoutMs?: number;\n}): Promise<PreviewServer> {\n if (!(await isPortFree(opts.port)))\n throw new Error(\n `port ${opts.port} is already in use — refusing to audit whatever is listening there. ` +\n `Pass --port <free port>, or --url to audit a server you started yourself.`,\n );\n const baseUrl = `http://127.0.0.1:${opts.port}`;\n const child: ChildProcess = spawn(\n 'npx',\n ['--no-install', 'astro', 'preview', '--host', '127.0.0.1', '--port', String(opts.port)],\n { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' },\n );\n let log = '';\n child.stdout?.on('data', (d) => (log += String(d)));\n child.stderr?.on('data', (d) => (log += String(d)));\n let exited: number | null | undefined;\n child.once('exit', (code) => (exited = code));\n\n const stop = () => {\n try {\n if (child.pid && process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');\n else child.kill('SIGTERM');\n } catch {\n /* already gone */\n }\n };\n\n const deadline = Date.now() + (opts.timeoutMs ?? 60_000);\n while (Date.now() < deadline) {\n if (exited !== undefined) {\n throw new Error(\n `astro preview exited early (code ${exited}). Did you build first? Pass --build.\\n${log.trim()}`,\n );\n }\n try {\n await fetch(baseUrl, { redirect: 'manual' });\n return { baseUrl, stop };\n } catch {\n await new Promise((r) => setTimeout(r, 250));\n }\n }\n stop();\n throw new Error(`astro preview did not answer on ${baseUrl} within the timeout.\\n${log.trim()}`);\n}\n\n// ── Browser tooling (structural types: these modules are optional) ───────────\n\ninterface AxeResult {\n violations: Array<{\n id: string;\n impact?: string | null;\n help: string;\n helpUrl: string;\n nodes: Array<{ target: unknown[] }>;\n }>;\n}\ninterface LighthouseAudits {\n [id: string]: { numericValue?: number } | undefined;\n}\ninterface LighthouseRun {\n lhr: {\n audits: LighthouseAudits;\n categories: Record<string, { score: number | null } | undefined>;\n };\n}\n\nfunction toViolations(res: AxeResult): AxeViolation[] {\n return res.violations.map((v) => ({\n id: v.id,\n impact: normalizeImpact(v.impact),\n help: v.help,\n helpUrl: v.helpUrl,\n nodeCount: v.nodes.length,\n targets: v.nodes.slice(0, 3).map((n) => String(n.target.flat().join(' '))),\n }));\n}\n\nfunction toMetrics(run: LighthouseRun): PageMetrics {\n const a = run.lhr.audits;\n const score = (id: string) => {\n const s = run.lhr.categories[id]?.score;\n return s == null ? undefined : Math.round(s * 100);\n };\n return {\n lcpMs: a['largest-contentful-paint']?.numericValue,\n cls: a['cumulative-layout-shift']?.numericValue,\n tbtMs: a['total-blocking-time']?.numericValue,\n scores: {\n performance: score('performance'),\n seo: score('seo'),\n bestPractices: score('best-practices'),\n },\n };\n}\n\nexport interface RunOptions {\n baseUrl: string;\n paths: string[];\n budgets: AuditBudgets;\n formFactor: FormFactor;\n onProgress?: (message: string) => void;\n}\n\n/** Audit each path. Never throws for a bad PAGE — only for missing tooling/browser. */\nexport async function runAudit(opts: RunOptions): Promise<PageResult[]> {\n const log = opts.onProgress ?? (() => {});\n const [pw, axe, lh] = await Promise.all([\n loadOptional<{\n chromium: {\n launch(o: { headless: boolean; args: string[] }): Promise<{\n newContext(): Promise<{\n newPage(): Promise<{\n goto(u: string, o: { waitUntil: string }): Promise<unknown>;\n }>;\n close(): Promise<void>;\n }>;\n close(): Promise<void>;\n }>;\n };\n }>('playwright'),\n loadOptional<{\n AxeBuilder: new (o: { page: unknown }) => {\n withTags(t: string[]): { analyze(): Promise<AxeResult> };\n };\n }>('@axe-core/playwright'),\n loadOptional<{\n default: (\n url: string,\n flags: Record<string, unknown>,\n config?: unknown,\n ) => Promise<LighthouseRun | undefined>;\n }>('lighthouse'),\n ]);\n\n const debugPort = await freePort();\n let browser;\n try {\n browser = await pw.chromium.launch({\n headless: true,\n args: [`--remote-debugging-port=${debugPort}`],\n });\n } catch (err) {\n throw new Error(\n `could not launch Chromium: ${(err as Error).message}\\n Install a browser: npx playwright install chromium`,\n { cause: err },\n );\n }\n\n const results: PageResult[] = [];\n try {\n for (const path of opts.paths) {\n const url = new URL(path, opts.baseUrl).href;\n let httpStatus: number | undefined;\n try {\n httpStatus = (await fetch(url, { redirect: 'follow' })).status;\n } catch {\n httpStatus = undefined;\n }\n const verdict = classifyStatus(path, httpStatus);\n if (verdict.status !== 'audited') {\n log(`${path}: ${verdict.status} (${verdict.reason})`);\n results.push(unauditedPage(path, url, httpStatus, verdict as never));\n continue;\n }\n\n log(`${path}: axe`);\n // axe-playwright refuses a page that did not come from `newContext()`.\n const context = await browser.newContext();\n const page = await context.newPage();\n let violations: AxeViolation[];\n try {\n await page.goto(url, { waitUntil: 'load' });\n violations = toViolations(await new axe.AxeBuilder({ page }).withTags(AXE_TAGS).analyze());\n } finally {\n await context.close();\n }\n\n log(`${path}: lighthouse`);\n const flags: Record<string, unknown> = {\n port: debugPort,\n output: 'json',\n logLevel: 'error',\n onlyCategories: ['performance', 'seo', 'best-practices'],\n formFactor: opts.formFactor,\n ...(opts.formFactor === 'desktop'\n ? {\n screenEmulation: {\n mobile: false,\n width: 1350,\n height: 940,\n deviceScaleFactor: 1,\n disabled: false,\n },\n throttling: {\n rttMs: 40,\n throughputKbps: 10 * 1024,\n cpuSlowdownMultiplier: 1,\n requestLatencyMs: 0,\n downloadThroughputKbps: 0,\n uploadThroughputKbps: 0,\n },\n }\n : {}),\n };\n const run = await lh.default(url, flags);\n const metrics: PageMetrics = run ? toMetrics(run) : { scores: {} };\n results.push(auditedPage(path, url, httpStatus!, metrics, violations, opts.budgets));\n }\n } finally {\n await browser.close();\n }\n return results;\n}\n"],"mappings":";;;;AAmBA,MAAa,kBAAgC;CAC3C,OAAO;CACP,KAAK;CACL,OAAO;CACP,iBAAiB,CAAC,WAAW,UAAU;AACzC;;;;;;AAOA,MAAa,WAAW;CAAC;CAAU;CAAW;CAAW;AAAU;;AAGnE,MAAa,gBAAgB;CAAC;CAAK;CAAY;CAAU;CAAY;AAAU;AAE/E,MAAa,aACX;AAIF,MAAa,gBAAgB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;AAwBA,SAAgB,cAAc,KAAoD;CAChF,IAAI,CAAC,KAAK,OAAO;CACjB,MAAM,IAAI,IAAI,KAAK;CACnB,IAAI,CAAC,EAAE,WAAW,GAAG,KAAK,EAAE,WAAW,IAAI,GAAG,OAAO;CACrD,MAAM,OAAO,EAAE,MAAM,MAAM,CAAC,CAAC,MAAM;CACnC,MAAM,OAAO,KAAK,SAAS,IAAI,KAAK,QAAQ,QAAQ,EAAE,IAAI;CAC1D,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,oBAAoB,KAAK,IAAI,GAAG,OAAO;CAC3C,IAAI,SAAS,cAAc,KAAK,WAAW,WAAW,GAAG,OAAO;CAChE,OAAO;AACT;AAEA,SAAS,KAAK,OAAmC,KAAqB;CACpE,KAAK,MAAM,QAAQ,SAAS,CAAC,GAAG;EAC9B,MAAM,IAAI,cAAc,KAAK,GAAG;EAChC,IAAI,GAAG,IAAI,KAAK,CAAC;EACjB,KAAK,KAAK,UAAU,GAAG;EACvB,KAAK,KAAK,OAAO,GAAG;CACtB;AACF;;AAGA,SAAgB,YAAY,MAAmC;CAC7D,OAAO,CAAC,CAAC,SAAS,KAAK,WAAW,SAAS,KAAK,KAAK,WAAW,QAAQ;AAC1E;;AAGA,SAAgB,eAAe,OAA6B;CAC1D,MAAM,MAAgB,CAAC;CACvB,KAAK,MAAM,KAAK,OAAO,IAAI,YAAY,EAAE,IAAI,GAAG,KAAK,EAAE,OAAO,GAAG;CACjE,OAAO;AACT;;AAGA,SAAgB,cAAc,MAAyB;CACrD,MAAM,QAAS,MAAqC;CACpD,OAAO,MAAM,QAAQ,KAAK,IAAI,eAAe,KAAmB,IAAI,CAAC;AACvE;;AAYA,SAAgB,UAAU,QAAuB,CAAC,GAAa;CAC7D,MAAM,uBAAO,IAAI,IAAY;CAC7B,MAAM,MAAgB,CAAC;CACvB,MAAM,OAAO,MAA0B;EACrC,IAAI,KAAK,CAAC,KAAK,IAAI,CAAC,GAAG;GACrB,KAAK,IAAI,CAAC;GACV,IAAI,KAAK,CAAC;EACZ;CACF;CACA,IAAI,GAAG;CACP,IAAI,MAAM,oBAAoB,OAAO,KAAK,MAAM,KAAK,eAAe,IAAI,CAAC;CACzE,KAAK,MAAM,KAAK,MAAM,aAAa,CAAC,GAAG,IAAI,CAAC;CAC5C,KAAK,MAAM,KAAK,MAAM,SAAS,CAAC,GAAG,IAAI,cAAc,CAAC,CAAC;CACvD,OAAO;AACT;;;;;;AAOA,SAAgB,eACd,MACA,YAC8D;CAC9D,IAAI,eAAe,QAAW,OAAO;EAAE,QAAQ;EAAS,QAAQ;CAAc;CAC9E,IAAI,cAAc,OAAO,aAAa,KAAK,OAAO,EAAE,QAAQ,UAAU;CACtE,IAAI,eAAe,OAAO,eAAe,KACvC,OAAO,SAAS,MACZ;EAAE,QAAQ;EAAS,QAAQ,cAAc,WAAW;CAA6B,IACjF;EAAE,QAAQ;EAAW,QAAQ,GAAG,WAAW;CAAqC;CAEtF,OAAO;EAAE,QAAQ;EAAS,QAAQ,QAAQ;CAAa;AACzD;AAIA,MAAM,cAAyC;CAAE,OAAO;CAAG,UAAU;CAAG,SAAS;CAAG,UAAU;AAAE;;AAGhG,SAAgB,gBAAgB,QAA8C;CAC5E,OAAO,UAAU,UAAU,cAAe,SAAuB;AACnE;;AAGA,SAAgB,aAAa,MAA2B;CACtD,MAAM,QAAQ,KACX,MAAM,GAAG,CAAC,CACV,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,CACpB,OAAO,OAAO;CACjB,KAAK,MAAM,KAAK,OACd,IAAI,EAAE,KAAK,cACT,MAAM,IAAI,MACR,uBAAuB,EAAE,oDAC3B;CACJ,IAAI,MAAM,WAAW,GAAG,MAAM,IAAI,MAAM,qCAAqC;CAC7E,OAAO;AACT;;AAGA,SAAgB,aACd,SACA,YACA,SACiB;CACjB,MAAM,WAA4B,CAAC;CAEnC,MAAM,SACJ,MACA,OACA,OACA,OACA,SACG;EACH,IAAI,UAAU,UAAa,OAAO,MAAM,KAAK,GAE3C,SAAS,KAAK;GAAE,MAAM;GAAkB,SAAS,GAAG,MAAM;EAAwB,CAAC;OAC9E,IAAI,QAAQ,OACjB,SAAS,KAAK;GACZ;GACA,SAAS,GAAG,MAAM,GAAG,IAAI,OAAO,IAAI,EAAE,yBAAyB,IAAI,OAAO,IAAI;EAChF,CAAC;CAEL;CACA,MAAM,OAAO,SAAS,OAAO,QAAQ,OAAO,OAAO,IAAI;CACvD,MAAM,OAAO,SAAS,KAAK,QAAQ,KAAK,OAAO,EAAE;CACjD,MAAM,OAAO,SAAS,OAAO,QAAQ,OAAO,OAAO,IAAI;CAEvD,KAAK,MAAM,KAAK,cAAc,CAAC,GAC7B,IAAI,QAAQ,gBAAgB,SAAS,EAAE,MAAM,GAC3C,SAAS,KAAK;EACZ,MAAM;EACN,SAAS,OAAO,EAAE,OAAO,IAAI,EAAE,GAAG,KAAK,EAAE,KAAK,IAAI,EAAE,UAAU,OAAO,EAAE,cAAc,IAAI,KAAK,IAAI;CACpG,CAAC;CAGL,OAAO;AACT;AAEA,SAAS,IAAI,GAAW,MAAsB;CAC5C,MAAM,IAAI,SAAS,OAAO,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,QAAQ,CAAC,CAAC;CAC7D,OAAO,OAAO,GAAG,EAAE,GAAG,SAAS,OAAO,CAAC;AACzC;;AAGA,SAAgB,cACd,MACA,KACA,YACA,SACY;CACZ,OAAO;EACL;EACA;EACA,QAAQ,QAAQ;EAChB,GAAI,eAAe,SAAY,EAAE,WAAW,IAAI,CAAC;EACjD,GAAI,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC;EACnD,UACE,QAAQ,WAAW,UACf,CACE;GACE,MAAM,SAAS,MAAM,0BAA0B;GAC/C,SAAS,QAAQ,UAAU;EAC7B,CACF,IACA,CAAC;CACT;AACF;AAEA,SAAgB,YACd,MACA,KACA,YACA,SACA,YACA,SACY;CACZ,OAAO;EACL;EACA;EACA,QAAQ;EACR;EACA;EACA;EACA,UAAU,aAAa,SAAS,YAAY,OAAO;CACrD;AACF;AAEA,SAAgB,YAAY,OAMZ;CACd,OAAO;EACL,aAAa,MAAM;EACnB,SAAS,MAAM;EACf,YAAY,MAAM;EAClB,SAAS,MAAM;EACf,SAAS;EACT,OAAO,MAAM;EACb,QAAQ,MAAM,MAAM,OAAO,MAAM,EAAE,SAAS,WAAW,CAAC;EACxD,YAAY;EACZ,cAAc;CAChB;AACF;;AAKA,SAAgB,SAAS,MAAsB;CAE7C,OADU,KAAK,QAAQ,cAAc,EAAE,CAAC,CAAC,QAAQ,kBAAkB,GAC5D,KAAK;AACd;AAEA,MAAM,QAAQ,GAAuB,MAA8B,MAAM,SAAY,MAAM,EAAE,CAAC;AAE9F,SAAgB,mBAAmB,MAAkB,SAA+B;CAClF,MAAM,QAAkB,CAAC;CACzB,MAAM,UACJ,KAAK,WAAW,YAAY,YAAY,KAAK,SAAS,WAAW,IAAI,SAAS;CAChF,MAAM,KAAK,SAAS,KAAK,KAAK,OAAO,SAAS;CAC9C,MAAM,KAAK,EAAE;CACb,IAAI,KAAK,WAAW,WAAW;EAC7B,MAAM,KAAK,KAAK,UAAU,KAAK,MAAM;EACrC,KAAK,MAAM,KAAK,KAAK,UAAU,MAAM,KAAK,OAAO,EAAE,SAAS;EAC5D,OAAO,MAAM,KAAK,IAAI;CACxB;CACA,MAAM,IAAI,KAAK;CACf,MAAM,KAAK,+BAA+B,qBAAqB;CAC/D,MAAM,KACJ,WAAW,KAAK,GAAG,QAAQ,MAAM,GAAG,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,QAAQ,MAAM,QAC3E,WAAW,KAAK,GAAG,MAAM,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,KAAK,QAAQ,IAAI,KAC9D,WAAW,KAAK,GAAG,QAAQ,MAAM,GAAG,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,QAAQ,MAAM,QAC3E,mBAAmB,KAAK,GAAG,OAAO,aAAa,MAAM,EAAE,SACvD,WAAW,KAAK,GAAG,OAAO,KAAK,MAAM,EAAE,SACvC,sBAAsB,KAAK,GAAG,OAAO,eAAe,MAAM,EAAE,OAC9D;CACA,MAAM,KAAK,EAAE;CACb,MAAM,IAAI,KAAK,cAAc,CAAC;CAC9B,IAAI,EAAE,WAAW,GAAG,MAAM,KAAK,qBAAqB;MAC/C;EACH,MAAM,KAAK,QAAQ,EAAE,OAAO,gBAAgB,EAAE,WAAW,IAAI,KAAK,IAAI,IAAI,EAAE;EAC5E,KAAK,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,GAAG,MAAM,YAAY,EAAE,UAAU,YAAY,EAAE,OAAO,GAAG;GACpF,MAAM,WAAW,QAAQ,gBAAgB,SAAS,EAAE,MAAM,IAAI,gBAAgB;GAC9E,MAAM,KACJ,OAAO,EAAE,OAAO,IAAI,SAAS,KAAK,EAAE,GAAG,OAAO,EAAE,KAAK,KAAK,EAAE,UAAU,OAAO,EAAE,cAAc,IAAI,KAAK,IAAI,YAAY,EAAE,QAAQ,EAClI;GACA,KAAK,MAAM,KAAK,EAAE,SAAS,MAAM,KAAK,SAAS,EAAE,GAAG;EACtD;CACF;CACA,IAAI,KAAK,SAAS,QAAQ;EACxB,MAAM,KAAK,IAAI,WAAW;EAC1B,KAAK,MAAM,KAAK,KAAK,UAAU,MAAM,KAAK,OAAO,EAAE,SAAS;CAC9D;CACA,OAAO,MAAM,KAAK,IAAI;AACxB;AAEA,SAAgB,eAAe,QAA6B;CAC1D,MAAM,QAAkB,CAAC;CACzB,MAAM,KAAK,yCAAyC,EAAE;CACtD,MAAM,KACJ,KAAK,OAAO,SAAS,SAAS,OAAO,OAAO,OAAO,QAAQ,KAAK,OAAO,WAAW,KAAK,OAAO,eAC9F,EACF;CACA,MAAM,KAAK,KAAK,OAAO,cAAc,EAAE;CACvC,MAAM,KACJ,kBAAkB,OAAO,QAAQ,MAAM,aAAa,OAAO,QAAQ,IAAI,UAAU,OAAO,QAAQ,MAAM,cAC1F,OAAO,QAAQ,gBAAgB,KAAK,MAAM,EAAE,yBAAyB,OAAO,QAAQ,KAAK,IAAI,EAAE,IAC3G,EACF;CACA,MAAM,KACJ,yDACA,mDACF;CACA,KAAK,MAAM,KAAK,OAAO,OAAO;EAC5B,MAAM,IAAI,EAAE;EACZ,MAAM,UAAU,EAAE,WAAW,YAAY,YAAY,EAAE,SAAS,SAAS,SAAS;EAClF,MAAM,KACJ,OAAO,EAAE,KAAK,OAAO,QAAQ,KAAK,KAAK,GAAG,QAAQ,MAAM,GAAG,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,KAAK,GAAG,MAAM,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,KAAK,KAAK,GAAG,QAAQ,MAAM,GAAG,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,KAAK,GAAG,OAAO,aAAa,MAAM,EAAE,KAAK,KAAK,GAAG,OAAO,KAAK,MAAM,EAAE,KAAK,KAAK,GAAG,OAAO,eAAe,MAAM,EAAE,GAC/R;CACF;CACA,MAAM,KAAK,EAAE;CACb,KAAK,MAAM,KAAK,OAAO,OAAO,MAAM,KAAK,mBAAmB,GAAG,OAAO,OAAO,GAAG,EAAE;CAClF,MAAM,KAAK,mCAAmC,EAAE;CAChD,KAAK,MAAM,KAAK,OAAO,cAAc,MAAM,KAAK,SAAS,GAAG;CAC5D,MAAM,KAAK,EAAE;CACb,OAAO,MAAM,KAAK,IAAI;AACxB;;AAGA,SAAgB,YAAY,QAA6C;CACvE,MAAM,QAAgC;EACpC,eAAe,GAAG,KAAK,UAAU,QAAQ,MAAM,CAAC,EAAE;EAClD,aAAa,eAAe,MAAM;CACpC;CACA,KAAK,MAAM,KAAK,OAAO,OAAO;EAC5B,IAAI,EAAE,WAAW,WAAW;EAC5B,MAAM,OAAO,SAAS,EAAE,IAAI;EAC5B,MAAM,SAAS,KAAK,UAAU,GAAG,KAAK,UAAU,GAAG,MAAM,CAAC,EAAE;EAC5D,MAAM,SAAS,KAAK,QAClB,GAAG,mBAAmB,GAAG,OAAO,OAAO,EAAE,QAAQ,OAAO,WAAW;CACvE;CACA,OAAO;AACT;;AAGA,SAAgB,cAAc,QAA6B;CACzD,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,KAAK,OAAO,OACrB,IAAI,EAAE,WAAW,WAAW,MAAM,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,OAAO,EAAE;MACzE,IAAI,EAAE,SAAS,WAAW,GAAG,MAAM,KAAK,QAAQ,EAAE,MAAM;MACxD;EACH,MAAM,KAAK,QAAQ,EAAE,MAAM;EAC3B,KAAK,MAAM,KAAK,EAAE,UAAU,MAAM,KAAK,UAAU,EAAE,SAAS;CAC9D;CAEF,MAAM,KAAK,IAAI,OAAO,SAAS,8BAA8B,gBAAgB;CAC7E,MAAM,KAAK,8EAA8E;CACzF,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;;;;;;;;;;AC9XA,MAAM,UACJ;;AAGF,eAAe,aAAgB,MAA0B;CACvD,IAAI;EAGF,OAAQ,MAAM;;GAA0B;;CAC1C,SAAS,KAAK;EACZ,MAAM,OAAQ,IAA0B;EACxC,IAAI,SAAS,0BAA0B,SAAS,oBAC9C,MAAM,IAAI,MACR,uBAAuB,KAAK,uEACZ,WAChB,EAAE,OAAO,IAAI,CACf;EACF,MAAM;CACR;AACF;;AAGA,SAAgB,WAAW,MAAgC;CACzD,OAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,IAAI,aAAa;EACvB,EAAE,KAAK,eAAe,QAAQ,KAAK,CAAC;EACpC,EAAE,OAAO,MAAM,mBAAmB,EAAE,YAAY,QAAQ,IAAI,CAAC,CAAC;CAChE,CAAC;AACH;AAEA,SAAS,WAA4B;CACnC,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,IAAI,aAAa;EACvB,EAAE,KAAK,SAAS,MAAM;EACtB,EAAE,OAAO,GAAG,mBAAmB;GAC7B,MAAM,EAAE,SAAS,EAAE,QAAQ;GAC3B,EAAE,YAAY,QAAQ,IAAI,CAAC;EAC7B,CAAC;CACH,CAAC;AACH;;AAGA,SAAgB,WAAW,KAAa,MAAgB,KAA4B;CAClF,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,QAAQ,MAAM,KAAK,MAAM;GAAE;GAAK,OAAO;GAAW,OAAO,QAAQ,aAAa;EAAQ,CAAC;EAC7F,MAAM,KAAK,SAAS,MAAM;EAC1B,MAAM,KAAK,SAAS,SAClB,SAAS,IAAI,QAAQ,IAAI,uBAAO,IAAI,MAAM,GAAG,IAAI,GAAG,KAAK,KAAK,GAAG,EAAE,eAAe,MAAM,CAAC,CAC3F;CACF,CAAC;AACH;;;;;;;;;AAeA,eAAsB,aAAa,MAIR;CACzB,IAAI,CAAE,MAAM,WAAW,KAAK,IAAI,GAC9B,MAAM,IAAI,MACR,QAAQ,KAAK,KAAK,8IAEpB;CACF,MAAM,UAAU,oBAAoB,KAAK;CACzC,MAAM,QAAsB,MAC1B,OACA;EAAC;EAAgB;EAAS;EAAW;EAAU;EAAa;EAAU,OAAO,KAAK,IAAI;CAAC,GACvF;EAAE,KAAK,KAAK;EAAK,OAAO;GAAC;GAAU;GAAQ;EAAM;EAAG,UAAU,QAAQ,aAAa;CAAQ,CAC7F;CACA,IAAI,MAAM;CACV,MAAM,QAAQ,GAAG,SAAS,MAAO,OAAO,OAAO,CAAC,CAAE;CAClD,MAAM,QAAQ,GAAG,SAAS,MAAO,OAAO,OAAO,CAAC,CAAE;CAClD,IAAI;CACJ,MAAM,KAAK,SAAS,SAAU,SAAS,IAAK;CAE5C,MAAM,aAAa;EACjB,IAAI;GACF,IAAI,MAAM,OAAO,QAAQ,aAAa,SAAS,QAAQ,KAAK,CAAC,MAAM,KAAK,SAAS;QAC5E,MAAM,KAAK,SAAS;EAC3B,QAAQ,CAER;CACF;CAEA,MAAM,WAAW,KAAK,IAAI,KAAK,KAAK,aAAa;CACjD,OAAO,KAAK,IAAI,IAAI,UAAU;EAC5B,IAAI,WAAW,QACb,MAAM,IAAI,MACR,oCAAoC,OAAO,yCAAyC,IAAI,KAAK,GAC/F;EAEF,IAAI;GACF,MAAM,MAAM,SAAS,EAAE,UAAU,SAAS,CAAC;GAC3C,OAAO;IAAE;IAAS;GAAK;EACzB,QAAQ;GACN,MAAM,IAAI,SAAS,MAAM,WAAW,GAAG,GAAG,CAAC;EAC7C;CACF;CACA,KAAK;CACL,MAAM,IAAI,MAAM,mCAAmC,QAAQ,wBAAwB,IAAI,KAAK,GAAG;AACjG;AAuBA,SAAS,aAAa,KAAgC;CACpD,OAAO,IAAI,WAAW,KAAK,OAAO;EAChC,IAAI,EAAE;EACN,QAAQ,gBAAgB,EAAE,MAAM;EAChC,MAAM,EAAE;EACR,SAAS,EAAE;EACX,WAAW,EAAE,MAAM;EACnB,SAAS,EAAE,MAAM,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,MAAM,OAAO,EAAE,OAAO,KAAK,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC;CAC3E,EAAE;AACJ;AAEA,SAAS,UAAU,KAAiC;CAClD,MAAM,IAAI,IAAI,IAAI;CAClB,MAAM,SAAS,OAAe;EAC5B,MAAM,IAAI,IAAI,IAAI,WAAW,GAAG,EAAE;EAClC,OAAO,KAAK,OAAO,SAAY,KAAK,MAAM,IAAI,GAAG;CACnD;CACA,OAAO;EACL,OAAO,EAAE,2BAA2B,EAAE;EACtC,KAAK,EAAE,0BAA0B,EAAE;EACnC,OAAO,EAAE,sBAAsB,EAAE;EACjC,QAAQ;GACN,aAAa,MAAM,aAAa;GAChC,KAAK,MAAM,KAAK;GAChB,eAAe,MAAM,gBAAgB;EACvC;CACF;AACF;;AAWA,eAAsB,SAAS,MAAyC;CACtE,MAAM,MAAM,KAAK,qBAAqB,CAAC;CACvC,MAAM,CAAC,IAAI,KAAK,MAAM,MAAM,QAAQ,IAAI;EACtC,aAYG,YAAY;EACf,aAIG,sBAAsB;EACzB,aAMG,YAAY;CACjB,CAAC;CAED,MAAM,YAAY,MAAM,SAAS;CACjC,IAAI;CACJ,IAAI;EACF,UAAU,MAAM,GAAG,SAAS,OAAO;GACjC,UAAU;GACV,MAAM,CAAC,2BAA2B,WAAW;EAC/C,CAAC;CACH,SAAS,KAAK;EACZ,MAAM,IAAI,MACR,8BAA+B,IAAc,QAAQ,yDACrD,EAAE,OAAO,IAAI,CACf;CACF;CAEA,MAAM,UAAwB,CAAC;CAC/B,IAAI;EACF,KAAK,MAAM,QAAQ,KAAK,OAAO;GAC7B,MAAM,MAAM,IAAI,IAAI,MAAM,KAAK,OAAO,CAAC,CAAC;GACxC,IAAI;GACJ,IAAI;IACF,cAAc,MAAM,MAAM,KAAK,EAAE,UAAU,SAAS,CAAC,EAAC,CAAE;GAC1D,QAAQ;IACN,aAAa;GACf;GACA,MAAM,UAAU,eAAe,MAAM,UAAU;GAC/C,IAAI,QAAQ,WAAW,WAAW;IAChC,IAAI,GAAG,KAAK,IAAI,QAAQ,OAAO,IAAI,QAAQ,OAAO,EAAE;IACpD,QAAQ,KAAK,cAAc,MAAM,KAAK,YAAY,OAAgB,CAAC;IACnE;GACF;GAEA,IAAI,GAAG,KAAK,MAAM;GAElB,MAAM,UAAU,MAAM,QAAQ,WAAW;GACzC,MAAM,OAAO,MAAM,QAAQ,QAAQ;GACnC,IAAI;GACJ,IAAI;IACF,MAAM,KAAK,KAAK,KAAK,EAAE,WAAW,OAAO,CAAC;IAC1C,aAAa,aAAa,MAAM,IAAI,IAAI,WAAW,EAAE,KAAK,CAAC,CAAC,CAAC,SAAS,QAAQ,CAAC,CAAC,QAAQ,CAAC;GAC3F,UAAU;IACR,MAAM,QAAQ,MAAM;GACtB;GAEA,IAAI,GAAG,KAAK,aAAa;GACzB,MAAM,QAAiC;IACrC,MAAM;IACN,QAAQ;IACR,UAAU;IACV,gBAAgB;KAAC;KAAe;KAAO;IAAgB;IACvD,YAAY,KAAK;IACjB,GAAI,KAAK,eAAe,YACpB;KACE,iBAAiB;MACf,QAAQ;MACR,OAAO;MACP,QAAQ;MACR,mBAAmB;MACnB,UAAU;KACZ;KACA,YAAY;MACV,OAAO;MACP,gBAAgB;MAChB,uBAAuB;MACvB,kBAAkB;MAClB,wBAAwB;MACxB,sBAAsB;KACxB;IACF,IACA,CAAC;GACP;GACA,MAAM,MAAM,MAAM,GAAG,QAAQ,KAAK,KAAK;GACvC,MAAM,UAAuB,MAAM,UAAU,GAAG,IAAI,EAAE,QAAQ,CAAC,EAAE;GACjE,QAAQ,KAAK,YAAY,MAAM,KAAK,YAAa,SAAS,YAAY,KAAK,OAAO,CAAC;EACrF;CACF,UAAU;EACR,MAAM,QAAQ,MAAM;CACtB;CACA,OAAO;AACT"}
|
package/dist/color/index.d.mts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//#region src/color/colors.d.ts
|
|
2
1
|
export interface RgbResult {
|
|
3
2
|
r: number;
|
|
4
3
|
g: number;
|
|
@@ -152,8 +151,13 @@ export declare function contrastLc(fg: string, bg: string): number;
|
|
|
152
151
|
/**
|
|
153
152
|
* Pick a tinted near-white or near-black foreground for best contrast on `bg`
|
|
154
153
|
* (a whisper of the background's hue mixed into off-white/off-black).
|
|
154
|
+
*
|
|
155
|
+
* `prefer` forces the polarity instead of choosing the better one — for a role
|
|
156
|
+
* whose author wants dark text on a bright fill (or the reverse). Whether that
|
|
157
|
+
* pairing is legible is the caller's contrast check to decide, not this
|
|
158
|
+
* function's.
|
|
155
159
|
*/
|
|
156
|
-
export declare function pickOn(bg: string, hue?: number): string;
|
|
160
|
+
export declare function pickOn(bg: string, hue?: number, prefer?: 'light' | 'dark'): string;
|
|
157
161
|
/**
|
|
158
162
|
* The tinted off-white / off-black endpoints for a hue — white/black with a
|
|
159
163
|
* whisper of the hue mixed in. These are the text/background tints every ramp
|
|
@@ -168,8 +172,6 @@ export declare function tintedEndpoints(hue: number, chroma?: number): {
|
|
|
168
172
|
light: string;
|
|
169
173
|
dark: string;
|
|
170
174
|
};
|
|
171
|
-
//#endregion
|
|
172
|
-
//#region src/color/color-harmony.d.ts
|
|
173
175
|
/**
|
|
174
176
|
* Color harmony strategies for generating complementary colors
|
|
175
177
|
*/
|
|
@@ -207,5 +209,5 @@ export declare const DEFAULT_FUNCTIONAL_HUES: {
|
|
|
207
209
|
* Suggests a functional color hue based on existing palette to maintain harmony
|
|
208
210
|
*/
|
|
209
211
|
export declare function suggestFunctionalColorHue(type: keyof typeof DEFAULT_FUNCTIONAL_HUES, existingHues: number[]): number;
|
|
210
|
-
|
|
212
|
+
export {};
|
|
211
213
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"mappings":"A,iBAiBiB;EACf;EACA;EACA;EACA;;EAEA;;;;;;wBAOc,YAAY,WAAW,WAAW,YAAY;;;;;wBAmB9C,UAAU,WAAW,WAAW,YAAY;;;;;wBAmB5C,mBACd,WACA,WACA,WACA;qBAUW;YACD,mBAAmB;qBAElB;YAWD,qBAAqB;;;;wBAKjB,WAAW;EAAgB;EAAW;EAAW;;wBAUjD,WAAW;EAAS;EAAW;EAAW;;;;;;wBAa1C,iBAAiB;;;;;wBAiBjB,gBAAgB;;;;wBA8GhB,aAAa;;;;wBAsBb,oBAAoB;qBAcvB;YASD,sBAAsB;iBAEjB;EACf;EACA;EACA;;qBAGW,qBAAqB,OAAO,aAAa;;;;;;;;;;;;;;;qBAoDzC,kBAAkB,OAAO;;;;;;;;;;;;;;;;;;;;qBAiCzB;;;;UAEH;EACR;EACA;EACA;;;;;;;wBAQc,WAAW,sBAAsB;;;;;wBAgCjC,sBAAsB,oBAAoB;;;;;;;;;wBAuB1C,gBAAgB,MAAM,UAAU;;;;;;;;;;;;;;;;;;;wBAyBhC,qBACd,2BAA2B,yBAC1B,OAAO;;wBAsFM,WAAW,YAAY;;;;;;;;;;wBAavB,OAAO,YAAY,cAAc;;;;;;;;;;;wBAmBjC,gBAAgB,aAAa;EAAoB;EAAe;;;;;YCtlBpE;iBAWK;EACf,IAAI;EACJ;EACA;EACA;;qBAGW,oBAAoB;;;;wBA4DjB,qBAAqB,iBAAiB,UAAU;;;;wBAgBhD,sBACd,kBACC;EAAQ,UAAU;EAAiB;EAAa;EAAe;;;;;qBAYrD;;;;;;;;;wBAUG,0BACd,mBAAmB,yBACnB"}
|