@mailwoman/codex 7.2.0 → 7.3.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 (66) hide show
  1. package/address-system-conventions.ts +68 -0
  2. package/au/delivery-service.ts +179 -0
  3. package/au/index.ts +15 -0
  4. package/au/level-designator.ts +209 -0
  5. package/au/postcode.ts +51 -0
  6. package/au/state.ts +35 -0
  7. package/ca/index.ts +12 -0
  8. package/ca/postal-code.ts +121 -0
  9. package/ca/province.ts +99 -0
  10. package/ca/street-type.ts +167 -0
  11. package/country/codes.ts +534 -0
  12. package/country/country.ts +125 -0
  13. package/country/index.ts +15 -0
  14. package/country/names.ts +274 -0
  15. package/country/official-languages.ts +397 -0
  16. package/country/reference-data.ts +267 -0
  17. package/country/reference.ts +47 -0
  18. package/country/subdivision.ts +93 -0
  19. package/de/bundesland.ts +102 -0
  20. package/de/index.ts +12 -0
  21. package/de/postleitzahl.ts +91 -0
  22. package/de/street-type.ts +83 -0
  23. package/fr/cedex.ts +56 -0
  24. package/fr/code-postal.ts +105 -0
  25. package/fr/departement.ts +142 -0
  26. package/fr/index.ts +14 -0
  27. package/fr/region.ts +93 -0
  28. package/fr/voie.ts +98 -0
  29. package/gb/country.ts +74 -0
  30. package/gb/index.ts +14 -0
  31. package/gb/postcode-area.ts +107 -0
  32. package/gb/postcode.ts +109 -0
  33. package/gb/street-type.ts +90 -0
  34. package/index.ts +38 -0
  35. package/jp/address-unit.ts +87 -0
  36. package/jp/index.ts +13 -0
  37. package/jp/postal-code.ts +93 -0
  38. package/jp/prefecture.ts +173 -0
  39. package/level-semantics.ts +623 -0
  40. package/nz/delivery-service.ts +211 -0
  41. package/nz/index.ts +12 -0
  42. package/nz/postcode.ts +42 -0
  43. package/out/country/index.d.ts +1 -0
  44. package/out/country/index.d.ts.map +1 -1
  45. package/out/country/index.js +1 -0
  46. package/out/country/index.js.map +1 -1
  47. package/out/country/subdivision.d.ts +44 -0
  48. package/out/country/subdivision.d.ts.map +1 -0
  49. package/out/country/subdivision.js +76 -0
  50. package/out/country/subdivision.js.map +1 -0
  51. package/package.json +81 -37
  52. package/postcode-systems.ts +68 -0
  53. package/tools/build-country-surface-lexicon.ts +166 -0
  54. package/tools/export-country-surfaces.ts +46 -0
  55. package/tools/generate-country-reference.ts +153 -0
  56. package/tools/generate-official-languages.ts +188 -0
  57. package/tools/index.ts +12 -0
  58. package/us/floor-designator.ts +119 -0
  59. package/us/index.ts +19 -0
  60. package/us/military-address.ts +199 -0
  61. package/us/po-box.ts +82 -0
  62. package/us/state.ts +156 -0
  63. package/us/street-directional.ts +220 -0
  64. package/us/street-suffix.ts +345 -0
  65. package/us/unit-designator.ts +223 -0
  66. package/us/zipcode.ts +212 -0
