@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- 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
|
+
}
|