@decocms/blocks 7.0.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 (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Built-in matchers matching deco-cx/apps website/matchers/*.
3
+ *
4
+ * These augment the matchers already handled inline in resolve.ts
5
+ * (always, never, device, random, utm) with the additional matchers
6
+ * that deco supported: cookie, cron, host, pathname, queryString,
7
+ * location, userAgent, environment, multi, negate.
8
+ *
9
+ * Register these at startup:
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
14
+ * registerBuiltinMatchers();
15
+ * ```
16
+ */
17
+
18
+ import type { MatcherContext } from "../cms/resolve";
19
+ import { evaluateMatcher, registerMatcher } from "../cms/resolve";
20
+ import { resolveCountryCode } from "./countryNames";
21
+
22
+ // -------------------------------------------------------------------------
23
+ // Cookie matcher
24
+ // -------------------------------------------------------------------------
25
+
26
+ function cookieMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
27
+ const name = rule.name as string | undefined;
28
+ const value = rule.value as string | undefined;
29
+ if (!name) return false;
30
+
31
+ const cookies = ctx.cookies ?? {};
32
+ const cookieValue = cookies[name];
33
+
34
+ if (cookieValue === undefined) return false;
35
+ if (value === undefined) return true;
36
+ return cookieValue === value;
37
+ }
38
+
39
+ // -------------------------------------------------------------------------
40
+ // Cron matcher
41
+ // -------------------------------------------------------------------------
42
+
43
+ function cronMatcher(rule: Record<string, unknown>, _ctx: MatcherContext): boolean {
44
+ const start = rule.start as string | undefined;
45
+ const end = rule.end as string | undefined;
46
+
47
+ if (!start && !end) return true;
48
+
49
+ const now = Date.now();
50
+
51
+ if (start) {
52
+ const startTime = new Date(start).getTime();
53
+ if (isNaN(startTime) || now < startTime) return false;
54
+ }
55
+
56
+ if (end) {
57
+ const endTime = new Date(end).getTime();
58
+ if (isNaN(endTime) || now > endTime) return false;
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ // -------------------------------------------------------------------------
65
+ // Host matcher
66
+ // -------------------------------------------------------------------------
67
+
68
+ function hostMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
69
+ const hostToMatch = rule.host as string | undefined;
70
+ if (!hostToMatch) return false;
71
+
72
+ const currentUrl = ctx.url;
73
+ if (!currentUrl) return false;
74
+
75
+ try {
76
+ const url = new URL(currentUrl);
77
+ return url.hostname === hostToMatch || url.host === hostToMatch;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // -------------------------------------------------------------------------
84
+ // Pathname matcher
85
+ // -------------------------------------------------------------------------
86
+
87
+ const MAX_PATTERN_LENGTH = 500;
88
+ const REDOS_HEURISTIC = /(\+|\*|\{[^}]*,\})\s*(\+|\*|\{[^}]*,\})/;
89
+
90
+ function isSafePattern(pattern: string): boolean {
91
+ if (pattern.length > MAX_PATTERN_LENGTH) return false;
92
+ if (REDOS_HEURISTIC.test(pattern)) return false;
93
+ return true;
94
+ }
95
+
96
+ function pathnameMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
97
+ const path = ctx.path ?? "";
98
+
99
+ // CMS "case" format: { type: "Includes" | "Equals" | "Not Includes" | "Starts With", pathname: "/..." }
100
+ const caseObj = rule.case as { type?: string; pathname?: string } | undefined;
101
+ if (caseObj?.pathname) {
102
+ switch (caseObj.type) {
103
+ case "Equals":
104
+ return path === caseObj.pathname;
105
+ case "Not Includes":
106
+ return !path.includes(caseObj.pathname);
107
+ case "Starts With":
108
+ return path.startsWith(caseObj.pathname);
109
+ case "Includes":
110
+ default:
111
+ return path.includes(caseObj.pathname);
112
+ }
113
+ }
114
+
115
+ // Standard format: pattern (regex), includes (exact/glob), excludes (exact/glob)
116
+ const pattern = rule.pattern as string | undefined;
117
+ const includes = rule.includes as string[] | undefined;
118
+ const excludes = rule.excludes as string[] | undefined;
119
+
120
+ if (pattern) {
121
+ if (!isSafePattern(pattern)) {
122
+ console.warn(
123
+ `[pathnameMatcher] Rejected potentially unsafe pattern: ${pattern.slice(0, 80)}`,
124
+ );
125
+ return false;
126
+ }
127
+ try {
128
+ const regex = new RegExp(pattern);
129
+ if (!regex.test(path)) return false;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ if (includes && includes.length > 0) {
136
+ const matches = includes.some((inc) => {
137
+ if (inc.includes("*")) {
138
+ const prefix = inc.replace(/\*+$/, "");
139
+ return path.startsWith(prefix);
140
+ }
141
+ return path === inc;
142
+ });
143
+ if (!matches) return false;
144
+ }
145
+
146
+ if (excludes && excludes.length > 0) {
147
+ const excluded = excludes.some((exc) => {
148
+ if (exc.includes("*")) {
149
+ const prefix = exc.replace(/\*+$/, "");
150
+ return path.startsWith(prefix);
151
+ }
152
+ return path === exc;
153
+ });
154
+ if (excluded) return false;
155
+ }
156
+
157
+ // No constraints specified — vacuously true
158
+ if (!pattern && !includes?.length && !excludes?.length) return false;
159
+
160
+ return true;
161
+ }
162
+
163
+ // -------------------------------------------------------------------------
164
+ // Query string matcher
165
+ // -------------------------------------------------------------------------
166
+
167
+ function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
168
+ const key = (rule.key ?? rule.param) as string | undefined;
169
+ const value = rule.value as string | undefined;
170
+
171
+ if (!key) return false;
172
+
173
+ const currentUrl = ctx.url;
174
+ if (!currentUrl) return false;
175
+
176
+ try {
177
+ const url = new URL(currentUrl);
178
+ const paramValue = url.searchParams.get(key);
179
+
180
+ if (paramValue === null) return false;
181
+ if (value === undefined) return true;
182
+ return paramValue === value;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ // -------------------------------------------------------------------------
189
+ // Location matcher — parity with deco-cx/apps/website/matchers/location.ts
190
+ //
191
+ // Each entry in includeLocations/excludeLocations is a union of:
192
+ // Location: { city?, regionCode?, country? }
193
+ // Map: { coordinates?: "lat,lng,radius_in_meters" }
194
+ // Fields may coexist on the same entry; the matcher AND's every constraint
195
+ // that is populated. An entry with zero constraints returns defaultNotMatched
196
+ // (true for includes, false for excludes), matching upstream semantics.
197
+ // -------------------------------------------------------------------------
198
+
199
+ interface LocationOrMap {
200
+ country?: string;
201
+ regionCode?: string;
202
+ city?: string;
203
+ /** "latitude,longitude,radius_in_meters" — e.g. "-23.5505,-46.6333,5000" */
204
+ coordinates?: string;
205
+ }
206
+
207
+ interface GeoSource {
208
+ country: string;
209
+ regionCode: string;
210
+ city: string;
211
+ /** "latitude,longitude" when available */
212
+ coordinates?: string;
213
+ }
214
+
215
+ function decodeCookie(value: string | undefined): string {
216
+ if (!value) return "";
217
+ try {
218
+ return decodeURIComponent(value);
219
+ } catch {
220
+ return value;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Extract geo data from the request context.
226
+ *
227
+ * Read order mirrors the original deco-cx/apps matcher (header-first), with
228
+ * additional fallbacks for environments that don't preserve the cf-* request
229
+ * headers (e.g. when TanStack Start re-wraps the request and drops them):
230
+ * 1. cf-* request headers (cf-region-code, cf-ipcity, ...)
231
+ * 2. request.cf (Cloudflare Workers native)
232
+ * 3. __cf_geo_* cookies (injected by createDecoWorkerEntry)
233
+ */
234
+ function getGeoData(ctx: MatcherContext): GeoSource {
235
+ const headers = ctx.headers ?? {};
236
+ const reqHeaders = ctx.request?.headers;
237
+ const h = (name: string): string =>
238
+ headers[name] ?? reqHeaders?.get(name) ?? "";
239
+
240
+ let regionCode = h("cf-region-code");
241
+ let country = h("cf-ipcountry") || h("x-vercel-ip-country");
242
+ let city = h("cf-ipcity");
243
+ let latitude = h("cf-iplatitude");
244
+ let longitude = h("cf-iplongitude");
245
+
246
+ if (!country || !regionCode || !city || !latitude || !longitude) {
247
+ const req = ctx.request;
248
+ const cf = req ? ((req as any).cf as Record<string, unknown> | undefined) : undefined;
249
+ if (cf) {
250
+ country = country || ((cf.country as string) ?? "");
251
+ regionCode = regionCode || ((cf.regionCode as string) ?? "");
252
+ city = city || ((cf.city as string) ?? "");
253
+ latitude = latitude || (cf.latitude != null ? String(cf.latitude) : "");
254
+ longitude = longitude || (cf.longitude != null ? String(cf.longitude) : "");
255
+ }
256
+ }
257
+
258
+ if (!country || !regionCode || !city || !latitude || !longitude) {
259
+ const cookies = ctx.cookies ?? {};
260
+ country = country || decodeCookie(cookies.__cf_geo_country);
261
+ regionCode = regionCode || decodeCookie(cookies.__cf_geo_region_code);
262
+ city = city || decodeCookie(cookies.__cf_geo_city);
263
+ latitude = latitude || decodeCookie(cookies.__cf_geo_lat);
264
+ longitude = longitude || decodeCookie(cookies.__cf_geo_lng);
265
+ }
266
+
267
+ const coordinates = latitude && longitude ? `${latitude},${longitude}` : undefined;
268
+ return { country, regionCode, city, coordinates };
269
+ }
270
+
271
+ /**
272
+ * Haversine "within radius" check.
273
+ *
274
+ * @param source "latitude,longitude" from the request's geo.
275
+ * @param target "latitude,longitude,radius_in_meters" from the rule entry.
276
+ */
277
+ function haversineWithinRadius(source: string, target: string): boolean {
278
+ const [slat, slng] = source.split(",").map(Number);
279
+ const parts = target.split(",").map(Number);
280
+ const [tlat, tlng, radiusMeters] = parts;
281
+ if (
282
+ !Number.isFinite(slat) ||
283
+ !Number.isFinite(slng) ||
284
+ !Number.isFinite(tlat) ||
285
+ !Number.isFinite(tlng) ||
286
+ !Number.isFinite(radiusMeters)
287
+ ) {
288
+ return false;
289
+ }
290
+ const R = 6371000;
291
+ const toRad = (d: number) => (d * Math.PI) / 180;
292
+ const dLat = toRad(tlat - slat);
293
+ const dLng = toRad(tlng - slng);
294
+ const a =
295
+ Math.sin(dLat / 2) ** 2 +
296
+ Math.cos(toRad(slat)) * Math.cos(toRad(tlat)) * Math.sin(dLng / 2) ** 2;
297
+ const distance = 2 * R * Math.asin(Math.sqrt(a));
298
+ return distance <= radiusMeters;
299
+ }
300
+
301
+ function matchLocation(defaultNotMatched: boolean, source: GeoSource) {
302
+ return (target: LocationOrMap): boolean => {
303
+ const hasRegion = !!target.regionCode;
304
+ const hasCity = !!target.city;
305
+ const hasCountry = !!target.country;
306
+ const hasCoords = !!target.coordinates;
307
+
308
+ if (!hasRegion && !hasCity && !hasCountry && !hasCoords) {
309
+ return defaultNotMatched;
310
+ }
311
+
312
+ let result =
313
+ !hasRegion ||
314
+ (target.regionCode!.toLowerCase() === source.regionCode.toLowerCase());
315
+
316
+ // Map-mode entries require the visitor to have coordinates — otherwise
317
+ // we can't tell whether they're inside the target radius, so the
318
+ // conservative default is "no match". This is a deliberate divergence
319
+ // from deco-cx/apps, which let coord-only rules vacuously pass when the
320
+ // visitor had no lat/lng — that behavior matches every visitor without
321
+ // geo data, which is a footgun in production.
322
+ result = result &&
323
+ (!hasCoords ||
324
+ (!!source.coordinates &&
325
+ haversineWithinRadius(source.coordinates, target.coordinates!)));
326
+
327
+ result = result &&
328
+ (!hasCity || target.city!.toLowerCase() === source.city.toLowerCase());
329
+
330
+ result = result &&
331
+ (!hasCountry ||
332
+ resolveCountryCode(target.country!).toUpperCase() ===
333
+ source.country.toUpperCase());
334
+
335
+ return result;
336
+ };
337
+ }
338
+
339
+ function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
340
+ const source = getGeoData(ctx);
341
+ const includeLocations = rule.includeLocations as LocationOrMap[] | undefined;
342
+ const excludeLocations = rule.excludeLocations as LocationOrMap[] | undefined;
343
+
344
+ if (excludeLocations?.some(matchLocation(false, source))) {
345
+ return false;
346
+ }
347
+ if (!includeLocations || includeLocations.length === 0) {
348
+ return true;
349
+ }
350
+ return includeLocations.some(matchLocation(true, source));
351
+ }
352
+
353
+ // -------------------------------------------------------------------------
354
+ // User Agent matcher
355
+ // -------------------------------------------------------------------------
356
+
357
+ function userAgentMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
358
+ const ua = ctx.userAgent ?? "";
359
+ const includes = rule.includes as string | undefined;
360
+ const match = rule.match as string | undefined;
361
+
362
+ if (includes && !ua.includes(includes)) return false;
363
+ if (match) {
364
+ if (!isSafePattern(match)) return false;
365
+ try {
366
+ if (!new RegExp(match, "i").test(ua)) return false;
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+ return true;
372
+ }
373
+
374
+ // -------------------------------------------------------------------------
375
+ // Environment matcher
376
+ // -------------------------------------------------------------------------
377
+
378
+ function environmentMatcher(rule: Record<string, unknown>, _ctx: MatcherContext): boolean {
379
+ const environment = rule.environment as string | undefined;
380
+ if (!environment) return true;
381
+
382
+ const isProd =
383
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production";
384
+
385
+ if (environment === "production") return isProd;
386
+ if (environment === "development") return !isProd;
387
+ return false;
388
+ }
389
+
390
+ // -------------------------------------------------------------------------
391
+ // Multi matcher (AND/OR combinator)
392
+ // -------------------------------------------------------------------------
393
+
394
+ function multiMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
395
+ const op = (rule.op as string) ?? "and";
396
+ const matchers = rule.matchers as Array<Record<string, unknown>> | undefined;
397
+
398
+ if (!matchers?.length) return true;
399
+
400
+ const results = matchers.map((m) => evaluateMatcher(m, ctx));
401
+ return op === "or" ? results.some(Boolean) : results.every(Boolean);
402
+ }
403
+
404
+ // -------------------------------------------------------------------------
405
+ // Negate matcher
406
+ // -------------------------------------------------------------------------
407
+
408
+ function negateMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
409
+ const matcher = rule.matcher as Record<string, unknown> | undefined;
410
+ if (!matcher) return false;
411
+ return !evaluateMatcher(matcher, ctx);
412
+ }
413
+
414
+ // -------------------------------------------------------------------------
415
+ // Registration
416
+ // -------------------------------------------------------------------------
417
+
418
+ /**
419
+ * Register all built-in matchers with the CMS resolver.
420
+ *
421
+ * Call once during app setup (in setup.ts or similar).
422
+ * These cover the matchers from deco-cx/apps that weren't
423
+ * handled inline in resolve.ts.
424
+ */
425
+ export function registerBuiltinMatchers(): void {
426
+ registerMatcher("website/matchers/cookie.ts", cookieMatcher);
427
+ registerMatcher("website/matchers/cron.ts", cronMatcher);
428
+ registerMatcher("website/matchers/date.ts", cronMatcher);
429
+ registerMatcher("website/matchers/host.ts", hostMatcher);
430
+ registerMatcher("website/matchers/pathname.ts", pathnameMatcher);
431
+ registerMatcher("website/matchers/queryString.ts", queryStringMatcher);
432
+ registerMatcher("website/matchers/location.ts", locationMatcher);
433
+ registerMatcher("website/matchers/userAgent.ts", userAgentMatcher);
434
+ registerMatcher("website/matchers/environment.ts", environmentMatcher);
435
+ registerMatcher("website/matchers/multi.ts", multiMatcher);
436
+ registerMatcher("website/matchers/negate.ts", negateMatcher);
437
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CMS country name → ISO 3166-1 alpha-2 code mapping.
3
+ *
4
+ * The CMS stores countries as full names ("Brasil", "United States").
5
+ * Cloudflare and other geo providers return ISO codes ("BR", "US").
6
+ * Keys are case-sensitive as authored in the CMS — callers should
7
+ * try both the raw value and a case-normalized lookup.
8
+ */
9
+ export const COUNTRY_NAME_TO_CODE: Record<string, string> = {
10
+ // Latin America
11
+ Brasil: "BR", Brazil: "BR",
12
+ Argentina: "AR",
13
+ Chile: "CL",
14
+ Colombia: "CO",
15
+ Mexico: "MX", "México": "MX",
16
+ Peru: "PE", "Perú": "PE",
17
+ Uruguay: "UY",
18
+ Paraguay: "PY",
19
+ Bolivia: "BO",
20
+ Ecuador: "EC",
21
+ Venezuela: "VE",
22
+ "Costa Rica": "CR",
23
+ Panama: "PA", "Panamá": "PA",
24
+ "Dominican Republic": "DO",
25
+ Guatemala: "GT",
26
+ Honduras: "HN",
27
+ "El Salvador": "SV",
28
+ Nicaragua: "NI",
29
+ Cuba: "CU",
30
+ "Puerto Rico": "PR",
31
+
32
+ // North America
33
+ "United States": "US", USA: "US", "Estados Unidos": "US",
34
+ Canada: "CA", "Canadá": "CA",
35
+
36
+ // Europe
37
+ Spain: "ES", "España": "ES",
38
+ Portugal: "PT",
39
+ Germany: "DE", Alemania: "DE", Deutschland: "DE",
40
+ France: "FR", Francia: "FR",
41
+ Italy: "IT", Italia: "IT",
42
+ "United Kingdom": "GB", UK: "GB",
43
+ Netherlands: "NL",
44
+ Switzerland: "CH",
45
+ Sweden: "SE",
46
+ Norway: "NO",
47
+ Denmark: "DK",
48
+ Finland: "FI",
49
+ Belgium: "BE",
50
+ Austria: "AT",
51
+ Ireland: "IE",
52
+ Turkey: "TR", "Türkiye": "TR",
53
+ Poland: "PL",
54
+ "Czech Republic": "CZ", Czechia: "CZ",
55
+ Romania: "RO",
56
+ Hungary: "HU",
57
+ Greece: "GR",
58
+ Croatia: "HR",
59
+
60
+ // Asia & Oceania
61
+ Japan: "JP", "Japón": "JP",
62
+ China: "CN",
63
+ "South Korea": "KR",
64
+ India: "IN",
65
+ Australia: "AU",
66
+ "New Zealand": "NZ",
67
+
68
+ // Middle East & Africa
69
+ Israel: "IL",
70
+ "Saudi Arabia": "SA",
71
+ "United Arab Emirates": "AE",
72
+ "South Africa": "ZA",
73
+ };
74
+
75
+ /**
76
+ * Resolve a country name (as stored in CMS) to its ISO code.
77
+ * Tries exact match first, then case-insensitive lookup.
78
+ */
79
+ export function resolveCountryCode(name: string): string {
80
+ if (COUNTRY_NAME_TO_CODE[name]) return COUNTRY_NAME_TO_CODE[name];
81
+
82
+ const lower = name.toLowerCase();
83
+ for (const [key, code] of Object.entries(COUNTRY_NAME_TO_CODE)) {
84
+ if (key.toLowerCase() === lower) return code;
85
+ }
86
+
87
+ // Assume it's already an ISO code
88
+ return name.toUpperCase();
89
+ }
90
+
91
+ /**
92
+ * Register an additional country name → ISO code mapping at runtime.
93
+ * Useful for site-specific CMS values not covered by the built-in list.
94
+ */
95
+ export function registerCountryMapping(name: string, code: string): void {
96
+ COUNTRY_NAME_TO_CODE[name] = code;
97
+ }
98
+
99
+ /**
100
+ * Register multiple country name → ISO code mappings at once.
101
+ */
102
+ export function registerCountryMappings(mappings: Record<string, string>): void {
103
+ Object.assign(COUNTRY_NAME_TO_CODE, mappings);
104
+ }