@piercebarney/whs-eleventy 2026.9.1 → 2026.9.3
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/_project.js +15 -1
- package/lib/a11y.js +8 -0
- package/lib/check-links.js +35 -3
- package/lib/compliance.js +167 -39
- package/lib/content-check.js +76 -24
- package/lib/doctor.js +25 -9
- package/lib/header-expect.js +34 -0
- package/package.json +2 -2
- package/standard/CHANGELOG.md +619 -2
- package/standard/core.md +80 -20
package/lib/_project.js
CHANGED
|
@@ -22,6 +22,20 @@ function tryProjectRequire(rel, fallback = null) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// The content-arm -> build-arm escalation drawer (core.md#content-model): one
|
|
26
|
+
// `YYYY-MM-DD-<slug>.md` per blocked structural request, triaged and deleted by
|
|
27
|
+
// the build arm. Returns the open notes' filenames (README.md excluded), sorted.
|
|
28
|
+
function openRequests(root = ROOT) {
|
|
29
|
+
try {
|
|
30
|
+
return fs
|
|
31
|
+
.readdirSync(path.join(root, "requests"))
|
|
32
|
+
.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md")
|
|
33
|
+
.sort();
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
// Locate the standard's text (core.md, CHANGELOG.md) for the drift check:
|
|
26
40
|
// 1. WHS_STANDARD env var (the authoring repo points this at itself)
|
|
27
41
|
// 2. the copy bundled into this package at publish time (standard/)
|
|
@@ -39,4 +53,4 @@ function resolveStandard() {
|
|
|
39
53
|
return null;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
module.exports = { ROOT, projectRequire, tryProjectRequire, resolveStandard };
|
|
56
|
+
module.exports = { ROOT, projectRequire, tryProjectRequire, openRequests, resolveStandard };
|
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
|
|
|
@@ -136,7 +152,9 @@ const CHECKS = {
|
|
|
136
152
|
}
|
|
137
153
|
const protocol =
|
|
138
154
|
has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
|
|
139
|
-
? "
|
|
155
|
+
? has("requests")
|
|
156
|
+
? "agent content-ops protocol present (CONTENT.md, content-check, requests/)"
|
|
157
|
+
: "CONTENT.md + content-check present, but no requests/ escalation drawer"
|
|
140
158
|
: "no CONTENT.md / content-check — add it if content is agent-edited";
|
|
141
159
|
return {
|
|
142
160
|
status: MANUAL,
|
|
@@ -146,7 +164,22 @@ const CHECKS = {
|
|
|
146
164
|
|
|
147
165
|
rendering: () => {
|
|
148
166
|
if (!has("src/404.njk")) return { status: FAIL, note: "no src/404.njk" };
|
|
149
|
-
|
|
167
|
+
// A page whose <main> is empty/near-empty in the built output is the
|
|
168
|
+
// classic "content only appears after client JS runs" regression the
|
|
169
|
+
// Contract forbids — a crude but real content-presence check, distinct
|
|
170
|
+
// from merely confirming a 404 template exists.
|
|
171
|
+
const thin = siteHtml().filter((f) => {
|
|
172
|
+
const root = parseHtml(fs.readFileSync(f, "utf8"));
|
|
173
|
+
const main = root.querySelector("main");
|
|
174
|
+
const text = (main ? main.text : root.text || "").replace(/\s+/g, " ").trim();
|
|
175
|
+
return text.length < 40;
|
|
176
|
+
});
|
|
177
|
+
if (thin.length)
|
|
178
|
+
return {
|
|
179
|
+
status: FAIL,
|
|
180
|
+
note: `${thin.length} page(s) with near-empty <main> — content may be client-rendered only`,
|
|
181
|
+
};
|
|
182
|
+
return { status: PASS, note: "server-rendered HTML with real content; 404 page present" };
|
|
150
183
|
},
|
|
151
184
|
|
|
152
185
|
assets: () => {
|
|
@@ -223,20 +256,44 @@ const CHECKS = {
|
|
|
223
256
|
},
|
|
224
257
|
|
|
225
258
|
"og-image": () => {
|
|
226
|
-
|
|
259
|
+
// PNG IHDR: an 8-byte signature, then a 4-byte length + "IHDR" + a
|
|
260
|
+
// 4-byte width + 4-byte height, big-endian, at fixed offsets — no decoder
|
|
261
|
+
// dependency needed to read just the dimensions.
|
|
262
|
+
const pngSize = (rel) => {
|
|
263
|
+
let buf;
|
|
264
|
+
try {
|
|
265
|
+
buf = fs.readFileSync(path.join(ROOT, rel));
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
if (buf.length < 24 || buf.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
270
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
271
|
+
};
|
|
272
|
+
const bad = [];
|
|
227
273
|
for (const f of siteHtml()) {
|
|
274
|
+
const rel = path.relative(ROOT, f);
|
|
228
275
|
const m = fs
|
|
229
276
|
.readFileSync(f, "utf8")
|
|
230
277
|
.match(/property="og:image" content="[^"]*?\/og\/([^"]+?)\.png"/);
|
|
231
278
|
if (!m) {
|
|
232
|
-
bad
|
|
279
|
+
bad.push(`${rel} (no og:image)`);
|
|
233
280
|
continue;
|
|
234
281
|
}
|
|
235
|
-
|
|
282
|
+
const cardRel = `_site/og/${m[1]}.png`;
|
|
283
|
+
if (!has(cardRel)) {
|
|
284
|
+
bad.push(`${rel} (${cardRel} missing)`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const dims = pngSize(cardRel);
|
|
288
|
+
if (!dims || dims.width !== 1200 || dims.height !== 630) {
|
|
289
|
+
bad.push(
|
|
290
|
+
`${rel} (${cardRel} is ${dims ? `${dims.width}x${dims.height}` : "not a PNG"}, not 1200x630)`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
236
293
|
}
|
|
237
|
-
return bad
|
|
238
|
-
? { status: FAIL, note: `${bad} page(s)
|
|
239
|
-
: { status: PASS, note: "every og:image resolves" };
|
|
294
|
+
return bad.length
|
|
295
|
+
? { status: FAIL, note: `${bad.length} page(s): ${bad.slice(0, 5).join("; ")}` }
|
|
296
|
+
: { status: PASS, note: "every og:image resolves at 1200x630" };
|
|
240
297
|
},
|
|
241
298
|
|
|
242
299
|
"structured-data": () => {
|
|
@@ -258,11 +315,31 @@ const CHECKS = {
|
|
|
258
315
|
};
|
|
259
316
|
},
|
|
260
317
|
|
|
261
|
-
noindex: () =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
318
|
+
noindex: () => {
|
|
319
|
+
// doctor's `prod-indexable` repo-tier check only *describes* the built
|
|
320
|
+
// output's current noindex state — it does not judge it (that's what
|
|
321
|
+
// `--deploy-preflight` is for). Judging the actual Contract — "a
|
|
322
|
+
// non-production build always carries noindex" — needs its own check
|
|
323
|
+
// here: verify the SOURCE gate exists (independent of whichever build
|
|
324
|
+
// happens to be sitting in _site/), and additionally check the built
|
|
325
|
+
// output when we can tell which target it was built for.
|
|
326
|
+
const layout = read("src/_includes/layout.njk") || "";
|
|
327
|
+
const robots = read("src/robots.njk") || "";
|
|
328
|
+
if (!/build\.isProduction/.test(layout) || !/noindex/i.test(layout))
|
|
329
|
+
return { status: FAIL, note: "layout.njk has no build.isProduction-gated noindex meta" };
|
|
330
|
+
if (!/build\.isProduction/.test(robots))
|
|
331
|
+
return { status: FAIL, note: "robots.njk Disallow is not keyed on build.isProduction" };
|
|
332
|
+
const home = read("_site/index.html");
|
|
333
|
+
if (home !== null && process.env.DEPLOY_TARGET !== "production") {
|
|
334
|
+
const indexable = !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
|
|
335
|
+
if (indexable)
|
|
336
|
+
return {
|
|
337
|
+
status: FAIL,
|
|
338
|
+
note: "_site/index.html is indexable but DEPLOY_TARGET is not production",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return { status: PASS, note: "layout.njk + robots.njk gate on build.isProduction" };
|
|
342
|
+
},
|
|
266
343
|
|
|
267
344
|
"brand-source": () => {
|
|
268
345
|
// Per the standard: grep src/, static/style.css, scripts/og-card.js|icons.js.
|
|
@@ -302,8 +379,19 @@ const CHECKS = {
|
|
|
302
379
|
return { status: PASS, note: "noindex; show-all toggle; all tabs present" };
|
|
303
380
|
},
|
|
304
381
|
|
|
305
|
-
"security-headers": () =>
|
|
306
|
-
|
|
382
|
+
"security-headers": () => {
|
|
383
|
+
if (!has("netlify.toml"))
|
|
384
|
+
return { status: FAIL, note: "no netlify.toml — no security headers declared" };
|
|
385
|
+
// A missing/incomplete header set, or a CSP that grants an origin outside
|
|
386
|
+
// the declared manifest, are mechanically-known Contract violations, not
|
|
387
|
+
// judgment calls — FAIL them rather than leaving them an invisible-to
|
|
388
|
+
// ---strict MANUAL.
|
|
389
|
+
const headers = fromDoctor("security-headers", "", { strict: true, missingIsFail: true });
|
|
390
|
+
const csp = fromDoctor("csp-thirdparties", "", { strict: true, missingIsFail: true });
|
|
391
|
+
if (headers.status === FAIL) return headers;
|
|
392
|
+
if (csp.status === FAIL) return csp;
|
|
393
|
+
return { status: PASS, note: [headers.note, csp.note].filter(Boolean).join(" · ") };
|
|
394
|
+
},
|
|
307
395
|
|
|
308
396
|
caching: () => {
|
|
309
397
|
const toml = read("netlify.toml") || "";
|
|
@@ -326,14 +414,22 @@ const CHECKS = {
|
|
|
326
414
|
},
|
|
327
415
|
|
|
328
416
|
privacy: () => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
417
|
+
// The privacy page may be a standalone src/privacy.njk or a pages.json
|
|
418
|
+
// entry rendered by a src/pages/ shell — check the built output, with a
|
|
419
|
+
// source fallback for when _site isn't built yet.
|
|
420
|
+
const built = has("_site/privacy/index.html");
|
|
421
|
+
const source =
|
|
422
|
+
has("src/privacy.njk") ||
|
|
423
|
+
/["']slug["']\s*:\s*["']privacy["']/.test(read("src/content/pages.json") || "");
|
|
424
|
+
if (!built && !source) return { status: FAIL, note: "no privacy page" };
|
|
425
|
+
// The third-party disclosure must be generated from _data/thirdparties.js,
|
|
426
|
+
// never hand-maintained — the loop lives in the privacy shell or an include.
|
|
427
|
+
if (grepSrc(/for\s+t\s+in\s+thirdparties/).length === 0)
|
|
332
428
|
return {
|
|
333
429
|
status: FAIL,
|
|
334
430
|
note: "privacy third-party list not rendered from _data/thirdparties.js",
|
|
335
431
|
};
|
|
336
|
-
return { status: PASS, note: "third-party list
|
|
432
|
+
return { status: PASS, note: "privacy page present; third-party list from the manifest" };
|
|
337
433
|
},
|
|
338
434
|
|
|
339
435
|
ads: () => {
|
|
@@ -353,7 +449,11 @@ const CHECKS = {
|
|
|
353
449
|
};
|
|
354
450
|
// Real ad CODE (not a bare origin string the audit page shows as data).
|
|
355
451
|
const AD_CODE = /adsbygoogle\.js|<ins[^>]+adsbygoogle|google-adsense-account/;
|
|
356
|
-
|
|
452
|
+
// Read the built output directly rather than regex-matching another
|
|
453
|
+
// check's human-readable detail string — the same source check-links.js
|
|
454
|
+
// and the noindex check above use.
|
|
455
|
+
const home = read("_site/index.html");
|
|
456
|
+
const prod = home !== null && !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
|
|
357
457
|
const leak = siteHtml().filter((f) => AD_CODE.test(fs.readFileSync(f, "utf8")));
|
|
358
458
|
if (!prod && leak.length)
|
|
359
459
|
return { status: FAIL, note: `ad code in ${leak.length} non-production page(s)` };
|
|
@@ -371,13 +471,21 @@ const CHECKS = {
|
|
|
371
471
|
},
|
|
372
472
|
|
|
373
473
|
forms: () => {
|
|
374
|
-
|
|
474
|
+
// A form can live anywhere in the built output, not only in layout.njk
|
|
475
|
+
// (a contact page's own template, say) — scan every page, and treat "no
|
|
476
|
+
// form anywhere" as N/A rather than a false FAIL on a site that has none.
|
|
477
|
+
const pagesWithForms = siteHtml().filter((f) => /<form[\s>]/i.test(fs.readFileSync(f, "utf8")));
|
|
478
|
+
if (!pagesWithForms.length) return { status: NA, note: "no <form> in the built output" };
|
|
479
|
+
const hasHoneypot = pagesWithForms.some((f) =>
|
|
480
|
+
/netlify-honeypot|name="bot-field"/.test(fs.readFileSync(f, "utf8")),
|
|
481
|
+
);
|
|
482
|
+
if (!hasHoneypot) return { status: FAIL, note: "no honeypot field in the form" };
|
|
375
483
|
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
484
|
if (handlers.length > 1)
|
|
379
485
|
return { status: FAIL, note: `submit handler in ${handlers.length} files — consolidate` };
|
|
380
|
-
|
|
486
|
+
if (handlers.length === 0)
|
|
487
|
+
return { status: FAIL, note: "a form exists but no submit handler was found" };
|
|
488
|
+
return { status: PASS, note: "one handler; honeypot present" };
|
|
381
489
|
},
|
|
382
490
|
|
|
383
491
|
"domain-tests": () => {
|
|
@@ -404,13 +512,18 @@ const CHECKS = {
|
|
|
404
512
|
};
|
|
405
513
|
},
|
|
406
514
|
|
|
407
|
-
"the-gate": () =>
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
),
|
|
515
|
+
"the-gate": () => {
|
|
516
|
+
// A gate missing a stage (e.g. a11y quietly dropped from `check`) is a
|
|
517
|
+
// mechanically-known Contract violation — FAIL it, don't bury it in MANUAL.
|
|
518
|
+
const leaked = /doctor|compliance/.test(
|
|
519
|
+
(JSON.parse(read("package.json") || "{}").scripts || {}).check || "",
|
|
520
|
+
);
|
|
521
|
+
if (leaked) return { status: FAIL, note: "doctor/compliance leaked into `npm run check`" };
|
|
522
|
+
return fromDoctor("gate-stages", "doctor + compliance kept out", {
|
|
523
|
+
strict: true,
|
|
524
|
+
missingIsFail: true,
|
|
525
|
+
});
|
|
526
|
+
},
|
|
414
527
|
|
|
415
528
|
compliance: () => {
|
|
416
529
|
const pkg = JSON.parse(read("package.json") || "{}");
|
|
@@ -564,7 +677,20 @@ function changelogSlugs(changelog, pin) {
|
|
|
564
677
|
const coreM = line.match(/^###\s+core:\s+(.+)/);
|
|
565
678
|
if (coreM) {
|
|
566
679
|
for (const part of coreM[1].split(/[·,]/)) {
|
|
567
|
-
|
|
680
|
+
// Strip wrapping punctuation a heading can carry — `(a, b, c)`
|
|
681
|
+
// splits into parts like `(a` and `c)` — before matching, or the
|
|
682
|
+
// leading "(" makes the slug regex miss the first entry entirely.
|
|
683
|
+
const cleaned = part.trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, "");
|
|
684
|
+
if (!cleaned) continue;
|
|
685
|
+
if (/^all$/i.test(cleaned)) {
|
|
686
|
+
// "### core: all" (the initial-split entry) means every chapter,
|
|
687
|
+
// not a literal slug named "all" — the registry has no such slug,
|
|
688
|
+
// so emitting it as one would print a `MANUAL: re-check #all` row
|
|
689
|
+
// that looks real but isn't.
|
|
690
|
+
slugs.add("(all core chapters)");
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
|
|
568
694
|
if (slug) slugs.add(slug);
|
|
569
695
|
}
|
|
570
696
|
}
|
|
@@ -674,4 +800,6 @@ function main() {
|
|
|
674
800
|
|
|
675
801
|
if (require.main === module) main();
|
|
676
802
|
|
|
677
|
-
|
|
803
|
+
// CHECKS is exported for the "coverage" test — nothing else should read it as
|
|
804
|
+
// data (call runCompliance() for results).
|
|
805
|
+
module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
|
package/lib/content-check.js
CHANGED
|
@@ -4,42 +4,94 @@
|
|
|
4
4
|
//
|
|
5
5
|
// whs content-check
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// A **staged** file outside the reserved content set is an error — a `content:`
|
|
8
|
+
// commit can't carry a code/structure change. An unstaged stray only warns (it
|
|
9
|
+
// won't be in the commit unless staged). Also reports the open requests/ notes
|
|
10
|
+
// (the content-arm → build-arm escalation channel, core.md#content-model).
|
|
9
11
|
|
|
10
12
|
const { execSync } = require("node:child_process");
|
|
13
|
+
const { openRequests } = require("./_project.js");
|
|
11
14
|
|
|
12
|
-
// Files an agent following CONTENT.md
|
|
13
|
-
|
|
15
|
+
// Files an agent following CONTENT.md may touch — the reserved content set plus
|
|
16
|
+
// its own escalation drawer. Everything else (the *.schema.json editor aids,
|
|
17
|
+
// schema.js, the page shells, the includes, config) is a build-arm change.
|
|
18
|
+
const RESERVED_SET = [
|
|
19
|
+
/^src\/content\/(?!.*\.schema\.json$)[^/]+\.json$/,
|
|
20
|
+
/^src\/_data\/nav\.js$/,
|
|
21
|
+
/^src\/_data\/glossary\.js$/,
|
|
22
|
+
/^requests\//,
|
|
23
|
+
];
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
// One `git status --porcelain` line → { path, staged }. `staged` is true when
|
|
26
|
+
// the index column (X) holds a real status letter — i.e. the change is part of
|
|
27
|
+
// the next commit. Untracked ("??") and worktree-only (" M") are not staged.
|
|
28
|
+
function parsePorcelain(out) {
|
|
29
|
+
return out
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((l) => {
|
|
33
|
+
const x = l[0];
|
|
34
|
+
const rest = l.slice(3);
|
|
35
|
+
const path = rest.includes(" -> ") ? rest.split(" -> ")[1] : rest;
|
|
36
|
+
return { path: path.trim(), staged: x !== " " && x !== "?" };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function changedEntries() {
|
|
16
41
|
try {
|
|
17
|
-
|
|
18
|
-
return out
|
|
19
|
-
.split("\n")
|
|
20
|
-
.map((l) => l.slice(3).trim())
|
|
21
|
-
.filter(Boolean)
|
|
22
|
-
.map((f) => (f.includes(" -> ") ? f.split(" -> ")[1] : f));
|
|
42
|
+
return parsePorcelain(execSync("git status --porcelain", { encoding: "utf8" }));
|
|
23
43
|
} catch {
|
|
24
44
|
return [];
|
|
25
45
|
}
|
|
26
46
|
}
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.warn("\n⚠ changes outside the content set — these are code changes, not content:");
|
|
31
|
-
for (const f of stray) console.warn(` ${f}`);
|
|
32
|
-
console.warn(" Commit them separately (a Claude Code task), or confirm they're intentional.\n");
|
|
48
|
+
function strayFiles(paths) {
|
|
49
|
+
return paths.filter((f) => !RESERVED_SET.some((re) => re.test(f)));
|
|
33
50
|
}
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
function splitStray(entries) {
|
|
53
|
+
return {
|
|
54
|
+
staged: strayFiles(entries.filter((e) => e.staged).map((e) => e.path)),
|
|
55
|
+
unstaged: strayFiles(entries.filter((e) => !e.staged).map((e) => e.path)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const { staged: stagedStray, unstaged: unstagedStray } = splitStray(changedEntries());
|
|
61
|
+
|
|
62
|
+
if (stagedStray.length) {
|
|
63
|
+
console.error("\n✗ staged changes outside the reserved content set (CONTENT.md):");
|
|
64
|
+
for (const f of stagedStray) console.error(` ${f}`);
|
|
65
|
+
console.error(
|
|
66
|
+
"\n A `content:` commit can't carry a code or structure change. Unstage them\n" +
|
|
67
|
+
" (git restore --staged <file>), or file a requests/ note for the build arm.\n",
|
|
68
|
+
);
|
|
42
69
|
process.exit(1);
|
|
43
70
|
}
|
|
71
|
+
if (unstagedStray.length) {
|
|
72
|
+
console.warn("\n⚠ uncommitted changes outside the reserved set (not staged):");
|
|
73
|
+
for (const f of unstagedStray) console.warn(` ${f}`);
|
|
74
|
+
console.warn(" They won't ride a `content:` commit unless you stage them.\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const reqs = openRequests();
|
|
78
|
+
if (reqs.length) {
|
|
79
|
+
console.log(`\nℹ ${reqs.length} open request(s) in requests/ awaiting the build arm.\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const steps = ["lint", "validate", "build", "links"];
|
|
83
|
+
for (const s of steps) {
|
|
84
|
+
process.stdout.write(`content-check: npm run ${s}\n`);
|
|
85
|
+
try {
|
|
86
|
+
execSync(`npm run ${s}`, { stdio: "inherit" });
|
|
87
|
+
} catch {
|
|
88
|
+
console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log("\ncontent-check: ok — lint · validate · build · links");
|
|
44
93
|
}
|
|
45
|
-
|
|
94
|
+
|
|
95
|
+
module.exports = { strayFiles, parsePorcelain, splitStray, RESERVED_SET };
|
|
96
|
+
|
|
97
|
+
if (require.main === module) main();
|
package/lib/doctor.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
const fs = require("node:fs");
|
|
22
22
|
const path = require("node:path");
|
|
23
23
|
const { execSync } = require("node:child_process");
|
|
24
|
-
const { ROOT, projectRequire } = require("./_project.js");
|
|
24
|
+
const { ROOT, projectRequire, openRequests } = 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
|
|
|
@@ -380,6 +376,26 @@ function main() {
|
|
|
380
376
|
}
|
|
381
377
|
|
|
382
378
|
if (preflight) {
|
|
379
|
+
// Open content-arm → build-arm requests gate the deploy: a conditional
|
|
380
|
+
// hard stop (core.md#content-model). Zero → ship; one or more → refuse
|
|
381
|
+
// unless --ack-requests says the operator has reviewed them.
|
|
382
|
+
const reqs = openRequests();
|
|
383
|
+
if (reqs.length && !args.includes("--ack-requests")) {
|
|
384
|
+
console.error(
|
|
385
|
+
`\ndoctor: ${reqs.length} open request(s) in requests/ — the content arm is ` +
|
|
386
|
+
`waiting on\nthe build arm. Clear them, or re-run the deploy with ` +
|
|
387
|
+
`--ack-requests to ship anyway:`,
|
|
388
|
+
);
|
|
389
|
+
reqs.forEach((r) => console.error(` - requests/${r}`));
|
|
390
|
+
console.error("\nRefusing to continue.");
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
console.log(
|
|
394
|
+
reqs.length
|
|
395
|
+
? `\nOpen requests: ${reqs.length} — acknowledged (--ack-requests).`
|
|
396
|
+
: "\nOpen requests: 0.",
|
|
397
|
+
);
|
|
398
|
+
|
|
383
399
|
const cr = complianceRegressions();
|
|
384
400
|
if (!cr.checked) {
|
|
385
401
|
console.log(
|
|
@@ -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.3",
|
|
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",
|