refactor: replace runtime Intl.DisplayNames with a static CLDR-generated country table

- 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
This commit is contained in:
2026-09-19 23:14:43 -04:00
parent 89fa13ffef
commit 3595e5b42c
4 changed files with 338 additions and 29 deletions
+253
View File
@@ -0,0 +1,253 @@
// Generated by scripts/generate-countries.mjs from CLDR (Unicode license). Do not edit by hand.
export const COUNTRY_NAMES: Record<string, string> = {
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 dIvoire",
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",
};
+9
View File
@@ -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" });
});
+12 -29
View File
@@ -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<string, string> = {
usa: "US",
@@ -32,6 +34,8 @@ const ALIASES: Record<string, string> = {
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<string, string> | 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];