In juli herschreef ik de kop van mijn site. Iedereen akkoord, tekst gepubliceerd, klaar. In augustus stond de oude belofte weer vooraan en de nieuwe was gezakt tot een bijzin, ergens onderaan de homepage.
Niemand had dat teruggedraaid. Er was geen commit met “revert” erin, geen bewuste keuze. Het kroop terug, pagina voor pagina: een meta-omschrijving hier, een machine-leesbare samenvatting daar, een kop die niemand had herlezen toen de tekst eromheen veranderde.
Marketingtekst heeft geen testsuite. Code die stuk is geeft een foutmelding. Tekst die stuk is ziet er precies zo overtuigend uit als tekst die klopt — en dus faalt er niets, tot iemand het toevallig opmerkt.
Dus schreef ik er eentje. Geen framework, geen dependency, iets meer dan honderd regels Node die na elke build over dist/client lopen: een lijst van twaalf verboden zinnen die nergens meer mogen staan, en een lijst verplichte zinnen die overal moeten staan waar ze horen. Tot dit artikel was dat een handmatige stap (pnpm check:copy); sindsdien draait de check automatisch mee in pnpm build, en breekt de deploy vóór de herstart af als hij faalt.
// scripts/check-copy.mjs — bewaakt de positionering in de gebouwde site.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const VERBODEN = [
'De balans tussen mens en technologie',
'luisteren, analyseren, adviseren',
'middel is, geen doel',
'Compliance die een controle overleeft',
'Compliance that survives an inspection',
// hier stond een AS-nummer als verboden zin — vervangen door een placeholder,
// want dit stuk gaat niet over de partij die het betrof
'AS-XXXXX',
// De ingetrokken AS-XXXXX-claim kwam terug als proza in plaats van als logo — deze
// zinnen zijn de exacte bewoording die de registratie-bewering opnieuw beweerde.
// Specifiek genoeg gekozen om "geregistreerd" in andere, legitieme context (bv. KvK)
// niet te raken.
'op mijn naam geregistreerd',
'registered in my own name',
'publiek geregistreerd',
'publicly registered',
'eigen routering',
'my own routing',
];
const VEREIST = {
'index.html': [
'Ik bouw wat andere adviseurs opschrijven',
'Wat er staat als ik klaar ben',
],
'en/index.html': [
'I build what other advisers put in writing',
'What is standing when I am done',
],
};
// llms.txt en de logo-SVG's zijn geen HTML, maar zijn wel gebouwde, publiek opvraagbare
// output onder dist/client — en precies waar de AS-XXXXX-claim eerder onopgemerkt bleef
// staan. Zelfde recursie, bredere extensieset.
const TE_SCANNEN_EXTENSIES = ['.html', '.txt', '.svg'];
const teScannenBestanden = (dir) =>
readdirSync(dir).flatMap((naam) => {
const pad = join(dir, naam);
return statSync(pad).isDirectory()
? teScannenBestanden(pad)
: TE_SCANNEN_EXTENSIES.some((ext) => pad.endsWith(ext))
? [pad]
: [];
});
// Astro behoudt regeleinden uit de brontekst; een zin die in de bron over meerdere
// regels loopt, staat in de HTML-output dus ook met een newline erin. Een kale
// `includes()` mist die zin. Runs van whitespace worden daarom vóór vergelijking
// samengevouwen tot één spatie, in zowel de haystack als de gezochte zin.
const escapeRegExp = (zin) => zin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const naarPatroon = (zin, hoofdletterongevoelig) =>
new RegExp(escapeRegExp(zin).replace(/\s+/g, '\\s+'), hoofdletterongevoelig ? 'i' : '');
// Sommige artikelen gáán over deze verboden zinnen en citeren ze daarom. Een lijst
// verboden zinnen kan "beweren" niet van "citeren" onderscheiden, dus die pagina's
// staan hier bij naam. Bewust geen patroon op /inzichten/: dan zou elk toekomstig
// artikel stilzwijgend vrijlopen, terwijl juist een artikel de oude positionering
// ongemerkt terug kan zetten.
const UITGEZONDERD = new Set([
'dist/client/inzichten/keurmerk-van-mijn-eigen-site/index.html',
'dist/client/inzichten/veertig-regels-tegen-terugval/index.html',
]);
const fouten = [];
for (const pad of teScannenBestanden('dist/client')) {
if (UITGEZONDERD.has(pad)) continue;
const inhoud = readFileSync(pad, 'utf8');
for (const zin of VERBODEN) {
const match = inhoud.match(naarPatroon(zin, true));
if (match) {
const gevonden = match[0].replace(/\s+/g, ' ');
fouten.push(`${pad}: verboden zin "${gevonden}"`);
}
}
}
for (const [relPad, zinnen] of Object.entries(VEREIST)) {
const inhoud = readFileSync(join('dist/client', relPad), 'utf8');
const headEnd = inhoud.indexOf('</head>');
const body = headEnd === -1 ? inhoud : inhoud.slice(headEnd + '</head>'.length);
for (const zin of zinnen) {
if (!naarPatroon(zin, false).test(body)) {
fouten.push(`dist/client/${relPad}: mist "${zin}"`);
}
}
}
fouten.forEach((f) => console.error(f));
console.log(fouten.length ? `${fouten.length} overtreding(en)` : 'copy-check akkoord');
process.exit(fouten.length ? 1 : 0);
Eén regel is bewust anders dan wat er echt draait: de werkelijke lijst bevat op die plek het echte AS-nummer, niet AS-XXXXX. De rest hierboven is letterlijk. Kopieer je deze lijst, dan neem je ook AS-XXXXX over als een zin die nooit ergens matcht — en die twee paden in UITGEZONDERD verwijzen naar artikelen die op jouw site niet bestaan. Verwijder ze, of vervang ze door je eigen paden, anders houdt die regel domweg nooit iets tegen of buiten.
Dat is het hele script. Geen abstracties, geen configuratiebestand, geen plugin-systeem. Twee lijsten om te controleren, één lijst met uitzonderingen, en twee for-loops. De waarde zit niet in de code — die schrijf je in een middagje. De waarde zit in wat er in die lijsten staat, en dat heeft me op vier plekken pijn gedaan of bijna pijn gedaan voordat ik het goed had — twee keer omdat ik er echt door gepakt werd, twee keer omdat ik het script kritisch herlas voordat dat kon gebeuren.
Waarom hoofdletterongevoelig — een echte ontsnapping
De eerste versie deed een kale includes(). Die miste een Engelse paginatitel die anders gekapitaliseerd was dan de verboden zin in mijn lijst. De oude belofte stond gewoon in de <title> — precies het stuk tekst dat zoekmachines en linkpreviews tonen — en de check zei alles akkoord. Dit is er niet eentje die ik bedacht heb; hij is me overkomen.
Sindsdien is elke vergelijking hoofdletterongevoelig, met opzet ook in <head>, waar oude positionering het eerst weglekt en het minst wordt herlezen.
Waarom niet alleen .html — ook een echte ontsnapping
Twee bestanden droegen de oude positionering onzichtbaar mee: een machine-leesbare sitesamenvatting die AI-crawlers expliciet wordt aangeraden te lezen en nog steeds de oude drieslag droeg (“luisteren, analyseren, adviseren”), en een logo-bestand met het ingetrokken AS-nummer erin gebakken als tekst-node in de SVG. Geen van beide is HTML, dus geen van beide werd gescand — allebei stonden ze gewoon op groen — tot ik de extensieset uitbreidde naar .txt en .svg.
Waarom <head> wordt afgeknipt bij de verplichte zinnen — een gat, geen ontsnapping
Deze vond ik niet doordat hij misging. Ik las het script terug en zag: de verplichte kop stond op de gebouwde pagina vier keer — in <title>, in og:title, in twitter:title, en pas dan in de echte <h1>. Je kon die kop dus uit de zichtbare pagina schrappen terwijl de eerste drie hem bleven herhalen, en de check zou nog steeds slagen. Het is nooit gebeurd. Het kón gebeuren, en dat was genoeg reden om het dicht te zetten.
Daarom knipt de verplichte-zinnen-check <head> eraf voordat hij zoekt. Alleen wat er ook echt op de pagina staat telt mee.
Waarom whitespace eerst wordt platgeslagen — ook een gat, geen ontsnapping
Ook deze vond ik door het script kritisch te lezen, niet doordat een verboden zin er ooit doorheen kwam. De sitegenerator behoudt regeleinden uit de brontekst: een zin die in de broncode over twee regels loopt, heeft in de HTML-output dus een newline er middenin staan, en een kale includes() mist zo’n zin volledig. Ik heb het aangetoond tegen de echte build — een zin die ik bewust over twee regels liet lopen, kwam er met een newline in het midden uit, en includes() zag hem niet. Dat was tot dan toe nooit zichtbaar geweest, omdat elke verboden zin die ik ooit had opgeschreven toevallig kort genoeg was om op één regel te passen. De check werkte tot dat moment per ongeluk.
Nu vouwt naarPatroon elke reeks witruimte samen tot één spatie, in de haystack én in de gezochte zin, voordat er iets vergeleken wordt.
Twee van deze vier leerde ik doordat ik betrapt werd. Twee leerde ik doordat ik het script zelf sceptisch las, niet doordat het ooit heeft gefaald. Dat onderscheid is het vermelden waard: een guard bedenken aan de tekentafel vindt de eerste soort niet — daar word je door ingehaald — maar de tweede soort vind je wél aan de tekentafel, als je bereid bent je eigen werk wantrouwend te lezen.
Waarom twee paden met naam, en geen patroon over heel /inzichten/
Dit artikel en het stuk over de badge citeren de verboden zinnen om ze uit te leggen. Een lijst verboden zinnen kan “beweren” niet van “citeren” onderscheiden — de check liet deze twee pagina’s stuklopen op precies de zinnen die ze bespreken.
De oplossing had een regel over heel /inzichten/ kunnen zijn: alles onder die map vrijstellen. Dat deed ik bewust niet. Dan zou elk toekomstig artikel stilzwijgend vrijlopen, en juist een artikel is de plek waar de oude positionering ongemerkt terug kan sluipen — proza, geherformuleerd, citeerbaar noch aanwijsbaar. In plaats daarvan staan er twee paden bij naam in UITGEZONDERD, met een comment die uitlegt waarom.
Elke uitzondering in een guard is een klein gat. Dat betekent niet dat je hem niet moet maken — soms moet het — maar wel dat je hem zo smal maakt als de situatie toelaat, en opschrijft waarom hij er is. Een pad met naam is smaller dan een patroon. Een patroon met commentaar is nog steeds breder dan het probleem dat het oploste.
Het interessante deel van dit script is trouwens niet de code. Het is de lijst VERBODEN. Elke zin daarin is een bewering die de organisatie ooit over zichzelf deed en heeft ingetrokken. Die lijst bijhouden — elke keer dat je iets herschrijft, de oude formulering erbij zetten — is meer werk dan het script zelf, en levert meer op dan het script zelf.
Het script controleert alleen wat je erin stopt. De discipline om erin te stoppen wat je hebt afgezworen, dat is het eigenlijke werk.