From 3595e5b42c20b6815272ff0049c3d665f5f2918e Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 19 Sep 2026 23:14:43 -0400 Subject: [PATCH] refactor: replace runtime `Intl.DisplayNames` with a static CLDR-generated country table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `scripts/generate-countries.mjs` to enumerate all current ISO 3166-1 alpha-2 codes via CLDR and emit `src/lib/countries-data.ts`; retired, exceptionally-reserved, and CLDR-only pseudo codes are explicitly excluded, XK (Kosovo) is kept - Replace the live `Intl.DisplayNames` calls in `countries.ts` with a direct lookup against the generated `COUNTRY_NAMES` record, eliminating variance between Node/ICU versions - Build the reverse `nameToCode` map from `COUNTRY_NAMES` entries instead of a double-nested loop over the alphabet - Fix `countryCodeFromName` to fall through to `ALIASES` for two-letter inputs that aren't valid codes (e.g. `"UK"` → `"GB"`) - Add `myanmar` / `"myanmar burma"` aliases so the CLDR-style name and the legacy WHOIS variant both resolve to `MM` - Extend `countries.test.ts` to assert pinned names, XK presence, retired/reserved code exclusion, and `UK` alias behaviour --- scripts/generate-countries.mjs | 64 +++++++++ src/lib/countries-data.ts | 253 +++++++++++++++++++++++++++++++++ src/lib/countries.test.ts | 9 ++ src/lib/countries.ts | 41 ++---- 4 files changed, 338 insertions(+), 29 deletions(-) create mode 100644 scripts/generate-countries.mjs create mode 100644 src/lib/countries-data.ts diff --git a/scripts/generate-countries.mjs b/scripts/generate-countries.mjs new file mode 100644 index 0000000..b45b0c2 --- /dev/null +++ b/scripts/generate-countries.mjs @@ -0,0 +1,64 @@ +// Regenerates src/lib/countries-data.ts from the local runtime's ICU (CLDR) region names. +// Usage: node scripts/generate-countries.mjs +import { writeFileSync } from "node:fs"; + +const dn = new Intl.DisplayNames(["en"], { type: "region" }); +// CLDR also knows reserved, retired and pseudo codes that aren't current ISO 3166-1 countries. +// Keep only current ISO 3166-1 alpha-2 codes plus XK (Kosovo, the de-facto user-assigned code). +const EXCLUDE = new Set([ + "AC", + "CP", + "DG", + "EA", + "EU", + "EZ", + "IC", + "TA", + "UN", // exceptionally reserved + "AN", + "BU", + "CQ", + "CS", + "DD", + "DY", + "FX", + "HV", + "NH", + "RH", + "SU", + "TP", + "VD", + "YD", + "YU", + "ZR", // retired + "UK", // reserved for GB; WHOIS "UK" is handled as an alias in countries.ts + "QO", + "XA", + "XB", // CLDR-only pseudo territories +]); + +const entries = []; +for (let a = 65; a <= 90; a++) { + for (let b = 65; b <= 90; b++) { + const code = String.fromCharCode(a, b); + if (EXCLUDE.has(code)) continue; + let name; + try { + name = dn.of(code); + } catch { + continue; + } + if (!name || name === code || name === "Unknown Region") continue; + entries.push([code, name]); + } +} + +const body = entries.map(([c, n]) => ` ${c}: ${JSON.stringify(n)},`).join("\n"); +writeFileSync( + new URL("../src/lib/countries-data.ts", import.meta.url), + `// Generated by scripts/generate-countries.mjs from CLDR (Unicode license). Do not edit by hand.\n` + + `export const COUNTRY_NAMES: Record = {\n${body}\n};\n`, +); +console.log( + `Wrote ${entries.length} countries (Node ${process.version}, ICU ${process.versions.icu}, CLDR ${process.versions.cldr})`, +); diff --git a/src/lib/countries-data.ts b/src/lib/countries-data.ts new file mode 100644 index 0000000..ebce337 --- /dev/null +++ b/src/lib/countries-data.ts @@ -0,0 +1,253 @@ +// Generated by scripts/generate-countries.mjs from CLDR (Unicode license). Do not edit by hand. +export const COUNTRY_NAMES: Record = { + AD: "Andorra", + AE: "United Arab Emirates", + AF: "Afghanistan", + AG: "Antigua & Barbuda", + AI: "Anguilla", + AL: "Albania", + AM: "Armenia", + AO: "Angola", + AQ: "Antarctica", + AR: "Argentina", + AS: "American Samoa", + AT: "Austria", + AU: "Australia", + AW: "Aruba", + AX: "Åland Islands", + AZ: "Azerbaijan", + BA: "Bosnia & Herzegovina", + BB: "Barbados", + BD: "Bangladesh", + BE: "Belgium", + BF: "Burkina Faso", + BG: "Bulgaria", + BH: "Bahrain", + BI: "Burundi", + BJ: "Benin", + BL: "St. Barthélemy", + BM: "Bermuda", + BN: "Brunei", + BO: "Bolivia", + BQ: "Caribbean Netherlands", + BR: "Brazil", + BS: "Bahamas", + BT: "Bhutan", + BV: "Bouvet Island", + BW: "Botswana", + BY: "Belarus", + BZ: "Belize", + CA: "Canada", + CC: "Cocos (Keeling) Islands", + CD: "Congo - Kinshasa", + CF: "Central African Republic", + CG: "Congo - Brazzaville", + CH: "Switzerland", + CI: "Côte d’Ivoire", + CK: "Cook Islands", + CL: "Chile", + CM: "Cameroon", + CN: "China", + CO: "Colombia", + CR: "Costa Rica", + CU: "Cuba", + CV: "Cape Verde", + CW: "Curaçao", + CX: "Christmas Island", + CY: "Cyprus", + CZ: "Czechia", + DE: "Germany", + DJ: "Djibouti", + DK: "Denmark", + DM: "Dominica", + DO: "Dominican Republic", + DZ: "Algeria", + EC: "Ecuador", + EE: "Estonia", + EG: "Egypt", + EH: "Western Sahara", + ER: "Eritrea", + ES: "Spain", + ET: "Ethiopia", + FI: "Finland", + FJ: "Fiji", + FK: "Falkland Islands", + FM: "Micronesia", + FO: "Faroe Islands", + FR: "France", + GA: "Gabon", + GB: "United Kingdom", + GD: "Grenada", + GE: "Georgia", + GF: "French Guiana", + GG: "Guernsey", + GH: "Ghana", + GI: "Gibraltar", + GL: "Greenland", + GM: "Gambia", + GN: "Guinea", + GP: "Guadeloupe", + GQ: "Equatorial Guinea", + GR: "Greece", + GS: "South Georgia & South Sandwich Islands", + GT: "Guatemala", + GU: "Guam", + GW: "Guinea-Bissau", + GY: "Guyana", + HK: "Hong Kong SAR China", + HM: "Heard & McDonald Islands", + HN: "Honduras", + HR: "Croatia", + HT: "Haiti", + HU: "Hungary", + ID: "Indonesia", + IE: "Ireland", + IL: "Israel", + IM: "Isle of Man", + IN: "India", + IO: "British Indian Ocean Territory", + IQ: "Iraq", + IR: "Iran", + IS: "Iceland", + IT: "Italy", + JE: "Jersey", + JM: "Jamaica", + JO: "Jordan", + JP: "Japan", + KE: "Kenya", + KG: "Kyrgyzstan", + KH: "Cambodia", + KI: "Kiribati", + KM: "Comoros", + KN: "St. Kitts & Nevis", + KP: "North Korea", + KR: "South Korea", + KW: "Kuwait", + KY: "Cayman Islands", + KZ: "Kazakhstan", + LA: "Laos", + LB: "Lebanon", + LC: "St. Lucia", + LI: "Liechtenstein", + LK: "Sri Lanka", + LR: "Liberia", + LS: "Lesotho", + LT: "Lithuania", + LU: "Luxembourg", + LV: "Latvia", + LY: "Libya", + MA: "Morocco", + MC: "Monaco", + MD: "Moldova", + ME: "Montenegro", + MF: "St. Martin", + MG: "Madagascar", + MH: "Marshall Islands", + MK: "North Macedonia", + ML: "Mali", + MM: "Myanmar (Burma)", + MN: "Mongolia", + MO: "Macao SAR China", + MP: "Northern Mariana Islands", + MQ: "Martinique", + MR: "Mauritania", + MS: "Montserrat", + MT: "Malta", + MU: "Mauritius", + MV: "Maldives", + MW: "Malawi", + MX: "Mexico", + MY: "Malaysia", + MZ: "Mozambique", + NA: "Namibia", + NC: "New Caledonia", + NE: "Niger", + NF: "Norfolk Island", + NG: "Nigeria", + NI: "Nicaragua", + NL: "Netherlands", + NO: "Norway", + NP: "Nepal", + NR: "Nauru", + NU: "Niue", + NZ: "New Zealand", + OM: "Oman", + PA: "Panama", + PE: "Peru", + PF: "French Polynesia", + PG: "Papua New Guinea", + PH: "Philippines", + PK: "Pakistan", + PL: "Poland", + PM: "St. Pierre & Miquelon", + PN: "Pitcairn Islands", + PR: "Puerto Rico", + PS: "Palestinian Territories", + PT: "Portugal", + PW: "Palau", + PY: "Paraguay", + QA: "Qatar", + RE: "Réunion", + RO: "Romania", + RS: "Serbia", + RU: "Russia", + RW: "Rwanda", + SA: "Saudi Arabia", + SB: "Solomon Islands", + SC: "Seychelles", + SD: "Sudan", + SE: "Sweden", + SG: "Singapore", + SH: "St. Helena", + SI: "Slovenia", + SJ: "Svalbard & Jan Mayen", + SK: "Slovakia", + SL: "Sierra Leone", + SM: "San Marino", + SN: "Senegal", + SO: "Somalia", + SR: "Suriname", + SS: "South Sudan", + ST: "São Tomé & Príncipe", + SV: "El Salvador", + SX: "Sint Maarten", + SY: "Syria", + SZ: "Eswatini", + TC: "Turks & Caicos Islands", + TD: "Chad", + TF: "French Southern Territories", + TG: "Togo", + TH: "Thailand", + TJ: "Tajikistan", + TK: "Tokelau", + TL: "Timor-Leste", + TM: "Turkmenistan", + TN: "Tunisia", + TO: "Tonga", + TR: "Türkiye", + TT: "Trinidad & Tobago", + TV: "Tuvalu", + TW: "Taiwan", + TZ: "Tanzania", + UA: "Ukraine", + UG: "Uganda", + UM: "U.S. Outlying Islands", + US: "United States", + UY: "Uruguay", + UZ: "Uzbekistan", + VA: "Vatican City", + VC: "St. Vincent & Grenadines", + VE: "Venezuela", + VG: "British Virgin Islands", + VI: "U.S. Virgin Islands", + VN: "Vietnam", + VU: "Vanuatu", + WF: "Wallis & Futuna", + WS: "Samoa", + XK: "Kosovo", + YE: "Yemen", + YT: "Mayotte", + ZA: "South Africa", + ZM: "Zambia", + ZW: "Zimbabwe", +}; diff --git a/src/lib/countries.test.ts b/src/lib/countries.test.ts index 81db170..08f81c6 100644 --- a/src/lib/countries.test.ts +++ b/src/lib/countries.test.ts @@ -19,3 +19,12 @@ test("resolveCountry fills whichever side is missing", () => { countryCode: undefined, }); }); + +test("static table: pinned names, XK kept, retired/reserved codes excluded, UK is GB", () => { + expect(countryNameFromCode("XK")).toBe("Kosovo"); + expect(countryNameFromCode("SU")).toBeUndefined(); + expect(countryNameFromCode("UK")).toBeUndefined(); + expect(countryCodeFromName("UK")).toBe("GB"); + expect(countryCodeFromName("Myanmar")).toBe("MM"); + expect(resolveCountry("UK", undefined)).toEqual({ country: "United Kingdom", countryCode: "GB" }); +}); diff --git a/src/lib/countries.ts b/src/lib/countries.ts index b7f7013..1f28435 100644 --- a/src/lib/countries.ts +++ b/src/lib/countries.ts @@ -1,4 +1,6 @@ -// Country name <-> ISO 3166-1 alpha-2 resolution, backed by the runtime's ICU data. +// Country name <-> ISO 3166-1 alpha-2 resolution, backed by a static table (see +// scripts/generate-countries.mjs) so results don't vary with the runtime's ICU version. +import { COUNTRY_NAMES } from "./countries-data"; const ALIASES: Record = { usa: "US", @@ -32,6 +34,8 @@ const ALIASES: Record = { holland: "NL", uae: "AE", burma: "MM", + myanmar: "MM", + "myanmar burma": "MM", "ivory coast": "CI", "cote d'ivoire": "CI", laos: "LA", @@ -51,45 +55,24 @@ const normalize = (s: string) => .replace(/\s+/g, " ") .trim(); -let displayNames: Intl.DisplayNames | undefined; let nameToCode: Map | undefined; -function getDisplayNames(): Intl.DisplayNames | undefined { - if (displayNames) return displayNames; - try { - displayNames = new Intl.DisplayNames(["en"], { type: "region" }); - } catch { - // Runtime without Intl.DisplayNames support - } - return displayNames; -} - /** English country name for an ISO 3166-1 alpha-2 code, or undefined if unknown. */ export function countryNameFromCode(code: string): string | undefined { - const c = code.trim().toUpperCase(); - if (!/^[A-Z]{2}$/.test(c)) return undefined; - try { - const name = getDisplayNames()?.of(c); - return name && name !== c && name !== "Unknown Region" ? name : undefined; - } catch { - return undefined; - } + return COUNTRY_NAMES[code.trim().toUpperCase()]; } /** ISO 3166-1 alpha-2 code for a country name (or the code itself), or undefined if unknown. */ export function countryCodeFromName(value: string): string | undefined { const v = value.trim(); if (!v) return undefined; - if (/^[A-Za-z]{2}$/.test(v)) return countryNameFromCode(v) ? v.toUpperCase() : undefined; + if (/^[A-Za-z]{2}$/.test(v)) { + return countryNameFromCode(v) ? v.toUpperCase() : ALIASES[v.toLowerCase()]; + } if (!nameToCode) { - nameToCode = new Map(); - for (let a = 65; a <= 90; a++) { - for (let b = 65; b <= 90; b++) { - const code = String.fromCharCode(a, b); - const name = countryNameFromCode(code); - if (name) nameToCode.set(normalize(name), code); - } - } + nameToCode = new Map( + Object.entries(COUNTRY_NAMES).map(([code, name]) => [normalize(name), code]), + ); } const key = normalize(v); return nameToCode.get(key) ?? ALIASES[key];