@whiteslove/parsing-lexicon 0.2.0

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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/index.d.ts +375 -0
  4. package/package.json +75 -0
  5. package/src/central-asia-locations.js +211 -0
  6. package/src/central-asia.js +233 -0
  7. package/src/contact.d.ts +44 -0
  8. package/src/contact.js +159 -0
  9. package/src/countries.js +175 -0
  10. package/src/country-context.d.ts +10 -0
  11. package/src/country-context.js +22 -0
  12. package/src/currency.d.ts +14 -0
  13. package/src/currency.js +66 -0
  14. package/src/geo.js +169 -0
  15. package/src/geography-central-asia.js +42 -0
  16. package/src/geography-detection.d.ts +14 -0
  17. package/src/geography-detection.js +64 -0
  18. package/src/geography-display.d.ts +13 -0
  19. package/src/geography-display.js +113 -0
  20. package/src/geography.js +210 -0
  21. package/src/hiring-advanced.js +147 -0
  22. package/src/hiring-candidate-fields.d.ts +9 -0
  23. package/src/hiring-candidate-fields.js +92 -0
  24. package/src/hiring-context.d.ts +64 -0
  25. package/src/hiring-context.js +412 -0
  26. package/src/hiring-language-extensions.d.ts +19 -0
  27. package/src/hiring-language-extensions.js +139 -0
  28. package/src/hiring-languages.js +135 -0
  29. package/src/hiring-location-fields.d.ts +3 -0
  30. package/src/hiring-location-fields.js +53 -0
  31. package/src/hiring-professions-ro.js +191 -0
  32. package/src/hiring-professions.js +437 -0
  33. package/src/hiring-requirements.d.ts +16 -0
  34. package/src/hiring-requirements.js +170 -0
  35. package/src/hiring-salary-context.d.ts +27 -0
  36. package/src/hiring-salary-context.js +76 -0
  37. package/src/hiring-semantics.d.ts +29 -0
  38. package/src/hiring-semantics.js +218 -0
  39. package/src/hiring-skills.d.ts +21 -0
  40. package/src/hiring-skills.js +315 -0
  41. package/src/hiring-source-aliases.d.ts +32 -0
  42. package/src/hiring-source-aliases.js +186 -0
  43. package/src/hiring-source-semantics.d.ts +31 -0
  44. package/src/hiring-source-semantics.js +293 -0
  45. package/src/hiring-temporal.d.ts +15 -0
  46. package/src/hiring-temporal.js +67 -0
  47. package/src/hiring-vacancy-fields.d.ts +5 -0
  48. package/src/hiring-vacancy-fields.js +23 -0
  49. package/src/hiring-work-semantics.d.ts +34 -0
  50. package/src/hiring-work-semantics.js +72 -0
  51. package/src/hiring.js +180 -0
  52. package/src/housing-address.d.ts +18 -0
  53. package/src/housing-address.js +210 -0
  54. package/src/housing-context.d.ts +36 -0
  55. package/src/housing-context.js +183 -0
  56. package/src/housing-features.js +30 -0
  57. package/src/housing-intent.d.ts +9 -0
  58. package/src/housing-intent.js +132 -0
  59. package/src/housing-listing-fields.js +131 -0
  60. package/src/housing-money.d.ts +7 -0
  61. package/src/housing-money.js +102 -0
  62. package/src/housing-source-aliases.d.ts +8 -0
  63. package/src/housing-source-aliases.js +51 -0
  64. package/src/housing-structured.d.ts +55 -0
  65. package/src/housing-structured.js +219 -0
  66. package/src/housing.js +200 -0
  67. package/src/index.js +44 -0
  68. package/src/kz-location-extensions.js +307 -0
  69. package/src/landmarks.js +21 -0
  70. package/src/lexicon-core.js +58 -0
  71. package/src/location-merge.js +106 -0
  72. package/src/locations.js +456 -0
  73. package/src/money-core.d.ts +10 -0
  74. package/src/money-core.js +81 -0
  75. package/src/money-lexicon.d.ts +5 -0
  76. package/src/money-lexicon.js +79 -0
  77. package/src/money.js +154 -0
  78. package/src/normalization.js +384 -0
  79. package/src/odesa-metropolitan.js +145 -0
  80. package/src/romania-geography.js +47 -0
  81. package/src/tashkent-colloquial.js +72 -0
  82. package/src/tashkent-housing-geography.d.ts +38 -0
  83. package/src/tashkent-housing-geography.js +211 -0
  84. package/src/tashkent-pois.js +215 -0
  85. package/src/tashkent-residential-complexes.js +272 -0
  86. package/src/ua-location-extensions-major.js +118 -0
  87. package/src/ua-location-extensions-metro.js +9 -0
  88. package/src/ua-location-extensions-regional.js +246 -0
  89. package/src/ua-secondary-cities.d.ts +2 -0
  90. package/src/ua-secondary-cities.js +57 -0
  91. package/src/ukraine.js +75 -0
  92. package/src/uz-location-extensions.js +259 -0
