@whiteslove/parsing-lexicon 0.5.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/geo.js CHANGED
@@ -160,7 +160,7 @@ export const TASHKENT_AREAS = Object.freeze({
160
160
  ]),
161
161
  Yunusabad: Object.freeze([
162
162
  area('Kashgar', ['кашгар', 'қашқар', 'qashqar', 'kashgar', 'ц 4', 'c 4']), area('Qiyot', ['киёт', 'кият', 'қиёт', 'qiyot', 'kiyot', 'markaz 5', 's 5']),
163
- area('Minor', ['минор', 'minor', 'ц 6', 'c 6']), area('TashGRES', ['ташгрэс', 'tashgres']), area('Manzara', ['манзара', 'manzara']),
163
+ area('Minor', ['минор', 'minor', 'ц 6', 'c 6']), area('ToshGRES', ['ташгрэс', 'tashgres', 'TashGRES', 'toshgres']), area('Manzara', ['манзара', 'manzara']),
164
164
  ]),
165
165
  Yakkasaray: Object.freeze([
166
166
  area('Bashlyk', ['башлык', 'бошлиқ', 'boshliq', 'bashlyk']), area('Kushbegi', ['кушбеги', 'қушбеги', 'qushbegi', 'kushbegi']),
@@ -1,13 +1,39 @@
1
- export type GeographyDisplayKind = 'country' | 'region' | 'city' | 'district' | 'metro' | 'any';
1
+ export type GeographyDisplayKind =
2
+ | 'country'
3
+ | 'region'
4
+ | 'city'
5
+ | 'district'
6
+ | 'microdistrict'
7
+ | 'mahalla'
8
+ | 'local_area'
9
+ | 'suburb'
10
+ | 'settlement'
11
+ | 'development_area'
12
+ | 'residential_complex'
13
+ | 'metro'
14
+ | 'street'
15
+ | 'poi'
16
+ | 'any';
17
+
18
+ export type GeographyDisplayContext = Readonly<{
19
+ country?: string | null;
20
+ city?: string | null;
21
+ }>;
2
22
 
3
23
  export const GEOGRAPHY_DISPLAY_NAMES: Readonly<Record<string, Readonly<{
4
24
  country: Readonly<Record<string, string>>;
5
25
  city: Readonly<Record<string, string>>;
6
26
  region?: Readonly<Record<string, string>>;
7
27
  district: Readonly<Record<string, string>>;
28
+ microdistrict: Readonly<Record<string, string>>;
8
29
  metro: Readonly<Record<string, string>>;
9
30
  metroAlias: Readonly<Record<string, string>>;
10
31
  }>>>;
11
32
 
12
- export function geographyDisplayName(value: unknown, locale?: string, kind?: GeographyDisplayKind): string;
33
+ export function geographyDisplayName(
34
+ value: unknown,
35
+ locale?: string,
36
+ kind?: GeographyDisplayKind,
37
+ context?: GeographyDisplayContext | null,
38
+ ): string;
13
39
  export function geographyMetroLabelWithAlias(value: unknown, locale?: string): string;
@@ -1,11 +1,34 @@
1
1
  import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
2
2
  import { CITIES, REGIONS } from './geography.js';
3
+ import { dictionaryFor } from './locations.js';
4
+ import { aliasesOf } from './normalization.js';
3
5
 
4
6
  // Centralized presentation names for canonical geography values.
5
7
  // Consumers must not maintain their own country/city/district/metro display dictionaries.
6
8
 
7
9
  const EMPTY_DISPLAY_NAMES = Object.freeze({});
8
10
 
11
+ const LOCATION_KIND_KEYS = Object.freeze({
12
+ district: Object.freeze(['districts']),
13
+ microdistrict: Object.freeze(['microdistricts']),
14
+ mahalla: Object.freeze(['mahallas']),
15
+ local_area: Object.freeze(['localAreas']),
16
+ suburb: Object.freeze(['suburbs']),
17
+ settlement: Object.freeze(['settlements']),
18
+ development_area: Object.freeze(['developmentAreas']),
19
+ residential_complex: Object.freeze(['residentialComplexes']),
20
+ metro: Object.freeze(['metro']),
21
+ street: Object.freeze(['streets']),
22
+ poi: Object.freeze(['landmarks', 'pois']),
23
+ });
24
+
25
+ const CYRILLIC_RE = /[А-ЯЁ]/iu;
26
+ const UZBEK_CYRILLIC_RE = /[ЎҚҒҲўқғҳ]/u;
27
+ const LATIN_RE = /[A-Z]/iu;
28
+ const UZBEK_LATIN_MARKER_RE = /(?:[ʻ‘’ʼ']|\b(?:ko[ʻ‘’ʼ']?chasi|shoh\s+ko[ʻ‘’ʼ']?chasi|bozori|bog[ʻ‘’ʼ']?i|maydoni|masjidi|vokzali|mahallasi|mavzesi|dahasi|massivi|savdo\s+markazi|toshkent|yunusobod|chilonzor|olmazor|o[ʻ‘’ʼ']?zbekiston)\b)/iu;
29
+ const ENGLISH_GENERIC_RE = /\b(?:street|avenue|square|park|bazaar|market|mall|mosque|railway|station|residence|city|garden|museum|theatre|theater|airport|university|hospital|school|district|microdistrict)\b/iu;
30
+ const RUSSIAN_GENERIC_RE = /\b(?:улица|проспект|площадь|сквер|рынок|базар|парк|мечеть|вокзал|аэропорт|университет|больница|школа|район|массив|махалл|жилой|жк|центр)\b/iu;
31
+
9
32
  function countryDisplayNames(locale) {
10
33
  return Object.freeze(Object.fromEntries(COUNTRIES.map((item) => [
11
34
  item.code,
@@ -22,6 +45,20 @@ export const GEOGRAPHY_DISPLAY_NAMES = Object.freeze({
22
45
  metro: EMPTY_DISPLAY_NAMES,
23
46
  metroAlias: EMPTY_DISPLAY_NAMES,
24
47
  }),
48
+ uz: Object.freeze({
49
+ country: countryDisplayNames('uz'),
50
+ city: Object.freeze({
51
+ Tashkent: 'Toshkent',
52
+ }),
53
+ district: Object.freeze({
54
+ Chilanzar: 'Chilonzor', Yunusabad: 'Yunusobod', 'Mirzo Ulugbek': 'Mirzo Ulug‘bek',
55
+ Yakkasaray: 'Yakkasaroy', Shaykhantahur: 'Shayxontohur', Yashnobod: 'Yashnobod', Sergeli: 'Sergeli',
56
+ Uchtepa: 'Uchtepa', Mirobod: 'Mirobod', Bektemir: 'Bektemir', Almazar: 'Olmazor', Yangihayot: 'Yangihayot',
57
+ }),
58
+ microdistrict: EMPTY_DISPLAY_NAMES,
59
+ metro: EMPTY_DISPLAY_NAMES,
60
+ metroAlias: EMPTY_DISPLAY_NAMES,
61
+ }),
25
62
  ru: Object.freeze({
26
63
  country: countryDisplayNames('ru'),
27
64
  city: Object.freeze({
@@ -131,7 +168,70 @@ function countryEntity(value) {
131
168
  return code ? countryByCode(code) : null;
132
169
  }
133
170
 
134
- export function geographyDisplayName(value, locale = 'en', kind = 'any') {
171
+ function locationEntryFor(value, kind, context) {
172
+ const keys = LOCATION_KIND_KEYS[kind];
173
+ const country = canonicalCountryCode(context?.country);
174
+ const city = String(context?.city || '').trim();
175
+ if (!keys || !country || !city) return null;
176
+ const dictionary = dictionaryFor(country, city);
177
+ if (!dictionary) return null;
178
+ for (const key of keys) {
179
+ const entry = (dictionary[key] || []).find((item) => (item.canonical || item.name) === value);
180
+ if (entry) return entry;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function bestAlias(aliases, score) {
186
+ let best = null;
187
+ let bestScore = -Infinity;
188
+ for (const alias of aliases) {
189
+ const value = String(alias || '').trim();
190
+ if (!value) continue;
191
+ const valueScore = score(value);
192
+ if (valueScore > bestScore || (valueScore === bestScore && best && value.length < best.length)) {
193
+ best = value;
194
+ bestScore = valueScore;
195
+ }
196
+ }
197
+ return bestScore > 0 ? best : null;
198
+ }
199
+
200
+ function locationAliasLabel(entry, locale) {
201
+ if (!entry) return null;
202
+ const language = languageKey(locale);
203
+ const explicit = entry.labels?.[language];
204
+ if (explicit) return explicit;
205
+ const canonical = entry.canonical || entry.name || null;
206
+ if (language === 'en') return entry.labels?.en || canonical;
207
+
208
+ const aliases = aliasesOf(entry);
209
+ if (language === 'ru') {
210
+ return bestAlias(aliases, (alias) => {
211
+ if (!CYRILLIC_RE.test(alias)) return -10;
212
+ let score = 5;
213
+ if (RUSSIAN_GENERIC_RE.test(alias)) score += 8;
214
+ if (UZBEK_CYRILLIC_RE.test(alias)) score -= 4;
215
+ return score;
216
+ }) || canonical;
217
+ }
218
+
219
+ if (language === 'uz') {
220
+ return bestAlias(aliases, (alias) => {
221
+ let score = 0;
222
+ if (LATIN_RE.test(alias)) score += 3;
223
+ if (UZBEK_LATIN_MARKER_RE.test(alias)) score += 10;
224
+ if (UZBEK_CYRILLIC_RE.test(alias)) score += 6;
225
+ if (CYRILLIC_RE.test(alias) && !UZBEK_CYRILLIC_RE.test(alias)) score -= 3;
226
+ if (ENGLISH_GENERIC_RE.test(alias)) score -= 8;
227
+ return score;
228
+ }) || canonical;
229
+ }
230
+
231
+ return canonical;
232
+ }
233
+
234
+ export function geographyDisplayName(value, locale = 'en', kind = 'any', context = null) {
135
235
  const text = String(value || '').trim();
136
236
  if (!text) return '';
137
237
  const language = languageKey(locale);
@@ -143,9 +243,17 @@ export function geographyDisplayName(value, locale = 'en', kind = 'any') {
143
243
  }
144
244
  if (kind === 'city') return tables?.city?.[text] || canonicalEntityLabel(CITIES, text, locale) || text;
145
245
  if (kind === 'region') return tables?.region?.[text] || canonicalEntityLabel(REGIONS, text, locale) || text;
246
+
247
+ const localEntry = locationEntryFor(text, kind, context);
248
+ if (localEntry) {
249
+ const localized = locationAliasLabel(localEntry, locale);
250
+ if (localized) return localized;
251
+ }
252
+
146
253
  if (kind === 'district') return tables?.district?.[text] || text;
147
254
  if (kind === 'microdistrict') return tables?.microdistrict?.[text] || numberedMicrodistrictDisplayName(text, locale) || text;
148
255
  if (kind === 'metro') return tables?.metro?.[text] || text;
256
+ if (LOCATION_KIND_KEYS[kind]) return text;
149
257
  return tables?.country?.[canonicalCountryCode(text)] || tables?.city?.[text] || tables?.region?.[text] || tables?.district?.[text] || tables?.microdistrict?.[text] || tables?.metro?.[text] || canonicalEntityLabel(CITIES, text, locale) || canonicalEntityLabel(REGIONS, text, locale) || preferredEntityLabel(countryEntity(text), locale) || text;
150
258
  }
151
259