@whiteslove/parsing-lexicon 0.2.0 → 0.2.2
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.
- package/LICENSE +21 -21
- package/README.md +137 -137
- package/index.d.ts +374 -374
- package/package.json +77 -75
- package/src/central-asia-locations.js +211 -211
- package/src/central-asia.js +233 -233
- package/src/contact.d.ts +44 -44
- package/src/contact.js +159 -159
- package/src/countries.js +175 -175
- package/src/country-context.d.ts +10 -10
- package/src/country-context.js +22 -22
- package/src/currency.d.ts +14 -14
- package/src/currency.js +66 -66
- package/src/geo.js +169 -169
- package/src/geography-central-asia.js +42 -42
- package/src/geography-detection.d.ts +14 -14
- package/src/geography-detection.js +64 -64
- package/src/geography-display.d.ts +13 -13
- package/src/geography-display.js +113 -113
- package/src/geography.js +210 -210
- package/src/hiring-advanced.js +147 -147
- package/src/hiring-candidate-fields.d.ts +9 -9
- package/src/hiring-candidate-fields.js +92 -92
- package/src/hiring-context.d.ts +64 -64
- package/src/hiring-context.js +412 -412
- package/src/hiring-language-extensions.d.ts +19 -19
- package/src/hiring-language-extensions.js +139 -139
- package/src/hiring-languages.js +135 -135
- package/src/hiring-location-fields.d.ts +3 -3
- package/src/hiring-location-fields.js +53 -53
- package/src/hiring-professions-ro.js +191 -191
- package/src/hiring-professions.js +437 -437
- package/src/hiring-requirements.d.ts +16 -16
- package/src/hiring-requirements.js +170 -170
- package/src/hiring-salary-context.d.ts +27 -27
- package/src/hiring-salary-context.js +76 -76
- package/src/hiring-semantics.d.ts +29 -29
- package/src/hiring-semantics.js +218 -218
- package/src/hiring-skills.d.ts +21 -21
- package/src/hiring-skills.js +315 -315
- package/src/hiring-source-aliases.d.ts +32 -32
- package/src/hiring-source-aliases.js +185 -185
- package/src/hiring-source-semantics.d.ts +31 -31
- package/src/hiring-source-semantics.js +293 -293
- package/src/hiring-temporal.d.ts +15 -15
- package/src/hiring-temporal.js +67 -67
- package/src/hiring-vacancy-fields.d.ts +5 -5
- package/src/hiring-vacancy-fields.js +23 -23
- package/src/hiring-work-semantics.d.ts +34 -34
- package/src/hiring-work-semantics.js +72 -72
- package/src/hiring.js +180 -180
- package/src/housing-address.d.ts +18 -18
- package/src/housing-address.js +217 -210
- package/src/housing-commercial.d.ts +1 -0
- package/src/housing-commercial.js +9 -0
- package/src/housing-context.d.ts +36 -36
- package/src/housing-context.js +183 -183
- package/src/housing-features.js +30 -30
- package/src/housing-intent.d.ts +9 -9
- package/src/housing-intent.js +132 -132
- package/src/housing-listing-fields.js +131 -131
- package/src/housing-money.d.ts +7 -7
- package/src/housing-money.js +102 -102
- package/src/housing-source-aliases.d.ts +8 -8
- package/src/housing-source-aliases.js +51 -51
- package/src/housing-structured.d.ts +55 -55
- package/src/housing-structured.js +219 -219
- package/src/housing.js +200 -200
- package/src/index.js +45 -44
- package/src/kz-location-extensions.js +307 -307
- package/src/landmarks.js +21 -21
- package/src/lexicon-core.js +58 -58
- package/src/location-merge.js +106 -106
- package/src/locations.js +456 -456
- package/src/money-core.d.ts +10 -10
- package/src/money-core.js +81 -81
- package/src/money-lexicon.d.ts +5 -5
- package/src/money-lexicon.js +79 -79
- package/src/money.js +154 -154
- package/src/normalization.js +384 -384
- package/src/odesa-metropolitan.js +144 -144
- package/src/romania-geography.js +47 -47
- package/src/tashkent-colloquial.js +80 -72
- package/src/tashkent-housing-geography.d.ts +38 -38
- package/src/tashkent-housing-geography.js +211 -211
- package/src/tashkent-pois.js +215 -215
- package/src/tashkent-residential-complexes.js +272 -272
- package/src/ua-location-extensions-major.js +118 -118
- package/src/ua-location-extensions-metro.js +9 -9
- package/src/ua-location-extensions-regional.js +246 -246
- package/src/ua-secondary-cities.d.ts +2 -2
- package/src/ua-secondary-cities.js +57 -57
- package/src/ukraine.js +75 -75
- package/src/uz-location-extensions.js +259 -259
package/src/normalization.js
CHANGED
|
@@ -1,384 +1,384 @@
|
|
|
1
|
-
import { LEXICON_LANGUAGES } from './lexicon-core.js';
|
|
2
|
-
|
|
3
|
-
const APOSTROPHES_RE = /[’‘ʻʼ`´]/g;
|
|
4
|
-
const DASHES_RE = /[‐‑‒–—―]/g;
|
|
5
|
-
|
|
6
|
-
/** Normalize Unicode and punctuation variants without destroying letters. */
|
|
7
|
-
export function normalizeUnicode(value) {
|
|
8
|
-
return String(value ?? '')
|
|
9
|
-
.normalize('NFKC')
|
|
10
|
-
.replace(APOSTROPHES_RE, "'")
|
|
11
|
-
.replace(DASHES_RE, '-');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Stable comparison form for parser dictionaries. */
|
|
15
|
-
export function normalizeForMatch(value) {
|
|
16
|
-
return normalizeUnicode(value)
|
|
17
|
-
.toLocaleLowerCase()
|
|
18
|
-
.replace(/ё/g, 'е')
|
|
19
|
-
.replace(/['-]+/g, ' ')
|
|
20
|
-
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
21
|
-
.replace(/\s+/g, ' ')
|
|
22
|
-
.trim();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Search-only apostrophe compaction for Uzbek/Karakalpak variants. */
|
|
26
|
-
function normalizeCompactApostropheForMatch(value) {
|
|
27
|
-
return normalizeUnicode(value)
|
|
28
|
-
.toLocaleLowerCase()
|
|
29
|
-
.replace(/ё/g, 'е')
|
|
30
|
-
.replace(/'+/g, '')
|
|
31
|
-
.replace(/-+/g, ' ')
|
|
32
|
-
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
33
|
-
.replace(/\s+/g, ' ')
|
|
34
|
-
.trim();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const CYRILLIC_SEARCH_MAP = Object.freeze({
|
|
38
|
-
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
|
|
39
|
-
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
|
|
40
|
-
с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',
|
|
41
|
-
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
|
42
|
-
ў: 'o', қ: 'q', ғ: 'g', ҳ: 'h',
|
|
43
|
-
ә: 'a', і: 'i', ң: 'ng', ө: 'o', ұ: 'u', ү: 'u', һ: 'h',
|
|
44
|
-
є: 'ye', ї: 'yi', ґ: 'g',
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const KAZAKH_SEARCH_EQUIVALENCE = Object.freeze({
|
|
48
|
-
ә: 'а', ғ: 'г', қ: 'к', ң: 'н', ө: 'о', ұ: 'у', ү: 'у', і: 'и', һ: 'х',
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
/** Search-oriented Cyrillic folding; canonical identity still comes from explicit aliases. */
|
|
52
|
-
export function foldCyrillicForSearch(value) {
|
|
53
|
-
return normalizeUnicode(value)
|
|
54
|
-
.toLocaleLowerCase()
|
|
55
|
-
.split('')
|
|
56
|
-
.map((char) => CYRILLIC_SEARCH_MAP[char] ?? char)
|
|
57
|
-
.join('');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function foldKazakhForSearch(value) {
|
|
61
|
-
return normalizeUnicode(value)
|
|
62
|
-
.toLocaleLowerCase()
|
|
63
|
-
.split('')
|
|
64
|
-
.map((char) => KAZAKH_SEARCH_EQUIVALENCE[char] ?? char)
|
|
65
|
-
.join('');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function normalizedAliasKeys(value, { transliteration = true } = {}) {
|
|
69
|
-
const forms = transliteration
|
|
70
|
-
? [value, foldCyrillicForSearch(value), foldKazakhForSearch(value)]
|
|
71
|
-
: [value];
|
|
72
|
-
return [...new Set(forms.flatMap((form) => [
|
|
73
|
-
normalizeForMatch(form),
|
|
74
|
-
normalizeCompactApostropheForMatch(form),
|
|
75
|
-
]).filter(Boolean))];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function aliasesOf(entry) {
|
|
79
|
-
const aliases = entry?.aliases;
|
|
80
|
-
if (Array.isArray(aliases)) return aliases;
|
|
81
|
-
if (!aliases || typeof aliases !== 'object') return [];
|
|
82
|
-
return Object.values(aliases).flatMap((values) => Array.isArray(values) ? values : []);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function valuesOf(entry) {
|
|
86
|
-
return [entry?.canonical, entry?.name, ...aliasesOf(entry)].filter(Boolean);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function createAliasIndex(entries, { transliteration = true } = {}) {
|
|
90
|
-
const index = new Map();
|
|
91
|
-
for (const entry of entries || []) {
|
|
92
|
-
for (const value of valuesOf(entry)) {
|
|
93
|
-
for (const key of normalizedAliasKeys(value, { transliteration })) {
|
|
94
|
-
if (!index.has(key)) index.set(key, entry);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return index;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function createAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
102
|
-
const index = new Map();
|
|
103
|
-
for (const entry of entries || []) {
|
|
104
|
-
for (const sourceAlias of valuesOf(entry)) {
|
|
105
|
-
const searchForms = transliteration
|
|
106
|
-
? [sourceAlias, foldCyrillicForSearch(sourceAlias), foldKazakhForSearch(sourceAlias)]
|
|
107
|
-
: [sourceAlias];
|
|
108
|
-
for (const searchAlias of searchForms) {
|
|
109
|
-
for (const key of normalizedAliasKeys(searchAlias, { transliteration: false })) {
|
|
110
|
-
if (!key) continue;
|
|
111
|
-
const owners = index.get(key) || [];
|
|
112
|
-
if (!owners.some((owner) => owner.entry === entry && owner.sourceAlias === sourceAlias)) {
|
|
113
|
-
owners.push(Object.freeze({ entry, sourceAlias, searchAlias }));
|
|
114
|
-
}
|
|
115
|
-
index.set(key, owners);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return index;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Build a fresh alias index. Prefer getAliasIndex() for repeated parser calls. */
|
|
124
|
-
export function buildAliasIndex(entries, options = {}) {
|
|
125
|
-
return createAliasIndex(entries, options);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const DEFAULT_ALIAS_INDEX_CACHE = new WeakMap();
|
|
129
|
-
const DIRECT_ALIAS_INDEX_CACHE = new WeakMap();
|
|
130
|
-
const DEFAULT_OWNERS_INDEX_CACHE = new WeakMap();
|
|
131
|
-
const DIRECT_OWNERS_INDEX_CACHE = new WeakMap();
|
|
132
|
-
const DEFAULT_MATCHER_CACHE = new WeakMap();
|
|
133
|
-
const DIRECT_MATCHER_CACHE = new WeakMap();
|
|
134
|
-
|
|
135
|
-
/** Cached alias index for stable/frozen lexicon arrays. */
|
|
136
|
-
export function getAliasIndex(entries, { transliteration = true } = {}) {
|
|
137
|
-
if (!entries || (typeof entries !== 'object' && typeof entries !== 'function')) {
|
|
138
|
-
return createAliasIndex(entries, { transliteration });
|
|
139
|
-
}
|
|
140
|
-
const cache = transliteration ? DEFAULT_ALIAS_INDEX_CACHE : DIRECT_ALIAS_INDEX_CACHE;
|
|
141
|
-
let index = cache.get(entries);
|
|
142
|
-
if (!index) {
|
|
143
|
-
index = createAliasIndex(entries, { transliteration });
|
|
144
|
-
cache.set(entries, index);
|
|
145
|
-
}
|
|
146
|
-
return index;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
150
|
-
if (!entries || (typeof entries !== 'object' && typeof entries !== 'function')) {
|
|
151
|
-
return createAliasOwnersIndex(entries, { transliteration });
|
|
152
|
-
}
|
|
153
|
-
const cache = transliteration ? DEFAULT_OWNERS_INDEX_CACHE : DIRECT_OWNERS_INDEX_CACHE;
|
|
154
|
-
let index = cache.get(entries);
|
|
155
|
-
if (!index) {
|
|
156
|
-
index = createAliasOwnersIndex(entries, { transliteration });
|
|
157
|
-
cache.set(entries, index);
|
|
158
|
-
}
|
|
159
|
-
return index;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function findCanonical(value, entries, { partial = false, transliteration = true } = {}) {
|
|
163
|
-
if (!value) return null;
|
|
164
|
-
const index = getAliasIndex(entries, { transliteration });
|
|
165
|
-
for (const key of normalizedAliasKeys(value, { transliteration })) {
|
|
166
|
-
const exact = index.get(key);
|
|
167
|
-
if (exact) return exact;
|
|
168
|
-
}
|
|
169
|
-
if (!partial) return null;
|
|
170
|
-
|
|
171
|
-
const textKeys = normalizedAliasKeys(value, { transliteration });
|
|
172
|
-
let best = null;
|
|
173
|
-
let bestLength = 0;
|
|
174
|
-
for (const [alias, entry] of index) {
|
|
175
|
-
if (alias.length <= bestLength) continue;
|
|
176
|
-
if (textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) {
|
|
177
|
-
best = entry;
|
|
178
|
-
bestLength = alias.length;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return best;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function escapeRegex(value) {
|
|
185
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function aliasPattern(value) {
|
|
189
|
-
return escapeRegex(normalizeUnicode(value).trim())
|
|
190
|
-
.replace(/[\s\-–—'’‘`ʻʼ]+/g, "[\\s\\-–—'’‘`ʻʼ]*");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function matcherFor(entries, { transliteration = true } = {}) {
|
|
194
|
-
const cache = transliteration ? DEFAULT_MATCHER_CACHE : DIRECT_MATCHER_CACHE;
|
|
195
|
-
if (entries && typeof entries === 'object' && cache.has(entries)) return cache.get(entries);
|
|
196
|
-
|
|
197
|
-
const owners = createAliasOwnersIndex(entries, { transliteration });
|
|
198
|
-
const searchAliases = [...new Set(
|
|
199
|
-
[...owners.values()].flatMap((values) => values.map((owner) => owner.searchAlias)),
|
|
200
|
-
)]
|
|
201
|
-
.filter(Boolean)
|
|
202
|
-
.sort((a, b) => b.length - a.length);
|
|
203
|
-
|
|
204
|
-
const result = searchAliases.length
|
|
205
|
-
? Object.freeze({
|
|
206
|
-
owners,
|
|
207
|
-
re: new RegExp(`(?<![\\p{L}\\p{N}_])(?:${searchAliases.map(aliasPattern).join('|')})(?![\\p{L}\\p{N}_])`, 'giu'),
|
|
208
|
-
})
|
|
209
|
-
: Object.freeze({ owners, re: null });
|
|
210
|
-
|
|
211
|
-
if (entries && typeof entries === 'object') cache.set(entries, result);
|
|
212
|
-
return result;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Return every canonical match with original-text offsets. Colliding aliases are
|
|
217
|
-
* deliberately returned as multiple matches instead of silently choosing one.
|
|
218
|
-
*/
|
|
219
|
-
export function findAllCanonical(value, entries, { transliteration = true } = {}) {
|
|
220
|
-
const text = normalizeUnicode(value ?? '');
|
|
221
|
-
if (!text || !entries?.length) return [];
|
|
222
|
-
const { owners, re } = matcherFor(entries, { transliteration });
|
|
223
|
-
if (!re) return [];
|
|
224
|
-
|
|
225
|
-
re.lastIndex = 0;
|
|
226
|
-
const matches = [];
|
|
227
|
-
const seen = new Set();
|
|
228
|
-
for (const match of text.matchAll(re)) {
|
|
229
|
-
const alias = match[0];
|
|
230
|
-
const start = match.index ?? 0;
|
|
231
|
-
const end = start + alias.length;
|
|
232
|
-
const keys = normalizedAliasKeys(alias, { transliteration });
|
|
233
|
-
const candidates = [];
|
|
234
|
-
for (const key of keys) {
|
|
235
|
-
for (const owner of owners.get(key) || []) candidates.push(owner);
|
|
236
|
-
}
|
|
237
|
-
for (const owner of candidates) {
|
|
238
|
-
const canonical = owner.entry?.canonical || owner.entry?.name || null;
|
|
239
|
-
const id = `${start}:${end}:${canonical}:${owner.sourceAlias}`;
|
|
240
|
-
if (seen.has(id)) continue;
|
|
241
|
-
seen.add(id);
|
|
242
|
-
matches.push(Object.freeze({
|
|
243
|
-
entry: owner.entry,
|
|
244
|
-
canonical,
|
|
245
|
-
alias,
|
|
246
|
-
sourceAlias: owner.sourceAlias,
|
|
247
|
-
normalizedAlias: normalizeForMatch(alias),
|
|
248
|
-
start,
|
|
249
|
-
end,
|
|
250
|
-
}));
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return matches.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Collect aliases mapping to more than one canonical entity. */
|
|
257
|
-
export function collectAliasCollisions(entries, { includeSearchFolds = false, allowed = [] } = {}) {
|
|
258
|
-
const allowedSet = new Set(allowed.map(normalizeForMatch));
|
|
259
|
-
const owners = new Map();
|
|
260
|
-
for (const entry of entries || []) {
|
|
261
|
-
const canonical = entry?.canonical || entry?.name;
|
|
262
|
-
if (!canonical) continue;
|
|
263
|
-
for (const value of [canonical, ...aliasesOf(entry)].filter(Boolean)) {
|
|
264
|
-
const keys = normalizedAliasKeys(value, { transliteration: includeSearchFolds });
|
|
265
|
-
for (const key of keys) {
|
|
266
|
-
if (!key || allowedSet.has(key)) continue;
|
|
267
|
-
const set = owners.get(key) || new Set();
|
|
268
|
-
set.add(canonical);
|
|
269
|
-
owners.set(key, set);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return [...owners.entries()]
|
|
274
|
-
.filter(([, canonicals]) => canonicals.size > 1)
|
|
275
|
-
.map(([alias, canonicals]) => Object.freeze({ alias, canonicals: Object.freeze([...canonicals]) }));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function validateAliasCollisions(entries, options = {}) {
|
|
279
|
-
const collisions = collectAliasCollisions(entries, options);
|
|
280
|
-
if (collisions.length) {
|
|
281
|
-
const preview = collisions.slice(0, 8).map((item) => `${item.alias} -> ${item.canonicals.join(', ')}`).join('; ');
|
|
282
|
-
throw new Error(`Lexicon alias collisions detected (${collisions.length}): ${preview}`);
|
|
283
|
-
}
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Validate structural lexicon invariants without changing source data.
|
|
289
|
-
* Intentional cross-entity collisions can be allow-listed by normalized alias.
|
|
290
|
-
*/
|
|
291
|
-
export function validateLexicon(entries, {
|
|
292
|
-
allowedCollisions = [],
|
|
293
|
-
allowedLanguageKeys = LEXICON_LANGUAGES,
|
|
294
|
-
allowDuplicateCanonicals = false,
|
|
295
|
-
} = {}) {
|
|
296
|
-
const errors = [];
|
|
297
|
-
const canonicalOwners = new Map();
|
|
298
|
-
const aliasOwners = new Map();
|
|
299
|
-
const allowedCollisionSet = new Set(allowedCollisions.map(normalizeForMatch));
|
|
300
|
-
const languageSet = new Set(allowedLanguageKeys);
|
|
301
|
-
|
|
302
|
-
for (const [entryIndex, entry] of (entries || []).entries()) {
|
|
303
|
-
const canonical = entry?.canonical || entry?.name;
|
|
304
|
-
if (!canonical || !String(canonical).trim()) {
|
|
305
|
-
errors.push(Object.freeze({ kind: 'missingCanonical', entryIndex }));
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const canonicalKey = normalizeForMatch(canonical);
|
|
310
|
-
const canonicalAliasOwners = aliasOwners.get(canonicalKey) || new Set();
|
|
311
|
-
canonicalAliasOwners.add(canonical);
|
|
312
|
-
aliasOwners.set(canonicalKey, canonicalAliasOwners);
|
|
313
|
-
if (!allowDuplicateCanonicals && canonicalOwners.has(canonicalKey)) {
|
|
314
|
-
errors.push(Object.freeze({ kind: 'duplicateCanonical', canonical, firstIndex: canonicalOwners.get(canonicalKey), entryIndex }));
|
|
315
|
-
} else if (!canonicalOwners.has(canonicalKey)) {
|
|
316
|
-
canonicalOwners.set(canonicalKey, entryIndex);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const aliasMap = entry?.aliases;
|
|
320
|
-
if (aliasMap && !Array.isArray(aliasMap) && typeof aliasMap === 'object') {
|
|
321
|
-
for (const key of Object.keys(aliasMap)) {
|
|
322
|
-
if (!languageSet.has(key)) errors.push(Object.freeze({ kind: 'unknownLanguageKey', canonical, key, entryIndex }));
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const aliasMapEntries = aliasMap && !Array.isArray(aliasMap) && typeof aliasMap === 'object'
|
|
327
|
-
? Object.entries(aliasMap).flatMap(([language, values]) => (Array.isArray(values) ? values : []).map((alias, aliasIndex) => ({ alias, aliasIndex, language })))
|
|
328
|
-
: (Array.isArray(aliasMap) ? aliasMap : []).map((alias, aliasIndex) => ({ alias, aliasIndex, language: null }));
|
|
329
|
-
const localExactAliases = new Map();
|
|
330
|
-
for (const { alias, aliasIndex, language } of aliasMapEntries) {
|
|
331
|
-
if (typeof alias !== 'string' || !alias.trim()) {
|
|
332
|
-
errors.push(Object.freeze({ kind: 'emptyAlias', canonical, aliasIndex, entryIndex, language }));
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
const normalized = normalizeForMatch(alias) || normalizeUnicode(alias).toLocaleLowerCase().trim();
|
|
336
|
-
if (!normalized) {
|
|
337
|
-
errors.push(Object.freeze({ kind: 'emptyNormalizedAlias', canonical, alias, aliasIndex, entryIndex, language }));
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
// Search normalization intentionally collapses spelling variants such as
|
|
341
|
-
// ё/е, apostrophe glyphs and hyphen/space forms. Those are useful aliases,
|
|
342
|
-
// not structural duplicates. A duplicate is the same source spelling
|
|
343
|
-
// modulo Unicode normalization, case and whitespace only.
|
|
344
|
-
const rawIdentity = String(alias).normalize('NFKC').toLocaleLowerCase().replace(/\s+/g, ' ').trim();
|
|
345
|
-
const duplicateKey = `${language || '*'}:${rawIdentity}`;
|
|
346
|
-
if (localExactAliases.has(duplicateKey)) {
|
|
347
|
-
errors.push(Object.freeze({ kind: 'duplicateAlias', canonical, alias, normalizedAlias: normalized, firstAliasIndex: localExactAliases.get(duplicateKey), aliasIndex, entryIndex, language }));
|
|
348
|
-
} else {
|
|
349
|
-
localExactAliases.set(duplicateKey, aliasIndex);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const owners = aliasOwners.get(normalized) || new Set();
|
|
353
|
-
owners.add(canonical);
|
|
354
|
-
aliasOwners.set(normalized, owners);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
for (const [alias, canonicals] of aliasOwners) {
|
|
359
|
-
if (canonicals.size > 1 && !allowedCollisionSet.has(alias)) {
|
|
360
|
-
errors.push(Object.freeze({ kind: 'crossCanonicalCollision', alias, canonicals: Object.freeze([...canonicals]) }));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
return Object.freeze({ ok: errors.length === 0, errors: Object.freeze(errors) });
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export function assertValidLexicon(entries, options = {}) {
|
|
368
|
-
const report = validateLexicon(entries, options);
|
|
369
|
-
if (!report.ok) {
|
|
370
|
-
const preview = report.errors.slice(0, 10).map((item) => JSON.stringify(item)).join('; ');
|
|
371
|
-
throw new Error(`Lexicon invariant validation failed (${report.errors.length}): ${preview}`);
|
|
372
|
-
}
|
|
373
|
-
return true;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
export function aliasesToRegex(values, flags = 'iu') {
|
|
377
|
-
const alternatives = [...new Set(values || [])]
|
|
378
|
-
.filter((value) => typeof value === 'string' && value.trim())
|
|
379
|
-
.map((value) => normalizeUnicode(value).trim())
|
|
380
|
-
.sort((a, b) => b.length - a.length)
|
|
381
|
-
.map(aliasPattern);
|
|
382
|
-
if (!alternatives.length) throw new TypeError('aliasesToRegex() requires at least one non-empty alias');
|
|
383
|
-
return new RegExp(`(?:^|[^\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?:$|[^\\p{L}\\p{N}_])`, flags);
|
|
384
|
-
}
|
|
1
|
+
import { LEXICON_LANGUAGES } from './lexicon-core.js';
|
|
2
|
+
|
|
3
|
+
const APOSTROPHES_RE = /[’‘ʻʼ`´]/g;
|
|
4
|
+
const DASHES_RE = /[‐‑‒–—―]/g;
|
|
5
|
+
|
|
6
|
+
/** Normalize Unicode and punctuation variants without destroying letters. */
|
|
7
|
+
export function normalizeUnicode(value) {
|
|
8
|
+
return String(value ?? '')
|
|
9
|
+
.normalize('NFKC')
|
|
10
|
+
.replace(APOSTROPHES_RE, "'")
|
|
11
|
+
.replace(DASHES_RE, '-');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Stable comparison form for parser dictionaries. */
|
|
15
|
+
export function normalizeForMatch(value) {
|
|
16
|
+
return normalizeUnicode(value)
|
|
17
|
+
.toLocaleLowerCase()
|
|
18
|
+
.replace(/ё/g, 'е')
|
|
19
|
+
.replace(/['-]+/g, ' ')
|
|
20
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Search-only apostrophe compaction for Uzbek/Karakalpak variants. */
|
|
26
|
+
function normalizeCompactApostropheForMatch(value) {
|
|
27
|
+
return normalizeUnicode(value)
|
|
28
|
+
.toLocaleLowerCase()
|
|
29
|
+
.replace(/ё/g, 'е')
|
|
30
|
+
.replace(/'+/g, '')
|
|
31
|
+
.replace(/-+/g, ' ')
|
|
32
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CYRILLIC_SEARCH_MAP = Object.freeze({
|
|
38
|
+
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
|
|
39
|
+
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
|
|
40
|
+
с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',
|
|
41
|
+
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
|
42
|
+
ў: 'o', қ: 'q', ғ: 'g', ҳ: 'h',
|
|
43
|
+
ә: 'a', і: 'i', ң: 'ng', ө: 'o', ұ: 'u', ү: 'u', һ: 'h',
|
|
44
|
+
є: 'ye', ї: 'yi', ґ: 'g',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const KAZAKH_SEARCH_EQUIVALENCE = Object.freeze({
|
|
48
|
+
ә: 'а', ғ: 'г', қ: 'к', ң: 'н', ө: 'о', ұ: 'у', ү: 'у', і: 'и', һ: 'х',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Search-oriented Cyrillic folding; canonical identity still comes from explicit aliases. */
|
|
52
|
+
export function foldCyrillicForSearch(value) {
|
|
53
|
+
return normalizeUnicode(value)
|
|
54
|
+
.toLocaleLowerCase()
|
|
55
|
+
.split('')
|
|
56
|
+
.map((char) => CYRILLIC_SEARCH_MAP[char] ?? char)
|
|
57
|
+
.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function foldKazakhForSearch(value) {
|
|
61
|
+
return normalizeUnicode(value)
|
|
62
|
+
.toLocaleLowerCase()
|
|
63
|
+
.split('')
|
|
64
|
+
.map((char) => KAZAKH_SEARCH_EQUIVALENCE[char] ?? char)
|
|
65
|
+
.join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizedAliasKeys(value, { transliteration = true } = {}) {
|
|
69
|
+
const forms = transliteration
|
|
70
|
+
? [value, foldCyrillicForSearch(value), foldKazakhForSearch(value)]
|
|
71
|
+
: [value];
|
|
72
|
+
return [...new Set(forms.flatMap((form) => [
|
|
73
|
+
normalizeForMatch(form),
|
|
74
|
+
normalizeCompactApostropheForMatch(form),
|
|
75
|
+
]).filter(Boolean))];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function aliasesOf(entry) {
|
|
79
|
+
const aliases = entry?.aliases;
|
|
80
|
+
if (Array.isArray(aliases)) return aliases;
|
|
81
|
+
if (!aliases || typeof aliases !== 'object') return [];
|
|
82
|
+
return Object.values(aliases).flatMap((values) => Array.isArray(values) ? values : []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function valuesOf(entry) {
|
|
86
|
+
return [entry?.canonical, entry?.name, ...aliasesOf(entry)].filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createAliasIndex(entries, { transliteration = true } = {}) {
|
|
90
|
+
const index = new Map();
|
|
91
|
+
for (const entry of entries || []) {
|
|
92
|
+
for (const value of valuesOf(entry)) {
|
|
93
|
+
for (const key of normalizedAliasKeys(value, { transliteration })) {
|
|
94
|
+
if (!index.has(key)) index.set(key, entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return index;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
102
|
+
const index = new Map();
|
|
103
|
+
for (const entry of entries || []) {
|
|
104
|
+
for (const sourceAlias of valuesOf(entry)) {
|
|
105
|
+
const searchForms = transliteration
|
|
106
|
+
? [sourceAlias, foldCyrillicForSearch(sourceAlias), foldKazakhForSearch(sourceAlias)]
|
|
107
|
+
: [sourceAlias];
|
|
108
|
+
for (const searchAlias of searchForms) {
|
|
109
|
+
for (const key of normalizedAliasKeys(searchAlias, { transliteration: false })) {
|
|
110
|
+
if (!key) continue;
|
|
111
|
+
const owners = index.get(key) || [];
|
|
112
|
+
if (!owners.some((owner) => owner.entry === entry && owner.sourceAlias === sourceAlias)) {
|
|
113
|
+
owners.push(Object.freeze({ entry, sourceAlias, searchAlias }));
|
|
114
|
+
}
|
|
115
|
+
index.set(key, owners);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return index;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build a fresh alias index. Prefer getAliasIndex() for repeated parser calls. */
|
|
124
|
+
export function buildAliasIndex(entries, options = {}) {
|
|
125
|
+
return createAliasIndex(entries, options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const DEFAULT_ALIAS_INDEX_CACHE = new WeakMap();
|
|
129
|
+
const DIRECT_ALIAS_INDEX_CACHE = new WeakMap();
|
|
130
|
+
const DEFAULT_OWNERS_INDEX_CACHE = new WeakMap();
|
|
131
|
+
const DIRECT_OWNERS_INDEX_CACHE = new WeakMap();
|
|
132
|
+
const DEFAULT_MATCHER_CACHE = new WeakMap();
|
|
133
|
+
const DIRECT_MATCHER_CACHE = new WeakMap();
|
|
134
|
+
|
|
135
|
+
/** Cached alias index for stable/frozen lexicon arrays. */
|
|
136
|
+
export function getAliasIndex(entries, { transliteration = true } = {}) {
|
|
137
|
+
if (!entries || (typeof entries !== 'object' && typeof entries !== 'function')) {
|
|
138
|
+
return createAliasIndex(entries, { transliteration });
|
|
139
|
+
}
|
|
140
|
+
const cache = transliteration ? DEFAULT_ALIAS_INDEX_CACHE : DIRECT_ALIAS_INDEX_CACHE;
|
|
141
|
+
let index = cache.get(entries);
|
|
142
|
+
if (!index) {
|
|
143
|
+
index = createAliasIndex(entries, { transliteration });
|
|
144
|
+
cache.set(entries, index);
|
|
145
|
+
}
|
|
146
|
+
return index;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
150
|
+
if (!entries || (typeof entries !== 'object' && typeof entries !== 'function')) {
|
|
151
|
+
return createAliasOwnersIndex(entries, { transliteration });
|
|
152
|
+
}
|
|
153
|
+
const cache = transliteration ? DEFAULT_OWNERS_INDEX_CACHE : DIRECT_OWNERS_INDEX_CACHE;
|
|
154
|
+
let index = cache.get(entries);
|
|
155
|
+
if (!index) {
|
|
156
|
+
index = createAliasOwnersIndex(entries, { transliteration });
|
|
157
|
+
cache.set(entries, index);
|
|
158
|
+
}
|
|
159
|
+
return index;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function findCanonical(value, entries, { partial = false, transliteration = true } = {}) {
|
|
163
|
+
if (!value) return null;
|
|
164
|
+
const index = getAliasIndex(entries, { transliteration });
|
|
165
|
+
for (const key of normalizedAliasKeys(value, { transliteration })) {
|
|
166
|
+
const exact = index.get(key);
|
|
167
|
+
if (exact) return exact;
|
|
168
|
+
}
|
|
169
|
+
if (!partial) return null;
|
|
170
|
+
|
|
171
|
+
const textKeys = normalizedAliasKeys(value, { transliteration });
|
|
172
|
+
let best = null;
|
|
173
|
+
let bestLength = 0;
|
|
174
|
+
for (const [alias, entry] of index) {
|
|
175
|
+
if (alias.length <= bestLength) continue;
|
|
176
|
+
if (textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) {
|
|
177
|
+
best = entry;
|
|
178
|
+
bestLength = alias.length;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return best;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function escapeRegex(value) {
|
|
185
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function aliasPattern(value) {
|
|
189
|
+
return escapeRegex(normalizeUnicode(value).trim())
|
|
190
|
+
.replace(/[\s\-–—'’‘`ʻʼ]+/g, "[\\s\\-–—'’‘`ʻʼ]*");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matcherFor(entries, { transliteration = true } = {}) {
|
|
194
|
+
const cache = transliteration ? DEFAULT_MATCHER_CACHE : DIRECT_MATCHER_CACHE;
|
|
195
|
+
if (entries && typeof entries === 'object' && cache.has(entries)) return cache.get(entries);
|
|
196
|
+
|
|
197
|
+
const owners = createAliasOwnersIndex(entries, { transliteration });
|
|
198
|
+
const searchAliases = [...new Set(
|
|
199
|
+
[...owners.values()].flatMap((values) => values.map((owner) => owner.searchAlias)),
|
|
200
|
+
)]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.sort((a, b) => b.length - a.length);
|
|
203
|
+
|
|
204
|
+
const result = searchAliases.length
|
|
205
|
+
? Object.freeze({
|
|
206
|
+
owners,
|
|
207
|
+
re: new RegExp(`(?<![\\p{L}\\p{N}_])(?:${searchAliases.map(aliasPattern).join('|')})(?![\\p{L}\\p{N}_])`, 'giu'),
|
|
208
|
+
})
|
|
209
|
+
: Object.freeze({ owners, re: null });
|
|
210
|
+
|
|
211
|
+
if (entries && typeof entries === 'object') cache.set(entries, result);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Return every canonical match with original-text offsets. Colliding aliases are
|
|
217
|
+
* deliberately returned as multiple matches instead of silently choosing one.
|
|
218
|
+
*/
|
|
219
|
+
export function findAllCanonical(value, entries, { transliteration = true } = {}) {
|
|
220
|
+
const text = normalizeUnicode(value ?? '');
|
|
221
|
+
if (!text || !entries?.length) return [];
|
|
222
|
+
const { owners, re } = matcherFor(entries, { transliteration });
|
|
223
|
+
if (!re) return [];
|
|
224
|
+
|
|
225
|
+
re.lastIndex = 0;
|
|
226
|
+
const matches = [];
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for (const match of text.matchAll(re)) {
|
|
229
|
+
const alias = match[0];
|
|
230
|
+
const start = match.index ?? 0;
|
|
231
|
+
const end = start + alias.length;
|
|
232
|
+
const keys = normalizedAliasKeys(alias, { transliteration });
|
|
233
|
+
const candidates = [];
|
|
234
|
+
for (const key of keys) {
|
|
235
|
+
for (const owner of owners.get(key) || []) candidates.push(owner);
|
|
236
|
+
}
|
|
237
|
+
for (const owner of candidates) {
|
|
238
|
+
const canonical = owner.entry?.canonical || owner.entry?.name || null;
|
|
239
|
+
const id = `${start}:${end}:${canonical}:${owner.sourceAlias}`;
|
|
240
|
+
if (seen.has(id)) continue;
|
|
241
|
+
seen.add(id);
|
|
242
|
+
matches.push(Object.freeze({
|
|
243
|
+
entry: owner.entry,
|
|
244
|
+
canonical,
|
|
245
|
+
alias,
|
|
246
|
+
sourceAlias: owner.sourceAlias,
|
|
247
|
+
normalizedAlias: normalizeForMatch(alias),
|
|
248
|
+
start,
|
|
249
|
+
end,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return matches.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Collect aliases mapping to more than one canonical entity. */
|
|
257
|
+
export function collectAliasCollisions(entries, { includeSearchFolds = false, allowed = [] } = {}) {
|
|
258
|
+
const allowedSet = new Set(allowed.map(normalizeForMatch));
|
|
259
|
+
const owners = new Map();
|
|
260
|
+
for (const entry of entries || []) {
|
|
261
|
+
const canonical = entry?.canonical || entry?.name;
|
|
262
|
+
if (!canonical) continue;
|
|
263
|
+
for (const value of [canonical, ...aliasesOf(entry)].filter(Boolean)) {
|
|
264
|
+
const keys = normalizedAliasKeys(value, { transliteration: includeSearchFolds });
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
if (!key || allowedSet.has(key)) continue;
|
|
267
|
+
const set = owners.get(key) || new Set();
|
|
268
|
+
set.add(canonical);
|
|
269
|
+
owners.set(key, set);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [...owners.entries()]
|
|
274
|
+
.filter(([, canonicals]) => canonicals.size > 1)
|
|
275
|
+
.map(([alias, canonicals]) => Object.freeze({ alias, canonicals: Object.freeze([...canonicals]) }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function validateAliasCollisions(entries, options = {}) {
|
|
279
|
+
const collisions = collectAliasCollisions(entries, options);
|
|
280
|
+
if (collisions.length) {
|
|
281
|
+
const preview = collisions.slice(0, 8).map((item) => `${item.alias} -> ${item.canonicals.join(', ')}`).join('; ');
|
|
282
|
+
throw new Error(`Lexicon alias collisions detected (${collisions.length}): ${preview}`);
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Validate structural lexicon invariants without changing source data.
|
|
289
|
+
* Intentional cross-entity collisions can be allow-listed by normalized alias.
|
|
290
|
+
*/
|
|
291
|
+
export function validateLexicon(entries, {
|
|
292
|
+
allowedCollisions = [],
|
|
293
|
+
allowedLanguageKeys = LEXICON_LANGUAGES,
|
|
294
|
+
allowDuplicateCanonicals = false,
|
|
295
|
+
} = {}) {
|
|
296
|
+
const errors = [];
|
|
297
|
+
const canonicalOwners = new Map();
|
|
298
|
+
const aliasOwners = new Map();
|
|
299
|
+
const allowedCollisionSet = new Set(allowedCollisions.map(normalizeForMatch));
|
|
300
|
+
const languageSet = new Set(allowedLanguageKeys);
|
|
301
|
+
|
|
302
|
+
for (const [entryIndex, entry] of (entries || []).entries()) {
|
|
303
|
+
const canonical = entry?.canonical || entry?.name;
|
|
304
|
+
if (!canonical || !String(canonical).trim()) {
|
|
305
|
+
errors.push(Object.freeze({ kind: 'missingCanonical', entryIndex }));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const canonicalKey = normalizeForMatch(canonical);
|
|
310
|
+
const canonicalAliasOwners = aliasOwners.get(canonicalKey) || new Set();
|
|
311
|
+
canonicalAliasOwners.add(canonical);
|
|
312
|
+
aliasOwners.set(canonicalKey, canonicalAliasOwners);
|
|
313
|
+
if (!allowDuplicateCanonicals && canonicalOwners.has(canonicalKey)) {
|
|
314
|
+
errors.push(Object.freeze({ kind: 'duplicateCanonical', canonical, firstIndex: canonicalOwners.get(canonicalKey), entryIndex }));
|
|
315
|
+
} else if (!canonicalOwners.has(canonicalKey)) {
|
|
316
|
+
canonicalOwners.set(canonicalKey, entryIndex);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const aliasMap = entry?.aliases;
|
|
320
|
+
if (aliasMap && !Array.isArray(aliasMap) && typeof aliasMap === 'object') {
|
|
321
|
+
for (const key of Object.keys(aliasMap)) {
|
|
322
|
+
if (!languageSet.has(key)) errors.push(Object.freeze({ kind: 'unknownLanguageKey', canonical, key, entryIndex }));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const aliasMapEntries = aliasMap && !Array.isArray(aliasMap) && typeof aliasMap === 'object'
|
|
327
|
+
? Object.entries(aliasMap).flatMap(([language, values]) => (Array.isArray(values) ? values : []).map((alias, aliasIndex) => ({ alias, aliasIndex, language })))
|
|
328
|
+
: (Array.isArray(aliasMap) ? aliasMap : []).map((alias, aliasIndex) => ({ alias, aliasIndex, language: null }));
|
|
329
|
+
const localExactAliases = new Map();
|
|
330
|
+
for (const { alias, aliasIndex, language } of aliasMapEntries) {
|
|
331
|
+
if (typeof alias !== 'string' || !alias.trim()) {
|
|
332
|
+
errors.push(Object.freeze({ kind: 'emptyAlias', canonical, aliasIndex, entryIndex, language }));
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const normalized = normalizeForMatch(alias) || normalizeUnicode(alias).toLocaleLowerCase().trim();
|
|
336
|
+
if (!normalized) {
|
|
337
|
+
errors.push(Object.freeze({ kind: 'emptyNormalizedAlias', canonical, alias, aliasIndex, entryIndex, language }));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// Search normalization intentionally collapses spelling variants such as
|
|
341
|
+
// ё/е, apostrophe glyphs and hyphen/space forms. Those are useful aliases,
|
|
342
|
+
// not structural duplicates. A duplicate is the same source spelling
|
|
343
|
+
// modulo Unicode normalization, case and whitespace only.
|
|
344
|
+
const rawIdentity = String(alias).normalize('NFKC').toLocaleLowerCase().replace(/\s+/g, ' ').trim();
|
|
345
|
+
const duplicateKey = `${language || '*'}:${rawIdentity}`;
|
|
346
|
+
if (localExactAliases.has(duplicateKey)) {
|
|
347
|
+
errors.push(Object.freeze({ kind: 'duplicateAlias', canonical, alias, normalizedAlias: normalized, firstAliasIndex: localExactAliases.get(duplicateKey), aliasIndex, entryIndex, language }));
|
|
348
|
+
} else {
|
|
349
|
+
localExactAliases.set(duplicateKey, aliasIndex);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const owners = aliasOwners.get(normalized) || new Set();
|
|
353
|
+
owners.add(canonical);
|
|
354
|
+
aliasOwners.set(normalized, owners);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const [alias, canonicals] of aliasOwners) {
|
|
359
|
+
if (canonicals.size > 1 && !allowedCollisionSet.has(alias)) {
|
|
360
|
+
errors.push(Object.freeze({ kind: 'crossCanonicalCollision', alias, canonicals: Object.freeze([...canonicals]) }));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return Object.freeze({ ok: errors.length === 0, errors: Object.freeze(errors) });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function assertValidLexicon(entries, options = {}) {
|
|
368
|
+
const report = validateLexicon(entries, options);
|
|
369
|
+
if (!report.ok) {
|
|
370
|
+
const preview = report.errors.slice(0, 10).map((item) => JSON.stringify(item)).join('; ');
|
|
371
|
+
throw new Error(`Lexicon invariant validation failed (${report.errors.length}): ${preview}`);
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function aliasesToRegex(values, flags = 'iu') {
|
|
377
|
+
const alternatives = [...new Set(values || [])]
|
|
378
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
379
|
+
.map((value) => normalizeUnicode(value).trim())
|
|
380
|
+
.sort((a, b) => b.length - a.length)
|
|
381
|
+
.map(aliasPattern);
|
|
382
|
+
if (!alternatives.length) throw new TypeError('aliasesToRegex() requires at least one non-empty alias');
|
|
383
|
+
return new RegExp(`(?:^|[^\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?:$|[^\\p{L}\\p{N}_])`, flags);
|
|
384
|
+
}
|