@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 ADDED
@@ -0,0 +1,61 @@
1
+ # @piercebarney/whs-eleventy
2
+
3
+ The [web house style](https://github.com/piercebarney/web-house-style)'s
4
+ **Eleventy + Netlify** tooling — one shared copy of the checks every project on
5
+ the stack runs, so a fix lands once and `npm update` carries it everywhere.
6
+
7
+ ```
8
+ whs compliance [--strict] codebase-vs-standard conformance sweep
9
+ whs doctor [--live|--deploy-preflight] infrastructure drift check
10
+ whs links built-output link / CSP / JSON-LD integrity
11
+ whs a11y axe-core WCAG 2.1 A/AA scan of _site/
12
+ whs content-check lint · validate · build · links (no browser)
13
+ whs --version tooling + bundled-standard version
14
+ ```
15
+
16
+ Each subcommand resolves the project against the current working directory and
17
+ expects the eleventy-netlify layout (`src/_data/*.js`, `netlify.toml`, `_site/`,
18
+ `test/`). It is not configurable — projects on this stack are copies of
19
+ `templates/eleventy-netlify/` and share that shape.
20
+
21
+ ## Install
22
+
23
+ ```
24
+ npm i -D @piercebarney/whs-eleventy
25
+ ```
26
+
27
+ Then in `package.json`:
28
+
29
+ ```json
30
+ {
31
+ "scripts": {
32
+ "links": "whs links",
33
+ "a11y": "whs a11y",
34
+ "doctor": "whs doctor",
35
+ "compliance": "whs compliance",
36
+ "content-check": "whs content-check"
37
+ }
38
+ }
39
+ ```
40
+
41
+ The `/audit/` data files import the API directly:
42
+
43
+ ```js
44
+ const { runCompliance, summarize } = require("@piercebarney/whs-eleventy/lib/compliance.js");
45
+ const { runRepoChecks, readCache } = require("@piercebarney/whs-eleventy/lib/doctor.js");
46
+ ```
47
+
48
+ ## The bundled standard
49
+
50
+ `whs compliance`'s drift check reads `core.md` + `CHANGELOG.md` to compare the
51
+ project's `standard-version` pin against the current standard. The package ships
52
+ a snapshot of both under `standard/`, cut when the version is published. Override
53
+ with `WHS_STANDARD=/path/to/web-house-style` (the authoring repo points this at
54
+ itself). When neither resolves, the drift row is a visible `MANUAL`, never a
55
+ silent pass.
56
+
57
+ ## Not in this package
58
+
59
+ `scripts/og.js`, `scripts/icons.js`, `scripts/og-card.js` stay in the project —
60
+ they are asset _generators_ wired into the eleventy build, with a per-project
61
+ page list and card design.
package/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // whs — the web house style's Eleventy + Netlify tooling.
3
+ //
4
+ // whs compliance [--strict] codebase-vs-standard conformance sweep
5
+ // whs doctor [--live|--deploy-preflight] infrastructure drift check
6
+ // whs links built-output link / CSP / JSON-LD integrity
7
+ // whs a11y axe-core WCAG 2.1 A/AA scan of _site/
8
+ // whs content-check the content-relevant slice of the gate
9
+ // whs --version print the tooling + bundled-standard version
10
+ //
11
+ // Each subcommand is a plain Node script under lib/, spawned with the project's
12
+ // cwd so `require.main === module` and exit codes behave exactly as a direct
13
+ // `node lib/<x>.js` run would.
14
+
15
+ const path = require("node:path");
16
+ const { spawnSync } = require("node:child_process");
17
+
18
+ const COMMANDS = {
19
+ compliance: "compliance.js",
20
+ doctor: "doctor.js",
21
+ links: "check-links.js",
22
+ a11y: "a11y.js",
23
+ "content-check": "content-check.js",
24
+ };
25
+
26
+ const [cmd, ...rest] = process.argv.slice(2);
27
+
28
+ if (cmd === "--version" || cmd === "-v") {
29
+ const pkg = require("./package.json");
30
+ const fs = require("node:fs");
31
+ const { resolveStandard } = require("./lib/_project.js");
32
+ const std = resolveStandard();
33
+ let standardVersion = "not resolved";
34
+ if (std) {
35
+ const core = fs.readFileSync(path.join(std, "core.md"), "utf8");
36
+ standardVersion = (core.match(/\*\*Version:\*\*\s*([0-9-]+)/) || [])[1] || "unknown";
37
+ }
38
+ console.log(`@piercebarney/whs-eleventy ${pkg.version} · standard ${standardVersion}`);
39
+ process.exit(0);
40
+ }
41
+
42
+ if (!cmd || cmd === "--help" || cmd === "-h") {
43
+ console.log(`usage: whs <${Object.keys(COMMANDS).join(" | ")}>`);
44
+ process.exit(0);
45
+ }
46
+
47
+ if (!COMMANDS[cmd]) {
48
+ console.error(`whs: unknown command '${cmd}' — try one of: ${Object.keys(COMMANDS).join(", ")}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const r = spawnSync(process.execPath, [path.join(__dirname, "lib", COMMANDS[cmd]), ...rest], {
53
+ stdio: "inherit",
54
+ });
55
+ process.exit(r.status ?? 1);
@@ -0,0 +1,42 @@
1
+ // Resolves the consuming project against the current working directory. Every
2
+ // `whs` subcommand is run from a project root (npm runs package scripts there),
3
+ // so the project's files live under process.cwd(), not next to this package.
4
+
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+
9
+ const ROOT = process.cwd();
10
+
11
+ // require() a file from the project tree (its _data modules, its content schema).
12
+ function projectRequire(rel) {
13
+ return require(path.join(ROOT, rel));
14
+ }
15
+
16
+ // Try to require() a project module; return fallback if it isn't there.
17
+ function tryProjectRequire(rel, fallback = null) {
18
+ try {
19
+ return projectRequire(rel);
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ // Locate the standard's text (core.md, CHANGELOG.md) for the drift check:
26
+ // 1. WHS_STANDARD env var (the authoring repo points this at itself)
27
+ // 2. the copy bundled into this package at publish time (standard/)
28
+ // 3. the legacy ~/.claude/standards/web-house-style symlink
29
+ // Returns an absolute dir path, or null if none of them has a core.md.
30
+ function resolveStandard() {
31
+ const candidates = [
32
+ process.env.WHS_STANDARD,
33
+ path.join(__dirname, "..", "standard"),
34
+ path.join(os.homedir(), ".claude", "standards", "web-house-style"),
35
+ ].filter(Boolean);
36
+ for (const dir of candidates) {
37
+ if (fs.existsSync(path.join(dir, "core.md"))) return dir;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ module.exports = { ROOT, projectRequire, tryProjectRequire, resolveStandard };
package/lib/a11y.js ADDED
@@ -0,0 +1,178 @@
1
+ // Automated accessibility scan — the `a11y` step of the gate (whs a11y). Serves the built
2
+ // _site/ with sirv, drives headless Chrome over every sitemap URL (plus
3
+ // /audit/), runs axe-core (WCAG 2.1 A + AA) in each page, and exits non-zero on
4
+ // any violation. axe-core is the engine pa11y/Lighthouse wrap; driving it
5
+ // directly keeps CI free of a bundled-chromium download and its CVE churn.
6
+
7
+ const fs = require("node:fs");
8
+ const os = require("node:os");
9
+ const path = require("node:path");
10
+ const http = require("node:http");
11
+ const { spawn } = require("node:child_process");
12
+ const sirv = require("sirv");
13
+ const axeSource = require("axe-core").source;
14
+
15
+ const SITE = "_site";
16
+ const PORT = 8411;
17
+ const DBG = 9333;
18
+
19
+ const CHROME =
20
+ process.env.CHROME_PATH ||
21
+ [
22
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
23
+ "/usr/bin/google-chrome",
24
+ "/usr/bin/chromium-browser",
25
+ "/usr/bin/chromium",
26
+ ].find((p) => {
27
+ try {
28
+ fs.accessSync(p);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ });
34
+
35
+ const getJSON = (url) =>
36
+ new Promise((res, rej) =>
37
+ http
38
+ .get(url, (r) => {
39
+ let d = "";
40
+ r.on("data", (c) => (d += c));
41
+ r.on("end", () => res(JSON.parse(d)));
42
+ })
43
+ .on("error", rej),
44
+ );
45
+
46
+ function sitemapPaths() {
47
+ const xml = fs.readFileSync(path.join(SITE, "sitemap.xml"), "utf8");
48
+ const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname);
49
+ paths.push("/audit/");
50
+ return [...new Set(paths)];
51
+ }
52
+
53
+ function connect(wsUrl) {
54
+ const ws = new WebSocket(wsUrl);
55
+ let id = 0;
56
+ const pending = {};
57
+ ws.addEventListener("message", (e) => {
58
+ const m = JSON.parse(e.data);
59
+ if (m.id && pending[m.id]) {
60
+ pending[m.id](m.result);
61
+ delete pending[m.id];
62
+ }
63
+ });
64
+ const ready = new Promise((r) => ws.addEventListener("open", r, { once: true }));
65
+ return {
66
+ ready,
67
+ ws,
68
+ send: (method, params) =>
69
+ new Promise((res) => {
70
+ const i = ++id;
71
+ pending[i] = res;
72
+ ws.send(JSON.stringify({ id: i, method, params }));
73
+ }),
74
+ };
75
+ }
76
+
77
+ async function main() {
78
+ if (!CHROME) {
79
+ console.error("a11y: no Chrome/Chromium found (set CHROME_PATH)");
80
+ process.exit(1);
81
+ }
82
+ if (!fs.existsSync(path.join(SITE, "sitemap.xml"))) {
83
+ console.error("a11y: _site/sitemap.xml missing — build first");
84
+ process.exit(1);
85
+ }
86
+
87
+ const handler = sirv(SITE, { dev: true });
88
+ const server = http.createServer((req, res) =>
89
+ handler(req, res, () => {
90
+ res.statusCode = 404;
91
+ res.end("not found");
92
+ }),
93
+ );
94
+ await new Promise((r) => server.listen(PORT, r));
95
+
96
+ const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "a11y-"));
97
+ const chrome = spawn(
98
+ CHROME,
99
+ [
100
+ "--headless",
101
+ "--disable-gpu",
102
+ "--no-sandbox",
103
+ "--no-first-run",
104
+ `--remote-debugging-port=${DBG}`,
105
+ `--user-data-dir=${userDir}`,
106
+ "about:blank",
107
+ ],
108
+ { stdio: "ignore" },
109
+ );
110
+
111
+ const cleanup = () => {
112
+ try {
113
+ chrome.kill("SIGKILL");
114
+ } catch {}
115
+ server.close();
116
+ try {
117
+ fs.rmSync(userDir, { recursive: true, force: true });
118
+ } catch {}
119
+ };
120
+
121
+ try {
122
+ let target;
123
+ for (let i = 0; i < 60 && !target; i++) {
124
+ await new Promise((r) => setTimeout(r, 200));
125
+ try {
126
+ const list = await getJSON(`http://localhost:${DBG}/json`);
127
+ target = list.find((t) => t.type === "page");
128
+ } catch {}
129
+ }
130
+ if (!target) throw new Error("Chrome devtools did not come up");
131
+
132
+ const cdp = connect(target.webSocketDebuggerUrl);
133
+ await cdp.ready;
134
+ await cdp.send("Page.enable");
135
+ await cdp.send("Runtime.enable");
136
+
137
+ const paths = sitemapPaths();
138
+ const report = [];
139
+
140
+ for (const p of paths) {
141
+ const url = `http://localhost:${PORT}${p}`;
142
+ await cdp.send("Page.navigate", { url });
143
+ await new Promise((r) => setTimeout(r, 800));
144
+ await cdp.send("Runtime.evaluate", { expression: axeSource });
145
+ const r = await cdp.send("Runtime.evaluate", {
146
+ expression:
147
+ "axe.run(document,{runOnly:['wcag2a','wcag2aa','wcag21a','wcag21aa']}).then(function(x){return JSON.stringify(x.violations)})",
148
+ awaitPromise: true,
149
+ returnByValue: true,
150
+ });
151
+ const violations = JSON.parse((r.result && r.result.value) || "[]");
152
+ if (violations.length) report.push({ path: p, violations });
153
+ }
154
+
155
+ cdp.ws.close();
156
+ cleanup();
157
+
158
+ if (report.length) {
159
+ const total = report.reduce((n, r) => n + r.violations.length, 0);
160
+ console.error(`\na11y: ${total} violation type(s) across ${report.length} page(s):\n`);
161
+ for (const { path: p, violations } of report) {
162
+ console.error(` ${p}`);
163
+ for (const v of violations) {
164
+ console.error(` [${v.impact}] ${v.id} — ${v.help}`);
165
+ for (const node of v.nodes.slice(0, 3)) console.error(` ${node.target.join(" ")}`);
166
+ }
167
+ }
168
+ process.exit(1);
169
+ }
170
+ console.log(` a11y: ${paths.length} pages, no WCAG 2.1 A/AA violations`);
171
+ } catch (e) {
172
+ cleanup();
173
+ console.error("a11y:", e.stack || e.message);
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ main();
@@ -0,0 +1,288 @@
1
+ // Build-time link & reference integrity. Walks the built _site/ and exits
2
+ // non-zero on:
3
+ // - an internal href/src that doesn't resolve to a generated file
4
+ // - an in-page #anchor with no matching id
5
+ // - a /glossary/#slug with no such glossary term
6
+ // - a referenced asset (og:image, icon, manifest, stylesheet, script, img)
7
+ // missing from the output
8
+ // - an external <a> without rel="noopener"/"noreferrer"
9
+ // - an external origin not declared in src/_data/thirdparties.js
10
+ // - a JSON-LD block that doesn't parse or lacks its @type's required fields
11
+ //
12
+ // Run by `npm run links` / `whs links` and as the `links` step of `npm run check`.
13
+
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+ const { parse } = require("node-html-parser");
17
+ const { projectRequire, tryProjectRequire } = require("./_project.js");
18
+
19
+ const SITE = "_site";
20
+ const site = projectRequire("src/_data/site.js");
21
+ const thirdparties = projectRequire("src/_data/thirdparties.js");
22
+
23
+ // The /glossary/#slug check runs only when add-ons/glossary/ is applied
24
+ // (it ships src/_data/glossary.js). No file → no glossary → the check is inert.
25
+ const glossary = tryProjectRequire("src/_data/glossary.js");
26
+ const glossarySlugs = new Set(glossary ? glossary.map((t) => t.slug) : []);
27
+ const declaredOrigins = thirdparties
28
+ .flatMap((t) => [t.origin, ...(t.cspOrigins || [])])
29
+ .filter(Boolean);
30
+ const allowedOrigins = new Set([site.url, ...declaredOrigins]);
31
+
32
+ // Privacy/ad opt-out destinations that policy prose links to, plus the
33
+ // search-engine webmaster consoles the /audit/ page links to. All are
34
+ // informational <a> targets — they never load a resource or receive visitor
35
+ // data — so they don't belong in thirdparties.js (which drives the "who sees
36
+ // your data" list). Allowed only as plain links.
37
+ const POLICY_LINK_ORIGINS = new Set([
38
+ "https://adssettings.google.com",
39
+ "https://policies.google.com",
40
+ "https://www.aboutads.info",
41
+ "https://youradchoices.com",
42
+ "https://optout.networkadvertising.org",
43
+ "https://www.google.com",
44
+ "https://search.google.com",
45
+ "https://www.bing.com",
46
+ ]);
47
+
48
+ const REQUIRED_LD = {
49
+ WebSite: ["name", "url"],
50
+ WebApplication: ["name", "url"],
51
+ WebPage: ["name"],
52
+ BreadcrumbList: ["itemListElement"],
53
+ DefinedTermSet: ["name"],
54
+ Organization: ["name"],
55
+ Article: ["headline"],
56
+ };
57
+
58
+ const errors = [];
59
+ const err = (file, msg) => errors.push(`${file}: ${msg}`);
60
+
61
+ // ---- CSP <-> origin manifest agreement -------------------------------------
62
+ (function checkCsp() {
63
+ let toml;
64
+ try {
65
+ toml = fs.readFileSync("netlify.toml", "utf8");
66
+ } catch (e) {
67
+ return err("netlify.toml", "missing — no security headers defined");
68
+ }
69
+ const m = toml.match(/Content-Security-Policy\s*=\s*"([^"]+)"/);
70
+ if (!m) return err("netlify.toml", "no Content-Security-Policy header");
71
+ const csp = m[1];
72
+ const cspOrigins = new Set(
73
+ (csp.match(/https?:\/\/[^\s;'"]+/g) || []).map((u) => {
74
+ try {
75
+ return new URL(u).origin;
76
+ } catch {
77
+ return u; // wildcard host (https://*.example.com) — keep the literal
78
+ }
79
+ }),
80
+ );
81
+ // Every origin the CSP grants must be a declared party — matched against each
82
+ // party's `origin` or its `cspOrigins` list. (The reverse isn't required: a
83
+ // host / form processor is in thirdparties.js but is not a browser-resource
84
+ // origin, so it needn't appear in the CSP.)
85
+ const declared = new Set(thirdparties.flatMap((t) => [t.origin, ...(t.cspOrigins || [])]));
86
+ for (const o of cspOrigins) {
87
+ if (!declared.has(o)) {
88
+ err("netlify.toml", `CSP allows an origin not in thirdparties.js: ${o}`);
89
+ }
90
+ }
91
+ })();
92
+
93
+ function walk(dir) {
94
+ const out = [];
95
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
96
+ const p = path.join(dir, e.name);
97
+ if (e.isDirectory()) out.push(...walk(p));
98
+ else out.push(p);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ const allFiles = walk(SITE);
104
+ const htmlFiles = allFiles.filter((f) => f.endsWith(".html"));
105
+
106
+ // id sets per rendered page path ("/foo/" -> Set of ids), for anchor checks.
107
+ const idsByUrl = {};
108
+ for (const f of htmlFiles) {
109
+ const url =
110
+ "/" +
111
+ path
112
+ .relative(SITE, f)
113
+ .replace(/index\.html$/, "")
114
+ .replace(/\\/g, "/");
115
+ const root = parse(fs.readFileSync(f, "utf8"));
116
+ idsByUrl[url] = new Set(root.querySelectorAll("[id]").map((n) => n.getAttribute("id")));
117
+ }
118
+
119
+ function outputHas(urlPath) {
120
+ const clean = urlPath.split(/[?#]/)[0];
121
+ if (clean === "/" || clean === "") return fs.existsSync(path.join(SITE, "index.html"));
122
+ const asFile = path.join(SITE, clean);
123
+ if (fs.existsSync(asFile)) return true;
124
+ if (clean.endsWith("/")) return fs.existsSync(path.join(SITE, clean, "index.html"));
125
+ return fs.existsSync(asFile + "/index.html") || fs.existsSync(asFile + ".html");
126
+ }
127
+
128
+ function checkRef(file, pageUrl, raw, { external = true } = {}) {
129
+ if (!raw) return;
130
+ const v = raw.trim();
131
+ if (v === "" || v.startsWith("data:") || v.startsWith("mailto:") || v.startsWith("tel:")) return;
132
+
133
+ if (/^https?:\/\//i.test(v)) {
134
+ if (!external) return;
135
+ let origin;
136
+ try {
137
+ origin = new URL(v).origin;
138
+ } catch {
139
+ return err(file, `unparseable URL: ${v}`);
140
+ }
141
+ if (origin === site.url) {
142
+ if (!outputHas(new URL(v).pathname)) err(file, `absolute link to a missing page: ${v}`);
143
+ } else if (!allowedOrigins.has(origin) && !POLICY_LINK_ORIGINS.has(origin)) {
144
+ err(file, `external origin not in thirdparties.js: ${origin}`);
145
+ }
146
+ return;
147
+ }
148
+
149
+ if (v.startsWith("#")) {
150
+ if (v.length > 1 && !(idsByUrl[pageUrl] || new Set()).has(v.slice(1))) {
151
+ err(file, `#anchor with no matching id: ${v}`);
152
+ }
153
+ return;
154
+ }
155
+
156
+ if (v.startsWith("/")) {
157
+ if (!outputHas(v)) err(file, `internal reference does not resolve: ${v}`);
158
+ const hash = v.includes("#") ? v.split("#")[1] : "";
159
+ if (hash) {
160
+ const targetUrl = v.split("#")[0];
161
+ if (targetUrl === "/glossary/" && !glossarySlugs.has(hash)) {
162
+ err(file, `/glossary/#${hash} is not a glossary term`);
163
+ } else if (idsByUrl[targetUrl] && !idsByUrl[targetUrl].has(hash)) {
164
+ err(file, `${targetUrl}#${hash} — no such id on that page`);
165
+ }
166
+ }
167
+ return;
168
+ }
169
+ // relative path — resolve against the page dir
170
+ const resolved = path.posix.join(pageUrl, v);
171
+ if (!outputHas(resolved)) err(file, `relative reference does not resolve: ${v}`);
172
+ }
173
+
174
+ for (const f of htmlFiles) {
175
+ const rel = "/" + path.relative(SITE, f).replace(/\\/g, "/");
176
+ const pageUrl = rel.replace(/index\.html$/, "");
177
+ const root = parse(fs.readFileSync(f, "utf8"));
178
+
179
+ // CSP-safety: the strict CSP (script-src 'self'; style-src 'self') rejects
180
+ // inline scripts, inline styles, and on* handlers. Fail the build if any
181
+ // reappear in the output.
182
+ const EXECUTABLE = ["", "text/javascript", "application/javascript", "module"];
183
+ root.querySelectorAll("script").forEach((s) => {
184
+ const type = (s.getAttribute("type") || "").toLowerCase();
185
+ if (!s.getAttribute("src") && s.text.trim() && EXECUTABLE.includes(type)) {
186
+ err(f, `inline <script> (blocked by CSP script-src 'self')`);
187
+ }
188
+ });
189
+ root
190
+ .querySelectorAll("style")
191
+ .forEach(() => err(f, `inline <style> (blocked by CSP style-src 'self')`));
192
+ root.querySelectorAll("*").forEach((el) => {
193
+ for (const name of Object.keys(el.attributes)) {
194
+ if (/^on/i.test(name)) err(f, `inline handler ${name}= (blocked by CSP script-src 'self')`);
195
+ if (name.toLowerCase() === "style") err(f, `inline style= (blocked by CSP style-src 'self')`);
196
+ }
197
+ });
198
+
199
+ root.querySelectorAll("a[href]").forEach((a) => {
200
+ const href = a.getAttribute("href");
201
+ checkRef(f, pageUrl, href);
202
+ if (/^https?:\/\//i.test(href || "")) {
203
+ let origin;
204
+ try {
205
+ origin = new URL(href).origin;
206
+ } catch {
207
+ origin = "";
208
+ }
209
+ if (origin && origin !== site.url) {
210
+ const rel2 = (a.getAttribute("rel") || "").toLowerCase();
211
+ if (!/\bnoopener\b|\bnoreferrer\b/.test(rel2)) {
212
+ err(f, `external <a> without rel="noopener": ${href}`);
213
+ }
214
+ }
215
+ }
216
+ });
217
+
218
+ root.querySelectorAll("link[href]").forEach((l) => checkRef(f, pageUrl, l.getAttribute("href")));
219
+ root.querySelectorAll("script[src]").forEach((s) => checkRef(f, pageUrl, s.getAttribute("src")));
220
+ root.querySelectorAll("img[src]").forEach((i) => checkRef(f, pageUrl, i.getAttribute("src")));
221
+ root
222
+ .querySelectorAll('meta[property="og:image"], meta[name="twitter:image"]')
223
+ .forEach((m) => checkRef(f, pageUrl, m.getAttribute("content")));
224
+
225
+ root.querySelectorAll('script[type="application/ld+json"]').forEach((s, i) => {
226
+ let data;
227
+ try {
228
+ data = JSON.parse(s.text);
229
+ } catch (e) {
230
+ return err(f, `JSON-LD block #${i + 1} does not parse: ${e.message}`);
231
+ }
232
+ for (const node of [].concat(data)) {
233
+ const type = node["@type"];
234
+ const req = REQUIRED_LD[type];
235
+ if (!type) err(f, `JSON-LD block #${i + 1} has no @type`);
236
+ else if (req) {
237
+ for (const k of req) {
238
+ if (node[k] === undefined) err(f, `JSON-LD ${type} missing required "${k}"`);
239
+ }
240
+ }
241
+ }
242
+ });
243
+ }
244
+
245
+ // ---- ads: production-only, ads.txt present (core.md#ads) -------------------
246
+ (function checkAds() {
247
+ const ads = tryProjectRequire("src/_data/ads.js", {});
248
+ if (!ads.pub) return;
249
+ // Infer prod-ness from the built output, not process.env — so `npm run links`
250
+ // is correct whichever build is on disk.
251
+ const home = fs.readFileSync(path.join(SITE, "index.html"), "utf8");
252
+ const isProdBuild = !/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
253
+ // Real ad/consent CODE — not a bare origin string (the audit page renders the
254
+ // origin manifest as data, which is fine).
255
+ const AD_CODE =
256
+ /adsbygoogle\.js|<ins[^>]+adsbygoogle|name=["']google-adsense-account["']|fundingchoicesmessages\.google\.com\/i\//;
257
+ if (!isProdBuild) {
258
+ for (const f of htmlFiles) {
259
+ if (AD_CODE.test(fs.readFileSync(f, "utf8"))) {
260
+ err(f, "ad / consent code in a non-production build (must be gated on build.isProduction)");
261
+ }
262
+ }
263
+ }
264
+ if (!fs.existsSync(path.join(SITE, "ads.txt"))) {
265
+ err("_site/ads.txt", "missing — src/ads.txt.njk should emit it on every build");
266
+ }
267
+ })();
268
+
269
+ if (errors.length) {
270
+ console.error(`\nLink check failed (${errors.length}):`);
271
+ errors.forEach((e) => console.error(" - " + e));
272
+ process.exit(1);
273
+ }
274
+
275
+ try {
276
+ fs.writeFileSync(
277
+ path.join(SITE, "links-report.json"),
278
+ JSON.stringify(
279
+ { generatedAt: new Date().toISOString(), pages: htmlFiles.length, checked: allFiles.length },
280
+ null,
281
+ 2,
282
+ ),
283
+ );
284
+ } catch (e) {
285
+ /* _site may be read-only in some contexts; the console line is the source of truth */
286
+ }
287
+
288
+ console.log(` links: ${htmlFiles.length} pages OK`);