spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { FacilityStatus, FacilityType } from "./types.js";23export function slugify(s: string): string {4 return s5 .normalize("NFKD")6 .replace(/[̀-ͯ]/g, "")7 .toLowerCase()8 .replace(/&/g, " and ")9 .replace(/[^a-z0-9]+/g, "-")10 .replace(/^-+|-+$/g, "")11 .slice(0, 96);12}1314const NOISE_WORDS = new Set(["data", "center", "centre", "datacenter", "datacentre", "dc", "facility", "campus", "site", "the", "of", "inc", "llc", "ltd", "limited", "corp", "corporation", "gmbh", "sa", "ag", "bv", "nv", "plc", "co", "company", "group", "holdings", "s.a.", "s.a", "srl", "sas", "pty", "kk", "k.k."]);1516/** Aggressive name normalization for entity resolution: lowercase, strip punctuation, drop noise tokens, sort-independent. */17export function normalizeName(name: string): string {18 return name19 .normalize("NFKD")20 .replace(/[̀-ͯ]/g, "")21 .toLowerCase()22 .replace(/\(.*?\)/g, " ")23 .replace(/[^a-z0-9]+/g, " ")24 .split(" ")25 .filter((t) => t && !NOISE_WORDS.has(t))26 .join(" ")27 .trim();28}2930/** Lighter normalization keeping identifiers like "DC12", "FR5", "AM3". */31export function normalizeNameKeepCodes(name: string): string {32 return name33 .normalize("NFKD")34 .replace(/[̀-ͯ]/g, "")35 .toLowerCase()36 .replace(/\b(data\s*cent(er|re)s?|datacent(er|re)s?)\b/g, " ")37 .replace(/[^a-z0-9]+/g, " ")38 .replace(/\s+/g, " ")39 .trim();40}4142/**43 * Words that precede a number without naming a facility ("Tier 3", "Route 7", "Suite 200") — never a facility code.44 * Units and address vocabulary are excluded so that "50 MW" or "Highway 9" never become identifiers.45 */46export const FACILITY_CODE_STOPWORDS: ReadonlySet<string> = new Set([47 "TIER", "LEVEL", "FLOOR", "SUITE", "UNIT", "ROUTE", "HIGHWAY", "HWY", "ROAD", "RD", "STREET", "ST", "AVENUE", "AVE", "BOULEVARD", "BLVD", "NO", "TOP", "MW", "KW", "GW", "PUE", "RACK", "RACKS", "ZONE", "REGION", "PHONE", "TEL", "FAX", "BOX", "PO", "KM", "MI", "SQ", "SQM", "SQFT", "ACRE", "ACRES", "PHASE",48 // prepositions / verbs that precede a figure in prose ("with 50 MW", "opened 2019", "built in 1998")49 "WITH", "OF", "AND", "IN", "AT", "TO", "THE", "OVER", "THAN", "ABOUT", "UP", "FOR", "BY", "ON", "FROM", "OR", "NEAR", "PLUS", "AROUND", "APPROX", "CIRCA", "SINCE", "BUILT", "EST", "ESTABLISHED", "OPENED", "OPEN", "OPENING", "YEAR", "NR", "NUM", "NUMBER", "TOTAL", "ONLY", "SOME", "ALL", "PER",50]);51/** A figure immediately followed by a unit is a quantity, not a code ("50 MW", "3 acres", "12%"). */52const UNIT_AFTER_RE = /^\s?(MW|GW|KW|MWH|KWH|TW|SQ|SQM|SQFT|M2|FT|ACRES?|HA|KM|MI|%|MILLION|BILLION|MILLIONS|RACKS?|STOREYS?|STORIES|FLOORS?|BUILDINGS?|HALLS?|PHASES?|YEARS?)(?![A-Z])/;5354/**55 * Extract facility codes: `DC12`, `FR5`, `LD8`, `SV1`, `LONDON6`, `LON6`, `DC-12`, `FRA01A`, `IAD12`, `SYD01`, `SC-2`,56 * `S3`, `Inzai 3`, `Fremont 2`, `Las Vegas 8` (→ `VEGAS8`). A single-letter prefix is only accepted when attached57 * (`S3`, `M1`), a word prefix may be separated by a space or a hyphen. Leading zeros are dropped (`SYD01` = `SYD1`)58 * so that a code compares equal across sources. Stop-words (`Tier 3`, `Route 7`, `50 MW`) never yield a code.59 */60export function extractFacilityCodes(name: string): string[] {61 const out = new Set<string>();62 const up = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toUpperCase();63 const add = (prefix: string, digits: string, suffix: string, rest: string) => {64 if (FACILITY_CODE_STOPWORDS.has(prefix) || UNIT_AFTER_RE.test(rest)) return;65 const n = digits.replace(/^0+(?=\d)/, "");66 out.add(`${prefix}${n}${suffix}`);67 };68 // attached: DC12, FRA01A, LONDON6, S3, MEL01D69 for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{1,10})(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length));70 // separated by a space or a hyphen: DC-12, SC-2, Inzai 3, Paris 5, Las Vegas 8, London 471 for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{2,10})[\s-]+(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length));72 return [...out];73}7475/** Alphabetic prefix of a facility code (`LONDON6` → `LONDON`). */76export function facilityCodePrefix(code: string): string {77 return code.replace(/\d.*$/, "");78}7980/**81 * Compare two code sets: `1` when a code is shared and no shared prefix disagrees, `0` when a prefix present on both82 * sides carries different numbers (`LONDON6` vs `LONDON5`, `Inzai 3` vs `Inzai 4`), `null` when either side has no83 * code or the prefixes do not overlap (`SYD1` vs `MEL1` says nothing — different prefixes are different naming schemes).84 */85export function compareFacilityCodes(a: string[], b: string[]): 0 | 1 | null {86 if (!a.length || !b.length) return null;87 const byPrefix = (codes: string[]) => {88 const m = new Map<string, Set<string>>();89 for (const c of codes) { const p = facilityCodePrefix(c); if (!m.has(p)) m.set(p, new Set()); m.get(p)!.add(c); }90 return m;91 };92 const pa = byPrefix(a), pb = byPrefix(b);93 // `SY5` and `SY5B` agree (a hall of the same building); `SY5A` vs `SY5B` do not94 const base = (c: string) => c.replace(/(\d)[A-Z]$/, "$1");95 const agree = (x: string, y: string) => x === y || (base(x) === base(y) && (x === base(x) || y === base(y)));96 let shared = false, conflict = false;97 for (const [prefix, ca] of pa) {98 const cb = pb.get(prefix);99 if (!cb) continue;100 if ([...ca].some((c) => [...cb].some((d) => agree(c, d)))) shared = true;101 else conflict = true;102 }103 if (conflict) return 0;104 return shared ? 1 : null;105}106107export function tokenSet(s: string): Set<string> {108 return new Set(normalizeName(s).split(" ").filter(Boolean));109}110111export function jaccard(a: Set<string>, b: Set<string>): number {112 if (!a.size && !b.size) return 1;113 let inter = 0;114 for (const t of a) if (b.has(t)) inter++;115 return inter / (a.size + b.size - inter);116}117118/** Parse a power figure in MW from free text like "36 MW", "36MW", "120 megawatts", "1.2 GW", "36,000 kW". Returns MW. */119export function parseMw(text: string | null | undefined): number | null {120 if (!text) return null;121 const s = text.replace(/ /g, " ");122 // "40+MW", "150+ MW" (at least) parse as the stated figure123 const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i);124 if (!m) return null;125 let num = m[1]!;126 // "36,000" → 36000 ; "1,2" (fr decimal) → 1.2 ; "1.2" → 1.2 ; "0.779" → 0.779 (a decimal, never 779) ;127 // "1.500.000" (two dot groups, European thousands) → 1500000. A single dot followed by three digits is a128 // decimal in every English-language capacity figure ("0.779MW", "1.250 MW") — DataBank MSP4 was stored as 779 MW.129 if (/^\d{1,3}(,\d{3})+$/.test(num)) num = num.replace(/,/g, "");130 else if (/^\d{1,3}(\.\d{3}){2,}$/.test(num)) num = num.replace(/\./g, "");131 else num = num.replace(",", ".");132 const v = Number(num);133 if (!Number.isFinite(v)) return null;134 const unit = m[2]!.toLowerCase();135 if (unit.startsWith("g")) return round(v * 1000, 3);136 if (unit.startsWith("k")) return round(v / 1000, 3);137 return round(v, 3);138}139140/** All MW figures in a text, deduplicated, descending. */141export function parseAllMw(text: string): number[] {142 const out = new Set<number>();143 for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) {144 const v = parseMw(m[0]);145 if (v != null && v > 0 && v < 100_000) out.add(v);146 }147 return [...out].sort((a, b) => b - a);148}149150/** Square feet / square metres → m². */151export function parseAreaSqm(text: string | null | undefined): number | null {152 if (!text) return null;153 const m = text.replace(/ /g, " ").match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:\.\d+)?)\s*(million\s+)?(sq\.?\s*ft|square\s*feet|sqft|ft²|sq\.?\s*m|square\s*met(?:er|re)s?|sqm|m²|m2)/i);154 if (!m) return null;155 let v = Number(m[1]!.replace(/,/g, "").replace(/\.(?=\d{3}\b)/g, ""));156 if (m[2]) v *= 1_000_000;157 if (!Number.isFinite(v)) return null;158 const unit = m[3]!.toLowerCase();159 return /ft|feet/.test(unit) ? round(v * 0.09290304, 1) : round(v, 1);160}161162/** Acres / hectares → hectares. */163export function parseAreaHa(text: string | null | undefined): number | null {164 if (!text) return null;165 const m = text.match(/(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(acres?|hectares?|ha)\b/i);166 if (!m) return null;167 const v = Number(m[1]!.replace(/,/g, ""));168 if (!Number.isFinite(v)) return null;169 return /acre/i.test(m[2]!) ? round(v * 0.40468564, 2) : round(v, 2);170}171172/** Money in USD from "$1.2 billion", "US$450 million", "€800m", "£2bn". Non-USD is returned as-is with currency flag. */173export function parseMoney(text: string | null | undefined): { amount: number; currency: string } | null {174 if (!text) return null;175 const m = text.replace(/ /g, " ").match(/(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(trillion|billion|million|thousand|bn|m|k|b|tn)?\b/i);176 if (!m) return null;177 let v = Number(m[2]!.replace(/,/g, ""));178 const mult = (m[3] ?? "").toLowerCase();179 if (mult === "billion" || mult === "bn" || mult === "b") v *= 1e9;180 else if (mult === "million" || mult === "m") v *= 1e6;181 else if (mult === "thousand" || mult === "k") v *= 1e3;182 else if (mult === "trillion" || mult === "tn") v *= 1e12;183 const cur = m[1]!.toUpperCase();184 const currency = cur === "$" || cur === "US$" || cur === "USD" ? "USD" : cur === "CA$" || cur === "C$" ? "CAD" : cur === "€" || cur === "EUR" ? "EUR" : cur === "£" || cur === "GBP" ? "GBP" : cur === "A$" || cur === "AUD" ? "AUD" : cur === "¥" || cur === "JPY" ? "JPY" : cur === "S$" || cur === "SGD" ? "SGD" : cur === "₹" || cur === "INR" ? "INR" : cur;185 return { amount: Math.round(v), currency };186}187188const MONTHS: Record<string, string> = { january: "01", february: "02", march: "03", april: "04", june: "06", july: "07", august: "08", september: "09", october: "10", november: "11", december: "12", jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", sept: "09", oct: "10", nov: "11", dec: "12", janvier: "01", février: "02", fevrier: "02", mars: "03", avril: "04", mai: "05", juin: "06", juillet: "07", août: "08", aout: "08", septembre: "09", octobre: "10", novembre: "11", décembre: "12", decembre: "12" };189190/**191 * Parse a (possibly partial) date into ISO-like partial: "2027", "2027-Q2", "2027-06", "2027-06-15", "2027-H1".192 * Accepts "Q2 2027", "second half of 2027", "mid-2027", "June 2027", "2027-06-15T…", "15 June 2027".193 */194export function parsePartialDate(text: string | null | undefined): string | null {195 if (!text) return null;196 const s = text.trim();197 let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);198 if (m) return `${m[1]}-${m[2]}-${m[3]}`;199 m = s.match(/^(\d{4})-(\d{2})$/);200 if (m) return `${m[1]}-${m[2]}`;201 m = s.match(/\b(?:Q|quarter\s*)([1-4])\s*(?:of\s*)?(\d{4})\b/i) ?? s.match(/\b(\d{4})\s*-?\s*Q([1-4])\b/i);202 if (m) {203 const [q, y] = /^\d{4}/.test(m[1]!) ? [m[2], m[1]] : [m[1], m[2]];204 return `${y}-Q${q}`;205 }206 m = s.match(/\b(first|second|1st|2nd)\s+half\s+(?:of\s+)?(\d{4})\b/i) ?? s.match(/\bH([12])\s*(\d{4})\b/i);207 if (m) {208 const h = /^(first|1st|1)$/i.test(m[1]!) ? 1 : 2;209 return `${m[2]}-H${h}`;210 }211 m = s.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zéû]+)\.?\s+(\d{4})\b/);212 if (m && MONTHS[m[2]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[2]!.toLowerCase()]}-${m[1]!.padStart(2, "0")}`;213 m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/);214 if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[1]!.toLowerCase()]}-${m[2]!.padStart(2, "0")}`;215 m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{4})\b/);216 if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[2]}-${MONTHS[m[1]!.toLowerCase()]}`;217 m = s.match(/\b(?:mid|early|late|end of|by|in)?-?\s*(20\d{2}|19\d{2})\b/);218 if (m) return m[1]!;219 return null;220}221222/** Sortable numeric key for a partial date (for ordering timelines): YYYY.fraction */223export function partialDateSortKey(d: string | null | undefined): number {224 if (!d) return Number.POSITIVE_INFINITY;225 const y = Number(d.slice(0, 4));226 if (!Number.isFinite(y)) return Number.POSITIVE_INFINITY;227 let frac = 0.5;228 const rest = d.slice(5);229 if (/^Q[1-4]/.test(rest)) frac = (Number(rest[1]) - 0.5) / 4;230 else if (/^H[12]/.test(rest)) frac = rest[1] === "1" ? 0.25 : 0.75;231 else if (/^\d{2}/.test(rest)) {232 const mo = Number(rest.slice(0, 2));233 const day = /^\d{2}-\d{2}/.test(rest) ? Number(rest.slice(3, 5)) : 15;234 frac = (mo - 1 + day / 31) / 12;235 }236 return y + frac;237}238239export function formatPartialDate(d: string | null | undefined): string {240 if (!d) return "—";241 const y = d.slice(0, 4);242 const rest = d.slice(5);243 if (!rest) return y;244 if (/^[QH]\d/.test(rest)) return `${rest} ${y}`;245 const mo = Number(rest.slice(0, 2));246 const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];247 const mn = names[mo - 1] ?? rest.slice(0, 2);248 if (/^\d{2}-\d{2}/.test(rest)) return `${mn} ${Number(rest.slice(3, 5))}, ${y}`;249 return `${mn} ${y}`;250}251252const STATUS_PATTERNS: Array<[RegExp, FacilityStatus]> = [253 [/\b(cancel+ed|scrapped|abandoned|withdrawn)\b/i, "cancelled"],254 [/\b(closed|decommissioned|shut\s?down|ceased operations)\b/i, "closed"],255 [/\b(delayed|postponed|on hold|paused)\b/i, "delayed"],256 [/\b(partially operational|phase\s*1\s*(is\s*)?(live|operational|complete))\b/i, "partially_operational"],257 [/\b(under construction|being built|construction (is )?under ?way|breaks? ground|breaking ground|broke ground|groundbreaking|topped out|topping out|construction (has )?(started|began|begins|commenced|starts))\b/i, "under_construction"],258 [/\b(approved|approval granted|granted planning|received approval|rezoning approved|permit(s)? (were |was )?(granted|issued))\b/i, "approved"],259 [/\b(planning application|permit application|rezoning (request|application)|seeking approval|applied for|filed plans|submitted plans|under review by)\b/i, "permitting"],260 [/\b(announced|announces|unveils?|unveiled|plans to build|plans? for|will build|to build|build of|to develop|to construct|set to build|reveals? plans|launch(es|ed)? plans|confirms? (the )?(build|plans?|development)|enters? [A-Z]\w+ with|doubles down)\b/i, "announced"],261 [/\b(proposed|proposal)\b/i, "proposed"],262 [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks)\b/i, "rumored"],263 [/\b(expansion|expanding|expands|adds? capacity|adds? \d+ ?\+?(MW|GW)|new phase|additional \d+ ?\+?MW)\b/i, "expansion"],264 [/\b(now open|opened|opens|operational|live|online|commissioned|inaugurat(ed|es|ion)|ready for service|RFS)\b/i, "operational"],265];266267export function inferStatus(text: string | null | undefined): FacilityStatus | null {268 if (!text) return null;269 for (const [re, st] of STATUS_PATTERNS) if (re.test(text)) return st;270 return null;271}272273export function normalizeStatus(raw: string | null | undefined): FacilityStatus | null {274 if (!raw) return null;275 const s = raw.toLowerCase().trim().replace(/[\s-]+/g, "_");276 const direct: Record<string, FacilityStatus> = {277 operational: "operational", live: "operational", active: "operational", open: "operational", ok: "operational", operating: "operational", existing: "operational",278 under_construction: "under_construction", construction: "under_construction", building: "under_construction",279 planned: "announced", plan: "announced", announced: "announced", proposed: "proposed", rumored: "rumored", rumoured: "rumored",280 permitting: "permitting", approved: "approved", delayed: "delayed", cancelled: "cancelled", canceled: "cancelled", closed: "closed", decommissioned: "closed",281 expansion: "expansion", partially_operational: "partially_operational", partial: "partially_operational",282 coming_soon: "announced", future: "announced", in_development: "announced", development: "announced",283 };284 return direct[s] ?? inferStatus(raw);285}286287export function inferFacilityType(text: string | null | undefined): FacilityType | null {288 if (!text) return null;289 const s = text.toLowerCase();290 if (/\b(internet exchange|ixp?\b|peering exchange)/.test(s)) return "internet_exchange";291 if (/\b(carrier hotel|meet-?me room|interconnection hub)/.test(s)) return "carrier_hotel";292 if (/\b(ai (data ?center|campus|factory|cluster|infrastructure)|gpu (cluster|campus|farm)|accelerated computing|training cluster|inference)/.test(s)) return "ai";293 if (/\b(supercomput|hpc|high[- ]performance computing|exascale|petaflop)/.test(s)) return "hpc";294 if (/\b(sovereign cloud|sovereign region)/.test(s)) return "sovereign_cloud";295 if (/\b(cloud region|availability zone|aws region|azure region|gcp region)/.test(s)) return "cloud_region";296 if (/\b(hyperscale|hyperscaler)/.test(s)) return "hyperscale";297 if (/\b(edge (data ?center|site|node|facility)|micro data ?center)/.test(s)) return "edge";298 if (/\b(wholesale|build[- ]to[- ]suit|powered shell)/.test(s)) return "wholesale";299 if (/\b(government|federal|ministry|public sector|municipal)/.test(s)) return "government";300 if (/\b(university|research|laboratory|national lab)/.test(s)) return "research";301 if (/\b(colocation|colo\b|retail colocation|interconnection)/.test(s)) return "colocation";302 return null;303}304305export function round(v: number, digits = 2): number {306 const f = 10 ** digits;307 return Math.round(v * f) / f;308}309310export function clamp(v: number, lo: number, hi: number): number {311 return Math.min(hi, Math.max(lo, v));312}313314export function cleanText(s: string | null | undefined): string | null {315 if (s == null) return null;316 const t = s.replace(/\s+/g, " ").trim();317 return t || null;318}319320/** Country ISO-2 from common names / ISO-3 / demonyms found in text. */321export const COUNTRY_ALIASES: Record<string, string> = {322 "united states": "US", usa: "US", "u.s.": "US", "u.s.a.": "US", "united states of america": "US", america: "US",323 canada: "CA", "united kingdom": "GB", uk: "GB", "u.k.": "GB", britain: "GB", "great britain": "GB", england: "GB", scotland: "GB", wales: "GB",324 germany: "DE", deutschland: "DE", france: "FR", netherlands: "NL", "the netherlands": "NL", holland: "NL", ireland: "IE", spain: "ES", portugal: "PT", italy: "IT",325 sweden: "SE", norway: "NO", denmark: "DK", finland: "FI", iceland: "IS", belgium: "BE", luxembourg: "LU", switzerland: "CH", austria: "AT", poland: "PL",326 czechia: "CZ", "czech republic": "CZ", hungary: "HU", romania: "RO", bulgaria: "BG", greece: "GR", turkey: "TR", türkiye: "TR", russia: "RU", ukraine: "UA",327 japan: "JP", singapore: "SG", australia: "AU", "new zealand": "NZ", india: "IN", china: "CN", "hong kong": "HK", taiwan: "TW", "south korea": "KR", korea: "KR",328 malaysia: "MY", indonesia: "ID", thailand: "TH", vietnam: "VN", philippines: "PH", "united arab emirates": "AE", uae: "AE", "saudi arabia": "SA", qatar: "QA",329 bahrain: "BH", oman: "OM", kuwait: "KW", israel: "IL", "south africa": "ZA", nigeria: "NG", kenya: "KE", egypt: "EG", morocco: "MA", brazil: "BR", mexico: "MX",330 chile: "CL", argentina: "AR", colombia: "CO", peru: "PE", uruguay: "UY", estonia: "EE", latvia: "LV", lithuania: "LT", slovakia: "SK", slovenia: "SI", croatia: "HR",331 serbia: "RS", cyprus: "CY", malta: "MT", pakistan: "PK", bangladesh: "BD", "sri lanka": "LK", kazakhstan: "KZ", ghana: "GH", tanzania: "TZ", uganda: "UG",332 rwanda: "RW", ethiopia: "ET", angola: "AO", "côte d'ivoire": "CI", "ivory coast": "CI", senegal: "SN", tunisia: "TN", algeria: "DZ", jordan: "JO", panama: "PA",333 "costa rica": "CR", "puerto rico": "PR", "dominican republic": "DO", ecuador: "EC", bolivia: "BO", paraguay: "PY", venezuela: "VE", guatemala: "GT",334 cambodia: "KH", laos: "LA", myanmar: "MM", nepal: "NP", mongolia: "MN", macau: "MO", macao: "MO", brunei: "BN", fiji: "FJ", "papua new guinea": "PG",335 georgia: "GE", armenia: "AM", azerbaijan: "AZ", uzbekistan: "UZ", belarus: "BY", moldova: "MD", albania: "AL", "north macedonia": "MK", "bosnia and herzegovina": "BA", montenegro: "ME", kosovo: "XK",336 usa_: "US",337};338339export function countryFromText(text: string | null | undefined): string | null {340 if (!text) return null;341 const s = text.toLowerCase();342 const keys = Object.keys(COUNTRY_ALIASES).sort((a, b) => b.length - a.length);343 for (const k of keys) if (new RegExp(`(^|[^a-z])${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z]|$)`, "i").test(s)) return COUNTRY_ALIASES[k]!;344 return null;345}346