#!/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.`, );