@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,166 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build the COUNTRY-SURFACE LEXICON for the country-lexicon soft-feed channel (#1104). This is the
7
+ * third atlas channel, a sibling of the postcode anchor (#239/#240) and the gazetteer anchor
8
+ * (#464): a per-token multi-hot clue the neural GRAMMAR conditions on but never obeys. Country is a
9
+ * CLOSED, ENUMERABLE class (~250 surfaces) — atlas, not grammar — so a dictionary phrase-lookup
10
+ * recovers the WOF-admin / resolver hierarchy case ("United States of America, Wyoming, <locality>")
11
+ * the learned tagger reads as a leading STREET. Pelias handled the same class the same way
12
+ * (`WhosOnFirstClassifier extends PhraseClassifier`); this is the model-first analogue.
13
+ *
14
+ * WHY A DEDICATED LEXICON (not just the gazetteer's `country` slot): the gazetteer already carries
15
+ * these surfaces in slot 0, and the shipped model already consumes them — yet the WOF-admin case
16
+ * still fails (model-card #1104: golden country recall 82.0% vs 88.6%). The country bit is one of a
17
+ * 5-hot vector sharing ONE learned projection with region/po_box/cedex/homograph, and it is ZEROED
18
+ * adjacent to a postcode by `suppress_gazetteer_near_postcode` (exactly where a trailing "…12345
19
+ * USA" sits). A dedicated channel de-entangles the country signal (its own projection + confidence
20
+ * weight) and is immune to that suppression. See
21
+ * docs/superpowers/plans/2026-07-14-country-lexicon-channel.md.
22
+ *
23
+ * The matcher REUSES the gazetteer's phrase-scan (longest-first n-gram over whitespace words,
24
+ * case-insensitive `entries` + uppercase-exact `code_entries`, char→piece projection) — one tested
25
+ * algorithm, two vocabularies. Only the vocabulary + the emitted feature differ. The emitted
26
+ * feature is 2-dim per piece: `[country_surface, country_ambiguous]`.
27
+ *
28
+ * - `country_surface` (bit 1): the piece is part of a recognized country surface phrase.
29
+ * - `country_ambiguous` (bit 2): the SURFACE is a homograph (also a US region) or a common-word
30
+ * name ("Georgia", "America", "England", "IN") — a SOFT version of Pelias's hard blacklist. The
31
+ * model learns to trust `surface & !ambiguous` (unambiguous long/code forms) strongly and
32
+ * `surface & ambiguous` weakly, using context — model-first, never a hard drop, so recall on
33
+ * "Republic of Georgia" is preserved.
34
+ *
35
+ * Source of truth: `@mailwoman/codex` (COUNTRY_SURFACE_FORMS + ISO2_TO_NAME) — the SAME data the
36
+ * corpus-python bridge `country-surfaces.json` is generated from (export-country-surfaces.ts), so
37
+ * the channel and the corpus shard synthesizer cannot diverge on what a country surface IS.
38
+ *
39
+ * Output: data/gazetteer/country-surface-lexicon-v1.json (small, committed, provenance-tracked).
40
+ * Regenerate: `node codex/tools/build-country-surface-lexicon.ts`
41
+ */
42
+
43
+ import { mkdirSync, writeFileSync } from "node:fs"
44
+ import { dirname, resolve } from "node:path"
45
+
46
+ import { COUNTRY_SURFACE_FORMS, ISO2_TO_NAME } from "../country/country.ts"
47
+ import { US_STATE_ABBREVIATIONS, US_STATE_NAMES } from "../us/state.ts"
48
+
49
+ const BIT = { country_surface: 1, country_ambiguous: 2 }
50
+ const SLOTS = ["country_surface", "country_ambiguous"]
51
+
52
+ // Committed output path (a codex-derived artifact, like export-country-surfaces.ts — no argv, so the
53
+ // no-process-globals lint policy holds; codex stays zero-runtime-dep).
54
+ const OUTPUT = resolve(import.meta.dirname, "../../data/gazetteer/country-surface-lexicon-v1.json")
55
+
56
+ /**
57
+ * THE shared word-normalization rule (identical to build-gazetteer-anchor-lexicon.mjs and mirrored in
58
+ * gazetteer_char_paint on both sides): per whitespace-word, strip LEADING/TRAILING characters that are not Unicode
59
+ * letters or digits (keep internal ones: "u.s.a", "timor-leste"), rejoin single-spaced. Entry keys and scanned tokens
60
+ * both pass through it, so "U.S.A." ≡ "u.s.a".
61
+ */
62
+ const wordNorm = (s: string): string =>
63
+ s
64
+ .split(/\s+/)
65
+ .map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
66
+ .filter(Boolean)
67
+ .join(" ")
68
+
69
+ const norm = (s: string): string => wordNorm(s).toLowerCase()
70
+
71
+ /** Short alphabetic code (≤3 letters once punctuation is dropped) → exact-uppercase matching. */
72
+ const isShortCode = (s: string): boolean => {
73
+ const letters = s.replace(/[^\p{L}]/gu, "")
74
+
75
+ return letters.length > 0 && letters.length <= 3 && /^[\p{L}.\s]+$/u.test(s)
76
+ }
77
+
78
+ // Homograph set: a single-word country surface that is ALSO a US region (name or abbreviation) reads
79
+ // ambiguously (Georgia the country vs the state, IN = India vs Indiana). Computed from codex so it
80
+ // tracks the US region table, never hand-maintained.
81
+ const usStateNames = new Set(US_STATE_NAMES.map((n) => n.toLowerCase()))
82
+ const usStateAbbrevs = new Set<string>(US_STATE_ABBREVIATIONS as readonly string[])
83
+
84
+ // Curated common-word country surfaces — single tokens that appear far more often as ordinary
85
+ // street/venue/locality words than as a trailing country. A SOFT flag (the model still decides), the
86
+ // model-first analogue of Pelias's blacklist (north/south/east/west/street/city/king). Tunable.
87
+ const COMMON_WORD_AMBIGUOUS = new Set(["america", "england", "britain", "turkey", "chad", "jordan", "jersey", "guinea"])
88
+
89
+ const isAmbiguousName = (lowerKey: string): boolean => usStateNames.has(lowerKey) || COMMON_WORD_AMBIGUOUS.has(lowerKey)
90
+
91
+ // surface → bits, split across the two match-rule maps (mirrors the gazetteer builder).
92
+ const entries = new Map<string, number>() // lowercase key
93
+ const codeEntries = new Map<string, number>() // exact-uppercase key
94
+ let maxNgram = 1
95
+
96
+ function add(surface: string): void {
97
+ const s = surface.trim()
98
+
99
+ if (!s) return
100
+
101
+ if (isShortCode(s)) {
102
+ const key = wordNorm(s).toUpperCase()
103
+
104
+ if (!key) return
105
+ // A code that collides with a US-state abbreviation (CA/IN/AL/CO/…) is a homograph → ambiguous.
106
+ const bits = BIT.country_surface | (usStateAbbrevs.has(key) ? BIT.country_ambiguous : 0)
107
+ codeEntries.set(key, (codeEntries.get(key) ?? 0) | bits)
108
+
109
+ return
110
+ }
111
+ const key = norm(s)
112
+
113
+ if (!key) return
114
+ const words = key.split(" ")
115
+ maxNgram = Math.max(maxNgram, words.length)
116
+ // Multi-word phrases are unambiguous by construction; single tokens consult the homograph +
117
+ // common-word rule.
118
+ const ambiguous = words.length === 1 && isAmbiguousName(key)
119
+ const bits = BIT.country_surface | (ambiguous ? BIT.country_ambiguous : 0)
120
+ entries.set(key, (entries.get(key) ?? 0) | bits)
121
+ }
122
+
123
+ // Curated rich surface forms first (US/GB/DE/… endonyms + abbreviations), then the canonical English
124
+ // name for every remaining ISO 3166-1 alpha-2 — exactly the merge country-surfaces.json performs.
125
+ for (const forms of Object.values(COUNTRY_SURFACE_FORMS)) {
126
+ for (const f of forms) {
127
+ add(f)
128
+ }
129
+ }
130
+
131
+ for (const [, name] of ISO2_TO_NAME) {
132
+ add(name)
133
+ }
134
+
135
+ const ambiguousEntries = [...entries, ...codeEntries].filter(([, b]) => b & BIT.country_ambiguous).map(([k]) => k)
136
+
137
+ const lexicon = {
138
+ version: 1,
139
+ generated_by:
140
+ "codex/tools/build-country-surface-lexicon.ts (source: @mailwoman/codex COUNTRY_SURFACE_FORMS + ISO2_TO_NAME)",
141
+ feature_dim: SLOTS.length,
142
+ slots: SLOTS,
143
+ bits: BIT,
144
+ max_ngram: maxNgram,
145
+ rules: {
146
+ word_norm:
147
+ "per whitespace-word: strip leading/trailing chars that are not Unicode letters/digits " +
148
+ "(keep internal: 'timor-leste', 'u.s.a'); rejoin single-spaced. Applied to BOTH entry keys and scanned tokens.",
149
+ entries:
150
+ "case-insensitive; key = word_norm lowercased. country_surface always set; country_ambiguous set for single-token homographs (US region) or curated common-word names.",
151
+ code_entries:
152
+ "case-SENSITIVE exact: word_norm(token) == key (keys uppercase; 'in' the word ≠ 'IN' India). n-gram length 1. country_ambiguous set when the code is also a US-state abbreviation.",
153
+ scan: "longest-first n-gram over whitespace words, left to right, non-overlapping (shared with the gazetteer matcher)",
154
+ feature:
155
+ "emitted per-piece row = [country_surface, country_ambiguous] (the raw bits); confidence = 1.0 where country_surface fires.",
156
+ },
157
+ entries: Object.fromEntries([...entries].sort(([a], [b]) => a.localeCompare(b))),
158
+ code_entries: Object.fromEntries([...codeEntries].sort(([a], [b]) => a.localeCompare(b))),
159
+ }
160
+
161
+ mkdirSync(dirname(OUTPUT), { recursive: true })
162
+ writeFileSync(OUTPUT, JSON.stringify(lexicon, null, 1) + "\n")
163
+ process.stderr.write(
164
+ `wrote ${OUTPUT}: ${entries.size} entries + ${codeEntries.size} code_entries, ` +
165
+ `max_ngram=${maxNgram}, ${ambiguousEntries.length} ambiguous: ${ambiguousEntries.slice(0, 12).join(", ")}${ambiguousEntries.length > 12 ? ", …" : ""}\n`
166
+ )
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * codex → corpus-python bridge: emit the authoritative country surface forms as JSON so the Python
7
+ * shard generators can synthesize address tails ("…, USA" / "…, United States of America") without
8
+ * re-deriving the country name/alias data. `@mailwoman/codex` stays the single source of truth
9
+ * (COUNTRY_SURFACE_FORMS + ISO2_TO_NAME, salvaged from isp-nexus spatial/countries); this writes a
10
+ * snapshot the language boundary can't import directly.
11
+ *
12
+ * Regenerate: `node codex/tools/export-country-surfaces.ts` (writes the corpus-python data file).
13
+ */
14
+
15
+ import { writeFileSync } from "node:fs"
16
+ import { resolve } from "node:path"
17
+
18
+ import { COUNTRY_SURFACE_FORMS, ISO2_TO_NAME } from "../country/country.ts"
19
+
20
+ // Merge: rich surface forms where the codex curates them, else the canonical English name for every
21
+ // ISO 3166-1 alpha-2. Canonical-name-first (the codex's own ordering) so the common form leads.
22
+ const surfaces: Record<string, string[]> = {}
23
+
24
+ for (const [iso2, forms] of Object.entries(COUNTRY_SURFACE_FORMS)) {
25
+ surfaces[iso2] = [...forms]
26
+ }
27
+
28
+ for (const [iso2, name] of ISO2_TO_NAME) {
29
+ if (!surfaces[iso2]) {
30
+ surfaces[iso2] = [name]
31
+ }
32
+ }
33
+
34
+ const out = resolve(import.meta.dirname, "../../corpus-python/src/mailwoman_train/data/country-surfaces.json")
35
+ writeFileSync(
36
+ out,
37
+ JSON.stringify(
38
+ {
39
+ _generated: "codex/tools/export-country-surfaces.ts from @mailwoman/codex COUNTRY_SURFACE_FORMS + ISO2_TO_NAME",
40
+ surfaces,
41
+ },
42
+ null,
43
+ 2
44
+ ) + "\n"
45
+ )
46
+ process.stderr.write(`wrote ${Object.keys(surfaces).length} countries → ${out}\n`)
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Regenerate `codex/country/reference-data.ts` — the per-country calling code (E.164) + currency
7
+ * (ISO 4217) table — from mledoze/countries (https://github.com/mledoze/countries, ODbL). The
8
+ * output is committed; this tool makes it reproducible (provenance), not a hand-typed dictionary.
9
+ *
10
+ * Calling-code rule: mledoze splits the code as `idd.root` + `idd.suffixes`. For most countries a
11
+ * single suffix completes the code (GB `+4` + `4` = 44); NANP members share root `+1` with their
12
+ * area code as the suffix, so they map to 1.
13
+ *
14
+ * Usage: mailwoman dev generate country-reference
15
+ */
16
+
17
+ import { writeFileSync } from "node:fs"
18
+ import { fileURLToPath } from "node:url"
19
+
20
+ const SOURCE = "https://raw.githubusercontent.com/mledoze/countries/master/countries.json"
21
+
22
+ /**
23
+ * The committed output path, resolved relative to this module (codex/tools/ → codex/country/). The codegen is
24
+ * repo-only, and in the repo `@mailwoman/codex/tools` always loads from source via the `node` exports condition, so
25
+ * `import.meta.url` points at the source tree. (`@mailwoman/core`'s `repoRootPath` would also work, but codex is
26
+ * zero-runtime-dep and `core` already references `codex` — importing core here would cycle the project graph.)
27
+ */
28
+ const DEFAULT_OUT = fileURLToPath(new URL("../country/reference-data.ts", import.meta.url))
29
+
30
+ /** A single country record from mledoze/countries, narrowed to the fields this tool reads. */
31
+ interface MledozeCountry {
32
+ cca2?: string
33
+ idd?: { root?: string; suffixes?: string[] }
34
+ currencies?: Record<string, { name?: string; symbol?: string }>
35
+ }
36
+
37
+ /** The emitted per-country reference row. */
38
+ interface CountryReferenceEntry {
39
+ callingCode?: number
40
+ currency?: { isoCode: string; name?: string; symbol?: string }
41
+ }
42
+
43
+ /** Options for {@linkcode generateCountryReference}. */
44
+ export interface GenerateCountryReferenceOptions {
45
+ /** Output path override. Default: `codex/country/reference-data.ts` (the committed table). */
46
+ out?: string
47
+ }
48
+
49
+ /** Summary returned by {@linkcode generateCountryReference}. */
50
+ export interface GenerateCountryReferenceSummary {
51
+ countries: number
52
+ outPath: string
53
+ }
54
+
55
+ function callingCode(country: MledozeCountry): number | undefined {
56
+ const root = (country.idd?.root ?? "").replace("+", "")
57
+ const suffixes = country.idd?.suffixes ?? []
58
+
59
+ if (!root) return undefined
60
+
61
+ if (root === "1") return 1
62
+
63
+ if (suffixes.length === 1) {
64
+ const n = Number(root + suffixes[0])
65
+
66
+ return Number.isFinite(n) ? n : undefined
67
+ }
68
+ const n = Number(root)
69
+
70
+ return Number.isFinite(n) ? n : undefined
71
+ }
72
+
73
+ const serialize = (o: CountryReferenceEntry): string =>
74
+ JSON.stringify(o, null, 0)
75
+ .replace(/"isoCode"/g, "isoCode")
76
+ .replace(/"callingCode"/g, "callingCode")
77
+ .replace(/"currency"/g, "currency")
78
+ .replace(/"name"/g, "name")
79
+ .replace(/"symbol"/g, "symbol")
80
+
81
+ /** Fetch mledoze/countries and regenerate the committed `COUNTRY_REFERENCE` table. */
82
+ export async function generateCountryReference(
83
+ options: GenerateCountryReferenceOptions = {},
84
+ report?: (line: string) => void
85
+ ): Promise<GenerateCountryReferenceSummary> {
86
+ const outPath = options.out ?? DEFAULT_OUT
87
+ const response = await fetch(SOURCE)
88
+
89
+ if (!response.ok) throw new Error(`fetch ${SOURCE} failed: ${response.status}`)
90
+ const countries = (await response.json()) as MledozeCountry[]
91
+
92
+ const rows: Record<string, CountryReferenceEntry> = {}
93
+
94
+ for (const country of countries) {
95
+ const alpha2 = country.cca2
96
+
97
+ if (!alpha2) continue
98
+ const entry: CountryReferenceEntry = {}
99
+ const cc = callingCode(country)
100
+
101
+ if (cc != null) {
102
+ entry.callingCode = cc
103
+ }
104
+ const currencyCodes = Object.keys(country.currencies ?? {}).sort()
105
+
106
+ if (currencyCodes.length) {
107
+ const code = currencyCodes[0]!
108
+ const info = country.currencies![code] ?? {}
109
+ entry.currency = { isoCode: code }
110
+
111
+ if (info.name) {
112
+ entry.currency.name = info.name
113
+ }
114
+
115
+ if (info.symbol) {
116
+ entry.currency.symbol = info.symbol
117
+ }
118
+ }
119
+
120
+ if (Object.keys(entry).length) {
121
+ rows[alpha2] = entry
122
+ }
123
+ }
124
+
125
+ const body = Object.keys(rows)
126
+ .sort()
127
+ .map((k) => `\t${k}: ${serialize(rows[k]!)},`)
128
+ .join("\n")
129
+
130
+ const header = `/**
131
+ * @copyright Sister Software
132
+ * @license AGPL-3.0
133
+ * @author Teffen Ellis, et al.
134
+ *
135
+ * GENERATED — do not edit by hand. Country calling codes (E.164) + currencies (ISO 4217), derived
136
+ * from mledoze/countries (https://github.com/mledoze/countries, ODbL). NANP members map to 1.
137
+ * Regenerate with: mailwoman dev generate country-reference
138
+ */
139
+
140
+ /** Static per-country reference: calling code + currency. */
141
+ export interface CountryReference {
142
+ callingCode?: number
143
+ currency?: { isoCode: string; name?: string; symbol?: string }
144
+ }
145
+
146
+ /** ISO 3166-1 alpha-2 → reference. */
147
+ export const COUNTRY_REFERENCE: Record<string, CountryReference> = {`
148
+
149
+ writeFileSync(outPath, `${header}\n${body}\n}\n`)
150
+ report?.(`wrote ${outPath} (${Object.keys(rows).length} countries)`)
151
+
152
+ return { countries: Object.keys(rows).length, outPath }
153
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Regenerates `codex/country/official-languages.ts` from Unicode CLDR supplemental data
7
+ * (territoryInfo `_officialStatus` + languageAlias). The emitted table is the #936 ingest bit's
8
+ * authority for "is this name row in an official language of its country?" — consumed by the
9
+ * gazetteer builders (`mailwoman gazetteer build`, `@mailwoman/resolver-wof-sqlite`'s GeoNames
10
+ * fold), never at query time.
11
+ *
12
+ * Each language is emitted under EVERY ISO-639 spelling CLDR aliases to it (fi + fin, sv + swe)
13
+ * so consumers can test WOF's 639-3 tags, Overture's BCP-47 keys, and GeoNames' mixed 2/3-letter
14
+ * codes without a mapping step.
15
+ *
16
+ * Usage: mailwoman dev generate official-languages [--cldr-dir <dir>] [--cldr-version 47.0.0]
17
+ *
18
+ * With `cldrDir`, reads cldr-territoryInfo.json + cldr-aliases.json from disk; otherwise fetches
19
+ * the pinned cldr-core release from jsdelivr.
20
+ */
21
+
22
+ import { readFileSync, writeFileSync } from "node:fs"
23
+ import { join } from "node:path"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ /**
27
+ * The committed output path, resolved relative to this module (codex/tools/ → codex/country/). The codegen is
28
+ * repo-only, and in the repo `@mailwoman/codex/tools` always loads from source via the `node` exports condition, so
29
+ * `import.meta.url` points at the source tree.
30
+ */
31
+ const DEFAULT_OUT = fileURLToPath(new URL("../country/official-languages.ts", import.meta.url))
32
+
33
+ /** Options for {@linkcode generateOfficialLanguages}. */
34
+ export interface GenerateOfficialLanguagesOptions {
35
+ /** Read cldr-territoryInfo.json + cldr-aliases.json from this directory instead of fetching. */
36
+ cldrDir?: string
37
+ /** Pinned cldr-core release fetched from jsdelivr when {@linkcode GenerateOfficialLanguagesOptions.cldrDir} is absent. */
38
+ cldrVersion?: string
39
+ /** Output path override. Default: `codex/country/official-languages.ts` (the committed table). */
40
+ out?: string
41
+ }
42
+
43
+ /** Summary returned by {@linkcode generateOfficialLanguages}. */
44
+ export interface GenerateOfficialLanguagesSummary {
45
+ territories: number
46
+ cldrVersion: string
47
+ outPath: string
48
+ }
49
+
50
+ interface LanguagePopulation {
51
+ _officialStatus?: string
52
+ }
53
+
54
+ async function loadCLDR(file: string, cldrDir: string | undefined, cldrVersion: string): Promise<unknown> {
55
+ if (cldrDir) return JSON.parse(readFileSync(join(cldrDir, `cldr-${file}.json`), "utf8"))
56
+ const url = `https://cdn.jsdelivr.net/npm/cldr-core@${cldrVersion}/supplemental/${file}.json`
57
+ const res = await fetch(url)
58
+
59
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`)
60
+
61
+ return res.json()
62
+ }
63
+
64
+ /** Regenerate the committed `OFFICIAL_LANGUAGES` table from CLDR supplemental data. */
65
+ export async function generateOfficialLanguages(
66
+ options: GenerateOfficialLanguagesOptions = {},
67
+ report?: (line: string) => void
68
+ ): Promise<GenerateOfficialLanguagesSummary> {
69
+ const cldrVersion = options.cldrVersion ?? "47.0.0"
70
+ const outPath = options.out ?? DEFAULT_OUT
71
+
72
+ const territoryInfo = (
73
+ (await loadCLDR("territoryInfo", options.cldrDir, cldrVersion)) as Record<
74
+ string,
75
+ Record<string, Record<string, unknown>>
76
+ >
77
+ ).supplemental!.territoryInfo as Record<string, { languagePopulation?: Record<string, LanguagePopulation> }>
78
+ const aliasesDoc = (await loadCLDR("aliases", options.cldrDir, cldrVersion)) as {
79
+ supplemental: { metadata: { alias: { languageAlias: Record<string, { _replacement?: string }> } } }
80
+ }
81
+ const languageAlias = aliasesDoc.supplemental.metadata.alias.languageAlias
82
+
83
+ // canonical code → every plain 2-3 letter alias spelling that maps to it (fi gains "fin")
84
+ const spellingsOf = new Map<string, Set<string>>()
85
+
86
+ for (const [alias, entry] of Object.entries(languageAlias)) {
87
+ const canon = entry._replacement
88
+
89
+ if (!canon || !/^[a-z]{2,3}$/.test(alias)) continue
90
+ let set = spellingsOf.get(canon)
91
+
92
+ if (!set) {
93
+ spellingsOf.set(canon, (set = new Set()))
94
+ }
95
+ set.add(alias)
96
+ }
97
+
98
+ const table: Record<string, { official: string[]; regional?: string[] }> = {}
99
+
100
+ for (const territory of Object.keys(territoryInfo).sort()) {
101
+ if (!/^[A-Z]{2}$/.test(territory)) continue
102
+ const pops = territoryInfo[territory]!.languagePopulation
103
+
104
+ if (!pops) continue
105
+ const official = new Set<string>()
106
+ const regional = new Set<string>()
107
+
108
+ for (const [lang, data] of Object.entries(pops)) {
109
+ const status = data._officialStatus
110
+
111
+ if (!status) continue
112
+ // CLDR keys can carry script subtags ("zh_Hant") — name tags use the base language.
113
+ const base = lang.split("_")[0]!
114
+ const spellings = [base, ...(spellingsOf.get(base) ?? [])].sort()
115
+
116
+ if (status === "official" || status === "de_facto_official") {
117
+ for (const s of spellings) {
118
+ official.add(s)
119
+ }
120
+ } else if (status === "official_regional") {
121
+ for (const s of spellings) {
122
+ regional.add(s)
123
+ }
124
+ }
125
+ }
126
+
127
+ if (official.size === 0 && regional.size === 0) continue
128
+ table[territory] = { official: [...official].sort() }
129
+
130
+ if (regional.size > 0) {
131
+ table[territory]!.regional = [...regional].sort()
132
+ }
133
+ }
134
+
135
+ const entries = Object.entries(table)
136
+ .map(([cc, v]) => {
137
+ const reg = v.regional ? `, regional: [${v.regional.map((l) => `"${l}"`).join(", ")}]` : ""
138
+
139
+ return `\t${cc}: { official: [${v.official.map((l) => `"${l}"`).join(", ")}]${reg} },`
140
+ })
141
+ .join("\n")
142
+
143
+ const header = `/**
144
+ * @copyright Sister Software
145
+ * @license AGPL-3.0
146
+ * @author Teffen Ellis, et al.
147
+ *
148
+ * GENERATED — do not edit by hand. Official languages per ISO 3166-1 territory, derived from
149
+ * Unicode CLDR ${cldrVersion} supplemental territoryInfo (\`_officialStatus\`). \`official\` merges
150
+ * CLDR's \`official\` + \`de_facto_official\`; \`regional\` is \`official_regional\` (kept separate —
151
+ * the #936 probe showed it pulls in cross-border quirks like Korean-in-CN, so consumers opt in).
152
+ * Every language appears under each ISO-639 spelling CLDR aliases to it (fi AND fin) so WOF
153
+ * 639-3 tags, Overture BCP-47 keys, and GeoNames codes all match without mapping.
154
+ * Regenerate with: mailwoman dev generate official-languages
155
+ */
156
+
157
+ /** Official-language spellings for one territory. */
158
+ export interface OfficialLanguageEntry {
159
+ /** CLDR \`official\` + \`de_facto_official\`, in every ISO-639 spelling. */
160
+ official: readonly string[]
161
+ /** CLDR \`official_regional\` (e.g. Catalan in ES) — opt-in for consumers. */
162
+ regional?: readonly string[]
163
+ }
164
+
165
+ /** ISO 3166-1 alpha-2 → official languages. */
166
+ export const OFFICIAL_LANGUAGES: Record<string, OfficialLanguageEntry> = {
167
+ ${entries}
168
+ }
169
+
170
+ /**
171
+ * Is \`language\` (any ISO-639 spelling: "sv", "swe", …) an official language of \`country\` (ISO
172
+ * 3166-1 alpha-2)? Regional-official languages count only with \`includeRegional\`.
173
+ */
174
+ export function isOfficialLanguage(country: string, language: string, includeRegional = false): boolean {
175
+ const entry = OFFICIAL_LANGUAGES[country.toUpperCase()]
176
+
177
+ if (!entry) return false
178
+ const lang = language.toLowerCase()
179
+
180
+ return entry.official.includes(lang) || (includeRegional && (entry.regional?.includes(lang) ?? false))
181
+ }
182
+ `
183
+
184
+ writeFileSync(outPath, header)
185
+ report?.(`Wrote ${outPath}: ${Object.keys(table).length} territories (CLDR ${cldrVersion})`)
186
+
187
+ return { territories: Object.keys(table).length, cldrVersion, outPath }
188
+ }
package/tools/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Codex operator tools — the `run()`-style modules behind `mailwoman dev generate …` commands. No
7
+ * argv, no `process.exit`: commands own parsing, rendering, and exit codes (see the 2026-07-09
8
+ * scripts→Pastel spec).
9
+ */
10
+
11
+ export * from "./generate-country-reference.ts"
12
+ export * from "./generate-official-languages.ts"