@piercebarney/whs-eleventy 2026.9.22 → 2026.9.24
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/check-links.js +37 -17
- package/lib/compliance.js +19 -1
- package/package.json +1 -1
- package/standard/CHANGELOG.md +80 -0
- package/standard/core.md +2 -2
package/lib/check-links.js
CHANGED
|
@@ -14,20 +14,32 @@
|
|
|
14
14
|
const fs = require("node:fs");
|
|
15
15
|
const path = require("node:path");
|
|
16
16
|
const { parse } = require("node-html-parser");
|
|
17
|
-
const {
|
|
17
|
+
const { tryProjectRequire } = require("./_project.js");
|
|
18
18
|
|
|
19
19
|
const SITE = "_site";
|
|
20
|
-
const site =
|
|
21
|
-
const thirdparties =
|
|
20
|
+
const site = tryProjectRequire("src/_data/site.js", null);
|
|
21
|
+
const thirdparties = tryProjectRequire("src/_data/thirdparties.js", null);
|
|
22
|
+
|
|
23
|
+
const errors = [];
|
|
24
|
+
const err = (file, msg) => errors.push(`${file}: ${msg}`);
|
|
25
|
+
|
|
26
|
+
// Reported exactly once, here — every origin-dependent check below skips
|
|
27
|
+
// silently on a missing site/thirdparties, relying on these two lines to
|
|
28
|
+
// explain why, rather than one err() per guarded call site.
|
|
29
|
+
if (!site) err("src/_data/site.js", "missing — cannot verify links against the site's own origin");
|
|
30
|
+
if (!thirdparties) {
|
|
31
|
+
err("src/_data/thirdparties.js", "missing — cannot verify declared third-party origins");
|
|
32
|
+
}
|
|
33
|
+
const canCheckOrigins = !!(site && thirdparties);
|
|
22
34
|
|
|
23
35
|
// The /glossary/#slug check runs only when add-ons/glossary/ is applied
|
|
24
36
|
// (it ships src/_data/glossary.js). No file → no glossary → the check is inert.
|
|
25
37
|
const glossary = tryProjectRequire("src/_data/glossary.js");
|
|
26
38
|
const glossarySlugs = new Set(glossary ? glossary.map((t) => t.slug) : []);
|
|
27
39
|
const declaredOrigins = thirdparties
|
|
28
|
-
.flatMap((t) => [t.origin, ...(t.cspOrigins || [])])
|
|
29
|
-
|
|
30
|
-
const allowedOrigins = new Set([site.url, ...declaredOrigins]);
|
|
40
|
+
? thirdparties.flatMap((t) => [t.origin, ...(t.cspOrigins || [])]).filter(Boolean)
|
|
41
|
+
: [];
|
|
42
|
+
const allowedOrigins = new Set([...(site ? [site.url] : []), ...declaredOrigins]);
|
|
31
43
|
|
|
32
44
|
// Privacy/ad opt-out destinations that policy prose links to, plus the
|
|
33
45
|
// search-engine webmaster consoles the /audit/ page links to. All are
|
|
@@ -55,9 +67,6 @@ const REQUIRED_LD = {
|
|
|
55
67
|
Article: ["headline"],
|
|
56
68
|
};
|
|
57
69
|
|
|
58
|
-
const errors = [];
|
|
59
|
-
const err = (file, msg) => errors.push(`${file}: ${msg}`);
|
|
60
|
-
|
|
61
70
|
// ---- CSP <-> origin manifest agreement -------------------------------------
|
|
62
71
|
(function checkCsp() {
|
|
63
72
|
let toml;
|
|
@@ -78,6 +87,7 @@ const err = (file, msg) => errors.push(`${file}: ${msg}`);
|
|
|
78
87
|
}
|
|
79
88
|
}),
|
|
80
89
|
);
|
|
90
|
+
if (!thirdparties) return; // reported once, above, when the load failed
|
|
81
91
|
// Every origin the CSP grants must be a declared party — matched against each
|
|
82
92
|
// party's `origin` or its `cspOrigins` list. (The reverse isn't required: a
|
|
83
93
|
// host / form processor is in thirdparties.js but is not a browser-resource
|
|
@@ -125,13 +135,14 @@ function outputHas(urlPath) {
|
|
|
125
135
|
return fs.existsSync(asFile + "/index.html") || fs.existsSync(asFile + ".html");
|
|
126
136
|
}
|
|
127
137
|
|
|
128
|
-
function checkRef(file, pageUrl, raw, { external = true, isHyperlink = false } = {}) {
|
|
138
|
+
function checkRef(file, pageUrl, resolveBase, raw, { external = true, isHyperlink = false } = {}) {
|
|
129
139
|
if (!raw) return;
|
|
130
140
|
const v = raw.trim();
|
|
131
141
|
if (v === "" || v.startsWith("data:") || v.startsWith("mailto:") || v.startsWith("tel:")) return;
|
|
132
142
|
|
|
133
143
|
if (/^https?:\/\//i.test(v)) {
|
|
134
144
|
if (!external) return;
|
|
145
|
+
if (!canCheckOrigins) return; // reported once, above, when the load failed
|
|
135
146
|
let origin;
|
|
136
147
|
try {
|
|
137
148
|
origin = new URL(v).origin;
|
|
@@ -174,13 +185,15 @@ function checkRef(file, pageUrl, raw, { external = true, isHyperlink = false } =
|
|
|
174
185
|
return;
|
|
175
186
|
}
|
|
176
187
|
// relative path — resolve against the page dir
|
|
177
|
-
const resolved = path.posix.join(
|
|
188
|
+
const resolved = path.posix.join(resolveBase, v);
|
|
178
189
|
if (!outputHas(resolved)) err(file, `relative reference does not resolve: ${v}`);
|
|
179
190
|
}
|
|
180
191
|
|
|
181
192
|
for (const f of htmlFiles) {
|
|
182
193
|
const rel = "/" + path.relative(SITE, f).replace(/\\/g, "/");
|
|
183
194
|
const pageUrl = rel.replace(/index\.html$/, "");
|
|
195
|
+
const dir = path.posix.dirname(rel);
|
|
196
|
+
const resolveBase = dir === "/" ? "/" : dir + "/";
|
|
184
197
|
const root = parse(fs.readFileSync(f, "utf8"));
|
|
185
198
|
|
|
186
199
|
// CSP-safety: the strict CSP (script-src 'self'; style-src 'self') rejects
|
|
@@ -205,7 +218,7 @@ for (const f of htmlFiles) {
|
|
|
205
218
|
|
|
206
219
|
root.querySelectorAll("a[href]").forEach((a) => {
|
|
207
220
|
const href = a.getAttribute("href");
|
|
208
|
-
checkRef(f, pageUrl, href, { isHyperlink: true });
|
|
221
|
+
checkRef(f, pageUrl, resolveBase, href, { isHyperlink: true });
|
|
209
222
|
if (/^https?:\/\//i.test(href || "")) {
|
|
210
223
|
let origin;
|
|
211
224
|
try {
|
|
@@ -213,7 +226,7 @@ for (const f of htmlFiles) {
|
|
|
213
226
|
} catch {
|
|
214
227
|
origin = "";
|
|
215
228
|
}
|
|
216
|
-
if (origin && origin !== site.url) {
|
|
229
|
+
if (canCheckOrigins && origin && origin !== site.url) {
|
|
217
230
|
const rel2 = (a.getAttribute("rel") || "").toLowerCase();
|
|
218
231
|
if (!/\bnoopener\b|\bnoreferrer\b/.test(rel2)) {
|
|
219
232
|
err(f, `external <a> without rel="noopener": ${href}`);
|
|
@@ -222,12 +235,18 @@ for (const f of htmlFiles) {
|
|
|
222
235
|
}
|
|
223
236
|
});
|
|
224
237
|
|
|
225
|
-
root
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
root
|
|
239
|
+
.querySelectorAll("link[href]")
|
|
240
|
+
.forEach((l) => checkRef(f, pageUrl, resolveBase, l.getAttribute("href")));
|
|
241
|
+
root
|
|
242
|
+
.querySelectorAll("script[src]")
|
|
243
|
+
.forEach((s) => checkRef(f, pageUrl, resolveBase, s.getAttribute("src")));
|
|
244
|
+
root
|
|
245
|
+
.querySelectorAll("img[src]")
|
|
246
|
+
.forEach((i) => checkRef(f, pageUrl, resolveBase, i.getAttribute("src")));
|
|
228
247
|
root
|
|
229
248
|
.querySelectorAll('meta[property="og:image"], meta[name="twitter:image"]')
|
|
230
|
-
.forEach((m) => checkRef(f, pageUrl, m.getAttribute("content")));
|
|
249
|
+
.forEach((m) => checkRef(f, pageUrl, resolveBase, m.getAttribute("content")));
|
|
231
250
|
|
|
232
251
|
root.querySelectorAll('script[type="application/ld+json"]').forEach((s, i) => {
|
|
233
252
|
let data;
|
|
@@ -256,6 +275,7 @@ for (const f of htmlFiles) {
|
|
|
256
275
|
// dynamically-built URL), but it catches the common case: a hardcoded
|
|
257
276
|
// external origin in client JS that was never added to the manifest.
|
|
258
277
|
(function checkJsOrigins() {
|
|
278
|
+
if (!canCheckOrigins) return; // reported once, above, when the load failed
|
|
259
279
|
const jsFiles = allFiles.filter((f) => f.endsWith(".js") && !f.endsWith("links-report.json"));
|
|
260
280
|
const ORIGIN_LITERAL = /(?:fetch|new\s+URL)\s*\(\s*[`'"](https?:\/\/[^`'"\s]+)[`'"]/g;
|
|
261
281
|
for (const f of jsFiles) {
|
package/lib/compliance.js
CHANGED
|
@@ -198,6 +198,13 @@ const CHECKS = {
|
|
|
198
198
|
},
|
|
199
199
|
|
|
200
200
|
styling: () => {
|
|
201
|
+
const njkRoots = ["src", "static", "scripts"].map((d) => path.join(ROOT, d));
|
|
202
|
+
const anyNjk = njkRoots.some((r) => walk(r).some((f) => f.endsWith(".njk")));
|
|
203
|
+
if (!anyNjk)
|
|
204
|
+
return {
|
|
205
|
+
status: MANUAL,
|
|
206
|
+
note: "no .njk files found — can't verify against a non-.njk layout",
|
|
207
|
+
};
|
|
201
208
|
const shift = grepSrc(/style=(["']).*?\1/, [".njk"]);
|
|
202
209
|
return shift.length
|
|
203
210
|
? { status: FAIL, note: `inline style= in ${shift.join(", ")}` }
|
|
@@ -350,8 +357,14 @@ const CHECKS = {
|
|
|
350
357
|
(f) =>
|
|
351
358
|
/\.(css|js|njk)$/.test(f) && f !== "src/_data/brand.js" && f !== "src/tokens.css.njk",
|
|
352
359
|
);
|
|
360
|
+
const existing = targets.filter((rel) => has(rel));
|
|
361
|
+
if (existing.length === 0)
|
|
362
|
+
return {
|
|
363
|
+
status: MANUAL,
|
|
364
|
+
note: "no static/style.css, scripts/og-card.js|icons.js, or src/ found — can't verify against a non-standard-layout project",
|
|
365
|
+
};
|
|
353
366
|
const bad = [];
|
|
354
|
-
for (const rel of
|
|
367
|
+
for (const rel of existing) {
|
|
355
368
|
const t = read(rel);
|
|
356
369
|
if (!t) continue;
|
|
357
370
|
for (const line of t.split("\n")) {
|
|
@@ -489,6 +502,11 @@ const CHECKS = {
|
|
|
489
502
|
},
|
|
490
503
|
|
|
491
504
|
"domain-tests": () => {
|
|
505
|
+
if (!has("static/calc"))
|
|
506
|
+
return {
|
|
507
|
+
status: MANUAL,
|
|
508
|
+
note: "no static/calc/ — can't verify against a non-standard-layout project",
|
|
509
|
+
};
|
|
492
510
|
const mods = walk(path.join(ROOT, "static/calc"))
|
|
493
511
|
.filter((f) => f.endsWith(".js"))
|
|
494
512
|
.map((f) => path.basename(f, ".js"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piercebarney/whs-eleventy",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.24",
|
|
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"
|
package/standard/CHANGELOG.md
CHANGED
|
@@ -21,6 +21,86 @@ enforced, new *opt-in* chapters/capabilities, tooling-only changes — is
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## 2026-09-24 [non-breaking]
|
|
25
|
+
|
|
26
|
+
### packages/whs-eleventy
|
|
27
|
+
|
|
28
|
+
`check-links.js` resolved a relative reference (`favicon.svg`, `styles.css`,
|
|
29
|
+
etc.) against `pageUrl`, a literal `index.html`-stripped path — correct for
|
|
30
|
+
directory-style permalinks (`/foo/index.html` → `/foo/`) but wrong for a
|
|
31
|
+
flat, non-directory permalink like `privacy.html`, where `pageUrl` stayed
|
|
32
|
+
`/privacy.html` and `path.posix.join` then treated it as if it were a
|
|
33
|
+
directory, producing false `relative reference does not resolve` findings
|
|
34
|
+
for files that genuinely exist and resolve correctly in a real browser.
|
|
35
|
+
`pageUrl` is also used for same-page `#anchor` lookups, where the literal
|
|
36
|
+
value is exactly what's needed — so the fix doesn't touch `pageUrl` itself.
|
|
37
|
+
Instead `checkRef()` now takes a separate `resolveBase` (the directory
|
|
38
|
+
containing the page, via `path.posix.dirname`) used only for relative-
|
|
39
|
+
reference resolution, leaving anchor lookups untouched. Found while
|
|
40
|
+
covenant-in-color's adoption session ran the newly-fixed `npm run links` for
|
|
41
|
+
the first time and filed as
|
|
42
|
+
`feedback/2026-09-23-check-links-relative-resolution-non-directory-permalink.md`;
|
|
43
|
+
fixed in `7edbc68`.
|
|
44
|
+
|
|
45
|
+
This is the fourth `packages/whs-eleventy`-only fix shipped this week (the
|
|
46
|
+
third, 2026.9.23, already published and live) — `core.md`, `stacks/*.md`,
|
|
47
|
+
and `index.json` content are unchanged; only the doc pins and
|
|
48
|
+
`packages/whs-eleventy/package.json` (now `2026.9.24`) moved.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2026-09-23 — Republish 2026-09-22-2 as a clean bare-date version [non-breaking]
|
|
53
|
+
|
|
54
|
+
### packages/whs-eleventy
|
|
55
|
+
|
|
56
|
+
No new content beyond what's already documented below. This bump republishes
|
|
57
|
+
everything already documented under the 2026-09-22-2 entry (the
|
|
58
|
+
`check-links.js` no-`src/`-layout crash fix) as a clean bare-date version,
|
|
59
|
+
specifically to trigger the npm publish of `@piercebarney/whs-eleventy` that
|
|
60
|
+
the 2026-09-22-2 same-day-suffix mechanism deliberately does not trigger on
|
|
61
|
+
its own (per that suffix's own documented exception in `CLAUDE.md`'s
|
|
62
|
+
Versions section: a suffixed pin gets no git tag and no npm publish).
|
|
63
|
+
|
|
64
|
+
This release also newly documents a second, separately-landed fix shipping
|
|
65
|
+
in the same publish: `styling`, `brand-source`, and `domain-tests` in
|
|
66
|
+
`lib/compliance.js` each built their scan-target list from hardcoded
|
|
67
|
+
standard-layout paths and treated "found zero files to scan" as "found zero
|
|
68
|
+
violations" — a false `PASS` on a project whose layout doesn't match the
|
|
69
|
+
standard yet, rather than the `MANUAL`/can't-verify result the other checks
|
|
70
|
+
correctly report in the same situation. Found on a real adoption and filed
|
|
71
|
+
as `feedback/2026-09-22-compliance-silent-pass-on-nonstandard-layout.md`;
|
|
72
|
+
fixed in `96aeac2` by checking whether each check's target(s) exist at all
|
|
73
|
+
before scanning — an existing-but-empty target (e.g. `static/calc/` with no
|
|
74
|
+
modules yet) still correctly reports `PASS`.
|
|
75
|
+
|
|
76
|
+
`core.md`, `stacks/*.md`, `index.json`, and every `standard-version:` pin
|
|
77
|
+
move from `2026-09-22-2` to `2026-09-23`; `packages/whs-eleventy/package.json`
|
|
78
|
+
moves to the corresponding npm version, `2026.9.23`. No `core.md` chapter,
|
|
79
|
+
`stacks/*.md`, or `index.json` content changed.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 2026-09-22-2 [non-breaking]
|
|
84
|
+
|
|
85
|
+
### packages/whs-eleventy
|
|
86
|
+
|
|
87
|
+
`check-links.js` had the same crash class as `doctor.js`'s already-fixed
|
|
88
|
+
`MODULE_NOT_FOUND`: an unconditional module-top-level
|
|
89
|
+
`projectRequire("src/_data/site.js")` (and `thirdparties.js`) that throws on
|
|
90
|
+
a project with no `src/` tree, instead of reporting the missing layout as a
|
|
91
|
+
finding — found while reconciling a real adoption's migration backlog and
|
|
92
|
+
filed as `feedback/2026-09-22-check-links-same-crash-unfixed.md`. Fixed in
|
|
93
|
+
`e73de7d`: the two missing-file conditions are now reported exactly once,
|
|
94
|
+
and each dependent origin-validation check (internal-link/anchor/asset/
|
|
95
|
+
JSON-LD) skips silently rather than crashing or false-positiving. This is a
|
|
96
|
+
same-day suffix release — per `CLAUDE.md`'s Versions section it deliberately
|
|
97
|
+
does **not** get a git tag or npm publish; `packages/whs-eleventy/package.json`
|
|
98
|
+
stays at `2026.9.22`, which does not yet include this fix, until a future
|
|
99
|
+
bare-date promotion release republishes it. No `core.md` chapter,
|
|
100
|
+
`stacks/*.md`, or `index.json` content changed.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
24
104
|
## 2026-09-22 [non-breaking]
|
|
25
105
|
|
|
26
106
|
### packages/whs-eleventy
|
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-24 · **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
|
|
@@ -1467,7 +1467,7 @@ governs it and follow that chapter — the slugs are the index (a `<script>` →
|
|
|
1467
1467
|
- production-url: https://example.com
|
|
1468
1468
|
- content-type: tool # tool | article (article => feeds)
|
|
1469
1469
|
- publishing-rate: ~5 pages/week
|
|
1470
|
-
- standard-version: 2026-09-
|
|
1470
|
+
- standard-version: 2026-09-24 # recommended — enables standard-version drift tracking (#compliance)
|
|
1471
1471
|
```
|
|
1472
1472
|
|
|
1473
1473
|
If the section is absent, the assistant's first action is to run the flow above
|