@open-mercato/shared 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7120.1.a0154ac412

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,40 @@
1
1
  import crypto from "crypto";
2
2
  import { resolveSearchConfig, resolveSearchTokenLimits } from "./config.js";
3
+ const NON_DECOMPOSING_FOLDS = {
4
+ "\u0142": "l",
5
+ "\u0141": "L",
6
+ "\xF8": "o",
7
+ "\xD8": "O",
8
+ "\u0111": "d",
9
+ "\u0110": "D",
10
+ "\xF0": "d",
11
+ "\xD0": "D",
12
+ "\u0127": "h",
13
+ "\u0126": "H",
14
+ "\u0131": "i",
15
+ "\u0138": "k",
16
+ "\u014B": "n",
17
+ "\u014A": "N",
18
+ "\u0167": "t",
19
+ "\u0166": "T",
20
+ "\xE6": "ae",
21
+ "\xC6": "AE",
22
+ "\u0153": "oe",
23
+ "\u0152": "OE",
24
+ "\xFE": "th",
25
+ "\xDE": "TH",
26
+ "\xDF": "ss",
27
+ "\u1E9E": "SS"
28
+ };
29
+ const NON_DECOMPOSING_PATTERN = new RegExp(
30
+ `[${Object.keys(NON_DECOMPOSING_FOLDS).join("").replace(/[\\\]^-]/g, "\\$&")}]`,
31
+ "g"
32
+ );
33
+ function foldNonDecomposingLetters(text) {
34
+ return text.replace(NON_DECOMPOSING_PATTERN, (char) => NON_DECOMPOSING_FOLDS[char]);
35
+ }
3
36
  function normalizeText(text) {
4
- return text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[%_]/g, " ").toLowerCase();
37
+ return foldNonDecomposingLetters(text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")).replace(/[%_]/g, " ").toLowerCase();
5
38
  }
