@piercebarney/whs-eleventy 2026.9.1
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 +61 -0
- package/cli.js +55 -0
- package/lib/_project.js +42 -0
- package/lib/a11y.js +178 -0
- package/lib/check-links.js +288 -0
- package/lib/compliance.js +677 -0
- package/lib/content-check.js +45 -0
- package/lib/doctor.js +416 -0
- package/package.json +44 -0
- package/scripts/bundle-standard.js +35 -0
- package/standard/CHANGELOG.md +625 -0
- package/standard/core.md +1493 -0
|
@@ -0,0 +1,45 @@
|
|
|
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).
|
|
4
|
+
//
|
|
5
|
+
// whs content-check
|
|
6
|
+
//
|
|
7
|
+
// Also warns — does not block — when the working tree has changes outside the
|
|
8
|
+
// content set, so a stray code edit doesn't ride along in a `content:` commit.
|
|
9
|
+
|
|
10
|
+
const { execSync } = require("node:child_process");
|
|
11
|
+
|
|
12
|
+
// Files an agent following CONTENT.md is expected to touch.
|
|
13
|
+
const CONTENT_SET = [/^src\/content\//, /^src\/_data\/nav\.js$/, /^src\/_data\/glossary\.js$/];
|
|
14
|
+
|
|
15
|
+
function changedFiles() {
|
|
16
|
+
try {
|
|
17
|
+
const out = execSync("git status --porcelain", { encoding: "utf8" });
|
|
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));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stray = changedFiles().filter((f) => !CONTENT_SET.some((re) => re.test(f)));
|
|
29
|
+
if (stray.length) {
|
|
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");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const steps = ["lint", "validate", "build", "links"];
|
|
36
|
+
for (const s of steps) {
|
|
37
|
+
process.stdout.write(`content-check: npm run ${s}\n`);
|
|
38
|
+
try {
|
|
39
|
+
execSync(`npm run ${s}`, { stdio: "inherit" });
|
|
40
|
+
} catch {
|
|
41
|
+
console.error(`\ncontent-check: '${s}' failed — fix it before committing.`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
console.log("\ncontent-check: ok — lint · validate · build · links");
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Infrastructure drift check (core.md#audit-page). Two tiers:
|
|
2
|
+
//
|
|
3
|
+
// repo tier — no network, always runs. Reads the files that encode
|
|
4
|
+
// cost/SEO-critical config (netlify.toml, .nvmrc, package.json,
|
|
5
|
+
// git config, the production _site/ output) and flags drift.
|
|
6
|
+
// live tier — opt-in. Shells `netlify api getSite` (reusing whatever auth
|
|
7
|
+
// the Netlify CLI already has) to confirm the dashboard matches
|
|
8
|
+
// the repo: builds stopped, custom domain == site.url.
|
|
9
|
+
//
|
|
10
|
+
// Modes:
|
|
11
|
+
// whs doctor repo tier, print, exit 0 (unless a blocker
|
|
12
|
+
// like a tracked .env)
|
|
13
|
+
// whs doctor --live repo + live, print, write cache
|
|
14
|
+
// whs doctor --deploy-preflight repo + live, and EXIT 1 on any cost/SEO-
|
|
15
|
+
// breaking mismatch. Run by bin/deploy after
|
|
16
|
+
// the prod build.
|
|
17
|
+
//
|
|
18
|
+
// The live snapshot is cached to .cache/doctor.json; src/_data/infra.js reads it
|
|
19
|
+
// for the /audit/ "Infrastructure" tab (and re-runs the repo tier itself).
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
const { execSync } = require("node:child_process");
|
|
24
|
+
const { ROOT, projectRequire } = require("./_project.js");
|
|
25
|
+
|
|
26
|
+
const CACHE = path.join(ROOT, ".cache", "doctor.json");
|
|
27
|
+
|
|
28
|
+
// Loaded lazily (inside the functions) so requiring this module for its API
|
|
29
|
+
// doesn't fail in a dir without a project tree.
|
|
30
|
+
const loadSite = () => projectRequire("src/_data/site.js");
|
|
31
|
+
const loadThirdparties = () => projectRequire("src/_data/thirdparties.js");
|
|
32
|
+
|
|
33
|
+
// Live values expected on every response — same patterns the /audit/ page uses.
|
|
34
|
+
const HEADER_EXPECT = {
|
|
35
|
+
"Content-Security-Policy": /default-src 'self'/,
|
|
36
|
+
"X-Content-Type-Options": /nosniff/,
|
|
37
|
+
"X-Frame-Options": /DENY|SAMEORIGIN/,
|
|
38
|
+
"Referrer-Policy": /strict-origin/,
|
|
39
|
+
"Permissions-Policy": /geolocation=\(\)/,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const GATE_STAGES = ["lint", "validate", "test", "build", "links", "a11y"];
|
|
43
|
+
|
|
44
|
+
function readText(rel) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(path.join(ROOT, rel), "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- repo tier -------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function runRepoChecks({ preflight = false } = {}) {
|
|
55
|
+
const checks = [];
|
|
56
|
+
const add = (id, label, ok, severity, detail) =>
|
|
57
|
+
checks.push({ id, label, ok, severity: ok ? "ok" : severity, detail });
|
|
58
|
+
const site = loadSite();
|
|
59
|
+
const thirdparties = loadThirdparties();
|
|
60
|
+
|
|
61
|
+
// 1. Security headers declared in netlify.toml
|
|
62
|
+
const toml = readText("netlify.toml");
|
|
63
|
+
if (!toml) {
|
|
64
|
+
add("netlify-toml", "netlify.toml present", false, "blocker", "file missing");
|
|
65
|
+
} else {
|
|
66
|
+
const missing = Object.entries(HEADER_EXPECT)
|
|
67
|
+
.filter(([h, re]) => {
|
|
68
|
+
const m = toml.match(new RegExp(h + '\\s*=\\s*"([^"]+)"'));
|
|
69
|
+
return !m || !re.test(m[1]);
|
|
70
|
+
})
|
|
71
|
+
.map(([h]) => h);
|
|
72
|
+
add(
|
|
73
|
+
"security-headers",
|
|
74
|
+
"netlify.toml declares the 5 baseline security headers",
|
|
75
|
+
missing.length === 0,
|
|
76
|
+
"warn",
|
|
77
|
+
missing.length ? `missing / unexpected: ${missing.join(", ")}` : "all present",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 2. Every origin the CSP grants is a declared third party
|
|
81
|
+
const cspMatch = toml.match(/Content-Security-Policy\s*=\s*"([^"]+)"/);
|
|
82
|
+
const cspOrigins = cspMatch
|
|
83
|
+
? [...new Set((cspMatch[1].match(/https?:\/\/[^\s;'"]+/g) || []).map(originOf))]
|
|
84
|
+
: [];
|
|
85
|
+
const declaredOrigins = new Set(
|
|
86
|
+
thirdparties.flatMap((t) => [t.origin, ...(t.cspOrigins || [])]),
|
|
87
|
+
);
|
|
88
|
+
const undeclared = cspOrigins.filter((o) => !declaredOrigins.has(o));
|
|
89
|
+
add(
|
|
90
|
+
"csp-thirdparties",
|
|
91
|
+
"CSP origins all appear in src/_data/thirdparties.js",
|
|
92
|
+
undeclared.length === 0,
|
|
93
|
+
"warn",
|
|
94
|
+
undeclared.length ? `undeclared: ${undeclared.join(", ")}` : "in sync",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. .nvmrc matches the running Node major
|
|
99
|
+
const nvmrc = (readText(".nvmrc") || "").trim().replace(/^v/, "");
|
|
100
|
+
const runningMajor = process.version.replace(/^v/, "").split(".")[0];
|
|
101
|
+
add(
|
|
102
|
+
"nvmrc-node",
|
|
103
|
+
".nvmrc major matches the running Node",
|
|
104
|
+
nvmrc !== "" && nvmrc.split(".")[0] === runningMajor,
|
|
105
|
+
"warn",
|
|
106
|
+
`.nvmrc ${nvmrc || "(unset)"} · running ${process.version}`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// 4. `npm run check` still runs every gate stage
|
|
110
|
+
let scripts = {};
|
|
111
|
+
try {
|
|
112
|
+
scripts = JSON.parse(readText("package.json")).scripts || {};
|
|
113
|
+
} catch {
|
|
114
|
+
/* handled below */
|
|
115
|
+
}
|
|
116
|
+
const check = scripts.check || "";
|
|
117
|
+
const absent = GATE_STAGES.filter((s) => !check.includes(s));
|
|
118
|
+
add(
|
|
119
|
+
"gate-stages",
|
|
120
|
+
"`npm run check` runs lint · validate · test · build · links · a11y",
|
|
121
|
+
check !== "" && absent.length === 0,
|
|
122
|
+
"warn",
|
|
123
|
+
absent.length ? `not referenced: ${absent.join(", ")}` : "complete",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 5. git hooks are activated
|
|
127
|
+
let hooksPath = "";
|
|
128
|
+
try {
|
|
129
|
+
hooksPath = execSync("git config --get core.hooksPath", {
|
|
130
|
+
cwd: ROOT,
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
133
|
+
}).trim();
|
|
134
|
+
} catch {
|
|
135
|
+
/* unset */
|
|
136
|
+
}
|
|
137
|
+
add(
|
|
138
|
+
"hooks-path",
|
|
139
|
+
"core.hooksPath points at .githooks/",
|
|
140
|
+
hooksPath === ".githooks",
|
|
141
|
+
"warn",
|
|
142
|
+
hooksPath ? `= ${hooksPath}` : "unset — run `npm install`",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// 6. No .env committed
|
|
146
|
+
let tracked = "";
|
|
147
|
+
try {
|
|
148
|
+
tracked = execSync("git ls-files", { cwd: ROOT, encoding: "utf8" });
|
|
149
|
+
} catch {
|
|
150
|
+
/* not a git repo */
|
|
151
|
+
}
|
|
152
|
+
const envFiles = tracked
|
|
153
|
+
.split("\n")
|
|
154
|
+
.filter((f) => /(^|\/)\.env($|\.)/.test(f) && !/\.env\.example$/.test(f));
|
|
155
|
+
add(
|
|
156
|
+
"env-untracked",
|
|
157
|
+
"no .env file is tracked in git",
|
|
158
|
+
envFiles.length === 0,
|
|
159
|
+
"blocker",
|
|
160
|
+
envFiles.length ? `tracked: ${envFiles.join(", ")}` : "clean",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// 7. The built _site/ output is indexable only in production
|
|
164
|
+
const home = readText("_site/index.html");
|
|
165
|
+
if (home === null) {
|
|
166
|
+
add(
|
|
167
|
+
"prod-indexable",
|
|
168
|
+
"production _site/ output has no noindex",
|
|
169
|
+
!preflight,
|
|
170
|
+
"blocker",
|
|
171
|
+
preflight ? "_site/index.html not found — build before preflight" : "verified at deploy time",
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
const noindex = /<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
|
|
175
|
+
if (preflight) {
|
|
176
|
+
add(
|
|
177
|
+
"prod-indexable",
|
|
178
|
+
"production _site/ output has no noindex",
|
|
179
|
+
!noindex,
|
|
180
|
+
"blocker",
|
|
181
|
+
noindex
|
|
182
|
+
? "home page carries <meta robots noindex> — build was not production"
|
|
183
|
+
: "indexable",
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
add(
|
|
187
|
+
"prod-indexable",
|
|
188
|
+
"built _site/ output reflects its deploy target",
|
|
189
|
+
true,
|
|
190
|
+
"warn",
|
|
191
|
+
noindex ? "noindex present (expected for a preview build)" : "indexable (production build)",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 8. site.url is a clean absolute origin
|
|
197
|
+
const cleanUrl = /^https:\/\/[^/]+\.[a-z]{2,}$/i.test(site.url);
|
|
198
|
+
add("site-url", "src/_data/site.js url is a clean https origin", cleanUrl, "warn", site.url);
|
|
199
|
+
|
|
200
|
+
return checks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- live tier ------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
function originOf(u) {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(u).origin;
|
|
208
|
+
} catch {
|
|
209
|
+
return u;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function runLiveChecks({ preflight = false } = {}) {
|
|
214
|
+
const site = loadSite();
|
|
215
|
+
const siteId = (process.env.NETLIFY_SITE_ID || "").trim() || stateSiteId();
|
|
216
|
+
if (!siteId) {
|
|
217
|
+
return {
|
|
218
|
+
available: false,
|
|
219
|
+
reason: "no NETLIFY_SITE_ID and no .netlify/state.json",
|
|
220
|
+
checks: [],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let info;
|
|
225
|
+
try {
|
|
226
|
+
const raw = execSync(
|
|
227
|
+
`npx netlify-cli api getSite --data '${JSON.stringify({ site_id: siteId })}'`,
|
|
228
|
+
{
|
|
229
|
+
cwd: ROOT,
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
232
|
+
timeout: 30000,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
info = JSON.parse(raw);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return {
|
|
238
|
+
available: false,
|
|
239
|
+
reason: `netlify api getSite failed (${(e.message || "").split("\n")[0]}) — CLI not logged in?`,
|
|
240
|
+
checks: [],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const checks = [];
|
|
245
|
+
const add = (id, label, ok, severity, detail) =>
|
|
246
|
+
checks.push({ id, label, ok, severity: ok ? "ok" : severity, detail });
|
|
247
|
+
|
|
248
|
+
const stopped = info.build_settings && info.build_settings.stop_builds === true;
|
|
249
|
+
add(
|
|
250
|
+
"stop-builds",
|
|
251
|
+
"Netlify continuous deployment is stopped",
|
|
252
|
+
!!stopped,
|
|
253
|
+
preflight ? "blocker" : "warn",
|
|
254
|
+
stopped
|
|
255
|
+
? "stopped — a git push builds nothing"
|
|
256
|
+
: "ENABLED — a git push will auto-build and bill",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const wantHost = originOf(site.url).replace(/^https?:\/\//, "");
|
|
260
|
+
const domains = [
|
|
261
|
+
info.custom_domain,
|
|
262
|
+
info.name && `${info.name}.netlify.app`,
|
|
263
|
+
...(info.domain_aliases || []),
|
|
264
|
+
].filter(Boolean);
|
|
265
|
+
const domainOk = domains.includes(wantHost);
|
|
266
|
+
add(
|
|
267
|
+
"domain-match",
|
|
268
|
+
"site.url host is served by this Netlify project",
|
|
269
|
+
domainOk,
|
|
270
|
+
preflight ? "blocker" : "warn",
|
|
271
|
+
`site.url ${wantHost} · project serves ${domains.join(", ") || "(none)"}`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const published = info.published_deploy && info.published_deploy.state;
|
|
275
|
+
add(
|
|
276
|
+
"published-deploy",
|
|
277
|
+
"project has a published production deploy",
|
|
278
|
+
published === "ready",
|
|
279
|
+
"warn",
|
|
280
|
+
`state: ${published || "none"}`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return { available: true, siteId, siteName: info.name, checks };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stateSiteId() {
|
|
287
|
+
try {
|
|
288
|
+
return (
|
|
289
|
+
JSON.parse(fs.readFileSync(path.join(ROOT, ".netlify", "state.json"), "utf8")).siteId || ""
|
|
290
|
+
);
|
|
291
|
+
} catch {
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- cache (for src/_data/infra.js) -------------------------------------
|
|
297
|
+
|
|
298
|
+
function readCache() {
|
|
299
|
+
try {
|
|
300
|
+
return JSON.parse(fs.readFileSync(CACHE, "utf8"));
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function writeCache(payload) {
|
|
307
|
+
try {
|
|
308
|
+
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
|
|
309
|
+
fs.writeFileSync(CACHE, JSON.stringify(payload, null, 2));
|
|
310
|
+
} catch {
|
|
311
|
+
/* .cache may be read-only in CI; the console output is the source of truth */
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- compliance regression diff (core.md#compliance) --------------------
|
|
316
|
+
// Warn (never block) when a chapter that was PASS at the last deploy is now
|
|
317
|
+
// FAIL — a real regression, distinct from the still-open migration backlog.
|
|
318
|
+
|
|
319
|
+
function complianceRegressions() {
|
|
320
|
+
const dir = path.join(ROOT, ".cache");
|
|
321
|
+
const load = (f) => {
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const cur = load("compliance.json");
|
|
329
|
+
const last = load("compliance-last.json");
|
|
330
|
+
if (!cur) return { checked: false, regressions: [] };
|
|
331
|
+
const lastStatus = {};
|
|
332
|
+
if (last) for (const r of last.results || []) lastStatus[r.slug] = r.status;
|
|
333
|
+
const regressions = (cur.results || [])
|
|
334
|
+
.filter((r) => r.status === "FAIL" && lastStatus[r.slug] === "PASS")
|
|
335
|
+
.map((r) => `#${r.slug}: PASS -> FAIL (${r.note})`);
|
|
336
|
+
try {
|
|
337
|
+
fs.copyFileSync(path.join(dir, "compliance.json"), path.join(dir, "compliance-last.json"));
|
|
338
|
+
} catch {
|
|
339
|
+
/* best effort */
|
|
340
|
+
}
|
|
341
|
+
return { checked: true, regressions, hadBaseline: !!last };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---- CLI ----------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function print(title, checks) {
|
|
347
|
+
console.log(`\n${title}`);
|
|
348
|
+
if (!checks.length) {
|
|
349
|
+
console.log(" (none)");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
for (const c of checks) {
|
|
353
|
+
const mark = c.severity === "ok" ? "ok " : c.severity === "blocker" ? "FAIL" : "warn";
|
|
354
|
+
console.log(` ${mark} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function main() {
|
|
359
|
+
const args = process.argv.slice(2);
|
|
360
|
+
const preflight = args.includes("--deploy-preflight");
|
|
361
|
+
const withLive = preflight || args.includes("--live");
|
|
362
|
+
|
|
363
|
+
const repo = runRepoChecks({ preflight });
|
|
364
|
+
print("Repo checks", repo);
|
|
365
|
+
|
|
366
|
+
let live = { available: false, reason: "live tier not requested (pass --live)", checks: [] };
|
|
367
|
+
if (withLive) {
|
|
368
|
+
live = runLiveChecks({ preflight });
|
|
369
|
+
if (live.available) print("Live checks (Netlify API)", live.checks);
|
|
370
|
+
else console.log(`\nLive checks skipped — ${live.reason}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (withLive) {
|
|
374
|
+
writeCache({
|
|
375
|
+
generatedAt: new Date().toISOString(),
|
|
376
|
+
live: live.available
|
|
377
|
+
? { checks: live.checks, siteName: live.siteName }
|
|
378
|
+
: { available: false, reason: live.reason },
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (preflight) {
|
|
383
|
+
const cr = complianceRegressions();
|
|
384
|
+
if (!cr.checked) {
|
|
385
|
+
console.log(
|
|
386
|
+
"\nCompliance diff skipped — no .cache/compliance.json (run `npm run compliance`).",
|
|
387
|
+
);
|
|
388
|
+
} else if (cr.regressions.length) {
|
|
389
|
+
console.log(
|
|
390
|
+
`\nCompliance regressions since last deploy (${cr.regressions.length}, warning only):`,
|
|
391
|
+
);
|
|
392
|
+
cr.regressions.forEach((r) => console.log(` - ${r}`));
|
|
393
|
+
} else {
|
|
394
|
+
console.log(
|
|
395
|
+
`\nCompliance diff: no PASS -> FAIL since last deploy${cr.hadBaseline ? "" : " (first baseline)"}.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const blockers = [...repo, ...live.checks].filter((c) => c.severity === "blocker");
|
|
401
|
+
if (blockers.length) {
|
|
402
|
+
console.error(`\ndoctor: ${blockers.length} blocking issue(s):`);
|
|
403
|
+
blockers.forEach((c) => console.error(` - ${c.label}: ${c.detail}`));
|
|
404
|
+
if (preflight || blockers.some((c) => c.id === "env-untracked")) {
|
|
405
|
+
console.error("\nRefusing to continue.");
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
console.log(
|
|
410
|
+
`\ndoctor: ${blockers.length ? "blockers present (non-preflight run)" : "no blockers"}.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (require.main === module) main();
|
|
415
|
+
|
|
416
|
+
module.exports = { runRepoChecks, runLiveChecks, readCache };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@piercebarney/whs-eleventy",
|
|
3
|
+
"version": "2026.9.1",
|
|
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
|
+
"bin": {
|
|
6
|
+
"whs": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"cli.js",
|
|
10
|
+
"lib/",
|
|
11
|
+
"scripts/",
|
|
12
|
+
"standard/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prepare": "node scripts/bundle-standard.js",
|
|
17
|
+
"lint": "prettier --check . && eslint .",
|
|
18
|
+
"test": "node --test test/*.test.js",
|
|
19
|
+
"check": "npm run lint && npm test"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/piercebarney/web-house-style.git",
|
|
28
|
+
"directory": "packages/whs-eleventy"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"axe-core": "^4.13.0",
|
|
35
|
+
"node-html-parser": "^9.0.2",
|
|
36
|
+
"sirv": "^3.0.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.39.5",
|
|
40
|
+
"eslint": "^9.39.5",
|
|
41
|
+
"globals": "^17.11.0",
|
|
42
|
+
"prettier": "^3.9.6"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copies the standard's text into standard/ so the published package carries the
|
|
2
|
+
// core.md + CHANGELOG.md the drift check reads. Runs as `prepare` — before
|
|
3
|
+
// `npm pack` / `npm publish`, and on a local `file:` install.
|
|
4
|
+
//
|
|
5
|
+
// Source is the repo root, three levels up (packages/whs-eleventy/scripts →
|
|
6
|
+
// repo). When that isn't there (the package was installed as a git dep, say)
|
|
7
|
+
// but standard/ is already populated, this is a no-op. Never hard-fails an
|
|
8
|
+
// install.
|
|
9
|
+
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
|
|
13
|
+
const REPO = path.join(__dirname, "..", "..", "..");
|
|
14
|
+
const OUT = path.join(__dirname, "..", "standard");
|
|
15
|
+
const FILES = ["core.md", "CHANGELOG.md"];
|
|
16
|
+
|
|
17
|
+
const haveSource = FILES.every((f) => fs.existsSync(path.join(REPO, f)));
|
|
18
|
+
const haveBundle = FILES.every((f) => fs.existsSync(path.join(OUT, f)));
|
|
19
|
+
|
|
20
|
+
if (!haveSource) {
|
|
21
|
+
if (haveBundle) {
|
|
22
|
+
console.log("bundle-standard: no repo source, standard/ already populated — skipping");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
console.warn(
|
|
26
|
+
"bundle-standard: no repo source and no bundled standard/ — the drift check will need WHS_STANDARD set",
|
|
27
|
+
);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
32
|
+
for (const f of FILES) {
|
|
33
|
+
fs.copyFileSync(path.join(REPO, f), path.join(OUT, f));
|
|
34
|
+
}
|
|
35
|
+
console.log(`bundle-standard: copied ${FILES.join(", ")} → standard/`);
|