@whiteslove/parsing-lexicon 0.9.8 → 0.9.10

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/README.md CHANGED
@@ -85,7 +85,44 @@ parseHiringContext(
85
85
  );
86
86
  ```
87
87
 
88
- Subpath exports are available for consumers that need narrower modules, including `./geo`, `./locations`, `./housing-context`, `./housing-structured`, `./hiring-context`, `./hiring-professions` and `./money`.
88
+ Subpath exports are available for consumers that need narrower modules, including `./geo`, `./locations`, `./housing-context`, `./housing-structured`, `./hiring-context`, `./hiring-professions`, `./housing-money`, `./money` and `./currency`.
89
+
90
+ ### Money & currency
91
+
92
+ Currency and magnitude vocabulary (symbols, ISO codes, spelled-out names, and
93
+ regional stand-ins like "у.е.") lives here once, in `money-lexicon.js`. A
94
+ consumer that needs to know whether some text mentions money at all — a fast
95
+ pre-filter before running the heavier parsers below, e.g. when scanning many
96
+ HTML card candidates scraped from a page — should use `moneyMentionPattern()`
97
+ rather than hand-copying a currency symbol list. A hand-copied list silently
98
+ drifts from this one; that exact bug (a missing "₸"/KZT symbol, and a missing
99
+ "у.е." token) is why this section exists.
100
+
101
+ ```js
102
+ import {
103
+ moneyCurrencyPattern, // just the currency alternation, e.g. for building a custom matcher
104
+ moneyMentionPattern, // currency OR a bare magnitude word ("15 млн") — boundary-guarded
105
+ moneyCurrencyFromText, // -> 'KZT' | 'USD' | ... | null
106
+ } from '@whiteslove/parsing-lexicon/currency';
107
+ import { parseHousingPrice } from '@whiteslove/parsing-lexicon/housing-money';
108
+ import { parseSalary } from '@whiteslove/parsing-lexicon/money';
109
+
110
+ const looksLikeMoney = new RegExp(moneyMentionPattern(), 'iu');
111
+ looksLikeMoney.test('300 000 ₸ в месяц'); // true
112
+ looksLikeMoney.test('2 до 3 месяцев'); // false — "м" inside "месяцев" does not count
113
+
114
+ moneyCurrencyFromText('300 000 ₸ в месяц'); // 'KZT'
115
+
116
+ parseHousingPrice('12 500 000 сум', 'UZS');
117
+ // -> { amount: 12500000, currency: 'UZS' }
118
+ ```
119
+
120
+ Do not write a source-local regex for "does this text contain a price" or
121
+ "what currency is this" — both already exist here and are kept in sync with
122
+ the full currency/magnitude vocabulary used by `parseHousingPrice`/
123
+ `parseSalary`. If a currency form you need is missing, add it to
124
+ `CURRENCY_TERMS` in `money-lexicon.js` (and `CURRENCY_SYMBOL_CANDIDATES` if
125
+ it's a standalone symbol) rather than working around the gap in the consumer.
89
126
 
90
127
  ## Data-quality rules
91
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -107,6 +107,41 @@ function indexFor(entries) {
107
107
  return index;
108
108
  }
109
109
 
110
+ /** Every GRAM-length window of `text`, precomputed once for reuse across lists. */
111
+ export function computeTextGrams(text) {
112
+ return textGrams(String(text || ''));
113
+ }
114
+
115
+ function candidateIndices(entries, value, grams) {
116
+ const { byGram, always } = indexFor(entries);
117
+ const candidates = new Set(always);
118
+ for (const gram of grams) {
119
+ const bucket = byGram.get(gram);
120
+ if (!bucket) continue;
121
+ for (const i of bucket) candidates.add(i);
122
+ }
123
+ return [...candidates].sort((a, b) => a - b);
124
+ }
125
+
126
+ /**
127
+ * Every entry in `entries` (original list order) the text could plausibly
128
+ * contain, per the gram index — a filter, not a verdict. Callers still run
129
+ * their own verification (regex, exact-token match, ...) on what comes back;
130
+ * see the module doc for why this is safe even for non-regex verifiers.
131
+ *
132
+ * `grams`, from `computeTextGrams()`, lets a caller scanning the same text
133
+ * against many lists compute the O(text length) gram pass once instead of
134
+ * once per list.
135
+ */
136
+ export function candidateEntries(entries, text, grams) {
137
+ if (!Array.isArray(entries) || !entries.length) return [];
138
+ const value = String(text || '');
139
+ if (!value) return [];
140
+
141
+ const indices = candidateIndices(entries, value, grams || textGrams(value));
142
+ return indices.map((i) => entries[i]);
143
+ }
144
+
110
145
  /**
111
146
  * First entry in `entries` whose alias regex matches `text`.
112
147
  *
@@ -124,16 +159,8 @@ export function matchFirstEntry(entries, text, accept) {
124
159
  const value = String(text || '');
125
160
  if (!value) return undefined;
126
161
 
127
- const { byGram, always } = indexFor(entries);
128
- const candidates = new Set(always);
129
- for (const gram of textGrams(value)) {
130
- const bucket = byGram.get(gram);
131
- if (!bucket) continue;
132
- for (const i of bucket) candidates.add(i);
133
- }
134
- if (!candidates.size) return undefined;
135
-
136
- for (const i of [...candidates].sort((a, b) => a - b)) {
162
+ const indices = candidateIndices(entries, value, textGrams(value));
163
+ for (const i of indices) {
137
164
  const entry = entries[i];
138
165
  // `re` carries no /g flag, so exec() is stateless and safe to reuse here.
139
166
  const match = entry?.re?.exec(value);
@@ -2,6 +2,7 @@ import { LOCATION_DICTIONARIES } from './locations-runtime.js';
2
2
  import { isMapDataEntry, LOCATION_LIST_KEYS } from './location-merge.js';
3
3
  import { CITIES_BY_COUNTRY, canonicalCity } from './geography.js';
4
4
  import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
5
+ import { candidateEntries, computeTextGrams } from './alias-prefilter.js';
5
6
  import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_SEARCH_CLUSTERS } from './kz-location-extensions.js';
6
7
  import { UZ_AMBIGUOUS_LOCAL_NAMES } from './uz-location-extensions.js';
7
8
 
@@ -237,12 +238,13 @@ function mapDataMatch(value, normalizedValue, item) {
237
238
  return null;
238
239
  }
239
240
 
240
- function findEntryMatches(text, cityName, data, { includeMapData = false } = {}) {
241
+ function findEntryMatches(text, cityName, data, { includeMapData = false, grams = null } = {}) {
241
242
  const value = String(text || '');
242
243
  const normalizedValue = normalizeForMatch(value);
244
+ const textGrams = grams || computeTextGrams(value);
243
245
  const raw = [];
244
246
  for (const key of LOCATION_LIST_KEYS) {
245
- for (const item of data?.[key] || []) {
247
+ for (const item of candidateEntries(data?.[key] || [], value, textGrams)) {
246
248
  if (isMapDataEntry(item) && !includeMapData) continue;
247
249
  const match = (isMapDataEntry(item) ? mapDataMatch(value, normalizedValue, item) : value.match(item?.re))
248
250
  || (key === 'residentialComplexes' ? markedResidentialMatch(value, item) : null);
@@ -329,16 +331,17 @@ export function matchCentralAsiaLocationEntities(text, countryCode, preferredCit
329
331
  const preferred = canonicalCity(preferredCity, countryCode) || preferredCity;
330
332
  const explicit = explicitCityFromText(text, countryCode);
331
333
  const scopedCity = preferred && country[preferred] ? preferred : explicit && country[explicit] ? explicit : null;
334
+ const grams = computeTextGrams(text);
332
335
 
333
336
  if (scopedCity) {
334
- const matches = findEntryMatches(text, scopedCity, country[scopedCity], { includeMapData: true });
337
+ const matches = findEntryMatches(text, scopedCity, country[scopedCity], { includeMapData: true, grams });
335
338
  const clusters = clusterMatches(matches, countryCode);
336
339
  return Object.freeze({ city: scopedCity, matches: Object.freeze(matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
337
340
  }
338
341
 
339
342
  const byCity = [];
340
343
  for (const [cityName, data] of Object.entries(country)) {
341
- const matches = findEntryMatches(text, cityName, data);
344
+ const matches = findEntryMatches(text, cityName, data, { grams });
342
345
  if (matches.length) byCity.push({ city: cityName, matches });
343
346
  }
344
347
 
package/src/currency.d.ts CHANGED
@@ -9,6 +9,7 @@ export const CURRENCY_SYMBOL_CANDIDATES: Readonly<Record<string, readonly string
9
9
  export function moneyCurrencyCandidatesFromText(value: unknown): readonly string[];
10
10
  export function moneyCurrencyFromText(value: unknown, fallbackCurrency?: string | null): string | null;
11
11
  export function moneyCurrencyPattern(): string;
12
+ export function moneyMentionPattern(): string;
12
13
  export function currencyDisplay(code: unknown, locale?: string): CurrencyDisplay | null;
13
14
  export function currencySymbol(code: unknown, locale?: string): string | null;
14
15
  export function currencyName(code: unknown, locale?: string): string | null;
package/src/currency.js CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  moneyCurrencyCandidatesFromText,
10
10
  moneyCurrencyFromText,
11
11
  moneyCurrencyPattern,
12
+ moneyMentionPattern,
12
13
  } from './money-core.js';
13
14
 
14
15
  const LOCALE_ALIASES = Object.freeze({
@@ -62,6 +62,34 @@ const COMMON_HOUSING_STRUCTURE_PATTERNS = Object.freeze([
62
62
  /(?:^|[^\p{L}\p{N}_])\d{1,5}(?:[.,]\d{1,2})?\s*(?:м(?:2|²)|m(?:2|²)|sqm|sq\.?\s*m|м\s*кв\.?)\s*(?=$|[^\p{L}\p{N}_])/giu,
63
63
  ]);
64
64
 
65
+ // These five patterns are built once here rather than inside
66
+ // extractHousingMoneyCandidates(): they depend only on the module-level
67
+ // constants above (PRICE_KEYWORD/MONEY_NUMBER_PATTERN/SCALE_PATTERN/
68
+ // PRICE_CURRENCY_*), never on the text being parsed, yet that function runs
69
+ // once per listing card. Constructing them with `new RegExp(...)` per call
70
+ // forces V8 to recompile a pattern that embeds moneyCurrencyPattern()'s
71
+ // ~250-branch currency alternation under the unicode ('u') flag from
72
+ // scratch every time; on a catalogue page with dozens of cards this
73
+ // repeated compilation of a large unicode-mode regex is expensive enough to
74
+ // exhaust the heap (observed as an OOM inside V8's regex code generation).
75
+ // Reusing the same compiled RegExp objects removes that per-call cost —
76
+ // they carry the /g flag but are only ever driven through matchAll(), which
77
+ // takes its own internal copy and never mutates lastIndex on the original.
78
+ const EXPANDED_UZBEK_THOUSANDS_RE = new RegExp(
79
+ `${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
80
+ 'igu',
81
+ );
82
+ const LABELLED_PRICE_RANGE_RE = new RegExp(
83
+ `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?(?=$|[^\\p{L}\\p{N}_])`,
84
+ 'igu',
85
+ );
86
+ const LABELLED_SCALE_RE = new RegExp(
87
+ `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
88
+ 'igu',
89
+ );
90
+ const PRICE_AMOUNT_AFTER_CURRENCY_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*[.]?\\s*${PRICE_CURRENCY_AFTER_NUMBER}`, 'igu');
91
+ const PRICE_AMOUNT_BEFORE_CURRENCY_RE = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
92
+
65
93
  function moneyParsingContext(value = '') {
66
94
  if (value && typeof value === 'object' && !Array.isArray(value)) {
67
95
  const country = canonicalCountryCode(value.country) || '';
@@ -239,11 +267,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
239
267
  // amount, while `2 million 500` is a single split-million amount rather
240
268
  // than two competing prices. These must be extracted before the shorter
241
269
  // generic currency/scale candidates below.
242
- const expandedUzbekThousandsRe = new RegExp(
243
- `${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
244
- 'igu',
245
- );
246
- for (const match of text.matchAll(expandedUzbekThousandsRe)) {
270
+ for (const match of text.matchAll(EXPANDED_UZBEK_THOUSANDS_RE)) {
247
271
  const start = match.index ?? 0;
248
272
  addCandidate({
249
273
  amount: Number(match[1]) * 1000,
@@ -262,11 +286,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
262
286
  // lower bound through the legacy single-price result. It must be collected
263
287
  // before generic amounts so the second endpoint cannot be selected merely
264
288
  // because it is larger.
265
- const labelledPriceRangeRe = new RegExp(
266
- `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?(?=$|[^\\p{L}\\p{N}_])`,
267
- 'igu',
268
- );
269
- for (const match of text.matchAll(labelledPriceRangeRe)) {
289
+ for (const match of text.matchAll(LABELLED_PRICE_RANGE_RE)) {
270
290
  // A scale stated on only one endpoint applies to both: "50-60 тыс сум"
271
291
  // means 50,000-60,000, not 50-60,000. Mirrors money.js's range parsing.
272
292
  const firstScale = match[2] || match[4] || null;
@@ -310,11 +330,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
310
330
  // glyph. In Uzbek listing prose, "narxi 850 ming" conventionally means
311
331
  // 850,000 UZS; retaining `scale` prevents a later generic fallback from
312
332
  // mistaking the base number for USD.
313
- const labelledScaleRe = new RegExp(
314
- `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
315
- 'igu',
316
- );
317
- for (const match of text.matchAll(labelledScaleRe)) {
333
+ for (const match of text.matchAll(LABELLED_SCALE_RE)) {
318
334
  const start = match.index ?? 0;
319
335
  const end = start + match[0].length;
320
336
  const scale = match[2];
@@ -339,10 +355,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
339
355
  });
340
356
  }
341
357
 
342
- for (const regex of [
343
- new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*[.]?\\s*${PRICE_CURRENCY_AFTER_NUMBER}`, 'igu'),
344
- new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu'),
345
- ]) {
358
+ for (const regex of [PRICE_AMOUNT_AFTER_CURRENCY_RE, PRICE_AMOUNT_BEFORE_CURRENCY_RE]) {
346
359
  for (const match of text.matchAll(regex)) {
347
360
  const amount = parseNumericAmount(match[1]);
348
361
  const start = match.index ?? 0; const end = start + match[0].length;
@@ -19,16 +19,16 @@ import {
19
19
  } from './housing-numeric-spans.js';
20
20
 
21
21
  const NUMBER_WORDS = Object.freeze([
22
- [/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|bir\s+xona(?:li)?|бир\s+хона(?:ли|лик)?|1\s*(?:-\s*)?к(?:омн\p{L}*)?|1\s*(?:-\s*)?xona(?:li)?|1\s*(?:-\s*)?хона(?:лик|ли)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
23
- [/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|ikki\s+xona(?:li)?|икки\s+хона(?:ли|лик)?|2\s*(?:-\s*)?к(?:омн\p{L}*)?|2\s*(?:-\s*)?xona(?:li)?|2\s*(?:-\s*)?хона(?:лик|ли)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
24
- [/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|uch\s+xona(?:li)?|уч\s+хона(?:ли|лик)?|3\s*(?:-\s*)?к(?:омн\p{L}*)?|3\s*(?:-\s*)?xona(?:li)?|3\s*(?:-\s*)?хона(?:лик|ли)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
25
- [/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|to['’]?rt\s+xona(?:li)?|tort\s+xona(?:li)?|тўрт\s+хона(?:ли|лик)?|турт\s+хона(?:ли|лик)?|4\s*(?:-\s*)?к(?:омн\p{L}*)?|4\s*(?:-\s*)?xona(?:li)?|4\s*(?:-\s*)?хона(?:лик|ли)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
26
- [/(?<![\p{L}\p{N}_])(?:пятикомнатн\p{L}*|besh\s+xona(?:li)?|беш\s+хона(?:ли|лик)?|5\s*(?:-\s*)?к(?:омн\p{L}*)?|5\s*(?:-\s*)?xona(?:li)?|5\s*(?:-\s*)?хона(?:лик|ли)?|five[- ]bedroom|five[- ]room)(?![\p{L}\p{N}_])/iu, 5],
27
- [/(?<![\p{L}\p{N}_])(?:шестикомнатн\p{L}*|olti\s+xona(?:li)?|олти\s+хона(?:ли|лик)?|6\s*(?:-\s*)?к(?:омн\p{L}*)?|6\s*(?:-\s*)?xona(?:li)?|6\s*(?:-\s*)?хона(?:лик|ли)?|six[- ]bedroom|six[- ]room)(?![\p{L}\p{N}_])/iu, 6],
28
- [/(?<![\p{L}\p{N}_])(?:семикомнатн\p{L}*|yetti\s+xona(?:li)?|етти\s+хона(?:ли|лик)?|7\s*(?:-\s*)?к(?:омн\p{L}*)?|7\s*(?:-\s*)?xona(?:li)?|7\s*(?:-\s*)?хона(?:лик|ли)?|seven[- ]bedroom|seven[- ]room)(?![\p{L}\p{N}_])/iu, 7],
29
- [/(?<![\p{L}\p{N}_])(?:восьмикомнатн\p{L}*|sakkiz\s+xona(?:li)?|саккиз\s+хона(?:ли|лик)?|8\s*(?:-\s*)?к(?:омн\p{L}*)?|8\s*(?:-\s*)?xona(?:li)?|8\s*(?:-\s*)?хона(?:лик|ли)?|eight[- ]bedroom|eight[- ]room)(?![\p{L}\p{N}_])/iu, 8],
30
- [/(?<![\p{L}\p{N}_])(?:девятикомнатн\p{L}*|to['’]?qqiz\s+xona(?:li)?|toqqiz\s+xona(?:li)?|тўққиз\s+хона(?:ли|лик)?|токкиз\s+хона(?:ли|лик)?|9\s*(?:-\s*)?к(?:омн\p{L}*)?|9\s*(?:-\s*)?xona(?:li)?|9\s*(?:-\s*)?хона(?:лик|ли)?|nine[- ]bedroom|nine[- ]room)(?![\p{L}\p{N}_])/iu, 9],
31
- [/(?<![\p{L}\p{N}_])(?:десятикомнатн\p{L}*|o['’]?n\s+xona(?:li)?|on\s+xona(?:li)?|ўн\s+хона(?:ли|лик)?|он\s+хона(?:ли|лик)?|10\s*(?:-\s*)?к(?:омн\p{L}*)?|10\s*(?:-\s*)?xona(?:li)?|10\s*(?:-\s*)?хона(?:лик|ли)?|ten[- ]bedroom|ten[- ]room)(?![\p{L}\p{N}_])/iu, 10],
22
+ [/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|однокімнатн\p{L}*|bir\s+xona(?:li)?|бир\s+хона(?:ли|лик)?|1\s*(?:-\s*)?к(?:омн\p{L}*)?|1\s*(?:-\s*)?xona(?:li)?|1\s*(?:-\s*)?хона(?:лик|ли)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
23
+ [/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|двокімнатн\p{L}*|ikki\s+xona(?:li)?|икки\s+хона(?:ли|лик)?|2\s*(?:-\s*)?к(?:омн\p{L}*)?|2\s*(?:-\s*)?xona(?:li)?|2\s*(?:-\s*)?хона(?:лик|ли)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
24
+ [/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|трикімнатн\p{L}*|uch\s+xona(?:li)?|уч\s+хона(?:ли|лик)?|3\s*(?:-\s*)?к(?:омн\p{L}*)?|3\s*(?:-\s*)?xona(?:li)?|3\s*(?:-\s*)?хона(?:лик|ли)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
25
+ [/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|чотирикімнатн\p{L}*|to['’]?rt\s+xona(?:li)?|tort\s+xona(?:li)?|тўрт\s+хона(?:ли|лик)?|турт\s+хона(?:ли|лик)?|4\s*(?:-\s*)?к(?:омн\p{L}*)?|4\s*(?:-\s*)?xona(?:li)?|4\s*(?:-\s*)?хона(?:лик|ли)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
26
+ [/(?<![\p{L}\p{N}_])(?:пятикомнатн\p{L}*|п['’]ятикімнатн\p{L}*|besh\s+xona(?:li)?|беш\s+хона(?:ли|лик)?|5\s*(?:-\s*)?к(?:омн\p{L}*)?|5\s*(?:-\s*)?xona(?:li)?|5\s*(?:-\s*)?хона(?:лик|ли)?|five[- ]bedroom|five[- ]room)(?![\p{L}\p{N}_])/iu, 5],
27
+ [/(?<![\p{L}\p{N}_])(?:шестикомнатн\p{L}*|шестикімнатн\p{L}*|olti\s+xona(?:li)?|олти\s+хона(?:ли|лик)?|6\s*(?:-\s*)?к(?:омн\p{L}*)?|6\s*(?:-\s*)?xona(?:li)?|6\s*(?:-\s*)?хона(?:лик|ли)?|six[- ]bedroom|six[- ]room)(?![\p{L}\p{N}_])/iu, 6],
28
+ [/(?<![\p{L}\p{N}_])(?:семикомнатн\p{L}*|семикімнатн\p{L}*|yetti\s+xona(?:li)?|етти\s+хона(?:ли|лик)?|7\s*(?:-\s*)?к(?:омн\p{L}*)?|7\s*(?:-\s*)?xona(?:li)?|7\s*(?:-\s*)?хона(?:лик|ли)?|seven[- ]bedroom|seven[- ]room)(?![\p{L}\p{N}_])/iu, 7],
29
+ [/(?<![\p{L}\p{N}_])(?:восьмикомнатн\p{L}*|восьмикімнатн\p{L}*|sakkiz\s+xona(?:li)?|саккиз\s+хона(?:ли|лик)?|8\s*(?:-\s*)?к(?:омн\p{L}*)?|8\s*(?:-\s*)?xona(?:li)?|8\s*(?:-\s*)?хона(?:лик|ли)?|eight[- ]bedroom|eight[- ]room)(?![\p{L}\p{N}_])/iu, 8],
30
+ [/(?<![\p{L}\p{N}_])(?:девятикомнатн\p{L}*|дев['’]ятикімнатн\p{L}*|to['’]?qqiz\s+xona(?:li)?|toqqiz\s+xona(?:li)?|тўққиз\s+хона(?:ли|лик)?|токкиз\s+хона(?:ли|лик)?|9\s*(?:-\s*)?к(?:омн\p{L}*)?|9\s*(?:-\s*)?xona(?:li)?|9\s*(?:-\s*)?хона(?:лик|ли)?|nine[- ]bedroom|nine[- ]room)(?![\p{L}\p{N}_])/iu, 9],
31
+ [/(?<![\p{L}\p{N}_])(?:десятикомнатн\p{L}*|десятикімнатн\p{L}*|o['’]?n\s+xona(?:li)?|on\s+xona(?:li)?|ўн\s+хона(?:ли|лик)?|он\s+хона(?:ли|лик)?|10\s*(?:-\s*)?к(?:омн\p{L}*)?|10\s*(?:-\s*)?xona(?:li)?|10\s*(?:-\s*)?хона(?:лик|ли)?|ten[- ]bedroom|ten[- ]room)(?![\p{L}\p{N}_])/iu, 10],
32
32
  ]);
33
33
 
34
34
  // Common classifieds shorthand: rooms/floor/total floors, optionally followed
@@ -75,23 +75,29 @@ export function parseHousingResidentialComplex(value) {
75
75
  return /[a-zA-Zа-яёіїґ]{2,}/i.test(name) ? name : null;
76
76
  }
77
77
 
78
+ const AREA_UNIT = String.raw`(?:m2|m²|мкв|м2|м²|sq\s?m|кв\.?\s*м|квадрат[а-яёіїґ]*(?:\s*м(?:етр(?:а|ов)?)?)?)`;
79
+ // Built once: parseHousingAreaFromText() runs per listing card, and neither
80
+ // pattern depends on the text being parsed (see the CURRENCY_ALT comment in
81
+ // housing-money.js for why rebuilding a regex on every call is costly).
82
+ const AREA_LABELLED_RE = new RegExp(
83
+ String.raw`(?:общая\s+площадь|площадь\s+общая|total\s+area|umumiy\s+maydon|suprafa(?:ță|ta)\s+total(?:ă|a)?)\s*[:=\-]?\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*${AREA_UNIT}`,
84
+ 'iu',
85
+ );
86
+ const AREA_GENERIC_RE = new RegExp(String.raw`(?<![\d.,])(\d{1,4}(?:[.,]\d{1,2})?)\s*${AREA_UNIT}`, 'iu');
87
+
78
88
  export function parseHousingAreaFromText(value) {
79
89
  const text = String(value || '');
80
90
  if (!text) return null;
81
91
 
82
- const unit = String.raw`(?:m2|m²|мкв|м2|м²|sq\s?m|кв\.?\s*м|квадрат[а-яёіїґ]*(?:\s*м(?:етр(?:а|ов)?)?)?)`;
83
92
  const toArea = (raw) => {
84
93
  const number = Number(String(raw || '').replace(/\s+/g, '').replace(',', '.'));
85
94
  return Number.isFinite(number) && number > 0 && number <= 100000 ? number : null;
86
95
  };
87
96
 
88
- const labelled = text.match(new RegExp(
89
- String.raw`(?:общая\s+площадь|площадь\s+общая|total\s+area|umumiy\s+maydon|suprafa(?:ță|ta)\s+total(?:ă|a)?)\s*[:=\-]?\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*${unit}`,
90
- 'iu',
91
- ));
97
+ const labelled = text.match(AREA_LABELLED_RE);
92
98
  if (labelled) return toArea(labelled[1]);
93
99
 
94
- const generic = text.match(new RegExp(String.raw`(?<![\d.,])(\d{1,4}(?:[.,]\d{1,2})?)\s*${unit}`, 'iu'));
100
+ const generic = text.match(AREA_GENERIC_RE);
95
101
  if (generic) return toArea(generic[1]);
96
102
 
97
103
  const compact = text.match(/(?:^|\n)[^\d\r\n]{0,8}[1-9]\s*[¹²³⁴⁵⁶⁷⁸⁹]?\s*\/\s*[0-9]{1,2}\s*\/\s*[0-9]{1,2}\s+(\d{2,4}(?:[.,]\d{1,2})?)\s*кв(?=\s|$)/im);
package/src/housing.js CHANGED
@@ -210,7 +210,7 @@ export function looksHousingRoomOnly(value) {
210
210
  if (!text) return false;
211
211
  const occupancy = resolveHousingOccupancy(text);
212
212
  if (occupancy === 'room' || occupancy === 'sharedRoom' || occupancy === 'bedSpace') return true;
213
- return /подселени|підселен|комнату\s+в|кімнату\s+в|сда[её]тся\s+комната|сдается\s+комната|сдам\s+комнату|здам\s+кімнат|room\s+in\s+a\s+(?:shared\s+)?flat|room\s+for\s+rent|shared\s+(?:flat|apartment|room)|roommate|flatmate|xona\s+ijaraga|xona\s+beriladi|sherik(?:ka|lik)|шерик(?:ка|лик)|(?:1|бир)\s*та\s*(?:бола|киши|қиз|киз)\s*керак|1\s*хонага[^\r\n]{0,40}(?:киши|одам)\s*турилади|бөлме\s+жалға|închiriez\s+camer[ăa]|ищу[^\r\n]{0,60}сосед|ищем[^\r\n]{0,60}сосед|нужен[^\r\n]{0,60}сосед|нужна[^\r\n]{0,60}сосед|шукаю[^\r\n]{0,60}сусід|шукаємо[^\r\n]{0,60}сусід|потрібен[^\r\n]{0,60}сусід|потрібна[^\r\n]{0,60}сусід|співмешкан|співжител|соседк|сусідк/iu.test(text);
213
+ return /подселени|підселен|комнату\s+в(?![\p{L}\p{N}_])|кімнату\s+в(?![\p{L}\p{N}_])|сда[её]тся\s+комната|сдается\s+комната|сдам\s+комнату|здам\s+кімнат|room\s+in\s+a\s+(?:shared\s+)?flat|room\s+for\s+rent|shared\s+(?:flat|apartment|room)|roommate|flatmate|xona\s+ijaraga|xona\s+beriladi|sherik(?:ka|lik)|шерик(?:ка|лик)|(?:1|бир)\s*та\s*(?:бола|киши|қиз|киз)\s*керак|1\s*хонага[^\r\n]{0,40}(?:киши|одам)\s*турилади|бөлме\s+жалға|închiriez\s+camer[ăa]|ищу[^\r\n]{0,60}сосед|ищем[^\r\n]{0,60}сосед|нужен[^\r\n]{0,60}сосед|нужна[^\r\n]{0,60}сосед|шукаю[^\r\n]{0,60}сусід|шукаємо[^\r\n]{0,60}сусід|потрібен[^\r\n]{0,60}сусід|потрібна[^\r\n]{0,60}сусід|співмешкан|співжител|соседк|сусідк/iu.test(text);
214
214
  }
215
215
 
216
216
  export function resolveHousingPropertyType(value) {
@@ -8,6 +8,7 @@ import {
8
8
  import { isMapDataEntry, LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
9
9
  import { canonicalCity } from './geography.js';
10
10
  import { normalizeForMatch } from './normalization.js';
11
+ import { candidateEntries, computeTextGrams } from './alias-prefilter.js';
11
12
  import { KG_LOCATION_EXTENSIONS } from './kg-location-extensions.js';
12
13
  import { KG_BISHKEK_AREA_EXTENSIONS } from './kg-bishkek-area-extensions.js';
13
14
  import { KG_BISHKEK_STREET_EXTENSIONS } from './kg-bishkek-street-extensions.js';
@@ -180,11 +181,12 @@ export function matchDictionaryLocation(text, countryCode, city = null) {
180
181
  const cities = canonical && country[canonical] ? [[canonical, country[canonical]]] : Object.entries(country);
181
182
  const value = String(text || '');
182
183
  const normalizedValue = normalizeForMatch(value);
184
+ const grams = computeTextGrams(value);
183
185
  let best = null;
184
186
 
185
187
  for (const [cityName, data] of cities) {
186
188
  for (const type of LOCATION_LIST_KEYS) {
187
- for (const entry of data[type] || []) {
189
+ for (const entry of candidateEntries(data[type] || [], value, grams)) {
188
190
  const mapData = isMapDataEntry(entry);
189
191
  const match = mapData ? mapDataMatch(entry, normalizedValue) : entry?.re?.exec(value);
190
192
  if (!match) continue;
@@ -8,3 +8,4 @@ export function parseScaledAmount(raw: unknown, scale?: unknown): number | null;
8
8
  export function moneyCurrencyCandidatesFromText(value: unknown): readonly string[];
9
9
  export function moneyCurrencyFromText(value: unknown, fallbackCurrency?: string | null): string | null;
10
10
  export function moneyCurrencyPattern(): string;
11
+ export function moneyMentionPattern(): string;
package/src/money-core.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aliasesOf, escapeRegex, findCanonical } from './normalization.js';
1
+ import { aliasesOf, escapeRegex, findCanonical, findCanonicalCandidates } from './normalization.js';
2
2
  import {
3
3
  CURRENCY_SYMBOL_CANDIDATES,
4
4
  CURRENCY_TERMS,
@@ -67,11 +67,25 @@ export function parseScaledAmount(raw, scale) {
67
67
  return value == null ? null : value * moneyScaleMultiplier(scale);
68
68
  }
69
69
 
70
- function explicitCurrencyFromText(value) {
70
+ // A term like "сом" can be a partial-match tie between multiple currencies
71
+ // (e.g. Kyrgyzstani "сом"/KGS and Uzbek "so'm"/UZS both fold to the search
72
+ // key "som"). Returns every tied currency, in registration order, so callers
73
+ // can break the tie using their own context instead of always keeping
74
+ // whichever currency happens to be registered first in the lexicon.
75
+ function explicitCurrencyCandidates(value) {
71
76
  const text = String(value || '');
72
77
  // Ambiguous glyphs are removed so they cannot hide an explicit ISO/name token.
73
78
  const lexicalText = text.replace(/[$¥¥]/g, ' ');
74
- return findCanonical(lexicalText, CURRENCY_TERMS, { partial: true })?.canonical || null;
79
+ return findCanonicalCandidates(lexicalText, CURRENCY_TERMS, { partial: true })
80
+ .map((entry) => entry?.canonical)
81
+ .filter(Boolean);
82
+ }
83
+
84
+ function explicitCurrencyFromText(value, fallbackCurrency = null) {
85
+ const candidates = explicitCurrencyCandidates(value);
86
+ if (!candidates.length) return null;
87
+ const fallback = String(fallbackCurrency || '').trim().toUpperCase();
88
+ return fallback && candidates.includes(fallback) ? fallback : candidates[0];
75
89
  }
76
90
 
77
91
  export function moneyCurrencyCandidatesFromText(value) {
@@ -81,7 +95,7 @@ export function moneyCurrencyCandidatesFromText(value) {
81
95
  if (currency && !candidates.includes(currency)) candidates.push(currency);
82
96
  };
83
97
 
84
- add(explicitCurrencyFromText(text));
98
+ explicitCurrencyCandidates(text).forEach(add);
85
99
 
86
100
  for (const [symbol, currencies] of Object.entries(CURRENCY_SYMBOL_CANDIDATES)) {
87
101
  if (!text.includes(symbol)) continue;
@@ -96,7 +110,7 @@ export function moneyCurrencyCandidatesFromText(value) {
96
110
  }
97
111
 
98
112
  export function moneyCurrencyFromText(value, fallbackCurrency = null) {
99
- const explicit = explicitCurrencyFromText(value);
113
+ const explicit = explicitCurrencyFromText(value, fallbackCurrency);
100
114
  if (explicit) return explicit;
101
115
 
102
116
  const candidates = moneyCurrencyCandidatesFromText(value);
@@ -112,3 +126,19 @@ export function moneyCurrencyPattern() {
112
126
  .map(escapeRegex)
113
127
  .join('|');
114
128
  }
129
+
130
+ // A cheap "does this text mention money at all" pre-filter — a currency term
131
+ // or a bare magnitude word (e.g. "15 млн", no currency spelled out) both
132
+ // count. Consumers that need to reject non-money text fast before running a
133
+ // full parseSalary/parseHousingPrice pass (e.g. scanning many HTML card
134
+ // candidates) can test against this instead of hand-copying a currency list,
135
+ // which drifts from the lexicon's own (see MONEY_SCALE_PATTERN for the
136
+ // magnitude half already used by MONEY_RANGE_RE/MONEY_SINGLE_RE).
137
+ //
138
+ // Word-boundary guarded for the same reason MONEY_RANGE_RE/MONEY_SINGLE_RE
139
+ // are: MONEY_SCALE_PATTERN includes bare single-letter abbreviations like "м"
140
+ // (million) that would otherwise match inside an unrelated word — e.g. "2 до
141
+ // 3 месяцев" ("months") must never register as a money mention.
142
+ export function moneyMentionPattern() {
143
+ return `(?<![\\p{L}\\p{N}_])(?:${moneyCurrencyPattern()}|${MONEY_SCALE_PATTERN})(?![\\p{L}\\p{N}_])`;
144
+ }
@@ -6,7 +6,7 @@ export const CURRENCY_TERMS = Object.freeze([
6
6
  group('USD', { ru: ['$', 'usd', 'доллар', 'доллара', 'долларов', 'бакс', 'у.е.', 'у е'], en: ['$', 'usd', 'dollar', 'dollars', 'us dollar'], uk: ['$', 'usd', 'долар', 'долари', 'доларів'], ro: ['$', 'usd', 'dolar', 'dolari'], uzLatn: ['$', 'usd', 'dollar'], uzCyrl: ['$', 'доллар'], kk: ['$', 'usd', 'доллар'] }),
7
7
  group('EUR', { ru: ['€', 'eur', 'евро'], en: ['€', 'eur', 'euro', 'euros'], uk: ['€', 'eur', 'євро'], ro: ['€', 'eur', 'euro'], uzLatn: ['€', 'eur', 'yevro'], uzCyrl: ['€', 'евро'], kk: ['€', 'eur', 'еуро'] }),
8
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'] }),
9
+ group('KZT', { ru: ['₸', 'тенге', 'тг', 'kzt'], en: ['₸', 'kzt', 'tenge'], uk: ['₸', 'тенге', 'kzt'], ro: ['₸', 'kzt', 'tenge'], uzLatn: ['₸', 'kzt', 'tenge'], uzCyrl: ['₸', 'kzt', 'тенге'], kk: ['₸', 'теңге', 'тенге', 'тг', 'kzt'] }),
10
10
  group('UAH', { ru: ['грн', 'гривна', 'гривны', 'гривен', '₴', 'uah'], en: ['uah', 'hryvnia', 'hryvnias', '₴'], uk: ['грн', 'гривня', 'гривні', 'гривень', '₴', 'uah'], ro: ['uah', 'hrivne', 'grivne'], uzLatn: ['uah'], uzCyrl: ['uah'], kk: ['uah'] }),
11
11
  group('RUB', { ru: ['₽', 'руб', 'руб.', 'рубль', 'рубля', 'рублей', 'rub', 'rur'], en: ['₽', 'rub', 'rur', 'ruble', 'rubles'], uk: ['₽', 'руб', 'рублів', 'rub'], ro: ['₽', 'rub', 'ruble'], uzLatn: ['₽', 'rub', 'rubl'], uzCyrl: ['₽', 'рубль'], kk: ['₽', 'rub', 'рубль'] }),
12
12
  group('GBP', { ru: ['£', 'gbp', 'фунт', 'фунтов'], en: ['£', 'gbp', 'pound', 'pounds', 'sterling'], uk: ['£', 'gbp', 'фунт', 'фунтів'], ro: ['£', 'gbp', 'liră sterlină', 'lira sterlina'], uzLatn: ['£', 'gbp', 'funt'], uzCyrl: ['£', 'фунт'], kk: ['£', 'gbp', 'фунт'] }),
@@ -41,6 +41,7 @@ export const CURRENCY_SYMBOL_CANDIDATES = Object.freeze({
41
41
  '€': Object.freeze(['EUR']),
42
42
  '₴': Object.freeze(['UAH']),
43
43
  '₽': Object.freeze(['RUB']),
44
+ '₸': Object.freeze(['KZT']),
44
45
  '£': Object.freeze(['GBP']),
45
46
  '₺': Object.freeze(['TRY']),
46
47
  '₾': Object.freeze(['GEL']),
package/src/money.js CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  MONEY_SINGLE_RE,
23
23
  moneyCurrencyFromText,
24
24
  moneyCurrencyPattern,
25
+ moneyMentionPattern,
25
26
  moneyScaleMultiplier,
26
27
  parseNumericAmount,
27
28
  parseScaledAmount,
@@ -183,32 +183,68 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
183
183
  return index;
184
184
  }
185
185
 
186
- export function findCanonical(value, entries, { partial = false, transliteration = true } = {}) {
187
- if (!value) return null;
186
+ /**
187
+ * Every entry tied for the longest partial alias match, in the order they
188
+ * first reach that length. Exact matches always resolve unambiguously to a
189
+ * single entry. Callers that need to disambiguate a partial tie against
190
+ * caller-supplied context (e.g. a fallback currency) should use this instead
191
+ * of findCanonical(), which silently keeps only the first-registered entry.
192
+ */
193
+ export function findCanonicalCandidates(value, entries, { partial = false, transliteration = true } = {}) {
194
+ if (!value) return [];
188
195
  const index = getAliasIndex(entries, { transliteration });
189
196
  for (const key of normalizedAliasKeys(value, { transliteration })) {
190
197
  const exact = index.get(key);
191
- if (exact) return exact;
198
+ if (exact) return [exact];
192
199
  }
193
- if (!partial) return null;
200
+ if (!partial) return [];
194
201
 
195
202
  const textKeys = normalizedAliasKeys(value, { transliteration });
196
- let best = null;
203
+ let tied = [];
197
204
  let bestLength = 0;
198
205
  for (const [alias, entry] of index) {
199
- if (alias.length <= bestLength) continue;
200
- if (textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) {
201
- best = entry;
206
+ if (alias.length < bestLength) continue;
207
+ if (!textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) continue;
208
+ if (alias.length > bestLength) {
202
209
  bestLength = alias.length;
210
+ tied = [entry];
211
+ } else if (!tied.includes(entry)) {
212
+ tied.push(entry);
203
213
  }
204
214
  }
205
- return best;
215
+ return tied;
216
+ }
217
+
218
+ export function findCanonical(value, entries, options = {}) {
219
+ return findCanonicalCandidates(value, entries, options)[0] || null;
206
220
  }
207
221
 
208
222
  export function escapeRegex(value) {
209
223
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
210
224
  }
211
225
 
226
+ // A word-boundary class scoped to the scripts this lexicon actually targets
227
+ // (Latin incl. Romanian/Karakalpak diacritics, Cyrillic incl. supplement,
228
+ // digits, underscore) instead of the full-Unicode \p{L}\p{N} property
229
+ // escapes. Under V8's unicode ('u') regex mode, \p{L}/\p{N} character
230
+ // classes compile to disproportionately large native code — benchmarked at
231
+ // roughly 20x slower and 4x more memory per compiled entry than this
232
+ // explicit range, which matters a lot here: aliasesToRegex()/matcherFor()
233
+ // compile one such regex per lexicon entry or per matcher (`entry.re`
234
+ // getters across the location/housing/hiring dictionaries, and
235
+ // findAllCanonical()'s combined matcher), and a single city's street list
236
+ // alone can hold thousands of entries. See the apps/flats housing-source-
237
+ // crawler OOM this was diagnosed against (norieltor.com.ua and others).
238
+ const ALIAS_BOUNDARY_CLASS = 'A-Za-z0-9_\\u00C0-\\u02AF\\u0370-\\u03FF\\u0400-\\u052F';
239
+ // For literal alternation (aliasesToRegex: "either start-of-string or a
240
+ // non-word char"), the class itself must be negated.
241
+ const ALIAS_NON_BOUNDARY_RE_SOURCE = `[^${ALIAS_BOUNDARY_CLASS}]`;
242
+ // For lookaround (matcherFor: "not preceded/followed by a word char"), the
243
+ // negation belongs on the lookaround itself, not inside the class too --
244
+ // `(?<![^X])` double-negates into "must be adjacent to X", the opposite of
245
+ // the intended boundary check.
246
+ const ALIAS_WORD_RE_SOURCE = `[${ALIAS_BOUNDARY_CLASS}]`;
247
+
212
248
  function aliasPattern(value) {
213
249
  const source = normalizeUnicode(value).trim();
214
250
  let pattern = '';
@@ -239,7 +275,7 @@ function matcherFor(entries, { transliteration = true } = {}) {
239
275
  const result = searchAliases.length
240
276
  ? Object.freeze({
241
277
  owners,
242
- re: new RegExp(`(?<![\\p{L}\\p{N}_])(?:${searchAliases.map(aliasPattern).join('|')})(?![\\p{L}\\p{N}_])`, 'giu'),
278
+ re: new RegExp(`(?<!${ALIAS_WORD_RE_SOURCE})(?:${searchAliases.map(aliasPattern).join('|')})(?!${ALIAS_WORD_RE_SOURCE})`, 'gi'),
243
279
  })
244
280
  : Object.freeze({ owners, re: null });
245
281
 
@@ -408,12 +444,15 @@ export function assertValidLexicon(entries, options = {}) {
408
444
  return true;
409
445
  }
410
446
 
411
- export function aliasesToRegex(values, flags = 'iu') {
447
+ export function aliasesToRegex(values, flags = 'i') {
412
448
  const alternatives = [...new Set(values || [])]
413
449
  .filter((value) => typeof value === 'string' && value.trim())
414
450
  .map((value) => normalizeUnicode(value).trim())
415
451
  .sort((a, b) => b.length - a.length)
416
452
  .map(aliasPattern);
417
453
  if (!alternatives.length) throw new TypeError('aliasesToRegex() requires at least one non-empty alias');
418
- return new RegExp(`(?:^|[^\\p{L}\\p{N}_])(?:${alternatives.join('|')})(?:$|[^\\p{L}\\p{N}_])`, flags);
454
+ return new RegExp(
455
+ `(?:^|${ALIAS_NON_BOUNDARY_RE_SOURCE})(?:${alternatives.join('|')})(?:$|${ALIAS_NON_BOUNDARY_RE_SOURCE})`,
456
+ flags.replace('u', ''),
457
+ );
419
458
  }