@piercebarney/whs-eleventy 2026.9.1 → 2026.9.2
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/lib/a11y.js +8 -0
- package/lib/check-links.js +35 -3
- package/lib/compliance.js +152 -34
- package/lib/doctor.js +4 -8
- package/lib/header-expect.js +34 -0
- package/package.json +2 -2
- package/standard/CHANGELOG.md +151 -2
- package/standard/core.md +30 -5
package/lib/a11y.js
CHANGED
|
@@ -46,11 +46,19 @@ const getJSON = (url) =>
|
|
|
46
46
|
function sitemapPaths() {
|
|
47
47
|
const xml = fs.readFileSync(path.join(SITE, "sitemap.xml"), "utf8");
|
|
48
48
|
const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname);
|
|
49
|
+
// The audit page and the 404 page are both deliberately excluded from the
|
|
50
|
+
// sitemap (core.md#sitemap-robots) but are still real pages a visitor
|
|
51
|
+
// lands on — the a11y Contract says "every page", not "every sitemap
|
|
52
|
+
// entry", so scan them explicitly.
|
|
49
53
|
paths.push("/audit/");
|
|
54
|
+
if (fs.existsSync(path.join(SITE, "404.html"))) paths.push("/404.html");
|
|
50
55
|
return [...new Set(paths)];
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function connect(wsUrl) {
|
|
59
|
+
// The global `WebSocket` is stable only from Node 22 — package.json's
|
|
60
|
+
// `engines.node` reflects that (it does not exist, even unflagged, on
|
|
61
|
+
// Node 20, which this file used to silently assume it did).
|
|
54
62
|
const ws = new WebSocket(wsUrl);
|
|
55
63
|
let id = 0;
|
|
56
64
|
const pending = {};
|
package/lib/check-links.js
CHANGED
|
@@ -125,7 +125,7 @@ function outputHas(urlPath) {
|
|
|
125
125
|
return fs.existsSync(asFile + "/index.html") || fs.existsSync(asFile + ".html");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function checkRef(file, pageUrl, raw, { external = true } = {}) {
|
|
128
|
+
function checkRef(file, pageUrl, raw, { external = true, isHyperlink = false } = {}) {
|
|
129
129
|
if (!raw) return;
|
|
130
130
|
const v = raw.trim();
|
|
131
131
|
if (v === "" || v.startsWith("data:") || v.startsWith("mailto:") || v.startsWith("tel:")) return;
|
|
@@ -140,7 +140,14 @@ function checkRef(file, pageUrl, raw, { external = true } = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
if (origin === site.url) {
|
|
142
142
|
if (!outputHas(new URL(v).pathname)) err(file, `absolute link to a missing page: ${v}`);
|
|
143
|
-
} else if (
|
|
143
|
+
} else if (
|
|
144
|
+
!allowedOrigins.has(origin) &&
|
|
145
|
+
// POLICY_LINK_ORIGINS are informational <a> targets that never load a
|
|
146
|
+
// resource or receive visitor data (see its definition above) — that
|
|
147
|
+
// exemption applies only to plain hyperlinks, never to a resource
|
|
148
|
+
// reference (script/link/img/og:image), which does load from the origin.
|
|
149
|
+
!(isHyperlink && POLICY_LINK_ORIGINS.has(origin))
|
|
150
|
+
) {
|
|
144
151
|
err(file, `external origin not in thirdparties.js: ${origin}`);
|
|
145
152
|
}
|
|
146
153
|
return;
|
|
@@ -198,7 +205,7 @@ for (const f of htmlFiles) {
|
|
|
198
205
|
|
|
199
206
|
root.querySelectorAll("a[href]").forEach((a) => {
|
|
200
207
|
const href = a.getAttribute("href");
|
|
201
|
-
checkRef(f, pageUrl, href);
|
|
208
|
+
checkRef(f, pageUrl, href, { isHyperlink: true });
|
|
202
209
|
if (/^https?:\/\//i.test(href || "")) {
|
|
203
210
|
let origin;
|
|
204
211
|
try {
|
|
@@ -242,6 +249,31 @@ for (const f of htmlFiles) {
|
|
|
242
249
|
});
|
|
243
250
|
}
|
|
244
251
|
|
|
252
|
+
// ---- JS-level origin scan (core.md#third-parties) --------------------------
|
|
253
|
+
// The HTML walk above only ever sees origins referenced in markup — a runtime
|
|
254
|
+
// browser `fetch()`/`new URL()` call baked into a shipped .js file is
|
|
255
|
+
// invisible to it. This is a best-effort literal-string scan (it cannot see a
|
|
256
|
+
// dynamically-built URL), but it catches the common case: a hardcoded
|
|
257
|
+
// external origin in client JS that was never added to the manifest.
|
|
258
|
+
(function checkJsOrigins() {
|
|
259
|
+
const jsFiles = allFiles.filter((f) => f.endsWith(".js") && !f.endsWith("links-report.json"));
|
|
260
|
+
const ORIGIN_LITERAL = /(?:fetch|new\s+URL)\s*\(\s*[`'"](https?:\/\/[^`'"\s]+)[`'"]/g;
|
|
261
|
+
for (const f of jsFiles) {
|
|
262
|
+
const text = fs.readFileSync(f, "utf8");
|
|
263
|
+
for (const m of text.matchAll(ORIGIN_LITERAL)) {
|
|
264
|
+
let origin;
|
|
265
|
+
try {
|
|
266
|
+
origin = new URL(m[1]).origin;
|
|
267
|
+
} catch {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (origin !== site.url && !allowedOrigins.has(origin)) {
|
|
271
|
+
err(f, `fetch()/URL() targets an origin not in thirdparties.js: ${origin}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
|
|
245
277
|
// ---- ads: production-only, ads.txt present (core.md#ads) -------------------
|
|
246
278
|
(function checkAds() {
|
|
247
279
|
const ads = tryProjectRequire("src/_data/ads.js", {});
|
package/lib/compliance.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const fs = require("node:fs");
|
|
17
17
|
const path = require("node:path");
|
|
18
18
|
const { execSync } = require("node:child_process");
|
|
19
|
+
const { parse: parseHtml } = require("node-html-parser");
|
|
19
20
|
const { runRepoChecks } = require("./doctor.js");
|
|
20
21
|
const { ROOT, resolveStandard } = require("./_project.js");
|
|
21
22
|
|
|
@@ -86,13 +87,23 @@ const loadDoctor = () => {
|
|
|
86
87
|
doc = {};
|
|
87
88
|
for (const c of runRepoChecks({ preflight: false })) doc[c.id] = c;
|
|
88
89
|
};
|
|
89
|
-
|
|
90
|
+
// `strict: true` is for chapters whose Contract has no "advisory" reading —
|
|
91
|
+
// doctor's own "warn" severity is right for its own informational printing,
|
|
92
|
+
// but a compliance chapter reporting MANUAL for "no CSP at all" or "wrong
|
|
93
|
+
// Node" hides a real, mechanically-known violation from `--strict`. Only the
|
|
94
|
+
// compliance view remaps warn -> FAIL; doctor.js's own severity is untouched.
|
|
95
|
+
const fromDoctor = (id, extra = "", { strict = false, missingIsFail = false } = {}) => {
|
|
90
96
|
const c = doc[id];
|
|
91
|
-
if (!c)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
if (!c) {
|
|
98
|
+
return missingIsFail
|
|
99
|
+
? {
|
|
100
|
+
status: FAIL,
|
|
101
|
+
note: [`doctor check ${id} could not run`, extra].filter(Boolean).join(" · "),
|
|
102
|
+
}
|
|
103
|
+
: { status: MANUAL, note: `doctor check ${id} not found` };
|
|
104
|
+
}
|
|
105
|
+
const status = c.severity === "ok" ? PASS : c.severity === "blocker" || strict ? FAIL : MANUAL;
|
|
106
|
+
return { status, note: [c.detail, extra].filter(Boolean).join(" · ") };
|
|
96
107
|
};
|
|
97
108
|
|
|
98
109
|
// ---- per-chapter checks ------------------------------------------------
|
|
@@ -115,6 +126,11 @@ const CHECKS = {
|
|
|
115
126
|
"runtime-pin": () => {
|
|
116
127
|
if (/NODE_VERSION/.test(read("netlify.toml") || ""))
|
|
117
128
|
return { status: FAIL, note: "NODE_VERSION in netlify.toml — use .nvmrc" };
|
|
129
|
+
if (!has(".nvmrc"))
|
|
130
|
+
return { status: FAIL, note: ".nvmrc missing — no single pinned runtime version" };
|
|
131
|
+
// A running-Node/.nvmrc major mismatch is a MANUAL judgment (the developer's
|
|
132
|
+
// local shell may just not have `nvm use`'d yet) — everything else about the
|
|
133
|
+
// pin (single source, present) is mechanically FAIL-able.
|
|
118
134
|
return fromDoctor("nvmrc-node");
|
|
119
135
|
},
|
|
120
136
|
|
|
@@ -146,7 +162,22 @@ const CHECKS = {
|
|
|
146
162
|
|
|
147
163
|
rendering: () => {
|
|
148
164
|
if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
|
|
149
|
-
|
|
165
|
+
// A page whose <main> is empty/near-empty in the built output is the
|
|
166
|
+
// classic "content only appears after client JS runs" regression the
|
|
167
|
+
// Contract forbids — a crude but real content-presence check, distinct
|
|
168
|
+
// from merely confirming a 404 template exists.
|
|
169
|
+
const thin = siteHtml().filter((f) => {
|
|
170
|
+
const root = parseHtml(fs.readFileSync(f, "utf8"));
|
|
171
|
+
const main = root.querySelector("main");
|
|
172
|
+
const text = (main ? main.text : root.text || "").replace(/\s+/g, " ").trim();
|
|
173
|
+
return text.length < 40;
|
|
174
|
+
});
|
|
175
|
+
if (thin.length)
|
|
176
|
+
return {
|
|
177
|
+
status: FAIL,
|
|
178
|
+
note: `${thin.length} page(s) with near-empty <main> — content may be client-rendered only`,
|
|
179
|
+
};
|
|
180
|
+
return { status: PASS, note: "server-rendered HTML with real content; 404 page present" };
|
|
150
181
|
},
|
|
151
182
|
|
|
152
183
|
assets: () => {
|
|
@@ -223,20 +254,44 @@ const CHECKS = {
|
|
|
223
254
|
},
|
|
224
255
|
|
|
225
256
|
"og-image": () => {
|
|
226
|
-
|
|
257
|
+
// PNG IHDR: an 8-byte signature, then a 4-byte length + "IHDR" + a
|
|
258
|
+
// 4-byte width + 4-byte height, big-endian, at fixed offsets — no decoder
|
|
259
|
+
// dependency needed to read just the dimensions.
|
|
260
|
+
const pngSize = (rel) => {
|
|
261
|
+
let buf;
|
|
262
|
+
try {
|
|
263
|
+
buf = fs.readFileSync(path.join(ROOT, rel));
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (buf.length < 24 || buf.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
268
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
269
|
+
};
|
|
270
|
+
const bad = [];
|
|
227
271
|
for (const f of siteHtml()) {
|
|
272
|
+
const rel = path.relative(ROOT, f);
|
|
228
273
|
const m = fs
|
|
229
274
|
.readFileSync(f, "utf8")
|
|
230
275
|
.match(/property="og:image" content="[^"]*?\/og\/([^"]+?)\.png"/);
|
|
231
276
|
if (!m) {
|
|
232
|
-
bad
|
|
277
|
+
bad.push(`${rel} (no og:image)`);
|
|
233
278
|
continue;
|
|
234
279
|
}
|
|
235
|
-
|
|
280
|
+
const cardRel = `_site/og/${m[1]}.png`;
|
|
281
|
+
if (!has(cardRel)) {
|
|
282
|
+
bad.push(`${rel} (${cardRel} missing)`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const dims = pngSize(cardRel);
|
|
286
|
+
if (!dims || dims.width !== 1200 || dims.height !== 630) {
|
|
287
|
+
bad.push(
|
|
288
|
+
`${rel} (${cardRel} is ${dims ? `${dims.width}x${dims.height}` : "not a PNG"}, not 1200x630)`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
236
291
|
}
|
|
237
|
-
return bad
|
|
238
|
-
? { status: FAIL, note: `${bad} page(s)
|
|
239
|
-
: { status: PASS, note: "every og:image resolves" };
|
|
292
|
+
return bad.length
|
|
293
|
+
? { status: FAIL, note: `${bad.length} page(s): ${bad.slice(0, 5).join("; ")}` }
|
|
294
|
+
: { status: PASS, note: "every og:image resolves at 1200x630" };
|
|
240
295
|
},
|
|
241
296
|
|
|
242
297
|
"structured-data": () => {
|
|
@@ -258,11 +313,31 @@ const CHECKS = {
|
|
|
258
313
|
};
|
|
259
314
|
},
|
|
260
315
|
|
|
261
|
-
noindex: () =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
316
|
+
noindex: () => {
|
|
317
|
+
// doctor's `prod-indexable` repo-tier check only *describes* the built
|
|
318
|
+
// output's current noindex state — it does not judge it (that's what
|
|
319
|
+
// `--deploy-preflight` is for). Judging the actual Contract — "a
|
|
320
|
+
// non-production build always carries noindex" — needs its own check
|
|
321
|
+
// here: verify the SOURCE gate exists (independent of whichever build
|
|
322
|
+
// happens to be sitting in _site/), and additionally check the built
|
|
323
|
+
// output when we can tell which target it was built for.
|
|
324
|
+
const layout = read("src/_includes/layout.njk") || "";
|
|
325
|
+
const robots = read("src/robots.njk") || "";
|
|
326
|
+
if (!/build\.isProduction/.test(layout) || !/noindex/i.test(layout))
|
|
327
|
+
return { status: FAIL, note: "layout.njk has no build.isProduction-gated noindex meta" };
|
|
328
|
+
if (!/build\.isProduction/.test(robots))
|
|
329
|
+
return { status: FAIL, note: "robots.njk Disallow is not keyed on build.isProduction" };
|
|
330
|
+
const home = read("_site/index.html");
|
|
331
|
+
if (home !== null && process.env.DEPLOY_TARGET !== "production") {
|
|
332
|
+
const indexable = !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
|
|
333
|
+
if (indexable)
|
|
334
|
+
return {
|
|
335
|
+
status: FAIL,
|
|
336
|
+
note: "_site/index.html is indexable but DEPLOY_TARGET is not production",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { status: PASS, note: "layout.njk + robots.njk gate on build.isProduction" };
|
|
340
|
+
},
|
|
266
341
|
|
|
267
342
|
"brand-source": () => {
|
|
268
343
|
// Per the standard: grep src/, static/style.css, scripts/og-card.js|icons.js.
|
|
@@ -302,8 +377,19 @@ const CHECKS = {
|
|
|
302
377
|
return { status: PASS, note: "noindex; show-all toggle; all tabs present" };
|
|
303
378
|
},
|
|
304
379
|
|
|
305
|
-
"security-headers": () =>
|
|
306
|
-
|
|
380
|
+
"security-headers": () => {
|
|
381
|
+
if (!has("netlify.toml"))
|
|
382
|
+
return { status: FAIL, note: "no netlify.toml — no security headers declared" };
|
|
383
|
+
// A missing/incomplete header set, or a CSP that grants an origin outside
|
|
384
|
+
// the declared manifest, are mechanically-known Contract violations, not
|
|
385
|
+
// judgment calls — FAIL them rather than leaving them an invisible-to
|
|
386
|
+
// ---strict MANUAL.
|
|
387
|
+
const headers = fromDoctor("security-headers", "", { strict: true, missingIsFail: true });
|
|
388
|
+
const csp = fromDoctor("csp-thirdparties", "", { strict: true, missingIsFail: true });
|
|
389
|
+
if (headers.status === FAIL) return headers;
|
|
390
|
+
if (csp.status === FAIL) return csp;
|
|
391
|
+
return { status: PASS, note: [headers.note, csp.note].filter(Boolean).join(" · ") };
|
|
392
|
+
},
|
|
307
393
|
|
|
308
394
|
caching: () => {
|
|
309
395
|
const toml = read("netlify.toml") || "";
|
|
@@ -353,7 +439,11 @@ const CHECKS = {
|
|
|
353
439
|
};
|
|
354
440
|
// Real ad CODE (not a bare origin string the audit page shows as data).
|
|
355
441
|
const AD_CODE = /adsbygoogle\.js|<ins[^>]+adsbygoogle|google-adsense-account/;
|
|
356
|
-
|
|
442
|
+
// Read the built output directly rather than regex-matching another
|
|
443
|
+
// check's human-readable detail string — the same source check-links.js
|
|
444
|
+
// and the noindex check above use.
|
|
445
|
+
const home = read("_site/index.html");
|
|
446
|
+
const prod = home !== null && !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
|
|
357
447
|
const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
|
|
358
448
|
if (!prod && leak.length)
|
|
359
449
|
return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
|
|
@@ -371,13 +461,21 @@ const CHECKS = {
|
|
|
371
461
|
},
|
|
372
462
|
|
|
373
463
|
forms: () => {
|
|
374
|
-
|
|
464
|
+
// A form can live anywhere in the built output, not only in layout.njk
|
|
465
|
+
// (a contact page's own template, say) — scan every page, and treat "no
|
|
466
|
+
// form anywhere" as N/A rather than a false FAIL on a site that has none.
|
|
467
|
+
const pagesWithForms = siteHtml().filter((f) => /<form[\s>]/i.test(fs.readFileSync(f, "utf8")));
|
|
468
|
+
if (!pagesWithForms.length) return { status: NA, note: "no <form> in the built output" };
|
|
469
|
+
const hasHoneypot = pagesWithForms.some((f) =>
|
|
470
|
+
/netlify-honeypot|name="bot-field"/.test(fs.readFileSync(f, "utf8")),
|
|
471
|
+
);
|
|
472
|
+
if (!hasHoneypot) return { status: FAIL, note: "no honeypot field in the form" };
|
|
375
473
|
const handlers = grepSrc(/addEventListener\(["']submit["']/, [".js"]);
|
|
376
|
-
if (!/netlify-honeypot|name="bot-field"/.test(layout))
|
|
377
|
-
return { status: FAIL, note: "no honeypot field in the form" };
|
|
378
474
|
if (handlers.length > 1)
|
|
379
475
|
return { status: FAIL, note: `submit handler in ${handlers.length} files — consolidate` };
|
|
380
|
-
|
|
476
|
+
if (handlers.length === 0)
|
|
477
|
+
return { status: FAIL, note: "a form exists but no submit handler was found" };
|
|
478
|
+
return { status: PASS, note: "one handler; honeypot present" };
|
|
381
479
|
},
|
|
382
480
|
|
|
383
481
|
"domain-tests": () => {
|
|
@@ -404,13 +502,18 @@ const CHECKS = {
|
|
|
404
502
|
};
|
|
405
503
|
},
|
|
406
504
|
|
|
407
|
-
"the-gate": () =>
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
),
|
|
505
|
+
"the-gate": () => {
|
|
506
|
+
// A gate missing a stage (e.g. a11y quietly dropped from `check`) is a
|
|
507
|
+
// mechanically-known Contract violation — FAIL it, don't bury it in MANUAL.
|
|
508
|
+
const leaked = /doctor|compliance/.test(
|
|
509
|
+
(JSON.parse(read("package.json") || "{}").scripts || {}).check || "",
|
|
510
|
+
);
|
|
511
|
+
if (leaked) return { status: FAIL, note: "doctor/compliance leaked into `npm run check`" };
|
|
512
|
+
return fromDoctor("gate-stages", "doctor + compliance kept out", {
|
|
513
|
+
strict: true,
|
|
514
|
+
missingIsFail: true,
|
|
515
|
+
});
|
|
516
|
+
},
|
|
414
517
|
|
|
415
518
|
compliance: () => {
|
|
416
519
|
const pkg = JSON.parse(read("package.json") || "{}");
|
|
@@ -564,7 +667,20 @@ function changelogSlugs(changelog, pin) {
|
|
|
564
667
|
const coreM = line.match(/^###\s+core:\s+(.+)/);
|
|
565
668
|
if (coreM) {
|
|
566
669
|
for (const part of coreM[1].split(/[·,]/)) {
|
|
567
|
-
|
|
670
|
+
// Strip wrapping punctuation a heading can carry — `(a, b, c)`
|
|
671
|
+
// splits into parts like `(a` and `c)` — before matching, or the
|
|
672
|
+
// leading "(" makes the slug regex miss the first entry entirely.
|
|
673
|
+
const cleaned = part.trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "");
|
|
674
|
+
if (!cleaned) continue;
|
|
675
|
+
if (/^all$/i.test(cleaned)) {
|
|
676
|
+
// "### core: all" (the initial-split entry) means every chapter,
|
|
677
|
+
// not a literal slug named "all" — the registry has no such slug,
|
|
678
|
+
// so emitting it as one would print a `MANUAL: re-check #all` row
|
|
679
|
+
// that looks real but isn't.
|
|
680
|
+
slugs.add("(all core chapters)");
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
|
|
568
684
|
if (slug) slugs.add(slug);
|
|
569
685
|
}
|
|
570
686
|
}
|
|
@@ -674,4 +790,6 @@ function main() {
|
|
|
674
790
|
|
|
675
791
|
if (require.main === module) main();
|
|
676
792
|
|
|
677
|
-
|
|
793
|
+
// CHECKS is exported for the "coverage" test — nothing else should read it as
|
|
794
|
+
// data (call runCompliance() for results).
|
|
795
|
+
module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
|
package/lib/doctor.js
CHANGED
|
@@ -22,6 +22,7 @@ const fs = require("node:fs");
|
|
|
22
22
|
const path = require("node:path");
|
|
23
23
|
const { execSync } = require("node:child_process");
|
|
24
24
|
const { ROOT, projectRequire } = require("./_project.js");
|
|
25
|
+
const { asRegexMap } = require("./header-expect.js");
|
|
25
26
|
|
|
26
27
|
const CACHE = path.join(ROOT, ".cache", "doctor.json");
|
|
27
28
|
|
|
@@ -30,14 +31,9 @@ const CACHE = path.join(ROOT, ".cache", "doctor.json");
|
|
|
30
31
|
const loadSite = () => projectRequire("src/_data/site.js");
|
|
31
32
|
const loadThirdparties = () => projectRequire("src/_data/thirdparties.js");
|
|
32
33
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"X-Content-Type-Options": /nosniff/,
|
|
37
|
-
"X-Frame-Options": /DENY|SAMEORIGIN/,
|
|
38
|
-
"Referrer-Policy": /strict-origin/,
|
|
39
|
-
"Permissions-Policy": /geolocation=\(\)/,
|
|
40
|
-
};
|
|
34
|
+
// Expected header value patterns — the single source (header-expect.js) also
|
|
35
|
+
// feeds the /audit/ page's live-header check via _data/headerExpect.js.
|
|
36
|
+
const HEADER_EXPECT = asRegexMap();
|
|
41
37
|
|
|
42
38
|
const GATE_STAGES = ["lint", "validate", "test", "build", "links", "a11y"];
|
|
43
39
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The baseline security-header value patterns (core.md#security-headers),
|
|
2
|
+
// shared by two runtimes that can't `require()` each other:
|
|
3
|
+
// - doctor.js (Node) — reads them against the netlify.toml *declaration*.
|
|
4
|
+
// - the audit page's static/audit.js (browser) — reads them against the
|
|
5
|
+
// *live* response headers on `/`.
|
|
6
|
+
// Regex patterns are kept as source strings, not RegExp objects, so this one
|
|
7
|
+
// array can cross the Node -> build-data -> browser boundary as JSON (a
|
|
8
|
+
// project's `_data/headerExpect.js` re-exports this; audit.njk embeds it as
|
|
9
|
+
// an inline JSON block; static/audit.js reads that instead of hardcoding its
|
|
10
|
+
// own copy). Changing an expected value here changes both checks at once.
|
|
11
|
+
|
|
12
|
+
const HEADER_EXPECT = [
|
|
13
|
+
["content-security-policy", "default-src 'self'"],
|
|
14
|
+
["x-content-type-options", "nosniff"],
|
|
15
|
+
["x-frame-options", "DENY|SAMEORIGIN"],
|
|
16
|
+
["referrer-policy", "strict-origin"],
|
|
17
|
+
["permissions-policy", "geolocation=\\(\\)"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Node-side convenience: the same list as compiled RegExp objects, keyed by
|
|
21
|
+
// the doctor.js header-name casing it already uses.
|
|
22
|
+
function asRegexMap() {
|
|
23
|
+
const map = {};
|
|
24
|
+
for (const [key, pattern] of HEADER_EXPECT) {
|
|
25
|
+
const headerName = key
|
|
26
|
+
.split("-")
|
|
27
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
28
|
+
.join("-");
|
|
29
|
+
map[headerName] = new RegExp(pattern);
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { HEADER_EXPECT, asRegexMap };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piercebarney/whs-eleventy",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.2",
|
|
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"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"directory": "packages/whs-eleventy"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": ">=22"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"axe-core": "^4.13.0",
|
package/standard/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,154 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## 2026-09-02 — closing the gap between the compliance grid and the Contracts it checks
|
|
12
|
+
|
|
13
|
+
A pass through every chapter's actual enforcement, prompted by an outside
|
|
14
|
+
evaluation that traced specific checks to specific failure modes they
|
|
15
|
+
couldn't catch. Nothing here is a new chapter — it's the existing ones made to
|
|
16
|
+
actually fail when their Contract is violated, plus the doc-accuracy and
|
|
17
|
+
consistency defects the same pass turned up.
|
|
18
|
+
|
|
19
|
+
### core: noindex
|
|
20
|
+
|
|
21
|
+
- **The header-backstop layer (spec 2) is honestly scoped.** It no longer
|
|
22
|
+
reads as a universal "bonus" — the wording now says an impl doc states
|
|
23
|
+
plainly whether the layer applies to that stack's deploy model at all, since
|
|
24
|
+
a platform's per-context header config may not reach every deploy path (a
|
|
25
|
+
manually-uploaded artifact bypassing the platform's normal build pipeline,
|
|
26
|
+
for one).
|
|
27
|
+
|
|
28
|
+
### core: adopting
|
|
29
|
+
|
|
30
|
+
- The `standard-version` pin's example comment changes from `# optional pin`
|
|
31
|
+
to `# recommended — enables standard-version drift tracking (#compliance)`
|
|
32
|
+
— `#compliance`'s own check already treats it as required; the doc
|
|
33
|
+
contradicted the code.
|
|
34
|
+
|
|
35
|
+
### docs — drift-guard registry sync
|
|
36
|
+
|
|
37
|
+
- `core.md`'s Appendix **shared-concept drift guard** list now matches
|
|
38
|
+
`CLAUDE.md`'s (it had drifted to two different lists): adds `compliance`,
|
|
39
|
+
`ads`, `llm-integration` to `core.md`'s Appendix and the `README.md` copy.
|
|
40
|
+
All three now list the same ten slugs. (Not written as `### core: <slug>`
|
|
41
|
+
above — the Appendix is a registry, not a chapter, and `changelogSlugs()`
|
|
42
|
+
would otherwise mis-parse "registry" as a literal chapter slug.)
|
|
43
|
+
|
|
44
|
+
### stacks/eleventy-netlify.md · phoenix.md · sveltekit.md
|
|
45
|
+
|
|
46
|
+
- **`repo-hygiene` · `runtime-pin` · `config-idiom` now have real mirrored
|
|
47
|
+
sections in all three files.** `core.md` has said "see your stack's impl
|
|
48
|
+
doc, same slug" for these three chapters from the start; no stack file ever
|
|
49
|
+
actually carried that heading — the mechanism lived under differently-named
|
|
50
|
+
impl-only sections instead. Each new section is a one-line Core-contract
|
|
51
|
+
pointer at the existing impl-only content, not a restatement. `bin/check`'s
|
|
52
|
+
second check (below) now actually depends on this.
|
|
53
|
+
|
|
54
|
+
### stacks/eleventy-netlify.md
|
|
55
|
+
|
|
56
|
+
- **`#noindex`'s header-backstop layer is not attempted on this stack**, and
|
|
57
|
+
says why: Netlify's own docs are explicit that `[[headers]]` — and so any
|
|
58
|
+
`[[context.<name>.headers]]` block — is not context-aware; a netlify.toml
|
|
59
|
+
header declaration is global for every deploy regardless of context. The
|
|
60
|
+
documented workaround needs Netlify's build system to run, which `#ci-cd`
|
|
61
|
+
here deliberately disables. `templates/eleventy-netlify/netlify.toml` drops
|
|
62
|
+
the `[[context.deploy-preview.headers]]` / `[[context.branch-deploy.headers]]`
|
|
63
|
+
blocks it used to ship — they TOML-parsed cleanly but did nothing.
|
|
64
|
+
- **`#caching`'s example now matches what the template actually ships** (a
|
|
65
|
+
per-file-type `/*.html` / `/*.css` / `/*.js` revalidate rule) instead of a
|
|
66
|
+
fingerprint-variant example that neither matched the shipped `netlify.toml`
|
|
67
|
+
nor satisfied `whs compliance`'s own `#caching` check. Also notes the
|
|
68
|
+
`/*.html` glob's directory-permalink matching isn't precisely documented by
|
|
69
|
+
Netlify (a real user report exists of `.html` not matching as expected) —
|
|
70
|
+
verify live rather than assume; `/audit/`'s live-header panel now shows the
|
|
71
|
+
actual `Cache-Control` value for exactly this.
|
|
72
|
+
|
|
73
|
+
### stacks/phoenix.md
|
|
74
|
+
|
|
75
|
+
- **Two fabricated APIs, corrected against the real source.** `Plug.CSP` and
|
|
76
|
+
a `content_security_policy` endpoint option don't exist (verified against
|
|
77
|
+
the real `plug`/`phoenix` packages) — the CSP-nonce section now describes
|
|
78
|
+
the actual mechanism (a custom plug assigning a per-request nonce before
|
|
79
|
+
`put_secure_browser_headers/2` builds the header). `Plug.Rewrite` doesn't
|
|
80
|
+
exist either — the redirect section now uses the real, verified API
|
|
81
|
+
(`conn |> put_status(:moved_permanently) |> redirect(to: …)`, since
|
|
82
|
+
`redirect/2` takes its status from `conn.status`, not a `:status` option).
|
|
83
|
+
- **`TODO` — no reference implementation exists** banners added to
|
|
84
|
+
`internal-links`, `a11y`, `the-gate`, and `compliance` — these presented
|
|
85
|
+
`mix links.check` / `mix a11y` / `mix whs.check` as shipped tooling; none of
|
|
86
|
+
it exists yet. `the-gate`'s `mix check` snippet now says explicitly which 2
|
|
87
|
+
of its 10 steps are placeholders vs. real off-the-shelf deps.
|
|
88
|
+
|
|
89
|
+
### tooling
|
|
90
|
+
|
|
91
|
+
- **`packages/whs-eleventy` — the compliance checks now actually fail.**
|
|
92
|
+
`#noindex` was a structurally unconditional `PASS` in the non-preflight
|
|
93
|
+
path (`doctor.js`'s `prod-indexable` check always set `ok: true` there); it
|
|
94
|
+
now verifies the `build.isProduction` gate exists in `layout.njk` /
|
|
95
|
+
`robots.njk` and, when the built output is available, that it matches. A
|
|
96
|
+
missing/incomplete CSP or a dropped gate stage previously degraded to an
|
|
97
|
+
invisible `MANUAL`; `#security-headers` and `#the-gate` now FAIL on those.
|
|
98
|
+
`#og-image` now reads the built card's actual PNG dimensions (no new
|
|
99
|
+
dependency — a fixed-offset IHDR read) instead of only checking the file
|
|
100
|
+
exists. `#rendering` now checks built pages for near-empty `<main>` content,
|
|
101
|
+
not only that `404.njk` exists. `#forms` returns `N/A` on a site with no
|
|
102
|
+
form instead of a false `FAIL`, and now scans the whole built output rather
|
|
103
|
+
than only `layout.njk`. `#ads`'s production-detection reads the built
|
|
104
|
+
output directly instead of regexing another check's human-readable string.
|
|
105
|
+
- **`check-links.js`:** the informational-link-only exemption
|
|
106
|
+
(`POLICY_LINK_ORIGINS`) no longer applies to `script`/`link`/`img`
|
|
107
|
+
references — only plain `<a href>`. A new best-effort scan of shipped `.js`
|
|
108
|
+
output catches a hardcoded `fetch()`/`new URL()` call to an origin outside
|
|
109
|
+
the manifest, which the HTML-only walk could never see.
|
|
110
|
+
- **`a11y.js`:** `engines.node` is now `>=22` (verified against real Node
|
|
111
|
+
20/22 installs — the global `WebSocket` this file uses doesn't exist on 20,
|
|
112
|
+
contradicting the previous `>=20`). The scan now also covers `/404.html`,
|
|
113
|
+
deliberately excluded from the sitemap but still a real page.
|
|
114
|
+
- **`changelogSlugs()`:** fixed dropping the first entry of a
|
|
115
|
+
parenthesis-wrapped multi-slug heading (`### core: (a, b, c)`), and no
|
|
116
|
+
longer emits a bogus literal `#all` slug for `### core: all` — that's now a
|
|
117
|
+
`(all core chapters)` sentinel.
|
|
118
|
+
- **New shared module `lib/header-expect.js`** — the baseline security-header
|
|
119
|
+
patterns `doctor.js` and the `/audit/` page's live-header check each used to
|
|
120
|
+
hardcode independently now come from one source; the template bakes it into
|
|
121
|
+
the audit page's existing data block at build time.
|
|
122
|
+
- **New fixture-based test coverage** (`test/compliance-checks.test.js`) for
|
|
123
|
+
the eight chapters above — a reusable throwaway-project harness, PASS and
|
|
124
|
+
FAIL cases each.
|
|
125
|
+
- **`bin/check`'s second check redesigned.** It used to hash each stack
|
|
126
|
+
file's *intersection* with core's slug set and compare hashes across
|
|
127
|
+
files — green even when a chapter was mirrored in *none* of them (exactly
|
|
128
|
+
the `repo-hygiene`/`runtime-pin`/`config-idiom` gap above). It now diffs
|
|
129
|
+
each **active** stack (`index.json` `status: active`) directly against
|
|
130
|
+
core's full slug set. A draft/parked stack is deliberately exempt from full
|
|
131
|
+
mirroring — check #1 still holds it to "any `core.md#` reference it cites
|
|
132
|
+
must resolve" — so the standard gaining a chapter no longer forces an edit
|
|
133
|
+
in a stack nothing is built on yet.
|
|
134
|
+
- The third check's restated-principle grep is widened (still a heuristic,
|
|
135
|
+
now says so). `bin/set-version` and `bin/check`'s version-agreement check
|
|
136
|
+
now also cover `README.md`'s `standard-version` example, which had drifted
|
|
137
|
+
stale and untracked.
|
|
138
|
+
|
|
139
|
+
### templates/eleventy-netlify
|
|
140
|
+
|
|
141
|
+
- **The `draft` content flag is now wired**, matching the spec
|
|
142
|
+
`stacks/eleventy-netlify.md#content-model` has always stated: an optional
|
|
143
|
+
boolean in `guides.schema.json` + `schema.js`, and `guides.11tydata.js`'s
|
|
144
|
+
`eleventyComputed` sets `permalink: false` + `eleventyExcludeFromCollections:
|
|
145
|
+
true` only when `draft && build.isProduction`.
|
|
146
|
+
- **`bin/init`** now warns loudly (and adds a numbered follow-up step) when a
|
|
147
|
+
brief's `css:` answer doesn't match what the template actually ships
|
|
148
|
+
(`pico-classes`) — previously the key was recorded in `CLAUDE.md` as if
|
|
149
|
+
applied, with nothing behind it. The tooling pin it writes is `>=X.Y.Z`, not
|
|
150
|
+
`^X.Y.Z` — the package's major digit is the release year, not a
|
|
151
|
+
breaking-change signal, so a caret range silently stopped matching every
|
|
152
|
+
January 1st.
|
|
153
|
+
- `.claude/settings.json`'s hook matcher and `og-guard.sh`'s watched-file
|
|
154
|
+
globs now match what `stacks/eleventy-netlify.md` documents (`MultiEdit`,
|
|
155
|
+
`static/fonts/*`).
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
11
159
|
## 2026-09-01 — the shared tooling package (#compliance mechanism, eleventy)
|
|
12
160
|
|
|
13
161
|
### stacks/eleventy-netlify.md
|
|
@@ -37,8 +185,9 @@ Format: `## <date>` → `### core: <slug>` / `### <stack>` entries.
|
|
|
37
185
|
- **`.github/workflows/publish.yml`** publishes `@piercebarney/whs-eleventy` to
|
|
38
186
|
npm when a `YYYY-MM-DD` version tag is pushed — it lints + tests the package,
|
|
39
187
|
checks the tag date (dotted) matches `package.json`, and publishes unless that
|
|
40
|
-
version is already up.
|
|
41
|
-
|
|
188
|
+
version is already up. Auth is npm **Trusted Publishing** (OIDC) — no token,
|
|
189
|
+
no secret, one-time `Trusted Publisher` setup on the package's npm settings.
|
|
190
|
+
Provenance is auto-skipped for the private source repo.
|
|
42
191
|
- `standard.yml` gains a `package` job (prettier + eslint + `node:test`); the
|
|
43
192
|
`templates` job installs the file: package's own tree and points
|
|
44
193
|
`WHS_STANDARD` at the checkout so drift is checked against the live standard.
|
package/standard/core.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Web project house style — CORE (stack-agnostic)
|
|
2
2
|
|
|
3
|
-
**Version:** 2026-09-
|
|
3
|
+
**Version:** 2026-09-02 · **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
|
|
@@ -412,7 +412,11 @@ by three layers in order of reliance.
|
|
|
412
412
|
non-production build.
|
|
413
413
|
2. **Header backstop:** the host's response headers add `X-Robots-Tag: noindex`
|
|
414
414
|
for preview/branch contexts where the platform supports it. Treated as a
|
|
415
|
-
bonus —
|
|
415
|
+
bonus — never relied on, and never assumed present without checking: a
|
|
416
|
+
platform's per-context header config may not apply to every deploy path
|
|
417
|
+
(e.g. a manually-uploaded artifact bypassing that platform's normal build
|
|
418
|
+
pipeline) — the impl doc states plainly whether this layer actually
|
|
419
|
+
applies to the stack's deploy model, or is a no-op there.
|
|
416
420
|
3. **Canonical:** every page's canonical points at the production origin, so an
|
|
417
421
|
indexed stray still redirects search engines to production.
|
|
418
422
|
|
|
@@ -1333,6 +1337,11 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
|
|
|
1333
1337
|
- A conflict between the current code and a chapter is a **migration gap** —
|
|
1334
1338
|
surface it; don't quietly fix it inside an unrelated task and don't quietly
|
|
1335
1339
|
route around it.
|
|
1340
|
+
- A defect in the house style *itself* — a `#compliance` or gate check that
|
|
1341
|
+
misfires, two chapters that contradict, a **Contract** that is wrong or
|
|
1342
|
+
unclear, a recurring need it is silent on — is reported back to the standard,
|
|
1343
|
+
not left in this session's transcript: a dated note in the standard repo's
|
|
1344
|
+
`feedback/` inbox (or the same note handed to a person to drop there).
|
|
1336
1345
|
- Never silently diverge from a **Contract**. A deliberate deviation is
|
|
1337
1346
|
recorded in this section with its reason.
|
|
1338
1347
|
- Where the house style is silent, do what is idiomatic for the stack.
|
|
@@ -1350,7 +1359,7 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
|
|
|
1350
1359
|
- production-url: https://example.com
|
|
1351
1360
|
- content-type: tool # tool | article (article => feeds)
|
|
1352
1361
|
- publishing-rate: ~5 pages/week
|
|
1353
|
-
- standard-version: 2026-09-
|
|
1362
|
+
- standard-version: 2026-09-02 # recommended — enables standard-version drift tracking (#compliance)
|
|
1354
1363
|
```
|
|
1355
1364
|
|
|
1356
1365
|
If the section is absent, the assistant's first action is to run the flow above
|
|
@@ -1363,6 +1372,18 @@ worked incrementally (the impl doc's migration path is the ordered version of
|
|
|
1363
1372
|
that backlog). The project is not expected to be all-`PASS` on day one; it is
|
|
1364
1373
|
expected to know exactly where it isn't.
|
|
1365
1374
|
|
|
1375
|
+
**Feedback to the standard.** Adopting the house style is what surfaces its
|
|
1376
|
+
bugs — a check that misfires, a `core.md` ↔ impl-doc contradiction, a Contract
|
|
1377
|
+
that doesn't survive a real codebase, a need it never anticipated. That signal
|
|
1378
|
+
belongs to the standard, not only to the project session that hit it: fixes
|
|
1379
|
+
flow standard → template → project, and this is the one path back. The
|
|
1380
|
+
mechanism is deliberately minimal — a dated note in the standard repo's
|
|
1381
|
+
`feedback/` inbox (what is wrong · which project · `standard-version`);
|
|
1382
|
+
`feedback/README.md` has the shape. A session that can't reach the standard's
|
|
1383
|
+
tree, or a content-only session, hands a person the same note to file. Triage
|
|
1384
|
+
empties the inbox into `CHANGELOG` changes or a one-line `wontfix`. This is
|
|
1385
|
+
separate from the project's own state and backlog.
|
|
1386
|
+
|
|
1366
1387
|
**Change checklist (stack-agnostic)** — run before merging to `main`, and again
|
|
1367
1388
|
before `bin/deploy`. (On a multi-committer project this is also the PR-review
|
|
1368
1389
|
checklist.)
|
|
@@ -1463,6 +1484,9 @@ Each impl doc keeps its own stack-specific out-of-scope list.
|
|
|
1463
1484
|
unknown what's actually left or what regressed.
|
|
1464
1485
|
- A `standard-version` pin left behind the standard's current version with no
|
|
1465
1486
|
`CHANGELOG`-delta re-check.
|
|
1487
|
+
- A defect in the standard found while adopting it — a check that misfires, a
|
|
1488
|
+
chapter contradiction, an unclear Contract — worked around silently or left in
|
|
1489
|
+
a session transcript instead of dropped in the standard's `feedback/` inbox.
|
|
1466
1490
|
- A permalink changed with no `301` from the old path.
|
|
1467
1491
|
- A default branch other than `main`.
|
|
1468
1492
|
- Deploying by pushing to a branch instead of the sanctioned gated command.
|
|
@@ -1489,5 +1513,6 @@ mirrored chapter; impl-only chapters take slugs that appear nowhere here.
|
|
|
1489
1513
|
`adopting` · `out-of-scope` · `anti-patterns`
|
|
1490
1514
|
|
|
1491
1515
|
**Shared-concept drift guard** — defined only here; an impl doc may state the
|
|
1492
|
-
*how*, never restate the *what*: `the-gate`, `
|
|
1493
|
-
`brand-source`, `noindex`, `content-model`,
|
|
1516
|
+
*how*, never restate the *what*: `the-gate`, `compliance`, `ci-cd`,
|
|
1517
|
+
`third-parties`, `brand-source`, `noindex`, `content-model`,
|
|
1518
|
+
`beyond-this-standard`, `ads`, `llm-integration`.
|