/** * ascii-fold.mjs — fold a name to ASCII for comparison against an ASCII target. * * Two places in the tree build a private key by DELETING every character * outside `[a-z0-9]` instead of folding it. The deletion is always wrong when * the thing being compared against is ASCII, because the accented letter has an * obvious ASCII counterpart that the target actually uses: * * - verify-portals.mjs "Telefónica" -> "telefnica", never the real * Greenhouse slug "telefonica" (#2930) * - providers/_trust-validator.mjs * "Société Générale" -> "socit gnrale", so the company * failed to match its own domain (#2924) * * WHY NFD IS RIGHT HERE AND WRONG IN foldStatusInput (tracker-utils.mjs, #2705): * there the fold must not collapse distinct identities — Żubr must never become * Zubr, because two different companies would merge. Here the comparison target * is ASCII by construction (a hostname, an ATS slug), so folding to the ASCII * base letter IS the intent: "telefonica" is the ASCII folding of "Telefónica". * Different question, different answer. * * A name with no Latin content at all (CJK, Cyrillic, Greek) folds to ''. That * is a real answer, not a failure: no ASCII target can contain it. Callers must * decide what '' means for them — see each call site. */ /** * Latin letters that do NOT decompose under NFD, so stripping combining marks * alone still deletes them. Lowercase only; `asciiFold` lowercases first. */ // A stroke or bar through a letter is part of the GLYPH, not a combining // mark, so NFD leaves it and the [^a-z0-9] strip below then deletes the letter // outright — the failure this whole module exists to stop, surviving inside // it. Unlike the hostname case there is no substring luck here: "Işık" derived // "isk" and never "isik", so --add probed a slug no board uses. // ŋ is "ng", not "n" — the plausible one-to-one mapping is the wrong one. // (CodeRabbit, reviewing #2927.) const NON_DECOMPOSING_LATIN = [ [/ø/g, 'o'], [/æ/g, 'ae'], [/œ/g, 'oe'], [/ß/g, 'ss'], [/đ/g, 'd'], [/ł/g, 'l'], [/þ/g, 'th'], [/ð/g, 'd'], [/ħ/g, 'h'], [/ı/g, 'i'], [/ŋ/g, 'ng'], [/ŧ/g, 't'], [/ĸ/g, 'k'], [/ſ/g, 's'], ]; /** * Lowercase and fold a name to ASCII letters, digits and single spaces. * * @param {string} value - Raw display name. * @param {{punctuation?: 'space'|'delete'}} [options] - How residual punctuation * is treated; see the note in the body. Word-level callers care, slug-style * callers do not. * @returns {string} Folded name, or '' when nothing Latin survives. */ export function asciiFold(value, { punctuation = 'space' } = {}) { let out = String(value ?? '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, ''); for (const [re, to] of NON_DECOMPOSING_LATIN) out = out.replace(re, to); // 'space' (default) turns residual punctuation into a separator, so // "Smith&Jones" becomes two words. 'delete' removes it, so it stays one. // // NOT cosmetic, which is why this is an option and not a harmonization // (#3040). A caller that then matches WORDS gains words it never had: // // Smith&Jones space -> "smith jones" words: [smith, jones] // delete -> "smithjones" words: [smithjones] // // and `smith` substring-matches smithfield.com, so a // company/hostname mismatch that should be flagged silently is not. // Slug-style callers that collapse spaces converge either way; word-level // callers do not, so the caller states which it needs. // // The 'delete' class is `[^a-z0-9 ]` with a LITERAL space, not `\s`: a tab or // newline is removed rather than collapsed, matching the behaviour // _trust-validator.mjs had before it moved here. out = punctuation === 'delete' ? out.replace(/[^a-z0-9 ]/g, '') : out.replace(/[^a-z0-9\s]/g, ' '); return out.replace(/\s+/g, ' ').trim(); }