// QA gate for the content migration: proves that, inside the narrative window // (everything between the first section head and the manifesto), the built // pages carry EXACTLY the legacy prose, line-for-line and in order, modulo // the documented categories below. Anything else fails loudly. // // Categories (each is reported with counts and samples): // labels built-only lines that are ToolCard field labels — in the // legacy page these strings were CSS-generated (::before // content), so they are absent from the legacy DOM text. // excluded legacy-only lines that belong to the deliberately deferred // legislative-status claims (see extract-log.json). // quotes " and ' are typeset as “ ” ‘ ’ by Astro's default // markdown `smartypants`; normalized before comparing. // spacing identical text but different whitespace at tag boundaries // (component templates put line breaks between inline elements // where the legacy HTML had none). // // Usage: node migration/compare.mjs (after extract + build + extract --built) import { readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const INV = join(dirname(fileURLToPath(import.meta.url)), 'inventory') const LANGS = ['en', 'fr', 'nl'] const LABELS = { en: new Set(["What it's for", 'Why it matters', 'For whom & when', 'Install & use']), fr: new Set(['À quoi ça sert', 'Pourquoi', 'Pour qui & quand', 'Installer & utiliser']), nl: new Set([ 'Waarvoor dient het', 'Waarom het ertoe doet', 'Voor wie & wanneer', 'Installeren & gebruiken', ]), } // The six cell lines per language of the two deferred #menace table rows. const EXCLUDED = { en: [ 'Duration', 'Temporary, extended to 3 April 2028', 'Permanent', 'Status (Jul 2026)', 'Adopted 9 July 2026', 'Under negotiation, 5th trilogue failed 29 June', ], fr: [ 'Durée', "Temporaire, prolongé jusqu'au 3 avril 2028", 'Permanent', 'Statut (juil. 2026)', 'Adopté le 9 juillet 2026', 'En négociation, 5e trilogue échoué le 29 juin', ], nl: [ 'Looptijd', 'Tijdelijk, verlengd tot 3 april 2028', 'Permanent', 'Status (juli 2026)', 'Aangenomen op 9 juli 2026', 'In onderhandeling, 5e trialoog mislukt op 29 juni', ], } const unquote = (s) => s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'") const START = /^(PART|PARTIE|DEEL|MEMO) 00 · |^(PART|PARTIE|DEEL) 00 · / const END = /(become ungovernable|devenir ingouvernable|onbestuurbaar worden)/i function narrativeWindow(lines, file) { const start = lines.findIndex((l) => START.test(l)) // the end sentinel is searched after the start (the new hero h1 also // contains "Become Ungovernable") const end = lines.findIndex((l, idx) => idx > start && END.test(l)) if (start === -1 || end === -1 || end < start) { throw new Error(`cannot find narrative window in ${file} (start=${start}, end=${end})`) } return { head: lines.slice(0, start), body: lines.slice(start, end + 1), tail: lines.slice(end + 1), } } const report = {} let failed = false for (const lang of LANGS) { const legacyAll = readFileSync(join(INV, `legacy-${lang}.txt`), 'utf8') .trimEnd() .split('\n') const builtAll = readFileSync(join(INV, `built-${lang}.txt`), 'utf8') .trimEnd() .split('\n') const legacy = narrativeWindow(legacyAll, `legacy-${lang}.txt`) const built = narrativeWindow(builtAll, `built-${lang}.txt`) const excludedPending = [...EXCLUDED[lang]] const stats = { labels: 0, excluded: [], quotes: 0, spacing: [], unexplained: [] } let i = 0 let j = 0 const L = legacy.body const B = built.body.map(unquote) const Braw = built.body while (i < L.length || j < B.length) { const a = L[i] const b = B[j] if (a !== undefined && b !== undefined && a === b) { if (Braw[j] !== b) stats.quotes++ i += 1 j += 1 continue } if (a !== undefined && b !== undefined && a.replace(/\s+/g, '') === b.replace(/\s+/g, '')) { if (Braw[j] !== b) stats.quotes++ stats.spacing.push(a.length > 90 ? a.slice(0, 90) + '…' : a) i += 1 j += 1 continue } if (b !== undefined && LABELS[lang].has(b)) { stats.labels++ j++ continue } if (a !== undefined && excludedPending.includes(a)) { excludedPending.splice(excludedPending.indexOf(a), 1) stats.excluded.push(a) i++ continue } stats.unexplained.push({ line: i + 1, legacy: a ?? '(end)', built: b ?? '(end)' }) failed = true if (stats.unexplained.length > 10) break i += 1 j += 1 } report[lang] = { legacy: { chromeHead: legacy.head.length, narrative: L.length, chromeTail: legacy.tail.length }, built: { chromeHead: built.head.length, narrative: B.length, chromeTail: built.tail.length }, matchedAfterCategories: !stats.unexplained.length, toolCardLabelLines: stats.labels, deferredLegislativeLines: stats.excluded, linesWithSmartQuotes: stats.quotes, tagBoundarySpacingLines: stats.spacing.length, tagBoundarySpacingSamples: stats.spacing.slice(0, 8), unexplained: stats.unexplained, } } writeFileSync(join(INV, 'compare-report.json'), JSON.stringify(report, null, 2) + '\n') for (const [lang, r] of Object.entries(report)) { console.log( `${lang}: narrative legacy=${r.legacy.narrative} built=${r.built.narrative} | ` + `labels=+${r.toolCardLabelLines} excluded=-${r.deferredLegislativeLines.length} ` + `smartquotes=${r.linesWithSmartQuotes} spacing=${r.tagBoundarySpacingLines} ` + `unexplained=${r.unexplained.length}`, ) for (const u of r.unexplained) console.log(' MISMATCH', u) } process.exit(failed ? 1 : 0)