@@ -0,0 +1,79 @@
1
+ import { lexiconEntity } from './lexicon-core.js';
2
+
3
+ const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
4
+
5
+ export const CURRENCY_TERMS = Object.freeze([
6
+ group('USD', { ru: ['$', 'usd', 'доллар', 'доллара', 'долларов', 'бакс', 'у.е.', 'у е'], en: ['$', 'usd', 'dollar', 'dollars', 'us dollar'], uk: ['$', 'usd', 'долар', 'долари', 'доларів'], ro: ['$', 'usd', 'dolar', 'dolari'], uzLatn: ['$', 'usd', 'dollar'], uzCyrl: ['$', 'доллар'], kk: ['$', 'usd', 'доллар'] }),
7
+ group('EUR', { ru: ['€', 'eur', 'евро'], en: ['€', 'eur', 'euro', 'euros'], uk: ['€', 'eur', 'євро'], ro: ['€', 'eur', 'euro'], uzLatn: ['€', 'eur', 'yevro'], uzCyrl: ['€', 'евро'], kk: ['€', 'eur', 'еуро'] }),
8
+ group('UZS', { ru: ['сум', 'сумов', 'узс'], en: ['uzs', 'uzbek som'], uk: ['uzs', 'сум'], ro: ['uzs'], uzLatn: ["so'm", 'so‘m', 'soʻm', 'sum', 'uzs'], uzCyrl: ['сўм', 'сум'], kk: ['uzs'] }),
9
+ group('KZT', { ru: ['тенге', 'тг', 'kzt'], en: ['kzt', 'tenge'], uk: ['тенге', 'kzt'], ro: ['kzt', 'tenge'], uzLatn: ['kzt', 'tenge'], uzCyrl: ['kzt', 'тенге'], kk: ['теңге', 'тенге', 'тг', 'kzt'] }),
10
+ group('UAH', { ru: ['грн', 'гривна', 'гривны', 'гривен', '₴', 'uah'], en: ['uah', 'hryvnia', 'hryvnias', '₴'], uk: ['грн', 'гривня', 'гривні', 'гривень', '₴', 'uah'], ro: ['uah', 'hrivne', 'grivne'], uzLatn: ['uah'], uzCyrl: ['uah'], kk: ['uah'] }),
11
+ group('RUB', { ru: ['₽', 'руб', 'руб.', 'рубль', 'рубля', 'рублей', 'rub', 'rur'], en: ['₽', 'rub', 'rur', 'ruble', 'rubles'], uk: ['₽', 'руб', 'рублів', 'rub'], ro: ['₽', 'rub', 'ruble'], uzLatn: ['₽', 'rub', 'rubl'], uzCyrl: ['₽', 'рубль'], kk: ['₽', 'rub', 'рубль'] }),
12
+ group('GBP', { ru: ['£', 'gbp', 'фунт', 'фунтов'], en: ['£', 'gbp', 'pound', 'pounds', 'sterling'], uk: ['£', 'gbp', 'фунт', 'фунтів'], ro: ['£', 'gbp', 'liră sterlină', 'lira sterlina'], uzLatn: ['£', 'gbp', 'funt'], uzCyrl: ['£', 'фунт'], kk: ['£', 'gbp', 'фунт'] }),
13
+ group('TRY', { ru: ['₺', 'try', 'лира', 'лир'], en: ['₺', 'try', 'turkish lira'], uk: ['₺', 'try', 'ліра', 'лір'], ro: ['₺', 'try', 'liră turcească', 'lira turceasca'], uzLatn: ['₺', 'try', 'lira'], uzCyrl: ['₺', 'лира'], kk: ['₺', 'try', 'лира'] }),
14
+ group('GEL', { ru: ['₾', 'gel', 'лари'], en: ['₾', 'gel', 'lari'], uk: ['₾', 'gel', 'ларі'], ro: ['₾', 'gel', 'lari'], uzLatn: ['₾', 'gel', 'lari'], uzCyrl: ['₾', 'лари'], kk: ['₾', 'gel', 'лари'] }),
15
+ group('AED', { ru: ['aed', 'дирхам', 'дирхамов'], en: ['aed', 'dirham', 'dirhams'], uk: ['aed', 'дирхам', 'дирхамів'], ro: ['aed', 'dirham'], uzLatn: ['aed', 'dirham'], uzCyrl: ['aed', 'дирҳам'], kk: ['aed', 'дирхам'] }),
16
+ group('PLN', { ru: ['pln', 'злотый', 'злотых'], en: ['pln', 'zloty', 'zł'], uk: ['pln', 'злотий', 'злотих'], ro: ['pln', 'zlot'], uzLatn: ['pln', 'zloty'], uzCyrl: ['pln'], kk: ['pln'] }),
17
+ group('RON', { ru: ['ron', 'лей', 'леев'], en: ['ron', 'romanian leu', 'lei'], uk: ['ron', 'лей', 'леїв'], ro: ['ron', 'leu', 'lei'], uzLatn: ['ron'], uzCyrl: ['ron'], kk: ['ron'] }),
18
+ group('KGS', { ru: ['kgs', 'сом', 'сомов'], en: ['kgs', 'kyrgyz som'], uk: ['kgs', 'сом'], ro: ['kgs'], uzLatn: ['kgs', 'som'], uzCyrl: ['kgs', 'сом'], kk: ['kgs', 'сом'] }),
19
+ group('CHF', { ru: ['chf', 'швейцарский франк', 'швейцарских франков'], en: ['chf', 'swiss franc', 'swiss francs'], uk: ['chf', 'швейцарський франк'], ro: ['chf', 'franc elvețian', 'franc elvetian'], uzLatn: ['chf', 'shveytsariya franki'], uzCyrl: ['chf'], kk: ['chf', 'швейцар франкі'] }),
20
+ group('CNY', { ru: ['cny', 'rmb', 'юань', 'юаней', '元'], en: ['cny', 'rmb', 'yuan', 'renminbi', '元'], uk: ['cny', 'юань', 'юанів'], ro: ['cny', 'yuan'], uzLatn: ['cny', 'yuan'], uzCyrl: ['cny', 'юань'], kk: ['cny', 'юань'] }),
21
+ group('JPY', { ru: ['jpy', 'иена', 'иены', 'иен', '円'], en: ['jpy', 'yen', 'japanese yen', '円'], uk: ['jpy', 'єна', 'єн'], ro: ['jpy', 'yen'], uzLatn: ['jpy', 'yen'], uzCyrl: ['jpy', 'иена'], kk: ['jpy', 'иена'] }),
22
+ group('KRW', { ru: ['₩', 'krw', 'вона', 'вон'], en: ['₩', 'krw', 'won', 'south korean won'], uk: ['₩', 'krw', 'вона'], ro: ['₩', 'krw', 'won'], uzLatn: ['₩', 'krw', 'won'], uzCyrl: ['₩', 'krw'], kk: ['₩', 'krw', 'вон'] }),
23
+ group('CAD', { ru: ['cad', 'канадский доллар', 'канадских долларов'], en: ['cad', 'canadian dollar', 'canadian dollars'], uk: ['cad', 'канадський долар'], ro: ['cad', 'dolar canadian'], uzLatn: ['cad', 'kanada dollari'], uzCyrl: ['cad'], kk: ['cad', 'канада доллары'] }),
24
+ group('AUD', { ru: ['aud', 'австралийский доллар', 'австралийских долларов'], en: ['aud', 'australian dollar', 'australian dollars'], uk: ['aud', 'австралійський долар'], ro: ['aud', 'dolar australian'], uzLatn: ['aud', 'avstraliya dollari'], uzCyrl: ['aud'], kk: ['aud', 'австралия доллары'] }),
25
+ group('NZD', { ru: ['nzd', 'новозеландский доллар'], en: ['nzd', 'new zealand dollar', 'new zealand dollars'], uk: ['nzd', 'новозеландський долар'], ro: ['nzd', 'dolar neozeelandez'], uzLatn: ['nzd'], uzCyrl: ['nzd'], kk: ['nzd'] }),
26
+ group('SGD', { ru: ['sgd', 'сингапурский доллар'], en: ['sgd', 'singapore dollar', 'singapore dollars'], uk: ['sgd', 'сінгапурський долар'], ro: ['sgd', 'dolar singaporez'], uzLatn: ['sgd'], uzCyrl: ['sgd'], kk: ['sgd'] }),
27
+ group('HKD', { ru: ['hkd', 'гонконгский доллар'], en: ['hkd', 'hong kong dollar', 'hong kong dollars'], uk: ['hkd', 'гонконзький долар'], ro: ['hkd', 'dolar hong kong'], uzLatn: ['hkd'], uzCyrl: ['hkd'], kk: ['hkd'] }),
28
+ group('INR', { ru: ['₹', 'inr', 'рупия', 'рупии', 'рупий'], en: ['₹', 'inr', 'indian rupee', 'indian rupees', 'rupee', 'rupees'], uk: ['₹', 'inr', 'рупія', 'рупій'], ro: ['₹', 'inr', 'rupie indiană', 'rupie indiana'], uzLatn: ['₹', 'inr', 'rupiya'], uzCyrl: ['₹', 'inr'], kk: ['₹', 'inr', 'рупия'] }),
29
+ group('CZK', { ru: ['czk', 'чешская крона', 'чешских крон'], en: ['czk', 'czech koruna', 'czech crown'], uk: ['czk', 'чеська крона'], ro: ['czk', 'coroană cehă', 'coroana ceha'], uzLatn: ['czk'], uzCyrl: ['czk'], kk: ['czk'] }),
30
+ group('HUF', { ru: ['huf', 'форинт', 'форинтов'], en: ['huf', 'forint', 'hungarian forint'], uk: ['huf', 'форинт'], ro: ['huf', 'forint'], uzLatn: ['huf'], uzCyrl: ['huf'], kk: ['huf'] }),
31
+ group('BGN', { ru: ['bgn', 'лев', 'лева', 'левов'], en: ['bgn', 'bulgarian lev', 'lev'], uk: ['bgn', 'болгарський лев'], ro: ['bgn', 'leva bulgărească', 'leva bulgareasca'], uzLatn: ['bgn'], uzCyrl: ['bgn'], kk: ['bgn'] }),
32
+ group('MDL', { ru: ['mdl', 'молдавский лей', 'молдавских леев'], en: ['mdl', 'moldovan leu', 'moldovan lei'], uk: ['mdl', 'молдовський лей'], ro: ['mdl', 'leu moldovenesc', 'lei moldovenești', 'lei moldovenesti'], uzLatn: ['mdl'], uzCyrl: ['mdl'], kk: ['mdl'] }),
33
+ group('AZN', { ru: ['₼', 'azn', 'манат', 'манатов'], en: ['₼', 'azn', 'azerbaijani manat', 'manat'], uk: ['₼', 'azn', 'манат'], ro: ['₼', 'azn', 'manat azer'], uzLatn: ['₼', 'azn', 'manat'], uzCyrl: ['₼', 'azn'], kk: ['₼', 'azn', 'манат'] }),
34
+ group('AMD', { ru: ['֏', 'amd', 'драм', 'драмов'], en: ['֏', 'amd', 'armenian dram', 'dram'], uk: ['֏', 'amd', 'драм'], ro: ['֏', 'amd', 'dram armean'], uzLatn: ['֏', 'amd'], uzCyrl: ['֏', 'amd'], kk: ['֏', 'amd', 'драм'] }),
35
+ group('TJS', { ru: ['tjs', 'сомони'], en: ['tjs', 'tajikistani somoni', 'somoni'], uk: ['tjs', 'сомоні'], ro: ['tjs', 'somoni'], uzLatn: ['tjs', 'somoni'], uzCyrl: ['tjs', 'сомони'], kk: ['tjs', 'сомони'] }),
36
+ group('TMT', { ru: ['tmt', 'туркменский манат'], en: ['tmt', 'turkmen manat'], uk: ['tmt', 'туркменський манат'], ro: ['tmt', 'manat turkmen'], uzLatn: ['tmt', 'turkman manati'], uzCyrl: ['tmt'], kk: ['tmt'] }),
37
+ ]);
38
+
39
+ export const CURRENCY_SYMBOL_CANDIDATES = Object.freeze({
40
+ '$': Object.freeze(['USD', 'CAD', 'AUD', 'NZD', 'SGD', 'HKD']),
41
+ '€': Object.freeze(['EUR']),
42
+ '₴': Object.freeze(['UAH']),
43
+ '₽': Object.freeze(['RUB']),
44
+ '£': Object.freeze(['GBP']),
45
+ '₺': Object.freeze(['TRY']),
46
+ '₾': Object.freeze(['GEL']),
47
+ '₩': Object.freeze(['KRW']),
48
+ '₹': Object.freeze(['INR']),
49
+ '₼': Object.freeze(['AZN']),
50
+ '֏': Object.freeze(['AMD']),
51
+ '¥': Object.freeze(['JPY', 'CNY']),
52
+ '¥': Object.freeze(['JPY', 'CNY']),
53
+ });
54
+
55
+ export const SALARY_PERIODS = Object.freeze([
56
+ group('hour', { ru: ['в час', 'за час', 'почасово', 'часовая ставка'], en: ['per hour', 'hourly', '/hour', '/hr'], uk: ['за годину', 'на годину', 'погодинно'], ro: ['pe oră', 'pe ora', 'orar'], uzLatn: ['soatiga', 'soatlik'], uzCyrl: ['соатига', 'соатлик'], kk: ['сағатына', 'сағаттық'] }),
57
+ group('day', { ru: ['в день', 'за день', 'дневная ставка'], en: ['per day', 'daily', '/day'], uk: ['за день', 'на день', 'щодня'], ro: ['pe zi', 'zilnic'], uzLatn: ['kuniga', 'kunlik'], uzCyrl: ['кунига', 'кунлик'], kk: ['күніне', 'күндік'] }),
58
+ group('shift', { ru: ['за смену', 'смена', 'за выход'], en: ['per shift', 'shift rate'], uk: ['за зміну', 'зміна'], ro: ['pe tură', 'pe tura'], uzLatn: ['smenaga', 'smena uchun'], uzCyrl: ['сменага', 'смена учун'], kk: ['ауысымға', 'ауысым үшін'] }),
59
+ group('week', { ru: ['в неделю', 'за неделю', 'еженедельно'], en: ['per week', 'weekly', '/week'], uk: ['на тиждень', 'за тиждень', 'щотижня'], ro: ['pe săptămână', 'pe saptamana', 'săptămânal'], uzLatn: ['haftasiga', 'haftalik'], uzCyrl: ['ҳафтасига', 'ҳафталик'], kk: ['аптасына', 'апталық'] }),
60
+ group('month', { ru: ['в месяц', 'за месяц', 'месячная', 'ежемесячно', 'в мес'], en: ['per month', 'monthly', '/month', '/mo', 'pcm'], uk: ['на місяць', 'за місяць', 'щомісяця'], ro: ['pe lună', 'pe luna', 'lunar'], uzLatn: ['oyiga', 'oylik'], uzCyrl: ['ойига', 'ойлик'], kk: ['айына', 'айлық'] }),
61
+ group('year', { ru: ['в год', 'за год', 'годовая', 'годовых'], en: ['per year', 'yearly', 'annual', 'annually', '/year', 'p.a.'], uk: ['на рік', 'за рік', 'річна'], ro: ['pe an', 'anual'], uzLatn: ['yiliga', 'yillik'], uzCyrl: ['йилига', 'йиллик'], kk: ['жылына', 'жылдық'] }),
62
+ group('project', { ru: ['за проект', 'за заказ'], en: ['per project', 'project fee'], uk: ['за проєкт', 'за замовлення'], ro: ['pe proiect'], uzLatn: ['loyiha uchun', 'buyurtma uchun'], uzCyrl: ['лойиҳа учун', 'буюртма учун'], kk: ['жобаға', 'тапсырысқа'] }),
63
+ group('piece', { ru: ['сдельно', 'за единицу', 'за штуку'], en: ['piece rate', 'per piece', 'per item'], uk: ['відрядно', 'за одиницю'], ro: ['la bucată', 'la bucata'], uzLatn: ['dona uchun', 'ishbay'], uzCyrl: ['дона учун', 'ишбай'], kk: ['данасына', 'кесімді'] }),
64
+ ]);
65
+
66
+ export const SALARY_MODIFIERS = Object.freeze({
67
+ from: group('from', { ru: ['от', 'начиная с'], en: ['from', 'starting at', 'at least'], uk: ['від', 'починаючи від'], ro: ['de la', 'începând de la', 'incepand de la'], uzLatn: ['dan', 'kamida'], uzCyrl: ['дан', 'камида'], kk: ['бастап', 'кемінде'] }),
68
+ to: group('to', { ru: ['до', 'максимум'], en: ['up to', 'maximum', 'max'], uk: ['до', 'максимум'], ro: ['până la', 'pana la', 'maximum'], uzLatn: ['gacha', 'maksimum'], uzCyrl: ['гача', 'максимум'], kk: ['дейін', 'максимум'] }),
69
+ approx: group('approx', { ru: ['около', 'примерно', 'порядка'], en: ['about', 'approx', 'approximately', 'around'], uk: ['близько', 'приблизно'], ro: ['aproximativ', 'circa'], uzLatn: ['taxminan', 'atrofida'], uzCyrl: ['тахминан', 'атрофида'], kk: ['шамамен', 'шамасы'] }),
70
+ negotiable: group('negotiable', { ru: ['по договоренности', 'по договорённости', 'договорная', 'обсуждается'], en: ['negotiable', 'competitive salary', 'salary discussed', 'doe'], uk: ['за домовленістю', 'договірна'], ro: ['negociabil', 'salariu negociabil'], uzLatn: ['kelishiladi', 'kelishilgan holda'], uzCyrl: ['келишилади'], kk: ['келісім бойынша', 'келісіледі'] }),
71
+ gross: group('gross', { ru: ['gross', 'гросс', 'до налогов', 'до вычета налогов', 'до вычета ндфл'], en: ['gross', 'before tax', 'pre-tax'], uk: ['gross', 'до податків', 'до вирахування податків'], ro: ['brut', 'înainte de taxe', 'inainte de taxe'], uzLatn: ['soliqdan oldin', 'gross'], uzCyrl: ['солиқдан олдин'], kk: ['салыққа дейін', 'gross'] }),
72
+ net: group('net', { ru: ['net', 'нетто', 'на руки', 'чистыми', 'после налогов', 'после вычета налогов'], en: ['net', 'after tax', 'take home', 'take-home'], uk: ['net', 'нетто', 'на руки', 'після податків'], ro: ['net', 'după taxe', 'dupa taxe'], uzLatn: ['qo‘lga', "qo'lga", 'soliqdan keyin', 'net'], uzCyrl: ['қўлга', 'солиқдан кейин'], kk: ['қолға', 'салықтан кейін', 'net'] }),
73
+ });
74
+
75
+ export const NUMBER_MULTIPLIERS = Object.freeze([
76
+ group('thousand', { ru: ['к', 'тыс', 'тыс.', 'тысяч', 'тысячи'], en: ['k', 'thousand'], uk: ['к', 'тис', 'тис.', 'тисяч'], ro: ['k', 'mii'], uzLatn: ['k', 'ming'], uzCyrl: ['минг'], kk: ['k', 'мың'] }, { multiplier: 1_000 }),
77
+ group('million', { ru: ['м', 'млн', 'млн.', 'миллион', 'миллионов'], en: ['m', 'mln', 'million', 'millions'], uk: ['м', 'млн', 'мільйон', 'мільйонів'], ro: ['m', 'mil', 'milioane'], uzLatn: ['m', 'mln', 'million'], uzCyrl: ['млн', 'миллион'], kk: ['м', 'млн', 'миллион'] }, { multiplier: 1_000_000 }),
78
+ group('billion', { ru: ['млрд', 'миллиард', 'миллиардов'], en: ['bn', 'billion'], uk: ['млрд', 'мільярд'], ro: ['mld', 'miliard'], uzLatn: ['mlrd', 'billion'], uzCyrl: ['млрд', 'миллиард'], kk: ['млрд', 'миллиард'] }, { multiplier: 1_000_000_000 }),
79
+ ]);
package/src/money.js ADDED
@@ -0,0 +1,154 @@
1
+ import { findPhoneLikeSpans } from './contact.js';
2
+ import { aliasesOf, findCanonical, normalizeUnicode } from './normalization.js';
3
+ import {
4
+ CURRENCY_TERMS,
5
+ NUMBER_MULTIPLIERS,
6
+ SALARY_MODIFIERS,
7
+ SALARY_PERIODS,
8
+ } from './money-lexicon.js';
9
+ import {
10
+ MONEY_RANGE_RE,
11
+ MONEY_SINGLE_RE,
12
+ moneyCurrencyFromText,
13
+ parseScaledAmount,
14
+ } from './money-core.js';
15
+
16
+ export { CURRENCY_TERMS, NUMBER_MULTIPLIERS, SALARY_MODIFIERS, SALARY_PERIODS } from './money-lexicon.js';
17
+ export {
18
+ MONEY_NUMBER_PATTERN,
19
+ MONEY_RANGE_RE,
20
+ MONEY_SCALE_PATTERN,
21
+ MONEY_SINGLE_RE,
22
+ moneyCurrencyFromText,
23
+ moneyCurrencyPattern,
24
+ moneyScaleMultiplier,
25
+ parseNumericAmount,
26
+ parseScaledAmount,
27
+ } from './money-core.js';
28
+
29
+ const CONTACT_MARKER_RE = /(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
30
+
31
+ function hasSalaryContext(text) {
32
+ return /(?:salary|зарплат|з\s*п\b|оплат|ставк|доход|оклад|компенсац|maosh|oylik|ish\s+haqi|жалақы|айлық|еңбекақы|salariu|оплата)/iu.test(text);
33
+ }
34
+
35
+ function periodFromText(text) {
36
+ return findCanonical(text, SALARY_PERIODS, { partial: true })?.canonical || null;
37
+ }
38
+
39
+ function hasModifier(text, key) {
40
+ return Boolean(findCanonical(text, [SALARY_MODIFIERS[key]], { partial: true }));
41
+ }
42
+
43
+ function protectedPhoneSpans(text) {
44
+ return findPhoneLikeSpans(text).filter((span) => {
45
+ const prefix = text.slice(Math.max(0, span.start - 32), span.start);
46
+ return CONTACT_MARKER_RE.test(prefix);
47
+ });
48
+ }
49
+
50
+ function overlapsProtectedPhone(start, end, spans) {
51
+ return spans.some((span) => start < span.end && end > span.start);
52
+ }
53
+
54
+ function moneyContextScore(text, start, end, scaled = false) {
55
+ const window = text.slice(Math.max(0, start - 36), Math.min(text.length, end + 40));
56
+ let score = 0;
57
+ if (moneyCurrencyFromText(window)) score += 5;
58
+ if (hasSalaryContext(window)) score += 4;
59
+ if (periodFromText(window)) score += 2;
60
+ if (scaled) score += 1;
61
+ return score;
62
+ }
63
+
64
+ function bestRange(text, protectedSpans) {
65
+ const ranges = new RegExp(MONEY_RANGE_RE.source, 'giu');
66
+ const candidates = [];
67
+ for (const match of text.matchAll(ranges)) {
68
+ const start = match.index ?? 0;
69
+ const end = start + match[0].length;
70
+ if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
71
+ const scaled = Boolean(match[2] || match[4]);
72
+ const score = moneyContextScore(text, start, end, scaled);
73
+ if (score <= 0) continue;
74
+ candidates.push({ match, score, start });
75
+ }
76
+ candidates.sort((a, b) => b.score - a.score || a.start - b.start);
77
+ return candidates[0]?.match || null;
78
+ }
79
+
80
+ export function parseSalary(value) {
81
+ const text = normalizeUnicode(value ?? '');
82
+ if (!text) return null;
83
+
84
+ const currency = moneyCurrencyFromText(text);
85
+ const negotiable = hasModifier(text, 'negotiable');
86
+ const gross = hasModifier(text, 'gross') ? true : hasModifier(text, 'net') ? false : null;
87
+ const period = periodFromText(text);
88
+
89
+ const salaryContext = hasSalaryContext(text)
90
+ || Boolean(currency)
91
+ || NUMBER_MULTIPLIERS.some((entry) => findCanonical(text, [entry], { partial: true }));
92
+ if (!salaryContext && !negotiable) return null;
93
+
94
+ const protectedSpans = protectedPhoneSpans(text);
95
+ const range = bestRange(text, protectedSpans);
96
+ let min = null;
97
+ let max = null;
98
+ const approximate = hasModifier(text, 'approx');
99
+
100
+ if (range) {
101
+ const firstScale = range[2] || range[4] || null;
102
+ const secondScale = range[4] || range[2] || null;
103
+ min = parseScaledAmount(range[1], firstScale);
104
+ max = parseScaledAmount(range[3], secondScale);
105
+ if (min != null && max != null && min > max) [min, max] = [max, min];
106
+ } else {
107
+ MONEY_SINGLE_RE.lastIndex = 0;
108
+ const candidates = [];
109
+ for (const match of text.matchAll(MONEY_SINGLE_RE)) {
110
+ const start = match.index ?? 0;
111
+ const end = start + match[0].length;
112
+ if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
113
+ const window = text.slice(Math.max(0, start - 24), Math.min(text.length, end + 32));
114
+ const scaled = Boolean(match[2]);
115
+ const score = moneyContextScore(text, start, end, scaled);
116
+ if (score <= 0) continue;
117
+ const parsed = parseScaledAmount(match[1], match[2]);
118
+ if (parsed == null) continue;
119
+ if (!scaled && !moneyCurrencyFromText(window) && parsed >= 1900 && parsed <= 2100 && !hasSalaryContext(window)) continue;
120
+ candidates.push({ value: parsed, score, start });
121
+ }
122
+ candidates.sort((a, b) => b.score - a.score || a.start - b.start);
123
+ if (candidates.length) {
124
+ const valueAmount = candidates[0].value;
125
+ if (hasModifier(text, 'to')) max = valueAmount;
126
+ else if (hasModifier(text, 'from') || /\+\s*(?:$|\D)/u.test(text)) min = valueAmount;
127
+ else min = max = valueAmount;
128
+ }
129
+ }
130
+
131
+ if (min == null && max == null && !negotiable) return null;
132
+ return Object.freeze({
133
+ min,
134
+ max,
135
+ currency,
136
+ period,
137
+ gross,
138
+ negotiable,
139
+ approximate,
140
+ });
141
+ }
142
+
143
+ export function salaryCurrency(value) {
144
+ return moneyCurrencyFromText(String(value || ''));
145
+ }
146
+
147
+ export function salaryPeriod(value) {
148
+ return periodFromText(String(value || ''));
149
+ }
150
+
151
+ export function currencyAliases(code) {
152
+ const entry = CURRENCY_TERMS.find((item) => item.canonical === code);
153
+ return entry ? [entry.canonical, ...aliasesOf(entry)] : [];
154
+ }
@@ -0,0 +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
+ }