6
39
  function splitTokens(text, minLength) {
7
40
  return normalizeText(text).split(/[^a-z0-9]+/i).filter((token) => token.length >= minLength);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/search/tokenize.ts"],
4
- "sourcesContent": ["import crypto from 'crypto'\nimport { resolveSearchConfig, resolveSearchTokenLimits, type SearchConfig } from './config'\n\nexport type TokenizationResult = {\n tokens: string[]\n hashes: string[]\n}\n\nfunction normalizeText(text: string): string {\n return text\n .normalize('NFKD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[%_]/g, ' ')\n .toLowerCase()\n}\n\nfunction splitTokens(text: string, minLength: number): string[] {\n return normalizeText(text)\n .split(/[^a-z0-9]+/i)\n .filter((token) => token.length >= minLength)\n}\n\nfunction appendExpandedToken(\n token: string,\n config: SearchConfig,\n seen: Set<string>,\n tokens: string[],\n limit: number,\n): void {\n const append = (candidate: string): boolean => {\n if (seen.has(candidate)) return tokens.length < limit\n seen.add(candidate)\n tokens.push(candidate)\n return tokens.length < limit\n }\n\n if (!config.enablePartials) {\n append(token)\n return\n }\n\n for (let length = config.minTokenLength; length <= token.length; length += 1) {\n if (!append(token.slice(0, length))) return\n }\n}\n\nexport function hashToken(token: string, config?: SearchConfig): string {\n const cfg = config ?? resolveSearchConfig()\n return crypto.createHash(cfg.hashAlgorithm).update(token).digest('hex')\n}\n\nexport function tokenizeText(text: string, config?: SearchConfig): TokenizationResult {\n const cfg = config ?? resolveSearchConfig()\n const limits = resolveSearchTokenLimits(cfg)\n const boundedText = limits.maxFieldChars > 0 ? text.slice(0, limits.maxFieldChars) : text\n const tokenLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const seen = new Set<string>()\n const tokens: string[] = []\n\n for (const token of splitTokens(boundedText, cfg.minTokenLength)) {\n if (tokens.length >= tokenLimit) break\n appendExpandedToken(token, cfg, seen, tokens, tokenLimit)\n }\n\n const hashes = tokens.map((token) => hashToken(token, cfg))\n return { tokens, hashes }\n}\n"],
5
- "mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,qBAAqB,gCAAmD;AAOjF,SAAS,cAAc,MAAsB;AAC3C,SAAO,KACJ,UAAU,MAAM,EAChB,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,SAAS,GAAG,EACpB,YAAY;AACjB;AAEA,SAAS,YAAY,MAAc,WAA6B;AAC9D,SAAO,cAAc,IAAI,EACtB,MAAM,aAAa,EACnB,OAAO,CAAC,UAAU,MAAM,UAAU,SAAS;AAChD;AAEA,SAAS,oBACP,OACA,QACA,MACA,QACA,OACM;AACN,QAAM,SAAS,CAAC,cAA+B;AAC7C,QAAI,KAAK,IAAI,SAAS,EAAG,QAAO,OAAO,SAAS;AAChD,SAAK,IAAI,SAAS;AAClB,WAAO,KAAK,SAAS;AACrB,WAAO,OAAO,SAAS;AAAA,EACzB;AAEA,MAAI,CAAC,OAAO,gBAAgB;AAC1B,WAAO,KAAK;AACZ;AAAA,EACF;AAEA,WAAS,SAAS,OAAO,gBAAgB,UAAU,MAAM,QAAQ,UAAU,GAAG;AAC5E,QAAI,CAAC,OAAO,MAAM,MAAM,GAAG,MAAM,CAAC,EAAG;AAAA,EACvC;AACF;AAEO,SAAS,UAAU,OAAe,QAA+B;AACtE,QAAM,MAAM,UAAU,oBAAoB;AAC1C,SAAO,OAAO,WAAW,IAAI,aAAa,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxE;AAEO,SAAS,aAAa,MAAc,QAA2C;AACpF,QAAM,MAAM,UAAU,oBAAoB;AAC1C,QAAM,SAAS,yBAAyB,GAAG;AAC3C,QAAM,cAAc,OAAO,gBAAgB,IAAI,KAAK,MAAM,GAAG,OAAO,aAAa,IAAI;AACrF,QAAM,aAAa,OAAO,oBAAoB,IAAI,OAAO,oBAAoB,OAAO;AACpF,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,SAAmB,CAAC;AAE1B,aAAW,SAAS,YAAY,aAAa,IAAI,cAAc,GAAG;AAChE,QAAI,OAAO,UAAU,WAAY;AACjC,wBAAoB,OAAO,KAAK,MAAM,QAAQ,UAAU;AAAA,EAC1D;AAEA,QAAM,SAAS,OAAO,IAAI,CAAC,UAAU,UAAU,OAAO,GAAG,CAAC;AAC1D,SAAO,EAAE,QAAQ,OAAO;AAC1B;",
4
+ "sourcesContent": ["import crypto from 'crypto'\nimport { resolveSearchConfig, resolveSearchTokenLimits, type SearchConfig } from './config'\n\nexport type TokenizationResult = {\n tokens: string[]\n hashes: string[]\n}\n\n/**\n * Latin letters that NFKD leaves intact because they are atomic codepoints rather than a\n * base letter plus a combining mark. Stripping combining marks therefore never folds them\n * to ASCII, and `splitTokens` then consumes them as separators \\u2014 truncating `\\u0141ukasz` to\n * `ukasz` and cutting `Za\\u017c\\u00f3\\u0142\\u0107` down to `zazo`. Because the same tokenizer runs at index\n * time and at query time, such a record becomes unreachable from every spelling. Only\n * characters with a single unambiguous ASCII fold belong here; anything language-dependent\n * must stay out.\n *\n * `normalizeText` applies this fold AFTER NFKD and mark stripping, and the order is\n * load-bearing: folding first would miss the characters that decompose *into* one of these\n * letters, such as `\\u01ff` (U+01FF \\u2192 `\\u00f8` + U+0301), `\\u01fd` (U+01FD), `\\u01e3` (U+01E3) and `\\u210f` (U+210F).\n * The letters below have no decomposition of their own, so NFKD passes them through\n * untouched and they still fold correctly in this position.\n *\n * The table covers every letter in Latin-1 Supplement (U+00C0-U+00FF) and Latin Extended-A\n * (U+0100-U+017F) that NFKD leaves un-folded; `tokenize.test.ts` pins that range so a gap\n * cannot silently reopen. Two entries look redundant and are not: `\\u0110` (U+0110, D with\n * stroke) and `\\u00d0` (U+00D0, Eth) are visually indistinguishable in uppercase and are\n * routinely substituted for one another in Croatian, Serbian and Vietnamese text, so both\n * must fold to `D` or the same rendered name yields two disjoint token sets. Do not delete\n * either as a duplicate of the other.\n *\n * Letters outside those two blocks are deliberately out of scope \\u2014 `\\u0259`/`\\u018f` (U+0259/U+018F,\n * common in Azerbaijani names such as `\\u018fliyev`) fold to `e` under ICU and belong here on\n * the same reasoning, but each addition forces operators through another `search_tokens`\n * reindex, so extending the range is tracked separately rather than done piecemeal.\n */\nconst NON_DECOMPOSING_FOLDS: Record<string, string> = {\n '\\u0142': 'l',\n '\\u0141': 'L',\n '\\u00f8': 'o',\n '\\u00d8': 'O',\n '\\u0111': 'd',\n '\\u0110': 'D',\n '\\u00f0': 'd',\n '\\u00d0': 'D',\n '\\u0127': 'h',\n '\\u0126': 'H',\n '\\u0131': 'i',\n '\\u0138': 'k',\n '\\u014b': 'n',\n '\\u014a': 'N',\n '\\u0167': 't',\n '\\u0166': 'T',\n '\\u00e6': 'ae',\n '\\u00c6': 'AE',\n '\\u0153': 'oe',\n '\\u0152': 'OE',\n '\\u00fe': 'th',\n '\\u00de': 'TH',\n '\\u00df': 'ss',\n '\\u1e9e': 'SS',\n}\n\nconst NON_DECOMPOSING_PATTERN = new RegExp(\n `[${Object.keys(NON_DECOMPOSING_FOLDS)\n .join('')\n .replace(/[\\\\\\]^-]/g, '\\\\$&')}]`,\n 'g',\n)\n\nfunction foldNonDecomposingLetters(text: string): string {\n return text.replace(NON_DECOMPOSING_PATTERN, (char) => NON_DECOMPOSING_FOLDS[char])\n}\n\nfunction normalizeText(text: string): string {\n return foldNonDecomposingLetters(text.normalize('NFKD').replace(/[\\u0300-\\u036f]/g, ''))\n .replace(/[%_]/g, ' ')\n .toLowerCase()\n}\n\nfunction splitTokens(text: string, minLength: number): string[] {\n return normalizeText(text)\n .split(/[^a-z0-9]+/i)\n .filter((token) => token.length >= minLength)\n}\n\nfunction appendExpandedToken(\n token: string,\n config: SearchConfig,\n seen: Set<string>,\n tokens: string[],\n limit: number,\n): void {\n const append = (candidate: string): boolean => {\n if (seen.has(candidate)) return tokens.length < limit\n seen.add(candidate)\n tokens.push(candidate)\n return tokens.length < limit\n }\n\n if (!config.enablePartials) {\n append(token)\n return\n }\n\n for (let length = config.minTokenLength; length <= token.length; length += 1) {\n if (!append(token.slice(0, length))) return\n }\n}\n\nexport function hashToken(token: string, config?: SearchConfig): string {\n const cfg = config ?? resolveSearchConfig()\n return crypto.createHash(cfg.hashAlgorithm).update(token).digest('hex')\n}\n\nexport function tokenizeText(text: string, config?: SearchConfig): TokenizationResult {\n const cfg = config ?? resolveSearchConfig()\n const limits = resolveSearchTokenLimits(cfg)\n const boundedText = limits.maxFieldChars > 0 ? text.slice(0, limits.maxFieldChars) : text\n const tokenLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const seen = new Set<string>()\n const tokens: string[] = []\n\n for (const token of splitTokens(boundedText, cfg.minTokenLength)) {\n if (tokens.length >= tokenLimit) break\n appendExpandedToken(token, cfg, seen, tokens, tokenLimit)\n }\n\n const hashes = tokens.map((token) => hashToken(token, cfg))\n return { tokens, hashes }\n}\n"],
5
+ "mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,qBAAqB,gCAAmD;AAmCjF,MAAM,wBAAgD;AAAA,EACpD,UAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAU;AAAA,EACV,QAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAU;AAAA,EACV,QAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAU;AAAA,EACV,QAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAU;AAAA,EACV,QAAU;AAAA,EACV,QAAU;AAAA,EACV,UAAU;AACZ;AAEA,MAAM,0BAA0B,IAAI;AAAA,EAClC,IAAI,OAAO,KAAK,qBAAqB,EAClC,KAAK,EAAE,EACP,QAAQ,aAAa,MAAM,CAAC;AAAA,EAC/B;AACF;AAEA,SAAS,0BAA0B,MAAsB;AACvD,SAAO,KAAK,QAAQ,yBAAyB,CAAC,SAAS,sBAAsB,IAAI,CAAC;AACpF;AAEA,SAAS,cAAc,MAAsB;AAC3C,SAAO,0BAA0B,KAAK,UAAU,MAAM,EAAE,QAAQ,oBAAoB,EAAE,CAAC,EACpF,QAAQ,SAAS,GAAG,EACpB,YAAY;AACjB;AAEA,SAAS,YAAY,MAAc,WAA6B;AAC9D,SAAO,cAAc,IAAI,EACtB,MAAM,aAAa,EACnB,OAAO,CAAC,UAAU,MAAM,UAAU,SAAS;AAChD;AAEA,SAAS,oBACP,OACA,QACA,MACA,QACA,OACM;AACN,QAAM,SAAS,CAAC,cAA+B;AAC7C,QAAI,KAAK,IAAI,SAAS,EAAG,QAAO,OAAO,SAAS;AAChD,SAAK,IAAI,SAAS;AAClB,WAAO,KAAK,SAAS;AACrB,WAAO,OAAO,SAAS;AAAA,EACzB;AAEA,MAAI,CAAC,OAAO,gBAAgB;AAC1B,WAAO,KAAK;AACZ;AAAA,EACF;AAEA,WAAS,SAAS,OAAO,gBAAgB,UAAU,MAAM,QAAQ,UAAU,GAAG;AAC5E,QAAI,CAAC,OAAO,MAAM,MAAM,GAAG,MAAM,CAAC,EAAG;AAAA,EACvC;AACF;AAEO,SAAS,UAAU,OAAe,QAA+B;AACtE,QAAM,MAAM,UAAU,oBAAoB;AAC1C,SAAO,OAAO,WAAW,IAAI,aAAa,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxE;AAEO,SAAS,aAAa,MAAc,QAA2C;AACpF,QAAM,MAAM,UAAU,oBAAoB;AAC1C,QAAM,SAAS,yBAAyB,GAAG;AAC3C,QAAM,cAAc,OAAO,gBAAgB,IAAI,KAAK,MAAM,GAAG,OAAO,aAAa,IAAI;AACrF,QAAM,aAAa,OAAO,oBAAoB,IAAI,OAAO,oBAAoB,OAAO;AACpF,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,SAAmB,CAAC;AAE1B,aAAW,SAAS,YAAY,aAAa,IAAI,cAAc,GAAG;AAChE,QAAI,OAAO,UAAU,WAAY;AACjC,wBAAoB,OAAO,KAAK,MAAM,QAAQ,UAAU;AAAA,EAC1D;AAEA,QAAM,SAAS,OAAO,IAAI,CAAC,UAAU,UAAU,OAAO,GAAG,CAAC;AAC1D,SAAO,EAAE,QAAQ,OAAO;AAC1B;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7112.1.d486e6f1b2";
1
+ const APP_VERSION = "0.7.1-develop.7120.1.a0154ac412";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7112.1.d486e6f1b2';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7120.1.a0154ac412';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.7.1-develop.7112.1.d486e6f1b2",
3
+ "version": "0.7.1-develop.7120.1.a0154ac412",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -109,7 +109,7 @@
109
109
  "@mikro-orm/core": "^7.1.8",
110
110
  "@mikro-orm/decorators": "^7.1.8",
111
111
  "@mikro-orm/postgresql": "^7.1.8",
112
- "@open-mercato/cache": "0.7.1-develop.7112.1.d486e6f1b2",
112
+ "@open-mercato/cache": "0.7.1-develop.7120.1.a0154ac412",
113
113
  "@types/html-to-text": "^9.0.4",
114
114
  "@types/sanitize-html": "^2.16.1",
115
115
  "dotenv": "^17.4.2",
@@ -10,6 +10,114 @@ const baseConfig: SearchConfig = {
10
10
  blocklistedFields: [],
11
11
  }
12
12
 
13
+ describe('tokenizeText diacritic folding', () => {
14
+ const wholeWordConfig: SearchConfig = { ...baseConfig, enablePartials: false }
15
+
16
+ test.each([
17
+ ['Łukasz', ['lukasz']],
18
+ ['lukasz', ['lukasz']],
19
+ ['Zażółć', ['zazolc']],
20
+ ['Łódź', ['lodz']],
21
+ ['Lodz', ['lodz']],
22
+ ])('folds non-decomposing Polish letters in %s', (input, expected) => {
23
+ const { tokens } = tokenizeText(input, wholeWordConfig)
24
+
25
+ expect(tokens).toEqual(expected)
26
+ })
27
+
28
+ test.each([
29
+ ['Bąk', ['bak']],
30
+ ['Wróbel', ['wrobel']],
31
+ ['Piotr Świątek', ['piotr', 'swiatek']],
32
+ ])('keeps folding NFKD-decomposable diacritics in %s', (input, expected) => {
33
+ const { tokens } = tokenizeText(input, wholeWordConfig)
34
+
35
+ expect(tokens).toEqual(expected)
36
+ })
37
+
38
+ test.each([
39
+ ['Jørgensen', ['jorgensen']],
40
+ ['Đurić', ['duric']],
41
+ ['Ħamrun', ['hamrun']],
42
+ ['Işık', ['isik']],
43
+ ['Æther', ['aether']],
44
+ ['Œuvre', ['oeuvre']],
45
+ ['Straße', ['strasse']],
46
+ ['Þórsdóttir', ['thorsdottir']],
47
+ ['Guðmundsdóttir', ['gudmundsdottir']],
48
+ ['Sæþór', ['saethor']],
49
+ ['Ŋoma', ['noma']],
50
+ ['Ŧorvald', ['torvald']],
51
+ ])('folds non-decomposing letters beyond Polish in %s', (input, expected) => {
52
+ const { tokens } = tokenizeText(input, wholeWordConfig)
53
+
54
+ expect(tokens).toEqual(expected)
55
+ })
56
+
57
+ test('folds Eth and D-with-stroke identically, since the two are visually indistinguishable', () => {
58
+ const dWithStroke = tokenizeText('Đurić', wholeWordConfig)
59
+ const eth = tokenizeText('Ðurić', wholeWordConfig)
60
+
61
+ expect(dWithStroke.tokens).toEqual(['duric'])
62
+ expect(eth.tokens).toEqual(['duric'])
63
+ expect(eth.hashes).toEqual(dWithStroke.hashes)
64
+ })
65
+
66
+ test.each([
67
+ ['Ǿrnulf', ['ornulf']],
68
+ ['Ǽlfric', ['aelfric']],
69
+ ['ǣrest', ['aerest']],
70
+ ['ℏbar', ['hbar']],
71
+ ])('folds %s, which NFKD decomposes into a non-decomposing letter', (input, expected) => {
72
+ const { tokens } = tokenizeText(input, wholeWordConfig)
73
+
74
+ expect(tokens).toEqual(expected)
75
+ })
76
+
77
+ test('produces identical hashes for the diacritic and ASCII spellings of a name', () => {
78
+ const indexed = tokenizeText('Łukasz Wałęsa', wholeWordConfig)
79
+ const queried = tokenizeText('lukasz walesa', wholeWordConfig)
80
+
81
+ expect(indexed.tokens).toEqual(queried.tokens)
82
+ expect(indexed.hashes).toEqual(queried.hashes)
83
+ })
84
+
85
+ test('expands prefixes from the folded token rather than the truncated one', () => {
86
+ const { tokens } = tokenizeText('Łódź', baseConfig)
87
+
88
+ expect(tokens).toEqual(['lod', 'lodz'])
89
+ })
90
+
91
+ // The property NON_DECOMPOSING_FOLDS actually exists to guarantee: no letter in the two
92
+ // blocks it draws from may split or truncate the word it sits in. Asserting the range
93
+ // directly is what catches a gap; enumerating characters by hand is what let nine of them
94
+ // through in the first place.
95
+ //
96
+ // The three exclusions are a different defect class, not missing table rows. NFKD *does*
97
+ // decompose them — into a base letter plus a non-combining separator (U+00B7 middle dot
98
+ // for the two L-with-middle-dot letters, U+02BC modifier apostrophe for U+0149) — which
99
+ // `splitTokens` then cuts the word at. A table entry for them would be dead code, because
100
+ // the fold runs after NFKD and the codepoint no longer exists by then. Fixing them means
101
+ // deciding whether that separator residue should survive tokenization at all, which also
102
+ // governs the far commoner ASCII spelling (`Paral·lel` normalizes identically to
103
+ // `Paraŀlel`), so it is tracked as its own change rather than smuggled in here.
104
+ const SEPARATOR_RESIDUE_LETTERS = ['U+013F Ŀ', 'U+0140 ŀ', 'U+0149 ʼn']
105
+
106
+ test('keeps every Latin-1 Supplement and Latin Extended-A letter inside a single token', () => {
107
+ const lost: string[] = []
108
+
109
+ for (let codePoint = 0xc0; codePoint <= 0x17f; codePoint += 1) {
110
+ const char = String.fromCodePoint(codePoint)
111
+ if (!/\p{L}/u.test(char)) continue
112
+ if (tokenizeText(`a${char}b`, wholeWordConfig).tokens.length !== 1) {
113
+ lost.push(`U+${codePoint.toString(16).toUpperCase().padStart(4, '0')} ${char}`)
114
+ }
115
+ }
116
+
117
+ expect(lost).toEqual(SEPARATOR_RESIDUE_LETTERS)
118
+ })
119
+ })
120
+
13
121
  describe('tokenizeText limits', () => {
14
122
  test('truncates oversized field text before tokenizing', () => {
15
123
  const config = { ...baseConfig, enablePartials: false, maxFieldChars: 10 }
@@ -6,10 +6,74 @@ export type TokenizationResult = {
6
6
  hashes: string[]
7
7
  }
8
8
 
9
+ /**
10
+ * Latin letters that NFKD leaves intact because they are atomic codepoints rather than a
11
+ * base letter plus a combining mark. Stripping combining marks therefore never folds them
12
+ * to ASCII, and `splitTokens` then consumes them as separators \u2014 truncating `\u0141ukasz` to
13
+ * `ukasz` and cutting `Za\u017c\u00f3\u0142\u0107` down to `zazo`. Because the same tokenizer runs at index
14
+ * time and at query time, such a record becomes unreachable from every spelling. Only
15
+ * characters with a single unambiguous ASCII fold belong here; anything language-dependent
16
+ * must stay out.
17
+ *
18
+ * `normalizeText` applies this fold AFTER NFKD and mark stripping, and the order is
19
+ * load-bearing: folding first would miss the characters that decompose *into* one of these
20
+ * letters, such as `\u01ff` (U+01FF \u2192 `\u00f8` + U+0301), `\u01fd` (U+01FD), `\u01e3` (U+01E3) and `\u210f` (U+210F).
21
+ * The letters below have no decomposition of their own, so NFKD passes them through
22
+ * untouched and they still fold correctly in this position.
23
+ *
24
+ * The table covers every letter in Latin-1 Supplement (U+00C0-U+00FF) and Latin Extended-A
25
+ * (U+0100-U+017F) that NFKD leaves un-folded; `tokenize.test.ts` pins that range so a gap
26
+ * cannot silently reopen. Two entries look redundant and are not: `\u0110` (U+0110, D with
27
+ * stroke) and `\u00d0` (U+00D0, Eth) are visually indistinguishable in uppercase and are
28
+ * routinely substituted for one another in Croatian, Serbian and Vietnamese text, so both
29
+ * must fold to `D` or the same rendered name yields two disjoint token sets. Do not delete
30
+ * either as a duplicate of the other.
31
+ *
32
+ * Letters outside those two blocks are deliberately out of scope \u2014 `\u0259`/`\u018f` (U+0259/U+018F,
33
+ * common in Azerbaijani names such as `\u018fliyev`) fold to `e` under ICU and belong here on
34
+ * the same reasoning, but each addition forces operators through another `search_tokens`
35
+ * reindex, so extending the range is tracked separately rather than done piecemeal.
36
+ */
37
+ const NON_DECOMPOSING_FOLDS: Record<string, string> = {
38
+ '\u0142': 'l',
39
+ '\u0141': 'L',
40
+ '\u00f8': 'o',
41
+ '\u00d8': 'O',
42
+ '\u0111': 'd',
43
+ '\u0110': 'D',
44
+ '\u00f0': 'd',
45
+ '\u00d0': 'D',
46
+ '\u0127': 'h',
47
+ '\u0126': 'H',
48
+ '\u0131': 'i',
49
+ '\u0138': 'k',
50
+ '\u014b': 'n',
51
+ '\u014a': 'N',
52
+ '\u0167': 't',
53
+ '\u0166': 'T',
54
+ '\u00e6': 'ae',
55
+ '\u00c6': 'AE',
56
+ '\u0153': 'oe',
57
+ '\u0152': 'OE',
58
+ '\u00fe': 'th',
59
+ '\u00de': 'TH',
60
+ '\u00df': 'ss',
61
+ '\u1e9e': 'SS',
62
+ }
63
+
64
+ const NON_DECOMPOSING_PATTERN = new RegExp(
65
+ `[${Object.keys(NON_DECOMPOSING_FOLDS)
66
+ .join('')
67
+ .replace(/[\\\]^-]/g, '\\$&')}]`,
68
+ 'g',
69
+ )
70
+
71
+ function foldNonDecomposingLetters(text: string): string {
72
+ return text.replace(NON_DECOMPOSING_PATTERN, (char) => NON_DECOMPOSING_FOLDS[char])
73
+ }
74
+
9
75
  function normalizeText(text: string): string {
10
- return text
11
- .normalize('NFKD')
12
- .replace(/[\u0300-\u036f]/g, '')
76
+ return foldNonDecomposingLetters(text.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''))
13
77
  .replace(/[%_]/g, ' ')
14
78
  .toLowerCase()
15
79
  }