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>
88 lines
2.9 KiB
JavaScript
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(/"/g, '"')
|
|
.replace(/&/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.`,
|
|
);
|