@open-mercato/shared 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
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\
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,qBAAqB,gCAAmD;
|
|
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
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7121.1.734c263bda';\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.
|
|
3
|
+
"version": "0.7.1-develop.7121.1.734c263bda",
|
|
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.
|
|
112
|
+
"@open-mercato/cache": "0.7.1-develop.7121.1.734c263bda",
|
|
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
|
}
|