#!/usr/bin/env node /** * clean-markers.mjs — audit/strip invisible Unicode from generated text outputs. * * Job descriptions, forms, and pasted recruiter messages are untrusted data. They can carry * invisible Unicode that gets copied into generated CVs, cover letters, or emails without the * user ever seeing it. This audits and optionally removes those hidden characters before output * leaves the user's machine. * * node clean-markers.mjs audit # report only, never modifies * node clean-markers.mjs clean # strip markers, then report * node clean-markers.mjs clean --ascii cover-letter.md # also force plain-ASCII punctuation * node clean-markers.mjs audit --keep-script-joiners f.md # keep joiners emoji/Persian/Indic spelling needs * node clean-markers.mjs --help # show this message (-h is an alias) * * Exit code 1 if any file FAILS audit — usable as a pre-send gate. Dependency-free: text only, * no package installs, no PDF metadata editing. */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { extname } from 'node:path'; import { validateFlags } from './lib/cli-flags.mjs'; const NAMED = { 0x200B:'ZERO-WIDTH SPACE', 0x200C:'ZWNJ', 0x200D:'ZWJ', 0x2060:'WORD JOINER', 0xFEFF:'BOM/ZWNBSP', 0x00AD:'SOFT HYPHEN', 0x180E:'MONGOLIAN VOWEL SEP', 0x200E:'LRM', 0x200F:'RLM', 0x202A:'LRE', 0x202B:'RLE', 0x202C:'PDF(bidi)', 0x202D:'LRO', 0x202E:'RLO', 0x2066:'LRI', 0x2067:'RLI', 0x2068:'FSI', 0x2069:'PDI', }; const isTag = cp => cp >= 0xE0000 && cp <= 0xE007F; const isVS = cp => (cp>=0xFE00&&cp<=0xFE0F) || (cp>=0xE0100&&cp<=0xE01EF); const label = cp => NAMED[cp] || (isTag(cp) ? `TAG U+${cp.toString(16).toUpperCase()}` : isVS(cp) ? `VARIATION-SEL U+${cp.toString(16).toUpperCase()}` : null); const TEXT_EXT = new Set(['.html','.htm','.md','.txt','.json','.csv','.svg','.xml','.tex']); // --keep-script-joiners (#5268): ZWJ, ZWNJ and VS16 are part of the spelling in emoji sequences, // Persian and Brahmic scripts. A single joiner is kept only between the characters that need it; // anything else — next to Latin letters or spaces, at a line edge, or in a run of two or more, // the shape a zero-width payload takes — is still stripped and reported. audit and clean both // call this one predicate, so they cannot disagree about a character. const ZWNJ = 0x200C, ZWJ = 0x200D, VS16 = 0xFE0F; const PICTO = /\p{Extended_Pictographic}/u; const SKIN_TONE = /\p{Emoji_Modifier}/u; // Letters only (with combining marks for the Brahmic virama): Arabic-Indic and Devanagari digits // share the script, and a joiner between two digits spells nothing. const ARABIC = /(?=\p{L})\p{Script=Arabic}/u; const BRAHMIC = ['Devanagari','Bengali','Gurmukhi','Gujarati','Oriya','Tamil','Telugu','Kannada','Malayalam','Sinhala'] .map(s => new RegExp(`(?=[\\p{L}\\p{M}])\\p{Script=${s}}`, 'u')); const is = (re, ch) => ch !== undefined && re.test(ch); const sameBrahmic = (a, b) => BRAHMIC.some(re => is(re, a) && is(re, b)); // The emoji a ZWJ continues: a pictograph, optionally followed by one skin tone or one VS16. function endsWithPictograph(chars, i){ if(is(SKIN_TONE, chars[i]) || chars[i]?.codePointAt(0) === VS16) i--; return is(PICTO, chars[i]); } function isScriptJoiner(chars, i){ const cp = chars[i].codePointAt(0), prev = chars[i-1], next = chars[i+1]; if(cp === VS16) return is(PICTO, prev); if(cp === ZWJ && is(PICTO, next) && endsWithPictograph(chars, i-1)) return true; if(cp === ZWNJ && is(ARABIC, prev) && is(ARABIC, next)) return true; return (cp === ZWJ || cp === ZWNJ) && sameBrahmic(prev, next); } function scanText(t, keepJoiners){ const f={}; let kept=0; const chars=[...t]; for(let i=0; i/