@@ -0,0 +1,623 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Per-locale LEVEL semantics — the fix for "Flr 1 means something different depending on where you
7
+ * are" (#1100, the secondary-address epic's second data deliverable). {@link "./us/unit-designator.ts"},
8
+ * {@link "./us/floor-designator.ts"}, and {@link "./au/level-designator.ts"} each standardize ONE
9
+ * address system's floor/level VOCABULARY — which surface tokens exist ("FL", "L 3", "Ground
10
+ * Floor"). This module standardizes the cross-locale ORDINAL SEMANTICS those tokens carry: the fact
11
+ * that "1st floor" names the SAME physical storey as "ground floor" in the United States
12
+ * (`firstNumberedIsGround: true`) but ONE STOREY ABOVE it in France, Germany, and most of
13
+ * continental Europe (`firstNumberedIsGround: false`), where ground already has its own name (RDC,
14
+ * EG, PLANTA BAJA, …) and claims ordinal 0 on its own.
15
+ *
16
+ * This is inherently a MULTI-LOCALE module — unlike `us/`, `au/`, `fr/`, … it doesn't belong to one
17
+ * address system, so it lives at the codex root and is exported only from the root barrel
18
+ * (mirroring `address-system-conventions.ts` and `postcode-systems.ts`, the other cross-system root
19
+ * modules). It does not get its own `@mailwoman/codex/<x>` subpath.
20
+ *
21
+ * Two tables do the work:
22
+ *
23
+ * 1. {@link LEVEL_DESIGNATORS_BY_FAMILY} — the designator LEXICON per LANGUAGE family (the surface
24
+ * vocabulary: FL/FLOOR/LVL/LEVEL, ÉTAGE/ÉT, OG/OBERGESCHOSS, …). Keyed by a bare language tag
25
+ * ("en", "fr", "de", …) because the WORDS don't vary by country — American and British English
26
+ * both say "floor", "basement", "penthouse".
27
+ * 2. {@link LEVEL_ORDINAL_CONVENTIONS} — the ORDINAL CONVENTION per full locale ("en-US", "en-GB",
28
+ * …), because the NUMBERING varies by country even within one language: American and Canadian
29
+ * buildings both call the ground floor "the 1st floor"; British buildings, like the rest of the
30
+ * IMDF/continental-European convention locales, do not.
31
+ *
32
+ * {@link levelToOrdinal} composes both: look up the designator's KIND in the locale's language
33
+ * family (ground / basement / numbered / fractional / special / fixed), then — for numbered
34
+ * designators only — apply the locale's numbering convention. IMDF (Apple's Indoor Mapping Data
35
+ * Format) is the schema precedent for encoding a level as a signed integer `ordinal` where ground is
36
+ * always 0; this table supplies the locale-aware mapping from a raw (designator, number) pair into
37
+ * that same ordinal space.
38
+ *
39
+ * Data is convention encoded from common postal/building usage, not a single postal authority
40
+ * publication — level-numbering conventions are cultural, not regulatory, so no Pub-28-style single
41
+ * source exists for most of these locales. Retrieved/encoded 2026-07-13, epic #1100.
42
+ *
43
+ * Deliberately-excluded ambiguities (handled by an explicit, documented rounding rule below, never a
44
+ * silent guess):
45
+ *
46
+ * - **Spanish PRINCIPAL / ENTRESUELO**: pre-metric Spanish buildings run BAJO (0) → ENTRESUELO
47
+ * (~0.5) → PRINCIPAL (1) → PISO 1/2 (2), but the exact offset varies by city and building age.
48
+ * ENTRESUELO's true position (0.5) isn't representable as an integer ordinal; it floors to 0
49
+ * (grouped with ground) — a documented approximation, not an empirical claim. PRINCIPAL is a
50
+ * fixed, always-ordinal-1 designator (it names a specific floor by convention, not by a number the
51
+ * caller supplies).
52
+ * - **English LOWER GROUND / UPPER GROUND** (UK mixed-use buildings): sit at roughly -0.5 and +0.5
53
+ * relative to ground. Both round DOWN (floor): LOWER GROUND → -1 (grouped with the first basement
54
+ * level), UPPER GROUND → 0 (grouped with ground). Convention choices, not measurements.
55
+ * - **PENTHOUSE / ROOF / ATTIC / DACHGESCHOSS / ÁTICO / ATTICO**: named by relationship to the TOP of
56
+ * a SPECIFIC building, not by a fixed distance from ground — there is no locale-independent integer
57
+ * to assign. {@link levelToOrdinal} returns `undefined` for this designator kind rather than
58
+ * guessing.
59
+ * - **Nordic ground-floor vocabulary**: Danish STUEN/STUEETAGE is a well-attested standard term
60
+ * (the "st." you see on Danish addresses). Norwegian has no equally standard, universally-agreed
61
+ * single word for "ground floor" distinct from "1. etasje" in everyday use; GATEPLAN is included
62
+ * here for structural parity with the other Nordic tables but is a lower-confidence, regional
63
+ * inclusion — flagged in-line, not asserted as authoritative.
64
+ *
65
+ * @see {@link https://register.apple.com/resources/imdf/Level/ IMDF Level — `ordinal` (Apple Indoor Mapping Data Format)}
66
+ */
67
+
68
+ /**
69
+ * How a level designator's ordinal is derived. See the module header for the rationale behind each non-obvious kind.
70
+ *
71
+ * - `"ground"` — always ordinal 0 (RDC, EG, PLANTA BAJA, …).
72
+ * - `"basement"` — ordinal is the negation of the trailing number, defaulting to 1 when the designator appears bare
73
+ * ("Basement" alone → -1, same as "B1").
74
+ * - `"numbered"` — ordinal depends on the locale's {@link LevelOrdinalConvention} (US/CA/JP-style vs
75
+ * continental-European/IMDF-style); requires a number.
76
+ * - `"fractionalAboveGround"` — conceptually between ground and the first numbered level (mezzanine, entresol/entresuelo,
77
+ * upper ground, German Zwischengeschoss); floors to ordinal 0.
78
+ * - `"fractionalBelowGround"` — conceptually between the first basement level and ground (UK lower ground, Italian
79
+ * seminterrato); floors to ordinal -1.
80
+ * - `"special"` — named by relationship to a SPECIFIC building's top (penthouse, roof, attic); no locale-independent
81
+ * ordinal exists. {@link levelToOrdinal} returns `undefined`.
82
+ * - `"fixedOrdinal"` — a specific named floor with its own fixed ordinal, independent of any number the caller supplies
83
+ * (Spanish PRINCIPAL is always ordinal 1).
84
+ */
85
+ export type LevelDesignatorKind =
86
+ | "ground"
87
+ | "basement"
88
+ | "numbered"
89
+ | "fractionalAboveGround"
90
+ | "fractionalBelowGround"
91
+ | "special"
92
+ | "fixedOrdinal"
93
+
94
+ /** One row of a per-language-family level-designator lexicon. */
95
+ export interface LevelDesignatorRow {
96
+ /** Canonical designator key (the language's native canonical spelling, uppercase). */
97
+ code: string
98
+ /** Human-readable name — the native word plus an English gloss in parentheses. */
99
+ name: string
100
+ /** Recognized surface variants, including the canonical code itself and common ASCII-folded / abbreviated spellings. */
101
+ variants: readonly string[]
102
+ /** How this designator maps to an ordinal — see {@link LevelDesignatorKind}. */
103
+ kind: LevelDesignatorKind
104
+ /** True when a secondary number typically follows ("FL 3", "B 2"); false for standalone designators ("EG", "RDC"). */
105
+ requiresNumber: boolean
106
+ /**
107
+ * Only present when `kind` is `"fixedOrdinal"` — the designator's own fixed ordinal, independent of any passed
108
+ * number.
109
+ */
110
+ fixedOrdinal?: number
111
+ }
112
+
113
+ /**
114
+ * English (American, British, Canadian, Australian, …) floor/level vocabulary. This is the GENERIC English lexicon for
115
+ * the ordinal-semantics table; it doesn't replace the more detailed per-system lexicons in
116
+ * {@link "./us/floor-designator.ts"} (USPS Pub-28 C2) or {@link "./au/level-designator.ts"} (AS 4590.1 / AMAS) — those
117
+ * drive span-proposer/synthesis vocabulary for their own address system. This table exists to answer a narrower
118
+ * question for ANY English-speaking locale: given a designator + number, what ordinal does it name.
119
+ */
120
+ export const EN_LEVEL_DESIGNATORS = [
121
+ {
122
+ code: "FLOOR",
123
+ name: "Floor",
124
+ variants: ["FLOOR", "FL", "FLR", "LEVEL", "LVL"],
125
+ kind: "numbered",
126
+ requiresNumber: true,
127
+ },
128
+ { code: "BASEMENT", name: "Basement", variants: ["BASEMENT", "BSMT", "B"], kind: "basement", requiresNumber: true },
129
+ { code: "PENTHOUSE", name: "Penthouse", variants: ["PENTHOUSE", "PH"], kind: "special", requiresNumber: false },
130
+ {
131
+ code: "GROUND",
132
+ name: "Ground",
133
+ variants: ["GROUND", "G", "GROUND FLOOR", "GF"],
134
+ kind: "ground",
135
+ requiresNumber: false,
136
+ },
137
+ {
138
+ code: "LOWER GROUND",
139
+ name: "Lower Ground",
140
+ variants: ["LOWER GROUND", "LG"],
141
+ kind: "fractionalBelowGround",
142
+ requiresNumber: false,
143
+ },
144
+ {
145
+ code: "UPPER GROUND",
146
+ name: "Upper Ground",
147
+ variants: ["UPPER GROUND", "UG"],
148
+ kind: "fractionalAboveGround",
149
+ requiresNumber: false,
150
+ },
151
+ {
152
+ code: "MEZZANINE",
153
+ name: "Mezzanine",
154
+ variants: ["MEZZANINE", "MEZZ", "M"],
155
+ kind: "fractionalAboveGround",
156
+ requiresNumber: false,
157
+ },
158
+ { code: "ROOF", name: "Roof", variants: ["ROOF", "RF"], kind: "special", requiresNumber: false },
159
+ ] as const satisfies readonly LevelDesignatorRow[]
160
+
161
+ /** French (France, and — for the vocabulary, not the numbering convention — Francophone Canada) floor/level vocabulary. */
162
+ export const FR_LEVEL_DESIGNATORS = [
163
+ {
164
+ code: "ÉTAGE",
165
+ name: "Étage (Floor)",
166
+ variants: ["ÉTAGE", "ETAGE", "ÉT", "ET"],
167
+ kind: "numbered",
168
+ requiresNumber: true,
169
+ },
170
+ {
171
+ code: "RDC",
172
+ name: "Rez-de-chaussée (Ground floor)",
173
+ variants: ["RDC", "REZ-DE-CHAUSSÉE", "REZ-DE-CHAUSSEE", "REZ DE CHAUSSEE"],
174
+ kind: "ground",
175
+ requiresNumber: false,
176
+ },
177
+ {
178
+ code: "SOUS-SOL",
179
+ name: "Sous-sol (Basement)",
180
+ variants: ["SOUS-SOL", "SOUS SOL", "SS"],
181
+ kind: "basement",
182
+ requiresNumber: true,
183
+ },
184
+ {
185
+ code: "ENTRESOL",
186
+ name: "Entresol (Mezzanine)",
187
+ variants: ["ENTRESOL"],
188
+ kind: "fractionalAboveGround",
189
+ requiresNumber: false,
190
+ },
191
+ ] as const satisfies readonly LevelDesignatorRow[]
192
+
193
+ /** German floor/level vocabulary (the -geschoss family). */
194
+ export const DE_LEVEL_DESIGNATORS = [
195
+ {
196
+ code: "OBERGESCHOSS",
197
+ name: "Obergeschoss (Upper floor)",
198
+ variants: ["OBERGESCHOSS", "OG"],
199
+ kind: "numbered",
200
+ requiresNumber: true,
201
+ },
202
+ {
203
+ code: "ERDGESCHOSS",
204
+ name: "Erdgeschoss (Ground floor)",
205
+ variants: ["ERDGESCHOSS", "EG"],
206
+ kind: "ground",
207
+ requiresNumber: false,
208
+ },
209
+ {
210
+ code: "UNTERGESCHOSS",
211
+ name: "Untergeschoss (Basement)",
212
+ variants: ["UNTERGESCHOSS", "UG"],
213
+ kind: "basement",
214
+ requiresNumber: true,
215
+ },
216
+ {
217
+ code: "DACHGESCHOSS",
218
+ name: "Dachgeschoss (Attic/roof floor)",
219
+ variants: ["DACHGESCHOSS", "DG"],
220
+ kind: "special",
221
+ requiresNumber: false,
222
+ },
223
+ {
224
+ code: "ZWISCHENGESCHOSS",
225
+ name: "Zwischengeschoss (Mezzanine)",
226
+ variants: ["ZWISCHENGESCHOSS", "ZG"],
227
+ kind: "fractionalAboveGround",
228
+ requiresNumber: false,
229
+ },
230
+ ] as const satisfies readonly LevelDesignatorRow[]
231
+
232
+ /**
233
+ * Spanish floor/level vocabulary. PRINCIPAL and ENTRESUELO offsets vary by city and building age (see the module
234
+ * header) — encoded here as a single convention, not an empirical universal.
235
+ */
236
+ export const ES_LEVEL_DESIGNATORS = [
237
+ { code: "PLANTA", name: "Planta/Piso (Floor)", variants: ["PLANTA", "PISO"], kind: "numbered", requiresNumber: true },
238
+ {
239
+ code: "PLANTA BAJA",
240
+ name: "Planta Baja (Ground floor)",
241
+ variants: ["PLANTA BAJA", "BAJO", "PB"],
242
+ kind: "ground",
243
+ requiresNumber: false,
244
+ },
245
+ {
246
+ code: "ENTRESUELO",
247
+ name: "Entresuelo (Mezzanine)",
248
+ variants: ["ENTRESUELO"],
249
+ kind: "fractionalAboveGround",
250
+ requiresNumber: false,
251
+ },
252
+ {
253
+ code: "PRINCIPAL",
254
+ name: "Principal",
255
+ variants: ["PRINCIPAL"],
256
+ kind: "fixedOrdinal",
257
+ requiresNumber: false,
258
+ fixedOrdinal: 1,
259
+ },
260
+ {
261
+ code: "SÓTANO",
262
+ name: "Sótano (Basement)",
263
+ variants: ["SÓTANO", "SOTANO"],
264
+ kind: "basement",
265
+ requiresNumber: true,
266
+ },
267
+ {
268
+ code: "ÁTICO",
269
+ name: "Ático (Attic/penthouse)",
270
+ variants: ["ÁTICO", "ATICO"],
271
+ kind: "special",
272
+ requiresNumber: false,
273
+ },
274
+ ] as const satisfies readonly LevelDesignatorRow[]
275
+
276
+ /** Italian floor/level vocabulary. */
277
+ export const IT_LEVEL_DESIGNATORS = [
278
+ { code: "PIANO", name: "Piano (Floor)", variants: ["PIANO"], kind: "numbered", requiresNumber: true },
279
+ {
280
+ code: "PIANO TERRA",
281
+ name: "Piano Terra (Ground floor)",
282
+ variants: ["PIANO TERRA", "PT"],
283
+ kind: "ground",
284
+ requiresNumber: false,
285
+ },
286
+ {
287
+ code: "SEMINTERRATO",
288
+ name: "Seminterrato (Semi-basement)",
289
+ variants: ["SEMINTERRATO"],
290
+ kind: "fractionalBelowGround",
291
+ requiresNumber: false,
292
+ },
293
+ {
294
+ code: "ATTICO",
295
+ name: "Attico (Attic/penthouse)",
296
+ variants: ["ATTICO"],
297
+ kind: "special",
298
+ requiresNumber: false,
299
+ },
300
+ ] as const satisfies readonly LevelDesignatorRow[]
301
+
302
+ /** Portuguese floor/level vocabulary. */
303
+ export const PT_LEVEL_DESIGNATORS = [
304
+ { code: "ANDAR", name: "Andar (Floor)", variants: ["ANDAR"], kind: "numbered", requiresNumber: true },
305
+ {
306
+ code: "RÉS-DO-CHÃO",
307
+ name: "Rés-do-chão (Ground floor)",
308
+ variants: ["RÉS-DO-CHÃO", "RES-DO-CHAO", "RC"],
309
+ kind: "ground",
310
+ requiresNumber: false,
311
+ },
312
+ { code: "CAVE", name: "Cave (Basement)", variants: ["CAVE"], kind: "basement", requiresNumber: true },
313
+ ] as const satisfies readonly LevelDesignatorRow[]
314
+
315
+ /** Dutch floor/level vocabulary. */
316
+ export const NL_LEVEL_DESIGNATORS = [
317
+ {
318
+ code: "VERDIEPING",
319
+ name: "Verdieping (Floor)",
320
+ variants: ["VERDIEPING", "VERD"],
321
+ kind: "numbered",
322
+ requiresNumber: true,
323
+ },
324
+ {
325
+ code: "BEGANE GROND",
326
+ name: "Begane Grond (Ground floor)",
327
+ variants: ["BEGANE GROND", "BG"],
328
+ kind: "ground",
329
+ requiresNumber: false,
330
+ },
331
+ { code: "KELDER", name: "Kelder (Basement)", variants: ["KELDER"], kind: "basement", requiresNumber: true },
332
+ ] as const satisfies readonly LevelDesignatorRow[]
333
+
334
+ /**
335
+ * Japanese (and generic CJK numeral+letter) floor/level vocabulary. Japanese addresses write the numbered floor as a
336
+ * trailing "F" suffix on the number ("2F", "地下1F"/"B1F") or the kanji "階" ("2階"); there is no distinct bare word for
337
+ * "ground floor" the way RDC/EG/PLANTA BAJA exist in Europe — "1F"/"1階" already IS ground (handled by the `"numbered"`
338
+ * kind + the ja-JP `firstNumberedIsGround: true` convention, not a separate `"ground"` row). "B" (and "地下", literally
339
+ * "underground") name the basement count the same way English "B1" does.
340
+ */
341
+ export const JA_LEVEL_DESIGNATORS = [
342
+ { code: "F", name: "階 (Floor)", variants: ["F", "階"], kind: "numbered", requiresNumber: true },
343
+ { code: "B", name: "地下 (Basement)", variants: ["B", "地下"], kind: "basement", requiresNumber: true },
344
+ { code: "RF", name: "屋上 (Rooftop)", variants: ["RF", "屋上", "ROOFTOP"], kind: "special", requiresNumber: false },
345
+ ] as const satisfies readonly LevelDesignatorRow[]
346
+
347
+ /** Swedish floor/level vocabulary. */
348
+ export const SV_LEVEL_DESIGNATORS = [
349
+ { code: "VÅNING", name: "Våning (Floor)", variants: ["VÅNING", "VANING"], kind: "numbered", requiresNumber: true },
350
+ {
351
+ code: "BOTTENVÅNING",
352
+ name: "Bottenvåning (Ground floor)",
353
+ variants: ["BOTTENVÅNING", "BOTTENVANING", "BV"],
354
+ kind: "ground",
355
+ requiresNumber: false,
356
+ },
357
+ {
358
+ code: "KÄLLARE",
359
+ name: "Källare (Basement)",
360
+ variants: ["KÄLLARE", "KALLARE"],
361
+ kind: "basement",
362
+ requiresNumber: true,
363
+ },
364
+ ] as const satisfies readonly LevelDesignatorRow[]
365
+
366
+ /**
367
+ * Norwegian floor/level vocabulary. GATEPLAN ("street level") is a lower-confidence, regional inclusion for the
368
+ * ground-floor row — see the module header's Nordic-vocabulary caveat.
369
+ */
370
+ export const NO_LEVEL_DESIGNATORS = [
371
+ { code: "ETASJE", name: "Etasje (Floor)", variants: ["ETASJE"], kind: "numbered", requiresNumber: true },
372
+ {
373
+ code: "GATEPLAN",
374
+ name: "Gateplan (Street/ground level)",
375
+ variants: ["GATEPLAN"],
376
+ kind: "ground",
377
+ requiresNumber: false,
378
+ },
379
+ { code: "KJELLER", name: "Kjeller (Basement)", variants: ["KJELLER"], kind: "basement", requiresNumber: true },
380
+ ] as const satisfies readonly LevelDesignatorRow[]
381
+
382
+ /** Danish floor/level vocabulary. STUEN/STUEETAGE ("st.") is the standard ground-floor term seen on Danish addresses. */
383
+ export const DA_LEVEL_DESIGNATORS = [
384
+ { code: "ETAGE", name: "Etage (Floor)", variants: ["ETAGE"], kind: "numbered", requiresNumber: true },
385
+ {
386
+ code: "STUEN",
387
+ name: "Stuen/Stueetage (Ground floor)",
388
+ variants: ["STUEN", "STUEETAGE", "ST"],
389
+ kind: "ground",
390
+ requiresNumber: false,
391
+ },
392
+ {
393
+ code: "KÆLDER",
394
+ name: "Kælder (Basement)",
395
+ variants: ["KÆLDER", "KAELDER"],
396
+ kind: "basement",
397
+ requiresNumber: true,
398
+ },
399
+ ] as const satisfies readonly LevelDesignatorRow[]
400
+
401
+ /** A bare language-family tag — the key into {@link LEVEL_DESIGNATORS_BY_FAMILY}. */
402
+ export type LevelLocaleFamily = "en" | "fr" | "de" | "es" | "it" | "pt" | "nl" | "ja" | "sv" | "no" | "da"
403
+
404
+ /** Every language family's level-designator lexicon, keyed by bare language tag. */
405
+ export const LEVEL_DESIGNATORS_BY_FAMILY: Readonly<Record<LevelLocaleFamily, readonly LevelDesignatorRow[]>> = {
406
+ en: EN_LEVEL_DESIGNATORS,
407
+ fr: FR_LEVEL_DESIGNATORS,
408
+ de: DE_LEVEL_DESIGNATORS,
409
+ es: ES_LEVEL_DESIGNATORS,
410
+ it: IT_LEVEL_DESIGNATORS,
411
+ pt: PT_LEVEL_DESIGNATORS,
412
+ nl: NL_LEVEL_DESIGNATORS,
413
+ ja: JA_LEVEL_DESIGNATORS,
414
+ sv: SV_LEVEL_DESIGNATORS,
415
+ no: NO_LEVEL_DESIGNATORS,
416
+ da: DA_LEVEL_DESIGNATORS,
417
+ }
418
+
419
+ /**
420
+ * Per-family inverse lookup (lowercase variant → its designator row), built once at module load. Structural integrity
421
+ * check runs here: every row must carry at least one non-blank variant, and no variant may repeat WITHIN a family
422
+ * (case-insensitive) — a malformed table throws loudly at import time rather than silently producing an ambiguous
423
+ * lexicon. Collisions ACROSS families are expected and fine (that's the entire point of this module: "UG" is Upper
424
+ * Ground in English but Untergeschoss in German — same token, different family, different meaning).
425
+ */
426
+ const LEVEL_DESIGNATOR_LOOKUP_BY_FAMILY: ReadonlyMap<
427
+ LevelLocaleFamily,
428
+ ReadonlyMap<string, LevelDesignatorRow>
429
+ > = (() => {
430
+ const byFamily = new Map<LevelLocaleFamily, ReadonlyMap<string, LevelDesignatorRow>>()
431
+
432
+ for (const family of Object.keys(LEVEL_DESIGNATORS_BY_FAMILY) as LevelLocaleFamily[]) {
433
+ const rows = LEVEL_DESIGNATORS_BY_FAMILY[family]
434
+ const lookup = new Map<string, LevelDesignatorRow>()
435
+
436
+ for (const row of rows) {
437
+ if (row.variants.length === 0) {
438
+ throw new Error(`[codex/level-semantics] family "${family}" designator "${row.code}" has no variants`)
439
+ }
440
+
441
+ for (const variant of row.variants) {
442
+ if (!variant || !variant.trim()) {
443
+ throw new Error(
444
+ `[codex/level-semantics] family "${family}" designator "${row.code}" has an empty or blank variant`
445
+ )
446
+ }
447
+
448
+ const key = variant.toLowerCase()
449
+ const existing = lookup.get(key)
450
+
451
+ if (existing) {
452
+ throw new Error(
453
+ `[codex/level-semantics] family "${family}" has a duplicate variant "${variant}" (designators "${existing.code}" and "${row.code}")`
454
+ )
455
+ }
456
+
457
+ lookup.set(key, row)
458
+ }
459
+ }
460
+
461
+ byFamily.set(family, lookup)
462
+ }
463
+
464
+ return byFamily
465
+ })()
466
+
467
+ const LEVEL_LOCALE_FAMILIES: ReadonlySet<LevelLocaleFamily> = new Set(
468
+ Object.keys(LEVEL_DESIGNATORS_BY_FAMILY) as LevelLocaleFamily[]
469
+ )
470
+
471
+ /** BCP-47-ish language tags that fold into the Norwegian family (Bokmål, Nynorsk, and the deprecated macrolanguage tag). */
472
+ const NORWEGIAN_LANGUAGE_TAGS: ReadonlySet<string> = new Set(["no", "nb", "nn"])
473
+
474
+ /**
475
+ * Split `locale` into `{ language, region }`, lowercasing the language and uppercasing the region. Tolerant of a bare
476
+ * language tag (no region).
477
+ */
478
+ function splitLocaleTag(locale: string): { language: string; region: string | undefined } {
479
+ const [language, region] = locale.split("-")
480
+
481
+ return { language: (language ?? "").toLowerCase(), region: region?.toUpperCase() }
482
+ }
483
+
484
+ /** Resolve a BCP-47-ish locale tag to its {@link LevelLocaleFamily}, or `undefined` if this module has no lexicon for it. */
485
+ function localeFamily(locale: string): LevelLocaleFamily | undefined {
486
+ const { language } = splitLocaleTag(locale)
487
+ const family = NORWEGIAN_LANGUAGE_TAGS.has(language) ? "no" : language
488
+
489
+ return LEVEL_LOCALE_FAMILIES.has(family as LevelLocaleFamily) ? (family as LevelLocaleFamily) : undefined
490
+ }
491
+
492
+ /**
493
+ * The locale's level-numbering convention: does the FIRST numbered level ("1st floor", "1F", "étage 1", …) coincide
494
+ * with ground (ordinal 0), or sit one storey above it?
495
+ */
496
+ export interface LevelOrdinalConvention {
497
+ /**
498
+ * True for the US/Canada/Japan-style convention, where the first numbered level IS ground ("1st floor" = ground floor
499
+ * = ordinal 0, so ordinal = number - 1). False for the continental-European / IMDF-style convention, where ground has
500
+ * its own designator (RDC, EG, PLANTA BAJA, …) and the first NUMBERED level sits one storey above it (ordinal =
501
+ * number) — also the convention in the UK.
502
+ */
503
+ readonly firstNumberedIsGround: boolean
504
+ }
505
+
506
+ /**
507
+ * Per-FULL-LOCALE ordinal convention overrides. Keyed by full locale (not bare language family) because English splits
508
+ * by country even though the vocabulary doesn't: American and Canadian buildings number the ground floor "1"; British
509
+ * buildings do not. "CA" (English or French) buckets with the US/Japan convention per real-world North American
510
+ * building-code practice, though individual Quebec buildings can and do vary.
511
+ */
512
+ export const LEVEL_ORDINAL_CONVENTIONS: Readonly<Record<string, LevelOrdinalConvention>> = {
513
+ "en-US": { firstNumberedIsGround: true },
514
+ "en-CA": { firstNumberedIsGround: true },
515
+ "en-GB": { firstNumberedIsGround: false },
516
+ "fr-CA": { firstNumberedIsGround: true },
517
+ "ja-JP": { firstNumberedIsGround: true },
518
+ }
519
+
520
+ /**
521
+ * Default convention per language family, used when {@link levelToOrdinal} is given a bare-language locale ("fr" with no
522
+ * country) or a country this table doesn't specifically override. Every entry here follows the
523
+ * continental-European/IMDF convention (ground is its own designator; numbered floors start at 1 for the storey above)
524
+ * except Japanese, which follows the US/CA convention. English has NO family-wide default — American/Canadian and
525
+ * British buildings disagree, so a bare "en" locale intentionally resolves to `undefined` rather than guessing.
526
+ */
527
+ const FAMILY_DEFAULT_ORDINAL_CONVENTION: Partial<Record<LevelLocaleFamily, LevelOrdinalConvention>> = {
528
+ fr: { firstNumberedIsGround: false },
529
+ de: { firstNumberedIsGround: false },
530
+ es: { firstNumberedIsGround: false },
531
+ it: { firstNumberedIsGround: false },
532
+ pt: { firstNumberedIsGround: false },
533
+ nl: { firstNumberedIsGround: false },
534
+ sv: { firstNumberedIsGround: false },
535
+ no: { firstNumberedIsGround: false },
536
+ da: { firstNumberedIsGround: false },
537
+ ja: { firstNumberedIsGround: true },
538
+ }
539
+
540
+ /**
541
+ * Resolve the ordinal convention for `locale`: an exact full-locale override, else the language family's default, else
542
+ * `undefined`.
543
+ */
544
+ function resolveOrdinalConvention(locale: string): LevelOrdinalConvention | undefined {
545
+ const { language, region } = splitLocaleTag(locale)
546
+ const normalized = region ? `${language}-${region}` : language
547
+
548
+ if (LEVEL_ORDINAL_CONVENTIONS[normalized]) {
549
+ return LEVEL_ORDINAL_CONVENTIONS[normalized]
550
+ }
551
+
552
+ const family = localeFamily(locale)
553
+
554
+ return family ? FAMILY_DEFAULT_ORDINAL_CONVENTION[family] : undefined
555
+ }
556
+
557
+ /**
558
+ * Look up a level designator (by canonical code, abbreviation, or any recognized variant) within a locale's language
559
+ * family. Case-insensitive. Returns `undefined` when the locale's family is unknown to this module, or the token isn't
560
+ * a recognized designator in that family.
561
+ */
562
+ export function lookupLevelDesignator(designator: string, locale: string): LevelDesignatorRow | undefined {
563
+ if (!designator || typeof designator !== "string") return undefined
564
+ const family = localeFamily(locale)
565
+
566
+ if (!family) return undefined
567
+
568
+ return LEVEL_DESIGNATOR_LOOKUP_BY_FAMILY.get(family)?.get(designator.trim().toLowerCase())
569
+ }
570
+
571
+ /** True when `input` is a recognized level designator (case-insensitive) in `locale`'s language family. */
572
+ export function isLevelDesignatorToken(input: unknown, locale: string): boolean {
573
+ return typeof input === "string" && lookupLevelDesignator(input, locale) !== undefined
574
+ }
575
+
576
+ /**
577
+ * Map a (designator, number) pair to an IMDF-style signed integer ordinal, given the semantics of `locale`. Ground is
578
+ * always 0. Returns `undefined` when:
579
+ *
580
+ * - `locale`'s language family has no lexicon in this module,
581
+ * - `designator` isn't a recognized token in that family,
582
+ * - The designator is `"special"` (penthouse/roof/attic — no locale-independent ordinal exists), or
583
+ * - The designator is `"numbered"` but either `number` is missing or the locale has no resolvable ordinal convention (a
584
+ * bare "en" locale, for example).
585
+ *
586
+ * @example
587
+ * levelToOrdinal("FL", 1, "en-US") // → 0 (US: 1st floor IS ground)
588
+ * levelToOrdinal("étage", 1, "fr-FR") // → 1 (FR: 1st étage is one storey above ground)
589
+ * levelToOrdinal("EG", undefined, "de-DE") // → 0 (ground, number ignored)
590
+ * levelToOrdinal("B", 1, "en-US") // → -1 (basement 1)
591
+ * levelToOrdinal("F", 1, "ja-JP") // → 0 (JP: 1F IS ground)
592
+ * levelToOrdinal("B", 1, "ja-JP") // → -1 (JP: B1F)
593
+ */
594
+ export function levelToOrdinal(designator: string, number: number | undefined, locale: string): number | undefined {
595
+ const row = lookupLevelDesignator(designator, locale)
596
+
597
+ if (!row) return undefined
598
+
599
+ switch (row.kind) {
600
+ case "ground":
601
+ return 0
602
+ case "fractionalAboveGround":
603
+ return 0
604
+ case "fractionalBelowGround":
605
+ return -1
606
+ case "special":
607
+ return undefined
608
+ case "fixedOrdinal":
609
+ return row.fixedOrdinal
610
+ case "basement":
611
+ return -Math.abs(number ?? 1)
612
+ case "numbered": {
613
+ if (number === undefined) return undefined
614
+ const convention = resolveOrdinalConvention(locale)
615
+
616
+ if (!convention) return undefined
617
+
618
+ return convention.firstNumberedIsGround ? number - 1 : number
619
+ }
620
+ default:
621
+ return undefined
622
+ }
623
+ }