Files
tested/scripts/check-notes.mjs
T
joshuaandClaude Opus 5.5 54ae9c49a1 Tested: static Astro site answering hard Bible questions
Built from the kit specs (CLAUDE.md, docs/01-05): question index import,
status seals, connections graph, Pagefind search, Scripture margin,
and 157 evidence articles (drafts unless published) with sources and
verified "Learn more" links. Includes link, owner-notes, a11y and
publish scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:46:19 +02:00

88 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
// Build guard: fails if any private owner note (authorNotes) appears anywhere in dist/,
// including inside the Pagefind search index. Runs at the end of `npm run build`.
// Pass a folder to scan something else, e.g. `node scripts/check-notes.mjs src/content/articles`.
//
// It looks for each whole note and for every run of 8 consecutive words from it, after
// normalising case, punctuation, tags and HTML entities, so a partial leak is caught too.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { gunzipSync } from "node:zlib";
const WINDOW = 8;
const data = JSON.parse(readFileSync("src/data/questions.json", "utf8"));
const normalise = (s) =>
s
.replace(/<[^>]+>/g, " ")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&[a-z]+;/g, " ")
.replace(/\\u([0-9a-f]{4})/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
// Every phrase to look for, remembering which question's note it came from.
const needles = [];
for (const q of data.questions) {
if (!q.authorNotes) continue;
const words = normalise(q.authorNotes).split(" ");
if (words.length <= WINDOW) needles.push({ id: q.id, text: words.join(" ") });
else
for (let i = 0; i + WINDOW <= words.length; i++)
needles.push({ id: q.id, text: words.slice(i, i + WINDOW).join(" ") });
}
const walk = (dir) =>
readdirSync(dir).flatMap((f) => {
const p = join(dir, f);
return statSync(p).isDirectory() ? walk(p) : [p];
});
function readText(file) {
const buf = readFileSync(file);
// Pagefind's index files are gzipped.
if (buf[0] === 0x1f && buf[1] === 0x8b) {
try {
return gunzipSync(buf).toString("utf8");
} catch {
return "";
}
}
return /\.(html|js|mjs|json|css|xml|txt|webmanifest|svg|mdx|md|pf_\w+)$/.test(file)
? buf.toString("utf8")
: "";
}
const problems = [];
let scanned = 0;
const root = process.argv[2] ?? "dist";
for (const file of walk(root)) {
const raw = readText(file);
if (!raw) continue;
scanned++;
if (raw.includes("authorNotes")) problems.push(`${file}: contains the "authorNotes" field`);
const text = ` ${normalise(raw)} `;
const found = new Set();
for (const { id, text: needle } of needles) {
if (!found.has(id) && text.includes(` ${needle} `)) {
found.add(id);
problems.push(`${file}: contains owner notes from question ${id}`);
}
}
}
if (problems.length) {
console.error(
`Owner notes leaked into the build (${problems.length}):\n- ${problems.join("\n- ")}`,
);
process.exit(1);
}
console.log(
`Owner notes check passed: ${scanned} files scanned, ${needles.length} phrases from ${data.questions.filter((q) => q.authorNotes).length} notes, none found.`,
);