@piercebarney/whs-eleventy 2026.10.7 → 2026.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,10 @@ the stack runs, so a fix lands once and `npm update` carries it everywhere.
7
7
  ```
8
8
  whs compliance [--strict] codebase-vs-standard conformance sweep
9
9
  whs doctor [--live|--deploy-preflight] infrastructure drift check
10
+ whs html html-validate (standard preset) over every _site/ page
10
11
  whs links built-output link / CSP / JSON-LD integrity
11
12
  whs a11y axe-core WCAG 2.1 A/AA scan of _site/
12
- whs content-check lint · validate · build · links (no browser)
13
+ whs content-check lint · validate · build · html · links (no browser)
13
14
  whs --version tooling + bundled-standard version
14
15
  ```
15
16
 
@@ -29,6 +30,7 @@ Then in `package.json`:
29
30
  ```json
30
31
  {
31
32
  "scripts": {
33
+ "html": "whs html",
32
34
  "links": "whs links",
33
35
  "a11y": "whs a11y",
34
36
  "doctor": "whs doctor",
package/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // whs status [--hook] standard-version drift, at a glance
5
5
  // whs compliance [--strict] codebase-vs-standard conformance sweep
6
6
  // whs doctor [--live|--deploy-preflight] infrastructure drift check
7
+ // whs html html-validate (standard preset) over every _site/ page
7
8
  // whs links built-output link / CSP / JSON-LD integrity
8
9
  // whs a11y axe-core WCAG 2.1 A/AA scan of _site/
9
10
  // whs content-check the content-relevant slice of the gate
@@ -20,6 +21,7 @@ const COMMANDS = {
20
21
  status: "status.js",
21
22
  compliance: "compliance.js",
22
23
  doctor: "doctor.js",
24
+ html: "html.js",
23
25
  links: "check-links.js",
24
26
  a11y: "a11y.js",
25
27
  "content-check": "content-check.js",
package/lib/compliance.js CHANGED
@@ -217,6 +217,12 @@ const CHECKS = {
217
217
 
218
218
  rendering: () => {
219
219
  if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
220
+ // core.md#rendering requires every built page to be valid HTML — mirrors
221
+ // the a11y chapter's own "is the scan actually wired into the gate" check
222
+ // below, rather than trusting that the stage exists just because the
223
+ // package ships one.
224
+ if (!/\bhtml\b/.test(JSON.parse(read("package.json") || "{}").scripts?.check || ""))
225
+ return { status: FAIL, note: "no html stage in the gate" };
220
226
  // A page whose <main> is empty/near-empty in the built output is the
221
227
  // classic "content only appears after client JS runs" regression the
222
228
  // Contract forbids — a crude but real content-presence check, distinct
@@ -232,7 +238,10 @@ const CHECKS = {
232
238
  status: FAIL,
233
239
  note: `${thin.length} page(s) with near-empty <main> — content may be client-rendered only`,
234
240
  };
235
- return { status: PASS, note: "server-rendered HTML with real content; 404 page present" };
241
+ return {
242
+ status: PASS,
243
+ note: "server-rendered HTML with real content; 404 page present; html-validate stage in the gate",
244
+ };
236
245
  },
237
246
 
238
247
  assets: () => {
@@ -516,6 +525,15 @@ const CHECKS = {
516
525
  /["']slug["']\s*:\s*["']privacy["']/.test(read("src/content/pages.json") || "");
517
526
  if (!builtPath && !source) return { status: FAIL, note: "no privacy page" };
518
527
 
528
+ // GPC (core.md#privacy): once ads are on, the built privacy page must say
529
+ // GPC is honoured — checked ahead of the disclosure branches below so it
530
+ // applies no matter which of them resolves the rest of the check.
531
+ if (adsMode !== "none" && builtPath) {
532
+ const html = read(builtPath) || "";
533
+ if (!/Global Privacy Control/i.test(html))
534
+ return { status: FAIL, note: "privacy page does not state GPC is honoured" };
535
+ }
536
+
519
537
  // Prefer checking the built page's own rendered HTML for every declared
520
538
  // third party's name — what a user actually sees, independent of which
521
539
  // source file or templating approach produced it. Falls back to the
@@ -571,10 +589,24 @@ const CHECKS = {
571
589
  const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
572
590
  if (!prod && leak.length)
573
591
  return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
574
- return {
575
- status: MANUAL,
576
- note: "wired; verify on production: decline consent => no ad requests, accept => ads + CLS ok",
577
- };
592
+
593
+ // GPC (core.md#ads): once a production build actually carries a live ad
594
+ // unit, the built site.js must set data-restrict-data-processing for a
595
+ // GPC visitor — checked against the built copy, not the source, so a
596
+ // stale copy-in shows up here rather than only in a manual browser check.
597
+ const INS_UNIT = /<ins[^>]+adsbygoogle/;
598
+ const adsActive = prod && siteHtml().some((f) => INS_UNIT.test(fs.readFileSync(f, "utf8")));
599
+ let note =
600
+ "wired; verify on production: decline consent => no ad requests, accept => ads + CLS ok";
601
+ if (adsActive) {
602
+ const builtJs = read("_site/site.js") || "";
603
+ const gpcHandled =
604
+ /navigator\.globalPrivacyControl/.test(builtJs) &&
605
+ /data-restrict-data-processing/.test(builtJs);
606
+ if (!gpcHandled) return { status: FAIL, note: "ads active but no GPC handling in built JS" };
607
+ note += "; GPC handled";
608
+ }
609
+ return { status: MANUAL, note };
578
610
  },
579
611
 
580
612
  "internal-links": () => {
@@ -1,6 +1,7 @@
1
1
  // The content-relevant slice of the gate, for an agent editing content
2
- // (CONTENT.md). Runs lint → validate → build → links — no browser, so no a11y
3
- // (that's backstopped by `npm run check` in bin/deploy before anything ships).
2
+ // (CONTENT.md). Runs lint → validate → build → html → links — no browser, so
3
+ // no a11y (that's backstopped by `npm run check` in bin/deploy before
4
+ // anything ships).
4
5
  //
5
6
  // whs content-check
6
7
  //
@@ -79,7 +80,7 @@ function main() {
79
80
  console.log(`\nℹ ${reqs.length} open request(s) in requests/ awaiting the build arm.\n`);
80
81
  }
81
82
 
82
- const steps = ["lint", "validate", "build", "links"];
83
+ const steps = ["lint", "validate", "build", "html", "links"];
83
84
  for (const s of steps) {
84
85
  process.stdout.write(`content-check: npm run ${s}\n`);
85
86
  try {
@@ -89,7 +90,7 @@ function main() {
89
90
  process.exit(1);
90
91
  }
91
92
  }
92
- console.log("\ncontent-check: ok — lint · validate · build · links");
93
+ console.log("\ncontent-check: ok — lint · validate · build · html · links");
93
94
  }
94
95
 
95
96
  module.exports = { strayFiles, parsePorcelain, splitStray, RESERVED_SET };
package/lib/doctor.js CHANGED
@@ -34,7 +34,7 @@ const loadSite = () => projectRequire("src/_data/site.js");
34
34
  // feeds the /audit/ page's live-header check via _data/headerExpect.js.
35
35
  const HEADER_EXPECT = asRegexMap();
36
36
 
37
- const GATE_STAGES = ["lint", "validate", "test", "build", "links", "a11y"];
37
+ const GATE_STAGES = ["lint", "validate", "test", "build", "html", "links", "a11y"];
38
38
 
39
39
  function readText(rel) {
40
40
  try {
@@ -122,7 +122,7 @@ function runRepoChecks({ preflight = false } = {}) {
122
122
  const absent = GATE_STAGES.filter((s) => !check.includes(s));
123
123
  add(
124
124
  "gate-stages",
125
- "`npm run check` runs lint · validate · test · build · links · a11y",
125
+ "`npm run check` runs lint · validate · test · build · html · links · a11y",
126
126
  check !== "" && absent.length === 0,
127
127
  "warn",
128
128
  absent.length ? `not referenced: ${absent.join(", ")}` : "complete",
package/lib/html.js ADDED
@@ -0,0 +1,61 @@
1
+ // HTML validity — the `html` step of the gate (whs html). Walks the built
2
+ // _site/ recursively (every *.html — error pages and /audit/ included, not
3
+ // just the sitemap) and runs html-validate's `html-validate:standard` preset
4
+ // against each file, exiting non-zero on any error.
5
+ //
6
+ // `standard`, not `recommended`: recommended layers in style opinions (e.g.
7
+ // uppercase DOCTYPE) that core.md#rendering's Contract never asked for — spec
8
+ // conformance only (core.md#rendering: "no stray end tags, no bad nesting, no
9
+ // duplicate ids"). An inline `<!-- [html-validate-disable-next] -->` comment
10
+ // is the escape hatch for a rule that's a false positive on a specific line;
11
+ // no per-project config file — one fixed config keeps every project on the
12
+ // stack checked the same way.
13
+
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const { HtmlValidate } = require("html-validate");
17
+
18
+ const SITE = "_site";
19
+
20
+ function walkHtml(dir) {
21
+ const out = [];
22
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
23
+ const p = path.join(dir, e.name);
24
+ if (e.isDirectory()) out.push(...walkHtml(p));
25
+ else if (e.name.endsWith(".html")) out.push(p);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ async function main() {
31
+ if (!fs.existsSync(SITE)) {
32
+ console.error("html: _site/ missing — build first");
33
+ process.exit(1);
34
+ }
35
+
36
+ const files = walkHtml(SITE);
37
+ if (!files.length) {
38
+ console.error("html: no *.html files found under _site/");
39
+ process.exit(1);
40
+ }
41
+
42
+ const hv = new HtmlValidate({ extends: ["html-validate:standard"] });
43
+ const { valid, results } = await hv.validateMultipleFiles(files);
44
+
45
+ const failing = results.filter((r) => r.messages.length);
46
+ if (!valid || failing.length) {
47
+ const totalErrors = failing.reduce((n, r) => n + r.errorCount, 0);
48
+ console.error(`\nhtml: ${totalErrors} error(s) across ${failing.length} page(s):\n`);
49
+ for (const r of failing) {
50
+ console.error(` ${path.relative(SITE, r.filePath)}`);
51
+ for (const m of r.messages) {
52
+ console.error(` [${m.ruleId}] ${m.line}:${m.column} — ${m.message}`);
53
+ }
54
+ }
55
+ process.exit(1);
56
+ }
57
+
58
+ console.log(` html: ${files.length} pages, valid per the WHATWG HTML Living Standard`);
59
+ }
60
+
61
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piercebarney/whs-eleventy",
3
- "version": "2026.10.7",
3
+ "version": "2026.10.9",
4
4
  "description": "The web house style's Eleventy + Netlify tooling — the compliance sweep, the infra doctor, the link/CSP integrity check, and the a11y scan, shared by every project on the stack.",
5
5
  "bin": {
6
6
  "whs": "cli.js"
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "axe-core": "^4.13.0",
35
+ "html-validate": "^11.16.0",
35
36
  "node-html-parser": "^9.0.2",
36
37
  "sirv": "^3.0.2"
37
38
  },
@@ -21,6 +21,201 @@ enforced, new *opt-in* chapters/capabilities, tooling-only changes — is
21
21
 
22
22
  ---
23
23
 
24
+ ## 2026-10-09 [breaking]
25
+
26
+ ### core: privacy
27
+
28
+ `#privacy`'s Specs now require **Global Privacy Control**: a site running
29
+ display ads or analytics that sells/shares personal data honours the GPC
30
+ signal — `navigator.globalPrivacyControl` in the browser, or the `Sec-GPC:
31
+ 1` request header where a server renders — as an automatic opt-out of sale,
32
+ sharing, and targeted advertising. No click required, and sharing analytics
33
+ does not load for a GPC visitor. The privacy page must state that GPC is
34
+ honoured wherever it applies. Verify gains: review confirms the privacy
35
+ page states this. Required by US state privacy laws (e.g. Montana's amended
36
+ MCDPA, California's CCPA regs), which apply by where the visitor lives, not
37
+ the site's own location. Decided by the owner in
38
+ `feedback/2026-09-26-external-standards-survey.md` ("Sequencing" step 3,
39
+ Decisions item 2).
40
+
41
+ **Breaking:** a fully compliant ad-running or sharing-analytics site before
42
+ this change had no GPC handling and no privacy-page statement about it —
43
+ neither was required. Both are required now, and `whs compliance`'s
44
+ `privacy` check (see `packages/whs-eleventy` below) `FAIL`s without the
45
+ statement once ads are active.
46
+
47
+ ### core: ads
48
+
49
+ `#ads`'s consent-gate spec now extends to the US-state opt-out: a visitor
50
+ sending GPC gets restricted/non-personalized ad requests automatically, and
51
+ the certified CMP also presents the US-state "Do Not Sell or Share My
52
+ Personal Information" opt-out. Verify gains a manual check alongside the
53
+ existing decline/accept checks: browser with GPC on → ad requests
54
+ restricted.
55
+
56
+ **Breaking:** a fully compliant ad-running site before this change served
57
+ personalized ads to a GPC visitor and had no US-state opt-out in its CMP —
58
+ neither was required. Both are required now, and `whs compliance`'s `ads`
59
+ check (see `packages/whs-eleventy` below) `FAIL`s without the built-JS GPC
60
+ handling once a live ad unit exists.
61
+
62
+ ### packages/whs-eleventy
63
+
64
+ `lib/compliance.js`'s `ads` check: once a production build carries a live
65
+ `<ins … adsbygoogle>` unit, it now `FAIL`s "ads active but no GPC handling
66
+ in built JS" unless the built `_site/site.js` matches both
67
+ `navigator.globalPrivacyControl` and `data-restrict-data-processing`;
68
+ otherwise it appends "GPC handled" to the existing `MANUAL` note. The
69
+ `privacy` check now `FAIL`s "privacy page does not state GPC is honoured"
70
+ when ads are on and the built privacy page doesn't match
71
+ `/Global Privacy Control/i`. New tests in `test/compliance-checks.test.js`
72
+ cover both branches (ads active + GPC present → no `FAIL`; ads active + GPC
73
+ missing → `FAIL`; privacy statement missing while ads are on → `FAIL`).
74
+
75
+ ### templates/eleventy-netlify
76
+
77
+ `static/site.js`'s unit-push loop now sets
78
+ `data-restrict-data-processing="1"` on each `<ins>` immediately before its
79
+ push when `navigator.globalPrivacyControl === true`.
80
+ `src/_includes/thirdparty-disclosure.njk`'s `if ads.pub` block gains a
81
+ paragraph stating GPC is honoured as an automatic opt-out, with a mention of
82
+ the "Do Not Sell or Share My Personal Information" link for visitors whose
83
+ browser doesn't send it. `src/_data/adsAudit.js` adds a `gpcHandled` check
84
+ (same `readFileSync` pattern as the existing CSP check) and `src/audit.njk`
85
+ surfaces it as a new Ads-panel row plus a manual-checklist line.
86
+ `add-ons/ads/README.md` step 5 adds the **US state regulations** Privacy &
87
+ messaging message alongside the GDPR one; the baseline-machinery list notes
88
+ the GPC handling now in `site.js`; step 7's manual verify list gains the GPC
89
+ check.
90
+
91
+ ### stacks/eleventy-netlify.md
92
+
93
+ Documents the mechanism above under `#privacy` and `#ads`, and adds a
94
+ correction while touching the push-loop bullet: the prior text claimed the
95
+ push was "guarded on the loader having defined `window.adsbygoogle`" — the
96
+ code has never done that (it sets `window.adsbygoogle = window.adsbygoogle
97
+ || []` itself and pushes in a `try`/`catch`); the bullet now describes the
98
+ actual mechanism instead of the stale claim.
99
+
100
+ ### stacks/sveltekit-netlify.md, stacks/phoenix.md
101
+
102
+ Document the same `#privacy`/`#ads` mechanism for each stack's own template
103
+ shape — no template change in either (neither builds ads today).
104
+ `stacks/sveltekit-netlify.md` notes the unit-push code sets the attribute
105
+ client-side, or `hooks.server.ts` can read `Sec-GPC` for a server-rendered
106
+ route. `stacks/phoenix.md` documents both a client-side hook/`app.js` check
107
+ and a server-side option: a plug reading `Sec-GPC: 1` into a
108
+ `conn.assigns.gpc?` so server-rendered ad units and sharing analytics can
109
+ respect it without reaching the client-side hook at all.
110
+
111
+ **Adopter action (eleventy, ad-running or sharing-analytics projects):**
112
+ `npm update @piercebarney/whs-eleventy`; re-copy the `static/site.js` push
113
+ loop (adds the GPC attribute) and the `thirdparty-disclosure.njk` GPC
114
+ paragraph from this repo; in the AdSense dashboard, enable the "US state
115
+ regulations" Privacy & messaging message alongside the existing GDPR one;
116
+ re-pin `standard-version` to `2026-10-09` once done. Projects with `ads:
117
+ none` and no sale/sharing analytics: pin update only, no code change
118
+ required.
119
+
120
+ ## 2026-10-08 [breaking]
121
+
122
+ ### core: rendering
123
+
124
+ `#rendering`'s Contract never required HTML *validity* — only that content
125
+ render without client JS and that server/client rendering logic not be
126
+ duplicated. It now adds: every built page — including error pages and
127
+ `/audit/` — must be valid HTML per the **WHATWG HTML Living Standard** (the
128
+ spec W3C itself defers to). Spec conformance only (no stray end tags, no bad
129
+ nesting, no duplicate `id`s) — no style opinions such as DOCTYPE case or
130
+ attribute quoting. Rationale: browsers repair malformed markup silently, so
131
+ invalid structure otherwise ships unseen, and WCAG 2.2 dropped its 4.1.1
132
+ Parsing criterion, so an axe scan no longer catches it either. Decided by
133
+ the owner in `feedback/2026-09-26-external-standards-survey.md`
134
+ ("Sequencing" step 2, row 3).
135
+
136
+ ### core: the-gate
137
+
138
+ `#the-gate`'s drift-guarded stage order gains an **HTML validity** step,
139
+ placed after `build` and before `link/reference integrity`: lint/format
140
+ check → content validation → domain tests → build → HTML validity →
141
+ link/reference integrity → a11y scan.
142
+
143
+ **Breaking:** a project that was fully compliant before this change (a gate
144
+ that ran build → links → a11y with no HTML-validity step) is no longer
145
+ compliant after it — `#the-gate` now requires an HTML-validity stage that
146
+ didn't exist as a requirement before, and `whs compliance` / `whs doctor`
147
+ (see `packages/whs-eleventy` below) now `FAIL`/warn on its absence rather
148
+ than silently passing.
149
+
150
+ **Adopter action (eleventy):** `npm update @piercebarney/whs-eleventy`, add
151
+ `"html": "whs html"` to `package.json` scripts, add `npm run html` to
152
+ `check` (after `build`, before `links`), then fix any invalid markup the new
153
+ check reports. Re-pin `standard-version` to `2026-10-08` once done.
154
+
155
+ ### packages/whs-eleventy
156
+
157
+ New `whs html` command (`lib/html.js`): walks every `*.html` file under the
158
+ built `_site/` recursively (error pages and `/audit/` included, not just the
159
+ sitemap) and runs it through `html-validate`'s `html-validate:standard`
160
+ preset (spec conformance only — not `recommended`'s style opinions), exiting
161
+ non-zero on any error. New dependency `html-validate ^11.16.0`. `whs
162
+ compliance`'s `#rendering` check now `FAIL`s if the project's `check` script
163
+ doesn't reference an `html` stage. `whs doctor`'s `GATE_STAGES` list and its
164
+ `gate-stages` check gain `html`. `whs content-check`'s lighter slice
165
+ (`lint → validate → build → links`) gains `html`, run before `links`. Tests
166
+ added for the new compliance/content-check behavior.
167
+
168
+ ### templates/eleventy-netlify
169
+
170
+ Wires the new stage: `package.json` gains an `"html": "whs html"` script and
171
+ inserts `npm run html` into `check` between `build` and `links`; `ci.yml`'s
172
+ inline gate-order comment updated to match. `CLAUDE.md`, `README.md`, and
173
+ `CONTENT.md` document the new stage in their gate-order prose.
174
+ `standard-version` re-pinned to `2026-10-08`.
175
+
176
+ ### templates/sveltekit-netlify
177
+
178
+ Implements the chapter for the first time (no prior HTML-validity tooling
179
+ existed for this stack): new `html-validate` devDependency, a
180
+ `.htmlvalidate.json` extending `html-validate:standard`, a new `"html":
181
+ "html-validate \"build/**/*.html\""` script run recursively over every
182
+ built page, and `npm run html` added to `check` after `build`. `README.md`,
183
+ `TEMPLATE.md`, and `ci.yml`'s inline gate-order comment updated to match.
184
+ This stack has no static error/404 page yet (`adapter-static`, no
185
+ `fallback`, no `+error.svelte`), so `core.md#rendering`'s "error pages"
186
+ clause has nothing to check today — the recursive glob will pick one up
187
+ automatically the day this binding adds one.
188
+
189
+ ### stacks/eleventy-netlify.md, stacks/sveltekit-netlify.md
190
+
191
+ Each `#rendering` Core-contract pointer line now mentions the "every built
192
+ page is valid HTML" requirement; each documents its stack's real mechanism
193
+ (`whs html` / `html-validate` respectively) and its place in `#the-gate`'s
194
+ now-seven-step order.
195
+
196
+ ### stacks/phoenix.md
197
+
198
+ Documented as a **`TODO`** in `#rendering`/`#the-gate` — no template change,
199
+ no tooling exists yet. The candidate approach is recorded (a ConnCase/
200
+ LiveView test fetching each sitemap route through html-validate or the Nu
201
+ Html Checker, wired as a `mix html_validity` alias). The `mix check`
202
+ placeholder-step snippet's order is also corrected while touching this
203
+ section: it previously listed `a11y` before `links.check`, ahead of where
204
+ `#the-gate`'s HTML-validity → links → a11y order calls for; it's now
205
+ `html_validity → links.check → a11y`, fixing that pre-existing drift as well
206
+ as adding the new placeholder step.
207
+
208
+ **Gaps, not fixed by this change:** Phoenix has no HTML-validity tooling —
209
+ `TODO`, same pattern as its existing `a11y`/`links.check` gaps. SvelteKit's
210
+ template has no 404/error page today, so nothing exists yet for its new
211
+ `html` step to check on that front.
212
+
213
+ **Adopter action (sveltekit):** `npm install` (picks up `html-validate` +
214
+ `.htmlvalidate.json`), add `"html": "html-validate \"build/**/*.html\""` to
215
+ scripts and into `check` after `build`, re-pin `standard-version`.
216
+
217
+ ---
218
+
24
219
  ## 2026-10-07 [breaking]
25
220
 
26
221
  ### core: a11y
package/standard/core.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Web project house style — CORE (stack-agnostic)
2
2
 
3
- **Version:** 2026-10-07 · **Status:** active
3
+ **Version:** 2026-10-09 · **Status:** active
4
4
 
5
5
  This is the stack-agnostic contract every web project follows, regardless of
6
6
  framework, host, or CSS system. It says **what** must be true, with concrete
@@ -173,7 +173,8 @@ validation status.
173
173
 
174
174
  **Contract.** Core content and navigation work with no client JavaScript. Client
175
175
  JS enhances; it never gates the essentials. Any logic that renders on both the
176
- server and the client is written once.
176
+ server and the client is written once. Every built page is valid HTML per the
177
+ WHATWG HTML Living Standard.
177
178
 
178
179
  **Specs.**
179
180
  - The server-rendered (or prerendered) HTML is complete: a crawler or a
@@ -186,9 +187,17 @@ server and the client is written once.
186
187
  - Nothing that matters (core content, primary CTA) blocks on a network fetch.
187
188
  Live data arrives after first paint and degrades to a clear fallback state
188
189
  (see `#third-parties`).
190
+ - Every built page — including error pages and `/audit/` — is valid HTML per
191
+ the **WHATWG HTML Living Standard** (the spec W3C itself defers to): no stray
192
+ end tags, no bad nesting, no duplicate `id`s. Browsers repair malformed
193
+ markup silently, so invalid structure otherwise ships unseen; WCAG 2.2
194
+ dropped its 4.1.1 Parsing criterion, so an axe scan no longer catches it
195
+ either. The check is spec conformance only — no style opinions (DOCTYPE
196
+ case, attribute quoting, and the like).
189
197
 
190
198
  **Verify.** Load a key page with JS disabled: content and links present.
191
- Grep for duplicated render logic.
199
+ Grep for duplicated render logic. An HTML validator runs over every built
200
+ page — including error pages and `/audit/` — in the gate.
192
201
 
193
202
  **Implementation.** See your stack's impl doc, same slug.
194
203
 
@@ -651,6 +660,13 @@ generated from the origin manifest, never hand-written.
651
660
  - **Display advertising (`#ads`)** always requires a provider-certified CMP and
652
661
  always sets cookies — it is never the privacy-first path. A site that runs ads
653
662
  has a consent banner by necessity.
663
+ - **Global Privacy Control.** A site running display ads or analytics that
664
+ sells/shares personal data honours the GPC signal — `navigator.globalPrivacyControl`
665
+ in the browser, or the `Sec-GPC: 1` request header where a server renders —
666
+ as an automatic opt-out of sale, sharing, and targeted advertising: no click
667
+ required, and sharing analytics does not load for a GPC visitor. The privacy
668
+ page states that GPC is honoured. Required by US state privacy laws, which
669
+ apply by where the visitor lives, not the site's own location.
654
670
  - The privacy page renders from the origin manifest (`#third-parties`) — the
655
671
  list of who sees visitor data and why. No hand-written prose enumerating
656
672
  services that goes stale when a dependency changes.
@@ -666,7 +682,8 @@ generated from the origin manifest, never hand-written.
666
682
  use.
667
683
 
668
684
  **Verify.** The privacy page's third-party list is generated. Review confirms
669
- any analytics is privacy-first or gated behind a CMP.
685
+ any analytics is privacy-first or gated behind a CMP, and that the privacy
686
+ page states GPC is honoured wherever it applies.
670
687
 
671
688
  **Implementation.** See your stack's impl doc, same slug.
672
689
 
@@ -690,7 +707,11 @@ ad directives, and documented.
690
707
  non-personalized only where the provider supports it). Whether the prompt
691
708
  shows to every visitor or only to visitors in regulated regions is a project
692
709
  decision recorded in `CLAUDE.md`. What is **non-compliant** regardless: the ad
693
- loader firing and setting cookies before the CMP has resolved.
710
+ loader firing and setting cookies before the CMP has resolved. The same gate
711
+ extends to the US-state opt-out (`#privacy`): a visitor sending GPC gets
712
+ restricted / non-personalized ad requests automatically, and the certified
713
+ CMP also presents the US-state "Do Not Sell or Share My Personal
714
+ Information" opt-out.
694
715
  - **Declared (`#third-parties`).** Every ad-provider origin (loader, ad-serving,
695
716
  ad iframes, the CMP, quality/verification endpoints) is in the origin manifest
696
717
  with `purpose: advertising` and its `dataSeen`. The privacy page renders it
@@ -741,8 +762,9 @@ the declared ad origins; any `'unsafe-*'` exception noted; the loader gated on
741
762
  link/reference check (`#internal-links`) fails on any
742
763
  `adsbygoogle` / ad-provider reference in a non-production build and on a missing
743
764
  `ads.txt` in the production output. Manually, in a fresh browser on production:
744
- decline consent → no ad requests in the network panel; accept → ads load; check
745
- CLS in the browser's own tools.
765
+ decline consent → no ad requests in the network panel; accept → ads load; with
766
+ GPC on (`navigator.globalPrivacyControl`) → ad requests are restricted /
767
+ non-personalized; check CLS in the browser's own tools.
746
768
 
747
769
  **Implementation.** See your stack's impl doc, same slug.
748
770
 
@@ -886,8 +908,9 @@ enforcement point runs that same command — never a hand-picked subset.
886
908
 
887
909
  **Specs.**
888
910
  - One gate command runs, ordered cheapest-first: lint/format check → content
889
- validation → domain tests → build → link/reference integrity → a11y scan.
890
- (Optionally a performance budget once the site has a baseline.)
911
+ validation → domain tests → build → HTML validity → link/reference
912
+ integrity → a11y scan. (Optionally a performance budget once the site has a
913
+ baseline.)
891
914
  - The same command is invoked by: the pre-push git hook, the CI workflow, the
892
915
  deploy script, and the assistant before it reports work complete.
893
916
  - There is **no "CI-only" variant** and no partial local run. If the gate
@@ -1470,7 +1493,7 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
1470
1493
  - production-url: https://example.com
1471
1494
  - content-type: tool # tool | article (article => feeds)
1472
1495
  - publishing-rate: ~5 pages/week
1473
- - standard-version: 2026-10-07 # recommended — enables standard-version drift tracking (#compliance)
1496
+ - standard-version: 2026-10-09 # recommended — enables standard-version drift tracking (#compliance)
1474
1497
  ```
1475
1498
 
1476
1499
  If the section is absent, the assistant's first action is to run the flow above