@decocms/start 0.22.0 → 0.22.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -356,11 +356,16 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
356
356
  // "resolved" short-circuit
357
357
  if (resolveType === "resolved") return obj.data ?? null;
358
358
 
359
- // Lazy section wrapper
359
+ // Lazy section wrapper — unwrap single inner section
360
360
  if (resolveType === "website/sections/Rendering/Lazy.tsx") {
361
361
  return obj.section ? internalResolve(obj.section, childCtx) : null;
362
362
  }
363
363
 
364
+ // Deferred section wrapper (legacy Fresh/HTMX) — unwrap inner sections array
365
+ if (resolveType === "website/sections/Rendering/Deferred.tsx") {
366
+ return obj.sections ? internalResolve(obj.sections, childCtx) : null;
367
+ }
368
+
364
369
  // Request param extraction
365
370
  if (resolveType === "website/functions/requestToParam.ts") {
366
371
  const paramName = (obj as any).param as string;
@@ -609,7 +614,7 @@ function isRawSectionLayout(section: unknown): string | null {
609
614
 
610
615
  /**
611
616
  * Resolve the final section component key by walking block references,
612
- * unwrapping Lazy wrappers, and evaluating multivariate flags.
617
+ * unwrapping Lazy/Deferred wrappers, and evaluating multivariate flags.
613
618
  * Returns null if not determinable.
614
619
  */
615
620
  function resolveFinalSectionKey(
@@ -625,6 +630,7 @@ function resolveFinalSectionKey(
625
630
  const rt = current.__resolveType as string | undefined;
626
631
  if (!rt) return null;
627
632
 
633
+ // Lazy wrapper — unwrap single inner section
628
634
  if (rt === "website/sections/Rendering/Lazy.tsx") {
629
635
  const inner = current.section;
630
636
  if (!inner || typeof inner !== "object") return null;
@@ -632,6 +638,22 @@ function resolveFinalSectionKey(
632
638
  continue;
633
639
  }
634
640
 
641
+ // Deferred wrapper (legacy) — unwrap first inner section if deterministic
642
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
643
+ const inner = current.sections;
644
+ if (!inner || typeof inner !== "object") return null;
645
+ if (Array.isArray(inner)) {
646
+ if (inner.length === 1 && inner[0] && typeof inner[0] === "object") {
647
+ current = inner[0] as Record<string, unknown>;
648
+ continue;
649
+ }
650
+ return null;
651
+ }
652
+ // sections is an object (e.g. a flag) — follow it
653
+ current = inner as Record<string, unknown>;
654
+ continue;
655
+ }
656
+
635
657
  if (
636
658
  rt === "website/flags/multivariate.ts" ||
637
659
  rt === "website/flags/multivariate/section.ts"
@@ -670,20 +692,61 @@ function resolveFinalSectionKey(
670
692
  }
671
693
 
672
694
  /**
673
- * Check if a raw CMS section is directly wrapped in `Lazy.tsx`.
674
- * Checks both the section itself and one level of block reference.
695
+ * Walk the full wrapper chain (block refs, multivariate flags, Lazy, Deferred)
696
+ * and return true if a deferral wrapper (Lazy.tsx or Deferred.tsx) is found
697
+ * at any level. This is used by shouldDeferSection to determine if the CMS
698
+ * editor intended this section to be deferred.
675
699
  */
676
- function isCmsLazyWrapped(section: unknown): boolean {
700
+ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): boolean {
677
701
  if (!section || typeof section !== "object") return false;
678
- const obj = section as Record<string, unknown>;
679
- const rt = obj.__resolveType as string | undefined;
680
- if (!rt) return false;
681
-
682
- if (rt === "website/sections/Rendering/Lazy.tsx") return true;
683
702
 
684
703
  const blocks = loadBlocks();
685
- const block = blocks[rt] as Record<string, unknown> | undefined;
686
- if (block && block.__resolveType === "website/sections/Rendering/Lazy.tsx") return true;
704
+ let current = section as Record<string, unknown>;
705
+
706
+ for (let depth = 0; depth < 10; depth++) {
707
+ const rt = current.__resolveType as string | undefined;
708
+ if (!rt) return false;
709
+
710
+ if (
711
+ rt === "website/sections/Rendering/Lazy.tsx" ||
712
+ rt === "website/sections/Rendering/Deferred.tsx"
713
+ ) {
714
+ return true;
715
+ }
716
+
717
+ // Walk through multivariate flags to check the matched variant
718
+ if (
719
+ rt === "website/flags/multivariate.ts" ||
720
+ rt === "website/flags/multivariate/section.ts"
721
+ ) {
722
+ const variants = current.variants as
723
+ | Array<{ value: unknown; rule?: unknown }>
724
+ | undefined;
725
+ if (!variants?.length) return false;
726
+
727
+ let matched: unknown = null;
728
+ for (const variant of variants) {
729
+ const rule = variant.rule as Record<string, unknown> | undefined;
730
+ if (evaluateMatcher(rule, matcherCtx ?? {})) {
731
+ matched = variant.value;
732
+ break;
733
+ }
734
+ }
735
+ if (!matched || typeof matched !== "object") return false;
736
+ current = matched as Record<string, unknown>;
737
+ continue;
738
+ }
739
+
740
+ // Named block reference — follow the chain
741
+ const block = blocks[rt] as Record<string, unknown> | undefined;
742
+ if (block) {
743
+ const { __resolveType: _, ...overrides } = current;
744
+ current = { ...block, ...overrides, __resolveType: block.__resolveType as string };
745
+ continue;
746
+ }
747
+
748
+ return false;
749
+ }
687
750
 
688
751
  return false;
689
752
  }
@@ -702,19 +765,15 @@ function shouldDeferSection(
702
765
  const rt = obj.__resolveType as string | undefined;
703
766
  if (!rt) return false;
704
767
 
705
- // Top-level flags (not wrapped in Lazy) — keep eager for safety
706
- if (rt === "website/flags/multivariate.ts" || rt === "website/flags/multivariate/section.ts") {
707
- return false;
708
- }
709
-
710
768
  const finalKey = resolveFinalSectionKey(section, matcherCtx);
711
769
  if (!finalKey) return false;
712
770
 
713
771
  if (cfg.alwaysEager.has(finalKey)) return false;
714
772
  if (isLayoutSection(finalKey)) return false;
715
773
 
716
- // Primary: respect CMS Lazy wrapper as source of truth
717
- if (cfg.respectCmsLazy && isCmsLazyWrapped(section)) return true;
774
+ // Walk the full wrapper chain (including multivariate flags) to detect
775
+ // Lazy.tsx or Deferred.tsx wrappers at any nesting level.
776
+ if (cfg.respectCmsLazy && isCmsDeferralWrapped(section, matcherCtx)) return true;
718
777
 
719
778
  // Fallback: threshold-based deferral for non-Lazy sections
720
779
  if (flatIndex >= cfg.foldThreshold) return true;
@@ -725,7 +784,10 @@ function shouldDeferSection(
725
784
  /**
726
785
  * Follow the block reference chain to find the final section component
727
786
  * and collect the CMS props WITHOUT running commerce loaders.
728
- * Resolves named block references, lazy wrappers, and multivariate flags.
787
+ * Resolves named block references, Lazy/Deferred wrappers, and multivariate flags.
788
+ *
789
+ * For Deferred.tsx with a single inner section, the inner section is returned.
790
+ * For Deferred.tsx with multiple inner sections, returns null (falls back to eager).
729
791
  */
730
792
  function resolveSectionShallow(
731
793
  section: unknown,
@@ -751,6 +813,23 @@ function resolveSectionShallow(
751
813
  continue;
752
814
  }
753
815
 
816
+ // Deferred wrapper (legacy) — unwrap if it contains a single section
817
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
818
+ const inner = current.sections;
819
+ if (!inner || typeof inner !== "object") return null;
820
+ if (Array.isArray(inner)) {
821
+ if (inner.length === 1 && inner[0] && typeof inner[0] === "object") {
822
+ current = inner[0] as Record<string, unknown>;
823
+ continue;
824
+ }
825
+ // Multiple inner sections can't be represented as a single DeferredSection
826
+ return null;
827
+ }
828
+ // sections is an object (e.g. a flag) — follow it
829
+ current = inner as Record<string, unknown>;
830
+ continue;
831
+ }
832
+
754
833
  // Multivariate flags — evaluate matchers and continue with matched variant
755
834
  if (
756
835
  rt === "website/flags/multivariate.ts" ||
@@ -265,7 +265,7 @@ function DeferredSectionWrapper({
265
265
  const skeleton = !optionsReady
266
266
  ? null
267
267
  : hasCustomFallback
268
- ? createElement(loadedOptions!.loadingFallback!)
268
+ ? createElement(loadedOptions!.loadingFallback!, deferred.rawProps)
269
269
  : (loadingFallback ??
270
270
  (isDev ? (
271
271
  <DevMissingFallbackWarning component={deferred.component} />
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { MatcherContext } from "../cms/resolve";
19
19
  import { evaluateMatcher, registerMatcher } from "../cms/resolve";
20
+ import { resolveCountryCode } from "./countryNames";
20
21
 
21
22
  // -------------------------------------------------------------------------
22
23
  // Cookie matcher
@@ -168,106 +169,90 @@ function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext):
168
169
  // Location matcher
169
170
  // -------------------------------------------------------------------------
170
171
 
171
- /**
172
- * CF country codes -> CMS country name mapping.
173
- * The CMS stores country as full names ("Brasil"), CF provides ISO codes ("BR").
174
- */
175
- const COUNTRY_NAME_TO_CODE: Record<string, string> = {
176
- Brasil: "BR", Brazil: "BR",
177
- Argentina: "AR", Chile: "CL",
178
- Colombia: "CO", Mexico: "MX", "México": "MX",
179
- Peru: "PE", "Perú": "PE",
180
- Uruguay: "UY", Paraguay: "PY",
181
- Bolivia: "BO", Ecuador: "EC",
182
- Venezuela: "VE",
183
- "United States": "US", USA: "US",
184
- "Estados Unidos": "US",
185
- Spain: "ES", "España": "ES",
186
- Portugal: "PT",
187
- Canada: "CA", "Canadá": "CA",
188
- Germany: "DE", Alemania: "DE", Deutschland: "DE",
189
- France: "FR", Francia: "FR",
190
- Italy: "IT", Italia: "IT",
191
- "United Kingdom": "GB", UK: "GB",
192
- Japan: "JP", "Japón": "JP",
193
- China: "CN",
194
- Australia: "AU",
195
- "South Korea": "KR",
196
- India: "IN",
197
- Netherlands: "NL",
198
- Switzerland: "CH",
199
- Sweden: "SE",
200
- Norway: "NO",
201
- Denmark: "DK",
202
- Finland: "FI",
203
- Belgium: "BE",
204
- Austria: "AT",
205
- Ireland: "IE",
206
- "New Zealand": "NZ",
207
- "South Africa": "ZA",
208
- Israel: "IL",
209
- "Saudi Arabia": "SA",
210
- "United Arab Emirates": "AE",
211
- Turkey: "TR", "Türkiye": "TR",
212
- Poland: "PL",
213
- "Czech Republic": "CZ", Czechia: "CZ",
214
- Romania: "RO",
215
- Hungary: "HU",
216
- Greece: "GR",
217
- Croatia: "HR",
218
- "Costa Rica": "CR",
219
- Panama: "PA", "Panamá": "PA",
220
- "Dominican Republic": "DO",
221
- Guatemala: "GT",
222
- Honduras: "HN",
223
- "El Salvador": "SV",
224
- Nicaragua: "NI",
225
- Cuba: "CU",
226
- "Puerto Rico": "PR",
227
- };
228
-
229
172
  interface LocationRule {
230
173
  country?: string;
231
174
  regionCode?: string;
232
175
  city?: string;
233
176
  }
234
177
 
178
+ interface GeoData {
179
+ country: string;
180
+ regionCode: string;
181
+ regionName: string;
182
+ city: string;
183
+ }
184
+
185
+ /**
186
+ * Extract geo data from the request context.
187
+ * Priority: request.cf (Cloudflare Workers) > geo cookies > geo headers.
188
+ */
189
+ function getGeoData(ctx: MatcherContext): GeoData {
190
+ // 1. Cloudflare Workers: request.cf has authoritative geo data
191
+ const req = ctx.request;
192
+ if (req) {
193
+ const cf = (req as any).cf as Record<string, unknown> | undefined;
194
+ if (cf?.country) {
195
+ return {
196
+ country: (cf.country as string) ?? "",
197
+ regionCode: (cf.regionCode as string) ?? (cf.region as string) ?? "",
198
+ regionName: (cf.region as string) ?? "",
199
+ city: (cf.city as string) ?? "",
200
+ };
201
+ }
202
+ }
203
+
204
+ // 2. Geo cookies (set by Cloudflare middleware on Fresh/Deno sites)
205
+ const cookies = ctx.cookies ?? {};
206
+ const cookieCountry = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
207
+ if (cookieCountry) {
208
+ return {
209
+ country: cookieCountry,
210
+ regionCode: cookies.__cf_geo_region_code ? decodeURIComponent(cookies.__cf_geo_region_code) : "",
211
+ regionName: cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "",
212
+ city: cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "",
213
+ };
214
+ }
215
+
216
+ // 3. Fallback: standard geo headers (Vercel, etc.)
217
+ const headers = ctx.headers ?? {};
218
+ return {
219
+ country: headers["cf-ipcountry"] ?? headers["x-vercel-ip-country"] ?? "",
220
+ regionCode: headers["cf-region"] ?? headers["x-vercel-ip-country-region"] ?? "",
221
+ regionName: "",
222
+ city: "",
223
+ };
224
+ }
225
+
235
226
  function matchesLocationRule(
236
227
  loc: LocationRule,
237
- regionName: string,
238
- regionCode: string,
239
- country: string,
240
- city: string,
228
+ geo: GeoData,
241
229
  ): boolean {
242
230
  if (loc.country) {
243
- const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
244
- if (code.toUpperCase() !== country.toUpperCase()) return false;
231
+ const code = resolveCountryCode(loc.country);
232
+ if (code.toUpperCase() !== geo.country.toUpperCase()) return false;
245
233
  }
246
234
  if (loc.regionCode) {
247
235
  // Match against both the short code ("SP") and full name ("São Paulo")
248
236
  // so rules authored against either format continue working.
249
237
  const ruleVal = loc.regionCode.toLowerCase();
250
- if (regionCode.toLowerCase() !== ruleVal && regionName.toLowerCase() !== ruleVal) return false;
238
+ if (geo.regionCode.toLowerCase() !== ruleVal && geo.regionName.toLowerCase() !== ruleVal) return false;
251
239
  }
252
- if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
240
+ if (loc.city && loc.city.toLowerCase() !== geo.city.toLowerCase()) return false;
253
241
  return true;
254
242
  }
255
243
 
256
244
  function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
257
- const cookies = ctx.cookies ?? {};
258
- const regionName = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
259
- const regionCode = cookies.__cf_geo_region_code ? decodeURIComponent(cookies.__cf_geo_region_code) : "";
260
- const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
261
- const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
245
+ const geo = getGeoData(ctx);
246
+ if (!geo.country) return !((rule.includeLocations as unknown[] | undefined)?.length);
262
247
 
263
248
  const includeLocations = rule.includeLocations as LocationRule[] | undefined;
264
249
  const excludeLocations = rule.excludeLocations as LocationRule[] | undefined;
265
250
 
266
- if (excludeLocations?.some((loc) => matchesLocationRule(loc, regionName, regionCode, country, city))) {
251
+ if (excludeLocations?.some((loc) => matchesLocationRule(loc, geo))) {
267
252
  return false;
268
253
  }
269
254
  if (includeLocations?.length) {
270
- return includeLocations.some((loc) => matchesLocationRule(loc, regionName, regionCode, country, city));
255
+ return includeLocations.some((loc) => matchesLocationRule(loc, geo));
271
256
  }
272
257
  return true;
273
258
  }
@@ -0,0 +1,89 @@
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
+ }
@@ -64,17 +64,19 @@ async function loadCmsPageInternal(fullPath: string) {
64
64
  ? new URL(fullPath, serverUrl.origin).toString()
65
65
  : serverUrl.toString();
66
66
 
67
+ const originRequest = getRequest();
67
68
  const matcherCtx: MatcherContext = {
68
69
  userAgent: getRequestHeader("user-agent") ?? "",
69
70
  url: urlWithSearch,
70
71
  path: basePath,
71
72
  cookies: getCookies(),
73
+ request: originRequest,
72
74
  };
73
75
  const page = await resolveDecoPage(basePath, matcherCtx);
74
76
  if (!page) return null;
75
77
 
76
78
  const request = new Request(urlWithSearch, {
77
- headers: getRequest().headers,
79
+ headers: originRequest.headers,
78
80
  });
79
81
  const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
80
82
 
@@ -113,16 +115,16 @@ export const loadCmsPage = createServerFn({ method: "GET" })
113
115
  * Avoids passing data through the server function for the homepage.
114
116
  */
115
117
  export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
118
+ const request = getRequest();
116
119
  const matcherCtx: MatcherContext = {
117
120
  userAgent: getRequestHeader("user-agent") ?? "",
118
121
  url: getRequestUrl().toString(),
119
122
  path: "/",
120
123
  cookies: getCookies(),
124
+ request,
121
125
  };
122
126
  const page = await resolveDecoPage("/", matcherCtx);
123
127
  if (!page) return null;
124
-
125
- const request = getRequest();
126
128
  const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
127
129
 
128
130
  const eagerKeys = enrichedSections.map((s) => s.component);
@@ -147,19 +149,21 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
147
149
  .handler(async (ctx) => {
148
150
  const { component, rawProps, pagePath, pageUrl } = ctx.data;
149
151
 
152
+ const originRequest = getRequest();
150
153
  const serverUrl = getRequestUrl().toString();
151
154
  const matcherCtx: MatcherContext = {
152
155
  userAgent: getRequestHeader("user-agent") ?? "",
153
156
  url: pageUrl || serverUrl,
154
157
  path: pagePath,
155
158
  cookies: getCookies(),
159
+ request: originRequest,
156
160
  };
157
161
 
158
162
  const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
159
163
  if (!section) return null;
160
164
 
161
165
  const request = new Request(pageUrl || serverUrl, {
162
- headers: getRequest().headers,
166
+ headers: originRequest.headers,
163
167
  });
164
168
  const enriched = await runSingleSectionLoader(section, request);
165
169
  return normalizeUrlsInObject(enriched);