@piercebarney/whs-eleventy 2026.9.2 → 2026.9.8
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/compliance.js +63 -23
- package/lib/content-check.js +76 -24
- package/lib/doctor.js +21 -1
- package/package.json +3 -3
- package/standard/CHANGELOG.md +1151 -13
- package/standard/core.md +104 -25
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/compliance.js
CHANGED
|
@@ -152,7 +152,9 @@ const CHECKS = {
|
|
|
152
152
|
}
|
|
153
153
|
const protocol =
|
|
154
154
|
has("CONTENT.md") && /"content-check"/.test(read("package.json") || "")
|
|
155
|
-
? "
|
|
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"
|
|
156
158
|
: "no CONTENT.md / content-check — add it if content is agent-edited";
|
|
157
159
|
return {
|
|
158
160
|
status: MANUAL,
|
|
@@ -412,14 +414,22 @@ const CHECKS = {
|
|
|
412
414
|
},
|
|
413
415
|
|
|
414
416
|
privacy: () => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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)
|
|
418
428
|
return {
|
|
419
429
|
status: FAIL,
|
|
420
430
|
note: "privacy third-party list not rendered from _data/thirdparties.js",
|
|
421
431
|
};
|
|
422
|
-
return { status: PASS, note: "third-party list
|
|
432
|
+
return { status: PASS, note: "privacy page present; third-party list from the manifest" };
|
|
423
433
|
},
|
|
424
434
|
|
|
425
435
|
ads: () => {
|
|
@@ -654,16 +664,26 @@ const CHECKS = {
|
|
|
654
664
|
|
|
655
665
|
// ---- standard-version drift -----------------------------------------
|
|
656
666
|
|
|
657
|
-
// Every
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
667
|
+
// Every CHANGELOG entry (`## <date> [breaking|non-breaking]`) dated after
|
|
668
|
+
// `pin`, with the chapter slugs it touched. `### core: a · b · c` yields
|
|
669
|
+
// every slug on the line, not just the first; a
|
|
670
|
+
// `### stacks/eleventy-netlify.md` heading yields the binding marker. An
|
|
671
|
+
// entry heading with no severity tag (a fixture, or a pre-2026-09-04
|
|
672
|
+
// CHANGELOG snapshot) defaults to non-breaking — the real standard's own
|
|
673
|
+
// CHANGELOG.md always carries the tag (bin/check's 6th check enforces it).
|
|
674
|
+
function changelogEntries(changelog, pin) {
|
|
675
|
+
const entries = [];
|
|
676
|
+
let current = null;
|
|
663
677
|
for (const line of (changelog || "").split("\n")) {
|
|
664
|
-
const dateM = line.match(
|
|
665
|
-
|
|
666
|
-
|
|
678
|
+
const dateM = line.match(
|
|
679
|
+
/^##\s+([0-9]{4}-[0-9]{2}-[0-9]{2}).*?(?:\[(breaking|non-breaking)\])?\s*$/,
|
|
680
|
+
);
|
|
681
|
+
if (dateM) {
|
|
682
|
+
current = dateM[1] > pin ? { severity: dateM[2] || "non-breaking", slugs: new Set() } : null;
|
|
683
|
+
if (current) entries.push(current);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!current) continue;
|
|
667
687
|
const coreM = line.match(/^###\s+core:\s+(.+)/);
|
|
668
688
|
if (coreM) {
|
|
669
689
|
for (const part of coreM[1].split(/[·,]/)) {
|
|
@@ -677,15 +697,23 @@ function changelogSlugs(changelog, pin) {
|
|
|
677
697
|
// not a literal slug named "all" — the registry has no such slug,
|
|
678
698
|
// so emitting it as one would print a `MANUAL: re-check #all` row
|
|
679
699
|
// that looks real but isn't.
|
|
680
|
-
slugs.add("(all core chapters)");
|
|
700
|
+
current.slugs.add("(all core chapters)");
|
|
681
701
|
continue;
|
|
682
702
|
}
|
|
683
703
|
const slug = (cleaned.match(/^[a-z0-9-]+/) || [])[0];
|
|
684
|
-
if (slug) slugs.add(slug);
|
|
704
|
+
if (slug) current.slugs.add(slug);
|
|
685
705
|
}
|
|
686
706
|
}
|
|
687
|
-
if (/^###\s+stacks\/eleventy-netlify\.md/.test(line)) slugs.add("(eleventy binding)");
|
|
707
|
+
if (/^###\s+stacks\/eleventy-netlify\.md/.test(line)) current.slugs.add("(eleventy binding)");
|
|
688
708
|
}
|
|
709
|
+
return entries.map((e) => ({ severity: e.severity, slugs: [...e.slugs] }));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Every chapter slug the CHANGELOG records as changed after `pin`, flattened
|
|
713
|
+
// (severity dropped) — kept for callers that only need the slug set.
|
|
714
|
+
function changelogSlugs(changelog, pin) {
|
|
715
|
+
const slugs = new Set();
|
|
716
|
+
for (const e of changelogEntries(changelog, pin)) for (const s of e.slugs) slugs.add(s);
|
|
689
717
|
return [...slugs];
|
|
690
718
|
}
|
|
691
719
|
|
|
@@ -706,11 +734,22 @@ function versionDrift() {
|
|
|
706
734
|
if (pin >= current) return { pin, current, rows: [] };
|
|
707
735
|
|
|
708
736
|
const changelog = read("CHANGELOG.md", STANDARD) || "";
|
|
737
|
+
// Worst-case severity per slug: one breaking touch marks it breaking for
|
|
738
|
+
// good, even if a later (or earlier) entry touching the same slug since
|
|
739
|
+
// the pin was non-breaking.
|
|
740
|
+
const severityBySlug = new Map();
|
|
741
|
+
for (const entry of changelogEntries(changelog, pin)) {
|
|
742
|
+
for (const slug of entry.slugs) {
|
|
743
|
+
if (severityBySlug.get(slug) !== "breaking") severityBySlug.set(slug, entry.severity);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
709
746
|
return {
|
|
710
747
|
pin,
|
|
711
748
|
current,
|
|
712
|
-
rows:
|
|
713
|
-
|
|
749
|
+
rows: [...severityBySlug.entries()].map(([slug, severity]) =>
|
|
750
|
+
severity === "breaking"
|
|
751
|
+
? `BREAKING: re-check #${slug} — changed since the pinned ${pin}`
|
|
752
|
+
: `MANUAL: re-check #${slug} — changed since the pinned ${pin}`,
|
|
714
753
|
),
|
|
715
754
|
};
|
|
716
755
|
}
|
|
@@ -741,10 +780,11 @@ function runCompliance() {
|
|
|
741
780
|
|
|
742
781
|
function summarize({ results, drift }) {
|
|
743
782
|
const n = (s) => results.filter((r) => r.status === s).length;
|
|
783
|
+
const breakingDrift = drift.rows.filter((r) => r.startsWith("BREAKING:")).length;
|
|
744
784
|
return {
|
|
745
785
|
pass: n(PASS),
|
|
746
|
-
fail: n(FAIL),
|
|
747
|
-
manual: n(MANUAL) + drift.rows.length,
|
|
786
|
+
fail: n(FAIL) + breakingDrift,
|
|
787
|
+
manual: n(MANUAL) + (drift.rows.length - breakingDrift),
|
|
748
788
|
na: n(NA),
|
|
749
789
|
};
|
|
750
790
|
}
|
|
@@ -783,7 +823,7 @@ function main() {
|
|
|
783
823
|
writeCache({ ...payload, summary: s });
|
|
784
824
|
|
|
785
825
|
if (strict && s.fail) {
|
|
786
|
-
console.error("compliance --strict: FAIL chapters present.");
|
|
826
|
+
console.error("compliance --strict: FAIL chapters or BREAKING standard-version drift present.");
|
|
787
827
|
process.exit(1);
|
|
788
828
|
}
|
|
789
829
|
}
|
|
@@ -792,4 +832,4 @@ if (require.main === module) main();
|
|
|
792
832
|
|
|
793
833
|
// CHECKS is exported for the "coverage" test — nothing else should read it as
|
|
794
834
|
// data (call runCompliance() for results).
|
|
795
|
-
module.exports = { runCompliance, summarize, changelogSlugs, CHECKS };
|
|
835
|
+
module.exports = { runCompliance, summarize, changelogSlugs, changelogEntries, 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,7 @@
|
|
|
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
25
|
const { asRegexMap } = require("./header-expect.js");
|
|
26
26
|
|
|
27
27
|
const CACHE = path.join(ROOT, ".cache", "doctor.json");
|
|
@@ -376,6 +376,26 @@ function main() {
|
|
|
376
376
|
}
|
|
377
377
|
|
|
378
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
|
+
|
|
379
399
|
const cr = complianceRegressions();
|
|
380
400
|
if (!cr.checked) {
|
|
381
401
|
console.log(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piercebarney/whs-eleventy",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.8",
|
|
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"
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"sirv": "^3.0.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@eslint/js": "^
|
|
40
|
-
"eslint": "^9.
|
|
39
|
+
"@eslint/js": "^10.0.1",
|
|
40
|
+
"eslint": "^10.9.1",
|
|
41
41
|
"globals": "^17.11.0",
|
|
42
42
|
"prettier": "^3.9.6"
|
|
43
43
|
}
|