@bounded-systems/conformance-kit 0.2.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/LICENSE +21 -0
- package/README.md +143 -0
- package/emitters/index.mjs +68 -0
- package/gates/axe-gate.mjs +325 -0
- package/gates/baseline-gate.mjs +112 -0
- package/gates/commonmark-runner.mjs +98 -0
- package/gates/conformance/conformance.mjs +389 -0
- package/gates/conformance/web-build.mjs +568 -0
- package/gates/conformance-report.mjs +128 -0
- package/gates/html-validator-gate.mjs +120 -0
- package/gates/readability-gate.mjs +134 -0
- package/gates/sbom/check-sbom.mjs +112 -0
- package/gates/sbom/gen-sbom.mjs +167 -0
- package/gates/semantic/deno.json +7 -0
- package/gates/semantic/gate.ts +34 -0
- package/gates/seo-gate.mjs +208 -0
- package/gates/shacl-runner.mjs +160 -0
- package/gates/vuln-gate.mjs +101 -0
- package/generators/gen-cid.mjs +144 -0
- package/generators/gen-identity.mjs +120 -0
- package/generators/gen-snapshots.mjs +108 -0
- package/generators/openapi.mjs +61 -0
- package/integrity/gen-provenance.mjs +137 -0
- package/integrity/gen-sitemanifest.mjs +66 -0
- package/integrity/http-probe.mjs +131 -0
- package/integrity/structure-audit/audit.mjs +159 -0
- package/integrity/structure-audit/package.json +12 -0
- package/integrity/verify/README.md +40 -0
- package/integrity/verify/verify.mjs +107 -0
- package/integrity/verify-site.mjs +160 -0
- package/lib/config.mjs +36 -0
- package/lib/schema-validate.mjs +68 -0
- package/package.json +71 -0
- package/provenance.json +41 -0
- package/vendor.example.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bounded Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @bounded-systems/conformance-kit
|
|
2
|
+
|
|
3
|
+
A standalone, **site-agnostic web-conformance toolkit**: build-integrity tooling,
|
|
4
|
+
fail-closed conformance gates, and provenance generators — extracted from
|
|
5
|
+
`bdelanghe/site` and `bounded-systems/site` and **generalized** so a site vendors
|
|
6
|
+
**one kit** instead of duplicating scripts.
|
|
7
|
+
|
|
8
|
+
Every site value (paths, thresholds, site URL, account/repo id, issuer/DID, SHACL
|
|
9
|
+
shapes, the markdown renderer, the prose corpus, the build itself) is an **INPUT**,
|
|
10
|
+
injected by the consumer via CLI args, env vars, or a passed config. Nothing here
|
|
11
|
+
hardcodes `robertdelanghe.dev`, `bounded.tools`, an account, or an email.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
integrity/ verify-site · verify (sigstore) · gen-sitemanifest · gen-provenance · structure-audit · http-probe
|
|
15
|
+
gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · vuln-gate (npm audit) · html-validator-gate (vnu) · baseline-gate (web-features) · readability-gate · commonmark-runner · semantic (lone)
|
|
16
|
+
gates/conformance/ conformance-report — lone's conformance() projection (Node port of jsr:@bounded-systems/lone@0.4) + a generic HTML renderer
|
|
17
|
+
generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · gen-snapshots (reader/markdown) · openapi (static-API helper core)
|
|
18
|
+
emitters/ reprDigest (RFC 9530) · securityTxt (RFC 9116) · webManifest · markdown-sibling headers
|
|
19
|
+
lib/ schema-validate (zero-dep JSON Schema) · config (env/arg helpers)
|
|
20
|
+
fixtures/ test/ isolated verification of the generic logic
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Design rules: zero-dep where the source was zero-dep; pure/offline gates read only
|
|
24
|
+
the built output; deterministic generators are a function of their inputs (no wall
|
|
25
|
+
clock); fail-closed (`exit 1`) on any violation.
|
|
26
|
+
|
|
27
|
+
## Install / vendor
|
|
28
|
+
|
|
29
|
+
Three consumption models:
|
|
30
|
+
|
|
31
|
+
1. **Vendor (recommended, matches the existing `vendor/integrity/` pattern).** Copy
|
|
32
|
+
the kit at a pinned commit into `vendor/conformance-kit/`, write a hash-pin
|
|
33
|
+
manifest (see [`vendor.example.json`](./vendor.example.json) — mirrors
|
|
34
|
+
`bdelanghe/site` `vendor/integrity/provenance.json`: `source`, `commit`,
|
|
35
|
+
`fetched`, `files{path: sha256}`), and verify against it before every use. The
|
|
36
|
+
site then `import`s / invokes the vendored copies. The kit's own
|
|
37
|
+
[`provenance.json`](./provenance.json) records which source repo + commit each
|
|
38
|
+
tool was generalized from.
|
|
39
|
+
2. **npm dep.** `npm i @bounded-systems/conformance-kit` and use the `ck-*` bins
|
|
40
|
+
(see `package.json`) or `import` the library modules.
|
|
41
|
+
3. **Nix flake (reproducible, runtime-bundled).** `nix run
|
|
42
|
+
github:bounded-systems/conformance-kit#ck-axe-gate -- dist`, or add the flake to
|
|
43
|
+
a `home-manager` / `nix profile`. Each `ck-*` bin is a hermetic, pinned closure;
|
|
44
|
+
the gates that shell out get their runtime bundled in — `ck-html-validator-gate`
|
|
45
|
+
carries a JRE for vnu, `ck-vuln-gate` carries npm — so no JRE/Node on `$PATH` is
|
|
46
|
+
needed. (`ck-axe-gate` still needs a browser the consumer supplies via
|
|
47
|
+
`$AXE_RUNNER`: `tezcatl` or Playwright.)
|
|
48
|
+
|
|
49
|
+
Runtime deps are declared in `package.json` (only the gates that need them pull
|
|
50
|
+
them: `linkedom`/`@mozilla/readability` for structure-audit; `jsonld`/`n3`/
|
|
51
|
+
`@zazuko/env-node`/`rdf-validate-shacl` for the SHACL runner; `sigstore` for the
|
|
52
|
+
in-process verifier). The Deno semantic runner pins its imports in
|
|
53
|
+
`gates/semantic/deno.json`.
|
|
54
|
+
|
|
55
|
+
## Tools — what each does + **how a site consumes it** (the input it must supply)
|
|
56
|
+
|
|
57
|
+
### integrity/
|
|
58
|
+
|
|
59
|
+
| Tool | Invoke | Consumer supplies |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `gen-sitemanifest.mjs` | `DIST=dist node …/gen-sitemanifest.mjs` | `$DIST` (build dir). Optional `$MANIFEST_EXCLUDE` (extra platform control files). Emits `$DIST/site.sha256`. |
|
|
62
|
+
| `gen-provenance.mjs` | run at deploy after signing | GitHub Actions env (`GITHUB_*`), `$OCI_REF`/`$OCI_DIGEST`, optional `$PROVENANCE_DOC_URL`, `$DIST`. The emitted `builder.repository` becomes the identity the verifiers enforce. |
|
|
63
|
+
| `verify-site.mjs` | `node …/verify-site.mjs <https://site \| ./dist>` | A deployed site (or local dir) carrying `provenance.json` + `site.sha256` + its `.sigstore.json` bundle. Identity is read from `provenance.builder.repository` — nothing hardcoded. Shells to `cosign` if present, else SKIPs with a recipe. |
|
|
64
|
+
| `verify/verify.mjs` | `node …/verify/verify.mjs <url\|dir>` | Same inputs; verifies the Sigstore **bundle** in-process (offline) via `sigstore-js`. |
|
|
65
|
+
| `structure-audit/audit.mjs` | `node …/audit.mjs <distDir> [--check]` | `<distDir>`. Optional `$STRUCTURE_ARTICLE_PREFIX` (default `blog/`), `$STRUCTURE_ERROR_PAGE` (default `404.html`), `$STRUCTURE_AUDIT_SIDECARS` (deploy-time live paths, e.g. `/resume.pdf`), `$STRUCTURE_BASELINE` (where the committed `structure.json` lives — keep it in the **consumer**, not the vendored kit). |
|
|
66
|
+
| `http-probe.mjs` | `node …/http-probe.mjs <https://site> [config.json]` | A live URL **and** a probe config: `$PROBE_CONFIG`/2nd arg JSON `{htmlRoutes,typed,missing}`, or `$PROBE_HTML_ROUTES`+`$PROBE_MISSING`. Routes are NOT hardcoded. |
|
|
67
|
+
|
|
68
|
+
### gates/
|
|
69
|
+
|
|
70
|
+
| Tool | Invoke | Consumer supplies |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `sbom/gen-sbom.mjs` | `ROOT=. DIST=dist node …/gen-sbom.mjs` | `$ROOT` (lockfiles live here), `$SBOM_LOCKFILES` (comma list, default `package-lock.json`), `$SBOM_NAME`, `$SBOM_NAMESPACE_BASE`, `$SBOM_CREATORS`. Reads `flake.lock` if present. Emits `$DIST/sbom.spdx.json`. |
|
|
73
|
+
| `sbom/check-sbom.mjs` | `ROOT=. DIST=dist node …/check-sbom.mjs` | Same `$ROOT`/`$DIST`. Fails closed unless pinned-set ⊆ SBOM ⊆ pinned-set and (optionally) the in-toto attestation reconciles. |
|
|
74
|
+
| `shacl-runner.mjs` | `node …/shacl-runner.mjs <shapes.ttl> <htmlDir>` | **The SHACL shapes file stays in the site** (its structured-data contract) + the built-HTML dir. Optional `$SHACL_CONTEXT` (custom offline JSON-LD context; default schema.org). Fails unless every JSON-LD block `conforms: true`. |
|
|
75
|
+
| `seo-gate.mjs` | `node …/seo-gate.mjs [distDir]` | `$DIST`. Optional `$SEO_ERROR_PAGE`, `$SEO_DEPLOY_SIDECARS`. Enforces canonical/title/description uniqueness + self-consistency, robots.txt (RFC 9309), sitemap, internal links. |
|
|
76
|
+
| `axe-gate.mjs` | `node …/axe-gate.mjs [distDir]` | `$DIST`. Optional `$AXE_PAGES` (comma list, default: every `*.html` in dist), `$AXE_TAGS` (default `wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa`), `$AXE_IMPACT_THRESHOLD` (`minor`/`moderate`/`serious`/`critical`, default `serious`), `$AXE_RUNNER` (`playwright` (CI, needs `playwright` + `@axe-core/playwright` + `npx playwright install chromium`) \| `tezcatl` (macOS WebKit, local)), `$AXE_REPORT` (write the JSON report). Serves dist over an ephemeral origin (so assets resolve), runs **axe-core** per page, and **fails closed** on any violation at/above the threshold. The emitted report's `axe: { serious, critical }` envelope is exactly what `conformance-report`'s `a11y.axe-serious-critical` criterion consumes — a clean run is what lets a site honestly assert it. |
|
|
77
|
+
| `vuln-gate.mjs` | `node …/vuln-gate.mjs [projectDir]` | `$VULN_ROOT` (lockfile lives here, default `.`). Optional `$VULN_OMIT_DEV` (`true`→production deps only, default `true`), `$VULN_THRESHOLD` (highest tolerated known critical/high, default `0`), `$VULN_REPORT` (write the JSON report). Runs **`npm audit`** and **fails closed** when the known critical/high count exceeds the threshold. The report's `vulns: { knownCriticalOrHighVulns }` envelope is what `conformance-report`'s `security.no-critical-vulns` criterion consumes. |
|
|
78
|
+
| `html-validator-gate.mjs` | `node …/html-validator-gate.mjs [distDir]` | `$HTML_DIST`. Optional `$HTML_PAGES` (comma list, default: every `*.html`), `$HTML_THRESHOLD` (default `0`), `$HTML_REPORT`. Runs **vnu** (the Nu Html Checker, a self-contained Java jar — needs a JRE) `--errors-only` over the built pages and **fails closed** above the threshold. The report's `htmlValidator: { errors }` envelope is what `conformance-report`'s `html.validator-clean` criterion consumes. |
|
|
79
|
+
| `baseline-gate.mjs` | `node …/baseline-gate.mjs [cssGlob]` | `$BASELINE_CSS` (default `dist/**/*.css`). Optional `$BASELINE_TARGET` (`widely`/`newly`, default `widely`), `$BASELINE_REPORT`. Maps the shipped CSS to **web-features Baseline** data (via `stylelint-plugin-use-baseline` — headless, no browser) and **fails closed** when the site-wide status is below target. A feature behind an `@supports` query is a tested fallback and doesn't count against it. The report's `baseline: { status, fallbackTested }` envelope is what `conformance-report`'s `compatibility.baseline` criterion consumes. |
|
|
80
|
+
| `readability-gate.mjs` | `node …/readability-gate.mjs <corpus.json> [--strict]` | **The corpus is an input** the site assembles from its copy: a JSON array of `{id,text}` or an `{id:text}` map. Optional `$READABILITY_THRESHOLDS`, `$READABILITY_MIN_WORDS`, `$READABILITY_KNOWN_ACRONYMS`. WARN-only unless `--strict`. |
|
|
81
|
+
| `commonmark-runner.mjs` | `node …/commonmark-runner.mjs <renderer.mjs> [fixtures.json]` | **The site's markdown renderer module** (export `renderMarkdown`, or set `$COMMONMARK_RENDER_EXPORT`). Default fixtures pin a safe CommonMark subset + 4 hostile-HTML escapes; a site with a different renderer supplies its own `fixtures.json`. |
|
|
82
|
+
| `semantic/gate.ts` | `deno run --allow-read --allow-net …/gate.ts` | Built HTML in `$SEMANTIC_DIR` (default `dist/blog`); `$SEMANTIC_SELECTOR` (subject node, default `article`). Imports `jsr:@bounded-systems/lone`; any error-severity finding fails CI. |
|
|
83
|
+
| `conformance-report.mjs` | `import { buildConformanceReport, renderConformanceReport } from "…/gates/conformance-report.mjs"` | **The site's evidence** — `loneFindings` (the semantic gate's DOM findings, or `null` when no DOM was blessed → those criteria report `not-assessed`) + an external-evidence envelope whose fields it gathers from its own gates (`jsonLdShacl`, `sbom`, `contentDigests`, `slsaProvenance`, …). `renderConformanceReport(report, { evidenceHref })` → a class-based HTML fragment; the consumer wraps it in its template and supplies per-criterion evidence URLs. Zero-dep; the conformance MODEL is a Node port of `jsr:@bounded-systems/lone@0.4`'s `conformance()` in `gates/conformance/`. |
|
|
84
|
+
|
|
85
|
+
The conformance projection makes overclaim impossible by construction: the strong
|
|
86
|
+
compact claim (`COMPACT_CLAIM`) is emitted **only** when every tier-1 `required`
|
|
87
|
+
criterion has passing evidence; unsupplied criteria (manual WCAG audit, OWASP ASVS,
|
|
88
|
+
field Core Web Vitals, Baseline) are `not-assessed`, never `met` — so automation can
|
|
89
|
+
never print "WCAG 2.2 AA" or "ASVS conformant" on its own. tier-2/tier-3/cognitive
|
|
90
|
+
criteria are reported + summarised per area but never widen the headline claim.
|
|
91
|
+
|
|
92
|
+
### generators/
|
|
93
|
+
|
|
94
|
+
| Tool | Invoke | Consumer supplies |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `gen-cid.mjs` | `DIST=dist node …/gen-cid.mjs` | `$DIST`. Walks the `site.sha256` file set (or `dist`), computes the IPFS UnixFS dir CIDv1 with no daemon, records it into `$DIST/provenance.json`. |
|
|
97
|
+
| `gen-identity.mjs` | `IDENTITY_DOMAIN=… IDENTITY_REPO=owner/repo node …/gen-identity.mjs` | `$IDENTITY_DOMAIN`, `$IDENTITY_REPO` (cert-identity regexp), `$IDENTITY_SUBJECT` (the credentialSubject JSON, default `$DIST/resume.json`), optional `$IDENTITY_SUBJECT_SCHEMA`, `$IDENTITY_VC_NAME/DESCRIPTION`, `$IDENTITY_VALID_FROM_PATH`. Emits `did.json` + a W3C VC 2.0. |
|
|
98
|
+
| `gen-snapshots.mjs` | `node …/gen-snapshots.mjs [distDir]` | `$SNAPSHOT_DIST` (default `dist`). Optional `$SNAPSHOT_PAGES`, `$SNAPSHOT_BASE_URL` (recorded as `source` in the front-matter), `$SNAPSHOT_SUFFIX` (default `.reader`). For every built page, runs **@mozilla/readability** (the Firefox/Safari Reader engine, via `linkedom` — headless, no browser) and writes a clean reader **`<page>.reader.html`** + an analysis-friendly **`<page>.reader.md`** (YAML front-matter + Markdown via `turndown`). The Markdown is the durable, diffable twin of the page — far easier to run NLP/LLM analysis over than scraping live HTML — and doubles as the AI-readable Markdown sibling. (The printed/PDF view needs a print-CSS renderer and is a separate generator.) |
|
|
99
|
+
| `openapi.mjs` | `import { sortKeys, writeApiFile, embedSchema, jsonResponse, validateOpenapi }` | The **generic core** of a static-API generator. The per-endpoint projection of a site's contracts (profile/posts/corpus/VC, etc.) stays in the site's build; this module provides deterministic JSON output, schema embedding, and OpenAPI 3.1/3.2 well-formedness validation. Pair with `lib/schema-validate.mjs` to self-check emitted docs. |
|
|
100
|
+
|
|
101
|
+
### emitters/
|
|
102
|
+
|
|
103
|
+
`import { reprDigest, securityTxt, securityTxtExpires, webManifest, markdownSiblingHeaders } from "…/emitters/index.mjs"` — pure helpers a site's own `build.mjs` calls to emit standards-compliant artifacts (RFC 9530 `Repr-Digest`, RFC 9116 `security.txt`, the W3C web app manifest, the `_headers` Content-Type rules for `.md` siblings). All values injected; the page **content** stays in the site.
|
|
104
|
+
|
|
105
|
+
## `@bounded-systems/verify` (vendored here; published elsewhere)
|
|
106
|
+
|
|
107
|
+
The in-process Sigstore verifier (`integrity/verify/verify.mjs`) is **vendored** in
|
|
108
|
+
this kit so sites can pull it into a hermetic build. It is no longer **published**
|
|
109
|
+
from here: the canonical home of the [`@bounded-systems/verify`](https://jsr.io/@bounded-systems/verify)
|
|
110
|
+
JSR package is now its own repo,
|
|
111
|
+
[`bounded-systems/verify`](https://github.com/bounded-systems/verify). That repo owns
|
|
112
|
+
the package manifest (`deno.json`) and the keyless-OIDC release workflow; cut releases
|
|
113
|
+
there. The copy here is kept byte-for-byte in sync with the published source.
|
|
114
|
+
|
|
115
|
+
Consumers run it straight from JSR:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
deno run -A jsr:@bounded-systems/verify https://your-site
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Test
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
npm install && npm test # 13 cases against fixtures/, in isolation
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The suite verifies the generic logic end-to-end: gen-sbom against a sample lockfile;
|
|
128
|
+
shacl-runner against sample shapes+HTML → `conforms: true`; structure-audit / seo /
|
|
129
|
+
readability / commonmark against sample inputs; gen-sitemanifest + gen-cid + verify-site
|
|
130
|
+
round-trip on a sample build; gen-identity; the emitter/openapi/schema helpers; the
|
|
131
|
+
conformance projection; and the **axe-gate** (its classification/threshold/report logic
|
|
132
|
+
deterministically, plus a real end-to-end pass on the known-bad + known-good
|
|
133
|
+
`fixtures/axe/` snippets when a browser engine — tezcatl or Playwright/Chromium — is on
|
|
134
|
+
PATH; skipped, like the cosign step, when none is). (The Deno semantic runner is
|
|
135
|
+
exercised by the consuming site, as it needs Deno + JSR.)
|
|
136
|
+
|
|
137
|
+
## Provenance / determinism
|
|
138
|
+
|
|
139
|
+
The gates are pure functions of the built output; the generators are deterministic
|
|
140
|
+
functions of their inputs (the SBOM creation date is derived from `flake.lock`, never
|
|
141
|
+
a wall clock; the CID re-derives from the served bytes with any IPFS implementation).
|
|
142
|
+
Site-specific artifacts — SHACL shapes, the prose corpus, the markdown renderer,
|
|
143
|
+
thresholds, copy, and `build.mjs` itself — are inputs, never part of the kit.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// emitters — pure helpers for the standards-compliant build artifacts a site's
|
|
2
|
+
// own build.mjs emits. Each is a PURE FUNCTION of injected config; nothing here
|
|
3
|
+
// reads a site's content model, so the actual copy/data stays in the consumer.
|
|
4
|
+
// Extracted from the emitters embedded in bdelanghe/site/build.mjs.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// RFC 9530 representation digest: sha-256 of a doc's exact served bytes, as a
|
|
8
|
+
// structured-field byte sequence — `sha-256=:<base64>:`. Compute over the bytes the
|
|
9
|
+
// build itself writes (self-contained), per canonical document, then add as a
|
|
10
|
+
// `Repr-Digest:` response header for that route.
|
|
11
|
+
export const reprDigest = (buf) =>
|
|
12
|
+
"sha-256=:" + createHash("sha256").update(Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).digest("base64") + ":";
|
|
13
|
+
|
|
14
|
+
// RFC 9116 /.well-known/security.txt — a machine-readable security-contact channel.
|
|
15
|
+
// securityTxt({ contact, canonical, expires, preferredLanguages })
|
|
16
|
+
// `contact` is one or more mailto:/https: values; `expires` is an ISO timestamp (a
|
|
17
|
+
// year out from the build date is the convention, so a weekly rebuild rolls it
|
|
18
|
+
// forward and it never goes stale).
|
|
19
|
+
export function securityTxt({ contact, canonical, expires, preferredLanguages = ["en"] } = {}) {
|
|
20
|
+
if (!contact) throw new Error("securityTxt: `contact` is required (mailto: or https: URL)");
|
|
21
|
+
if (!expires) throw new Error("securityTxt: `expires` (ISO timestamp) is required");
|
|
22
|
+
const contacts = Array.isArray(contact) ? contact : [contact];
|
|
23
|
+
const lines = [
|
|
24
|
+
...contacts.map((c) => `Contact: ${c}`),
|
|
25
|
+
`Expires: ${expires}`,
|
|
26
|
+
...(canonical ? [`Canonical: ${canonical}`] : []),
|
|
27
|
+
...(preferredLanguages?.length ? [`Preferred-Languages: ${preferredLanguages.join(", ")}`] : []),
|
|
28
|
+
];
|
|
29
|
+
return lines.join("\n") + "\n";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `expires` one year out from a reference date (ISO string), the security.txt convention.
|
|
33
|
+
export const securityTxtExpires = (fromISO = new Date().toISOString()) => {
|
|
34
|
+
const d = new Date(fromISO);
|
|
35
|
+
d.setUTCFullYear(d.getUTCFullYear() + 1);
|
|
36
|
+
return d.toISOString();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// W3C Web App Manifest (no service worker). All values injected by the consumer —
|
|
40
|
+
// typically from its design tokens so the <head> theme-color and the manifest can't
|
|
41
|
+
// drift. Returns the manifest object; the caller JSON.stringifies + writes it to
|
|
42
|
+
// /site.webmanifest and serves it as `application/manifest+json`.
|
|
43
|
+
export function webManifest({
|
|
44
|
+
name, shortName, description,
|
|
45
|
+
themeColor, backgroundColor,
|
|
46
|
+
display = "standalone", startUrl = "/", icons = [],
|
|
47
|
+
} = {}) {
|
|
48
|
+
if (!name) throw new Error("webManifest: `name` is required");
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
...(shortName ? { short_name: shortName } : { short_name: name.split(" ")[0] }),
|
|
52
|
+
...(description ? { description } : {}),
|
|
53
|
+
...(themeColor ? { theme_color: themeColor } : {}),
|
|
54
|
+
...(backgroundColor ? { background_color: backgroundColor } : {}),
|
|
55
|
+
display,
|
|
56
|
+
start_url: startUrl,
|
|
57
|
+
icons,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The Cloudflare-_headers Content-Type rules a site needs once it serves Markdown
|
|
62
|
+
// siblings (/index.md, /resume.md, /blog/<slug>.md) + a web app manifest. Returned
|
|
63
|
+
// as a string a consumer concatenates into its own _headers file. The Markdown
|
|
64
|
+
// SIBLING CONTENT itself (rendering a page to text/markdown) is the site's job —
|
|
65
|
+
// only the serving rule is generic.
|
|
66
|
+
export const markdownSiblingHeaders = () =>
|
|
67
|
+
`/*.md\n Content-Type: text/markdown; charset=utf-8\n` +
|
|
68
|
+
`/site.webmanifest\n Content-Type: application/manifest+json; charset=utf-8\n`;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// axe accessibility gate — turns "we ran axe once" into a CONTINUOUSLY-ENFORCED
|
|
3
|
+
// member of the conformance contract. It loads each BUILT page in a real browser,
|
|
4
|
+
// runs axe-core with the WCAG 2.x A/AA ruleset, and FAILS CLOSED (exit 1) on any
|
|
5
|
+
// violation at or above a configurable impact threshold (default: serious). The
|
|
6
|
+
// machine-readable result it emits is exactly the shape lone's conformance() model
|
|
7
|
+
// consumes for `a11y.axe-serious-critical` (`{ serious, critical }`), so a clean run
|
|
8
|
+
// is what lets a site honestly assert that criterion — and a regression turns CI red.
|
|
9
|
+
//
|
|
10
|
+
// node gates/axe-gate.mjs [distDir] # build gate (exit 1 on any blocking violation)
|
|
11
|
+
//
|
|
12
|
+
// Pure data in → typed report out. The browser is the ONLY impurity: axe needs real
|
|
13
|
+
// layout/computed-style (e.g. colour-contrast, target-size), so a DOM shim is not
|
|
14
|
+
// enough. Two interchangeable runners drive a real engine:
|
|
15
|
+
// - playwright (default) — `@axe-core/playwright` + Playwright's bundled Chromium.
|
|
16
|
+
// The CI runner: hermetic, headless, cross-platform.
|
|
17
|
+
// - tezcatl — macOS-native headless WebKit. Injects axe.min.js into the
|
|
18
|
+
// served page and reads the result back. The LOCAL runner (no Chromium download).
|
|
19
|
+
// Both serve dist/ over an ephemeral localhost HTTP origin first, so absolute asset
|
|
20
|
+
// paths (`/assets/…css`, fonts) resolve — running file:// would strip the styles and
|
|
21
|
+
// fabricate layout-dependent violations.
|
|
22
|
+
//
|
|
23
|
+
// Everything is config-driven; NOTHING about any one site is hard-coded:
|
|
24
|
+
// argv[2] / $DIST built output dir (default: "dist")
|
|
25
|
+
// $AXE_PAGES comma list of page paths under dist to scan
|
|
26
|
+
// (default: every *.html discovered in dist)
|
|
27
|
+
// $AXE_TAGS comma list of axe ruleset tags
|
|
28
|
+
// (default: wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa)
|
|
29
|
+
// $AXE_IMPACT_THRESHOLD lowest impact that BLOCKS: minor|moderate|serious|critical
|
|
30
|
+
// (default: serious)
|
|
31
|
+
// $AXE_RUNNER playwright | tezcatl (default: playwright)
|
|
32
|
+
// $AXE_REPORT path to write the JSON report (default: none → stdout only)
|
|
33
|
+
// $AXE_TEZCATL_WAIT ms to let axe settle, tezcatl runner (default: 3000)
|
|
34
|
+
//
|
|
35
|
+
// The pure evaluation/report functions are exported for unit testing without a browser.
|
|
36
|
+
import { readFile, readdir, access, mkdtemp, writeFile } from "node:fs/promises";
|
|
37
|
+
import { createServer } from "node:http";
|
|
38
|
+
import { join, relative, resolve, extname } from "node:path";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { createRequire } from "node:module";
|
|
41
|
+
import { spawn } from "node:child_process";
|
|
42
|
+
|
|
43
|
+
// ── Pure core (browser-free; unit-testable) ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Impact levels, weakest → strongest. A violation BLOCKS when its impact ranks at
|
|
46
|
+
* or above the configured threshold. axe may report `impact: null`; such findings
|
|
47
|
+
* rank below `minor` and so never block (but are still counted/reported). */
|
|
48
|
+
export const IMPACT_ORDER = ["minor", "moderate", "serious", "critical"];
|
|
49
|
+
export const DEFAULT_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
|
50
|
+
export const DEFAULT_THRESHOLD = "serious";
|
|
51
|
+
|
|
52
|
+
export const impactRank = (impact) => IMPACT_ORDER.indexOf(impact); // -1 when null/unknown
|
|
53
|
+
export const blocksAt = (impact, threshold) => {
|
|
54
|
+
const t = impactRank(threshold);
|
|
55
|
+
return t >= 0 && impactRank(impact) >= t;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Normalise one axe violation to the compact, stable shape we report/persist. */
|
|
59
|
+
export function normalizeViolation(v) {
|
|
60
|
+
const nodes = Array.isArray(v.nodes) ? v.nodes : [];
|
|
61
|
+
const targets = nodes
|
|
62
|
+
.map((n) => (Array.isArray(n.target) ? n.target.join(" ") : String(n.target ?? "")))
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
return {
|
|
65
|
+
id: v.id,
|
|
66
|
+
impact: v.impact ?? null,
|
|
67
|
+
help: v.help ?? "",
|
|
68
|
+
helpUrl: v.helpUrl ?? "",
|
|
69
|
+
nodes: nodes.length,
|
|
70
|
+
targets: targets.slice(0, 8), // cap; full detail lives in axe's own helpUrl
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Empty {critical,serious,moderate,minor,unknown} counter. */
|
|
75
|
+
const emptyCounts = () => ({ critical: 0, serious: 0, moderate: 0, minor: 0, unknown: 0 });
|
|
76
|
+
|
|
77
|
+
/** Evaluate one page's raw axe violations against the threshold. */
|
|
78
|
+
export function evaluatePage(page, rawViolations, threshold = DEFAULT_THRESHOLD) {
|
|
79
|
+
const violations = (rawViolations ?? []).map(normalizeViolation);
|
|
80
|
+
const counts = emptyCounts();
|
|
81
|
+
let blocking = 0;
|
|
82
|
+
for (const v of violations) {
|
|
83
|
+
counts[v.impact && v.impact in counts ? v.impact : "unknown"]++;
|
|
84
|
+
if (blocksAt(v.impact, threshold)) blocking++;
|
|
85
|
+
}
|
|
86
|
+
// Group by impact for the machine-readable report (serious/critical first).
|
|
87
|
+
const byImpact = {};
|
|
88
|
+
for (const lvl of [...IMPACT_ORDER].reverse()) {
|
|
89
|
+
const inLvl = violations.filter((v) => v.impact === lvl);
|
|
90
|
+
if (inLvl.length) byImpact[lvl] = inLvl;
|
|
91
|
+
}
|
|
92
|
+
const unknown = violations.filter((v) => impactRank(v.impact) < 0);
|
|
93
|
+
if (unknown.length) byImpact.unknown = unknown;
|
|
94
|
+
return { page, counts, blocking, violations: byImpact };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Fold per-page evaluations into the whole-run report consumed by conformance(). */
|
|
98
|
+
export function summarize(pageResults, { threshold = DEFAULT_THRESHOLD, tags = DEFAULT_TAGS, runner = "playwright" } = {}) {
|
|
99
|
+
const totals = emptyCounts();
|
|
100
|
+
let blocking = 0;
|
|
101
|
+
for (const p of pageResults) {
|
|
102
|
+
for (const k of Object.keys(totals)) totals[k] += p.counts[k];
|
|
103
|
+
blocking += p.blocking;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
tool: "axe-core",
|
|
107
|
+
runner,
|
|
108
|
+
standard: "WCAG 2.x A/AA (axe ruleset)",
|
|
109
|
+
tags,
|
|
110
|
+
impactThreshold: threshold,
|
|
111
|
+
generatedAt: new Date().toISOString(),
|
|
112
|
+
pages: pageResults,
|
|
113
|
+
totals,
|
|
114
|
+
// The exact envelope lone's `a11y.axe-serious-critical` evaluator reads.
|
|
115
|
+
axe: { serious: totals.serious, critical: totals.critical },
|
|
116
|
+
blocking, // count of violations at/above threshold across all pages
|
|
117
|
+
passed: blocking === 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── dist discovery + static origin (shared by both runners) ──────────────────
|
|
122
|
+
|
|
123
|
+
async function walkHtml(dir, base = dir) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
126
|
+
const abs = join(dir, e.name);
|
|
127
|
+
if (e.isDirectory()) out.push(...await walkHtml(abs, base));
|
|
128
|
+
else if (e.name.endsWith(".html")) out.push(relative(base, abs).replace(/\\/g, "/"));
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const MIME = {
|
|
134
|
+
".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "application/javascript",
|
|
135
|
+
".mjs": "application/javascript", ".json": "application/json", ".svg": "image/svg+xml",
|
|
136
|
+
".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon",
|
|
137
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".xml": "application/xml",
|
|
138
|
+
".txt": "text/plain", ".webmanifest": "application/manifest+json", ".pdf": "application/pdf",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Serve `root` over an ephemeral localhost origin. When `inject` is set, HTML
|
|
143
|
+
* responses get axe-core + a runner appended before </body>, and `/__axe-core.js`
|
|
144
|
+
* serves the axe source — used by the tezcatl runner, which cannot inject async JS
|
|
145
|
+
* itself. Returns { origin, close }.
|
|
146
|
+
*/
|
|
147
|
+
async function startServer(root, { inject = false, tags = DEFAULT_TAGS } = {}) {
|
|
148
|
+
let axeSrc = "";
|
|
149
|
+
if (inject) {
|
|
150
|
+
const require = createRequire(import.meta.url);
|
|
151
|
+
axeSrc = await readFile(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
152
|
+
}
|
|
153
|
+
const runnerScript =
|
|
154
|
+
`<script src="/__axe-core.js"></script><script>` +
|
|
155
|
+
`window.addEventListener("load",function(){setTimeout(function(){` +
|
|
156
|
+
`axe.run(document,{runOnly:{type:"tag",values:${JSON.stringify(tags)}}}).then(function(r){` +
|
|
157
|
+
`var e=document.createElement("script");e.type="application/json";e.id="__axe_results";` +
|
|
158
|
+
`e.textContent=JSON.stringify({violations:r.violations});document.documentElement.appendChild(e);` +
|
|
159
|
+
`}).catch(function(err){var e=document.createElement("script");e.type="application/json";` +
|
|
160
|
+
`e.id="__axe_results";e.textContent=JSON.stringify({error:String(err)});document.documentElement.appendChild(e);});` +
|
|
161
|
+
`},150);});</script>`;
|
|
162
|
+
|
|
163
|
+
const server = createServer(async (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
|
166
|
+
if (inject && urlPath === "/__axe-core.js") {
|
|
167
|
+
res.writeHead(200, { "content-type": "application/javascript" });
|
|
168
|
+
return res.end(axeSrc);
|
|
169
|
+
}
|
|
170
|
+
let file = join(root, urlPath);
|
|
171
|
+
if (urlPath.endsWith("/")) file = join(file, "index.html");
|
|
172
|
+
let buf;
|
|
173
|
+
try { buf = await readFile(file); }
|
|
174
|
+
catch { try { buf = await readFile(file + ".html"); file += ".html"; } catch { res.writeHead(404); return res.end("not found"); } }
|
|
175
|
+
const ext = extname(file).toLowerCase();
|
|
176
|
+
if (inject && ext === ".html") {
|
|
177
|
+
let html = buf.toString("utf8");
|
|
178
|
+
html = html.includes("</body>") ? html.replace("</body>", runnerScript + "</body>") : html + runnerScript;
|
|
179
|
+
buf = Buffer.from(html, "utf8");
|
|
180
|
+
}
|
|
181
|
+
res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream" });
|
|
182
|
+
res.end(buf);
|
|
183
|
+
} catch (e) { res.writeHead(500); res.end(String(e)); }
|
|
184
|
+
});
|
|
185
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
186
|
+
const { port } = server.address();
|
|
187
|
+
return { origin: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Runners: page → raw axe violations[] ─────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async function collectWithPlaywright(pages, { dist, tags }) {
|
|
193
|
+
let chromium, AxeBuilder;
|
|
194
|
+
try {
|
|
195
|
+
({ chromium } = await import("playwright"));
|
|
196
|
+
({ default: AxeBuilder } = await import("@axe-core/playwright"));
|
|
197
|
+
} catch (e) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"playwright runner needs `playwright` + `@axe-core/playwright` installed " +
|
|
200
|
+
"(and `npx playwright install --with-deps chromium`). " + e.message,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const srv = await startServer(dist, { inject: false });
|
|
204
|
+
const browser = await chromium.launch();
|
|
205
|
+
const out = new Map();
|
|
206
|
+
try {
|
|
207
|
+
const ctx = await browser.newContext();
|
|
208
|
+
for (const page of pages) {
|
|
209
|
+
const pg = await ctx.newPage();
|
|
210
|
+
await pg.goto(`${srv.origin}/${page}`, { waitUntil: "load" });
|
|
211
|
+
const results = await new AxeBuilder({ page: pg }).withTags(tags).analyze();
|
|
212
|
+
out.set(page, results.violations);
|
|
213
|
+
await pg.close();
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
await browser.close();
|
|
217
|
+
await srv.close();
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Run tezcatl async (NOT execFileSync) — the static origin lives on this same event
|
|
223
|
+
// loop, so a blocking child would deadlock its own server. Resolves to trimmed stdout.
|
|
224
|
+
function tezcatl(args) {
|
|
225
|
+
return new Promise((res, rej) => {
|
|
226
|
+
const ch = spawn("tezcatl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
227
|
+
let out = "", err = "";
|
|
228
|
+
ch.stdout.on("data", (d) => (out += d));
|
|
229
|
+
ch.stderr.on("data", (d) => (err += d));
|
|
230
|
+
ch.on("error", (e) => rej(new Error(`tezcatl not runnable (on PATH?): ${e.message}`)));
|
|
231
|
+
ch.on("close", (code) => (code === 0 ? res(out.trim()) : rej(new Error(`tezcatl exit ${code}: ${err.trim() || out.trim()}`))));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function collectWithTezcatl(pages, { dist, tags }) {
|
|
236
|
+
const waitMs = Number(process.env.AXE_TEZCATL_WAIT || 3000);
|
|
237
|
+
const readResults = `--eval=(function(){var e=document.getElementById('__axe_results');return e?e.textContent:'';})()`;
|
|
238
|
+
const srv = await startServer(dist, { inject: true, tags });
|
|
239
|
+
const out = new Map();
|
|
240
|
+
try {
|
|
241
|
+
for (const page of pages) {
|
|
242
|
+
const url = `${srv.origin}/${page}`;
|
|
243
|
+
let text = "";
|
|
244
|
+
for (const attempt of [waitMs, waitMs * 2]) { // one retry with a longer settle window
|
|
245
|
+
const raw = await tezcatl([url, `--wait=${attempt}`, readResults]);
|
|
246
|
+
if (raw && raw !== "NORESULT") { text = raw; break; }
|
|
247
|
+
}
|
|
248
|
+
if (!text) throw new Error(`tezcatl: no axe result for ${page} (raise $AXE_TEZCATL_WAIT?)`);
|
|
249
|
+
const parsed = JSON.parse(text);
|
|
250
|
+
if (parsed.error) throw new Error(`axe failed on ${page}: ${parsed.error}`);
|
|
251
|
+
out.set(page, parsed.violations || []);
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
await srv.close();
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const RUNNERS = { playwright: collectWithPlaywright, tezcatl: collectWithTezcatl };
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run the configured runner over `pages` of `dist` and return the summarized report.
|
|
263
|
+
* Exposed for programmatic use (and the kit's own test) in addition to the CLI.
|
|
264
|
+
*/
|
|
265
|
+
export async function runAxeGate({ dist, pages, tags = DEFAULT_TAGS, threshold = DEFAULT_THRESHOLD, runner = "playwright" }) {
|
|
266
|
+
const collect = RUNNERS[runner];
|
|
267
|
+
if (!collect) throw new Error(`unknown runner "${runner}" (expected: ${Object.keys(RUNNERS).join(", ")})`);
|
|
268
|
+
const raw = await collect(pages, { dist, tags });
|
|
269
|
+
const pageResults = pages.map((p) => evaluatePage(p, raw.get(p) || [], threshold));
|
|
270
|
+
return summarize(pageResults, { threshold, tags, runner });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async function main() {
|
|
276
|
+
const dist = resolve(process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : process.env.DIST || "dist");
|
|
277
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
278
|
+
if (!(await exists(dist))) { console.error(`✗ axe-gate: ${dist} not found — build first.`); process.exit(2); }
|
|
279
|
+
|
|
280
|
+
const tags = (process.env.AXE_TAGS || DEFAULT_TAGS.join(",")).split(",").map((s) => s.trim()).filter(Boolean);
|
|
281
|
+
const threshold = (process.env.AXE_IMPACT_THRESHOLD || DEFAULT_THRESHOLD).trim();
|
|
282
|
+
if (!IMPACT_ORDER.includes(threshold)) {
|
|
283
|
+
console.error(`✗ axe-gate: $AXE_IMPACT_THRESHOLD must be one of ${IMPACT_ORDER.join("|")} (got "${threshold}")`);
|
|
284
|
+
process.exit(2);
|
|
285
|
+
}
|
|
286
|
+
const runner = (process.env.AXE_RUNNER || "playwright").trim();
|
|
287
|
+
let pages = (process.env.AXE_PAGES || "").split(",").map((s) => s.trim().replace(/^\//, "")).filter(Boolean);
|
|
288
|
+
if (pages.length === 0) pages = (await walkHtml(dist)).sort();
|
|
289
|
+
if (pages.length === 0) { console.error(`✗ axe-gate: no HTML pages found under ${dist}`); process.exit(2); }
|
|
290
|
+
|
|
291
|
+
console.log(`axe-gate: ${runner} runner · ${pages.length} page(s) · tags [${tags.join(", ")}] · block ≥ ${threshold}`);
|
|
292
|
+
const report = await runAxeGate({ dist, pages, tags, threshold, runner });
|
|
293
|
+
|
|
294
|
+
if (process.env.AXE_REPORT) {
|
|
295
|
+
await writeFile(resolve(process.env.AXE_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
296
|
+
console.log(` ↳ wrote ${process.env.AXE_REPORT}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const p of report.pages) {
|
|
300
|
+
const tally = IMPACT_ORDER.map((l) => `${p.counts[l]} ${l}`).join(", ");
|
|
301
|
+
const mark = p.blocking ? "✗" : "✓";
|
|
302
|
+
console.log(` ${mark} ${p.page} — ${tally}${p.counts.unknown ? `, ${p.counts.unknown} unknown` : ""}`);
|
|
303
|
+
if (p.blocking) {
|
|
304
|
+
for (const lvl of ["critical", "serious", "moderate", "minor"]) {
|
|
305
|
+
for (const v of p.violations[lvl] || []) {
|
|
306
|
+
if (!blocksAt(lvl, threshold)) continue;
|
|
307
|
+
console.error(` [${lvl}] ${v.id} — ${v.help} (${v.nodes} node(s)) ${v.helpUrl}`);
|
|
308
|
+
for (const t of v.targets) console.error(` · ${t}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log("");
|
|
315
|
+
if (!report.passed) {
|
|
316
|
+
console.error(`✗ axe-gate: ${report.blocking} violation(s) at or above "${threshold}" across ${report.pages.length} page(s) (${report.totals.critical} critical, ${report.totals.serious} serious).`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
console.log(`✓ axe-gate: ${report.pages.length} page(s) clean — 0 violations at or above "${threshold}" (axe ${tags.includes("wcag22aa") ? "WCAG 2.2 A/AA" : "WCAG A/AA"}).`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Only run the CLI when invoked directly (not when imported by a test).
|
|
323
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
324
|
+
main().catch((e) => { console.error("✗ axe-gate: error —", e.stack || e.message); process.exit(1); });
|
|
325
|
+
}
|