@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,2003 @@
1
+ import {
2
+ type ActionConfig,
3
+ type LoaderConfig,
4
+ registerActionSchemas,
5
+ registerLoaderSchemas,
6
+ } from "./schema";
7
+ import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
8
+ import { getMeter, MetricNames, withTracing } from "../middleware/observability";
9
+ import { djb2Hex } from "../sdk/djb2";
10
+ import { withInflightTimeout } from "../sdk/inflightTimeout";
11
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
12
+ import { findPageByPath, loadBlocks } from "./loader";
13
+ import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
14
+ import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
15
+
16
+ // globalThis-backed: share state across Vite server function split modules
17
+ const G = globalThis as any;
18
+ if (!G.__deco) G.__deco = {};
19
+ if (!G.__deco.commerceLoaders) G.__deco.commerceLoaders = {};
20
+ if (!G.__deco.customMatchers) G.__deco.customMatchers = {};
21
+ if (!G.__deco.eagerSectionKeys) G.__deco.eagerSectionKeys = new Set<string>();
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // onBeforeResolveProps helper — eagerly loads the section module if needed
25
+ // ---------------------------------------------------------------------------
26
+
27
+ async function applyOnBeforeResolveProps(
28
+ sectionType: string,
29
+ props: Record<string, unknown>,
30
+ ): Promise<Record<string, unknown>> {
31
+ let fn = getOnBeforeResolveProps(sectionType);
32
+ if (!fn) {
33
+ const loader = getSection(sectionType);
34
+ if (loader) {
35
+ try {
36
+ const mod = await loader();
37
+ if (mod?.onBeforeResolveProps) {
38
+ registerOnBeforeResolveProps(sectionType, mod.onBeforeResolveProps);
39
+ fn = mod.onBeforeResolveProps;
40
+ }
41
+ } catch {}
42
+ }
43
+ }
44
+ return fn ? fn(props) : props;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Well-known resolve types — extracted as constants so they're searchable
49
+ // and overridable. Consumers migrating from deco-cx/deco may have blocks
50
+ // with these __resolveType strings in their CMS JSON.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** @internal */
54
+ export const WELL_KNOWN_TYPES = {
55
+ LAZY: "website/sections/Rendering/Lazy.tsx",
56
+ DEFERRED: "website/sections/Rendering/Deferred.tsx",
57
+ REQUEST_TO_PARAM: "website/functions/requestToParam.ts",
58
+ COMMERCE_EXT_DETAILS: "commerce/loaders/product/extensions/detailsPage.ts",
59
+ COMMERCE_EXT_LISTING: "commerce/loaders/product/extensions/listingPage.ts",
60
+ MULTIVARIATE: "website/flags/multivariate.ts",
61
+ MULTIVARIATE_SECTION: "website/flags/multivariate/section.ts",
62
+ PAGE: "website/pages/Page.tsx",
63
+ } as const;
64
+
65
+ export type ResolvedSection = {
66
+ component: string;
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ props: Record<string, any>;
69
+ key: string;
70
+ /** Original position in the raw section list (used by mergeSections). */
71
+ index?: number;
72
+ };
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Deferred section — a placeholder for sections loaded on scroll
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface DeferredSection {
79
+ component: string;
80
+ key: string;
81
+ /** Position in the original page section list. */
82
+ index: number;
83
+ /**
84
+ * Short hash of rawProps for client-side cache busting.
85
+ * Keeps the serialized payload small — full rawProps are resolved
86
+ * server-side from the deferred props cache or page re-resolution.
87
+ */
88
+ propsHash: string;
89
+ /**
90
+ * CMS-resolved props without section-loader enrichment.
91
+ * @deprecated Stripped before serialization to reduce HTML payload.
92
+ * Only present server-side in the rawProps cache.
93
+ */
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ rawProps?: Record<string, any>;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Async rendering configuration
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export interface AsyncRenderingConfig {
103
+ /**
104
+ * When true, sections wrapped in `website/sections/Rendering/Lazy.tsx`
105
+ * in the CMS are deferred and loaded on scroll. This respects the
106
+ * editor's per-section choices made in the admin.
107
+ * @default true
108
+ */
109
+ respectCmsLazy: boolean;
110
+ /**
111
+ * Fold threshold: sections at or above this flat index are DEFERRED
112
+ * (rendered as a skeleton and loaded on scroll), so their resolved props are
113
+ * not serialized into the SSR hydration payload. Sections below it stay
114
+ * eager. This is an explicit, position-based opt-in that applies ONLY to
115
+ * sections the editor did NOT mark async (⚡) in the admin — it never
116
+ * overrides a CMS Lazy/Deferred wrapper.
117
+ *
118
+ * Defaults to {@link DEFAULT_FOLD_THRESHOLD} (`Infinity`), which disables
119
+ * position-based deferral entirely so the admin ⚡ toggle is the sole source
120
+ * of truth. Set a finite value per-site to also defer unmarked sections by
121
+ * position.
122
+ * @default {@link DEFAULT_FOLD_THRESHOLD}
123
+ */
124
+ foldThreshold: number;
125
+ /** Section component keys that must always be rendered eagerly. */
126
+ alwaysEager: Set<string>;
127
+ /**
128
+ * Bot-aware page SEO. When true, the page-level `seo` block's
129
+ * commerce-loader-backed props (e.g. `jsonLD: { __resolveType: "PLP Loader" }`)
130
+ * are SKIPPED for human (non-bot) requests — so SSR doesn't run the heavy
131
+ * upstream fetch and the product payload never reaches the human HTML. Bots
132
+ * still get the full SEO (JSON-LD) for indexing.
133
+ *
134
+ * OFF by default: the optimization removes the commerce data a human `<title>`
135
+ * may derive from, so it is only safe once the site provides a lightweight
136
+ * title fallback (e.g. a cheap category-metadata loader). Sites that just bump
137
+ * the framework keep the previous behavior (full SEO for everyone) and are not
138
+ * regressed. Opt in with `setAsyncRenderingConfig({ botAwareSeo: true })`.
139
+ * @default false
140
+ */
141
+ botAwareSeo: boolean;
142
+ }
143
+
144
+ /**
145
+ * Default fold threshold. `Infinity` disables position-based deferral entirely:
146
+ * sections are deferred if and only if the editor marked them async (⚡) in the
147
+ * admin (wrapped in CMS `Lazy.tsx`/`Deferred.tsx`). The admin is the source of
148
+ * truth; a section's position on the page never drives the SSR-vs-deferred
149
+ * decision by default.
150
+ *
151
+ * Sites that want to additionally defer unmarked sections by position can opt
152
+ * in explicitly via `setAsyncRenderingConfig({ foldThreshold: N })` — keeping
153
+ * the first N sections eager (above-the-fold LCP/SEO) and deferring the rest to
154
+ * trim the SSR hydration payload. Even then, the threshold only affects
155
+ * sections NOT marked ⚡; it never overrides the editor's choice.
156
+ */
157
+ export const DEFAULT_FOLD_THRESHOLD = Infinity;
158
+
159
+ // Always read from globalThis so split-module copies see updates
160
+ function getAsyncConfig(): AsyncRenderingConfig | null {
161
+ return G.__deco.asyncConfig ?? null;
162
+ }
163
+
164
+ /**
165
+ * Enable async section rendering.
166
+ *
167
+ * The admin is the source of truth: any section the editor marked async ⚡
168
+ * (wrapped in `website/sections/Rendering/Lazy.tsx` or `Deferred.tsx`) is
169
+ * deferred and loaded on scroll; every other section is rendered eagerly (SSR),
170
+ * regardless of its position. `foldThreshold` defaults to
171
+ * {@link DEFAULT_FOLD_THRESHOLD} (`Infinity`), so position-based deferral is
172
+ * OFF by default. Pass a finite `foldThreshold: N` to additionally defer
173
+ * UNMARKED sections from index N onward (an opt-in optimization that never
174
+ * overrides the editor's ⚡ choice).
175
+ *
176
+ * When not called, all sections are resolved eagerly (backward compatible).
177
+ */
178
+ export function setAsyncRenderingConfig(config?: {
179
+ foldThreshold?: number;
180
+ alwaysEager?: string[];
181
+ respectCmsLazy?: boolean;
182
+ botAwareSeo?: boolean;
183
+ }): void {
184
+ const existing = getAsyncConfig();
185
+ const merged = new Set([...(existing?.alwaysEager ?? []), ...(config?.alwaysEager ?? [])]);
186
+ G.__deco.asyncConfig = {
187
+ respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
188
+ foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? DEFAULT_FOLD_THRESHOLD,
189
+ alwaysEager: merged,
190
+ botAwareSeo: config?.botAwareSeo ?? existing?.botAwareSeo ?? false,
191
+ };
192
+ }
193
+
194
+ /** Read-only access to the current config (null when disabled). */
195
+ export function getAsyncRenderingConfig(): AsyncRenderingConfig | null {
196
+ return getAsyncConfig();
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Permanent eager section registry — survives setAsyncRenderingConfig() calls
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Register sections that declared `export const eager = true`.
205
+ * This is a permanent registry that cannot be overwritten by
206
+ * subsequent calls to `setAsyncRenderingConfig()`.
207
+ */
208
+ export function registerEagerSections(keys: string[]): void {
209
+ const set: Set<string> = G.__deco.eagerSectionKeys;
210
+ for (const k of keys) set.add(k);
211
+ }
212
+
213
+ function isEagerSection(key: string): boolean {
214
+ return (G.__deco.eagerSectionKeys as Set<string>).has(key);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Permanent neverDefer section registry
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Register sections that declared `export const neverDefer = true`.
223
+ * These sections are NEVER deferred regardless of fold threshold —
224
+ * use for interactive components that need their props on the client
225
+ * during hydration (search filters, configurators, etc.).
226
+ */
227
+ export function registerNeverDeferSections(keys: string[]): void {
228
+ const set: Set<string> = G.__deco.neverDeferSectionKeys ??= new Set();
229
+ for (const k of keys) set.add(k);
230
+ }
231
+
232
+ function isNeverDeferSection(key: string): boolean {
233
+ return (G.__deco.neverDeferSectionKeys as Set<string> | undefined)?.has(key) ?? false;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Deferred rawProps cache — keeps rawProps server-side to trim HTML payload
238
+ // ---------------------------------------------------------------------------
239
+
240
+ const DEFERRED_PROPS_TTL = 120_000; // 2 minutes
241
+ const deferredRawPropsCache = new Map<string, { rawProps: Record<string, unknown>; ts: number }>();
242
+
243
+ function deferredPropsCacheKey(pagePath: string, component: string, index: number): string {
244
+ return `${pagePath}::${component}::${index}`;
245
+ }
246
+
247
+ export function cacheDeferredRawProps(
248
+ pagePath: string,
249
+ component: string,
250
+ index: number,
251
+ rawProps: Record<string, unknown>,
252
+ ): void {
253
+ const key = deferredPropsCacheKey(pagePath, component, index);
254
+ deferredRawPropsCache.set(key, { rawProps, ts: Date.now() });
255
+
256
+ // Lazy eviction: remove expired entries when cache grows
257
+ if (deferredRawPropsCache.size > 500) {
258
+ const now = Date.now();
259
+ for (const [k, v] of deferredRawPropsCache) {
260
+ if (now - v.ts > DEFERRED_PROPS_TTL) deferredRawPropsCache.delete(k);
261
+ }
262
+ }
263
+ }
264
+
265
+ export function getDeferredRawProps(
266
+ pagePath: string,
267
+ component: string,
268
+ index: number,
269
+ ): Record<string, unknown> | null {
270
+ const key = deferredPropsCacheKey(pagePath, component, index);
271
+ const entry = deferredRawPropsCache.get(key);
272
+ if (!entry || Date.now() - entry.ts > DEFERRED_PROPS_TTL) return null;
273
+ return entry.rawProps;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Bot detection — bots always receive fully eager pages for SEO
278
+ // ---------------------------------------------------------------------------
279
+
280
+ const botPatterns: RegExp[] = [
281
+ /bot|crawl|spider|slurp|facebookexternalhit|mediapartners|google|bing|yandex|baidu|duckduck|teoma|ia_archiver|semrush|ahrefs|lighthouse/i,
282
+ ];
283
+
284
+ /**
285
+ * Add a custom bot detection regex.
286
+ * Requests matching any bot pattern receive fully eager pages for SEO.
287
+ */
288
+ export function registerBotPattern(pattern: RegExp): void {
289
+ botPatterns.push(pattern);
290
+ }
291
+
292
+ export function isBot(userAgent?: string): boolean {
293
+ if (!userAgent) return false;
294
+ return botPatterns.some((re) => re.test(userAgent));
295
+ }
296
+
297
+ /**
298
+ * Explicit override to force the eager (full SSR / crawler) render from a normal
299
+ * browser — for QA and SEO auditing without spoofing the User-Agent. Triggered
300
+ * by `?__deco_ssr=1` (alias `?__bot=1`) on the request URL.
301
+ *
302
+ * The flag lives in the query string, so it is part of the edge cache key: the
303
+ * forced-eager response gets its own cache bucket and never contaminates the
304
+ * human page. Cookies are intentionally NOT honored here — they are not in the
305
+ * cache key and would risk serving a stale human entry.
306
+ */
307
+ function hasForceEagerParam(ctx?: MatcherContext): boolean {
308
+ const url = ctx?.url;
309
+ if (!url) return false;
310
+ try {
311
+ const sp = new URL(url, "https://localhost").searchParams;
312
+ return sp.get("__deco_ssr") === "1" || sp.get("__bot") === "1";
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * True for a programmatic, non-navigation request to a page URL — an AJAX
320
+ * `fetch()`/XHR (e.g. the PLP "Ver mais"/load-more pagination), an embed, or a
321
+ * server-to-server fetch. These consumers read the static SSR HTML and never
322
+ * run the client-side deferred-section resolution (IntersectionObserver +
323
+ * `loadDeferredSection` serverFn), so any `⚡` deferred section is invisible to
324
+ * them — they only ever see its skeleton. Rendering eagerly puts the real
325
+ * content in the HTML they actually parse.
326
+ *
327
+ * Detected via `Sec-Fetch-Dest: empty`, which a `fetch()`/XHR sends and which a
328
+ * top-level browser navigation (`document`) and subresource loads
329
+ * (`image`/`script`/`style`/`font`) do not — navigations stay deferred because
330
+ * they CAN hydrate and resolve deferred sections. SPA navigations (TanStack
331
+ * `<Link>` → `/_serverFn`) also send `empty` but set `isClientNavigation`, and
332
+ * are excluded here so page-SEO commerce loaders stay off for humans
333
+ * (decocms/deco-start#286); their sections already render eagerly via the
334
+ * `!isClientNav` branch of the `useAsync` gate.
335
+ *
336
+ * Like {@link hasForceEagerParam}, this must stay in lock-step with the edge
337
+ * cache key: `workerEntry` keys `Sec-Fetch-Dest: empty` page requests into a
338
+ * separate `__fetch=1` bucket, so the eager response never poisons the
339
+ * navigation (deferred) entry, and vice-versa.
340
+ */
341
+ function isProgrammaticFetch(ctx?: MatcherContext): boolean {
342
+ if (ctx?.isClientNavigation) return false;
343
+ const dest = ctx?.request?.headers.get("sec-fetch-dest") ??
344
+ ctx?.headers?.["sec-fetch-dest"];
345
+ return dest === "empty";
346
+ }
347
+
348
+ /**
349
+ * True when the request should receive the full eager (crawler) render: real
350
+ * search-engine bots (by User-Agent), an explicit `?__deco_ssr=1` override, or
351
+ * a programmatic non-navigation fetch (see {@link isProgrammaticFetch}). Used
352
+ * to gate both section deferral and page-SEO commerce resolution.
353
+ */
354
+ export function isEagerRequest(ctx?: MatcherContext): boolean {
355
+ return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) ||
356
+ isProgrammaticFetch(ctx);
357
+ }
358
+
359
+ /**
360
+ * A loader registered against a `__resolveType` key. The runtime invokes it
361
+ * through two paths:
362
+ *
363
+ * 1. CMS resolution (`commerceLoader(resolvedProps)`) — 1-arg call.
364
+ * 2. `/deco/invoke/...` endpoint — `(props, request)` 2-arg call.
365
+ *
366
+ * Loaders that need the `Request` (cookies, geo, headers) declare the second
367
+ * parameter; pure loaders ignore it. This shape lets a single registry serve
368
+ * both invocation paths without `as any` casts at every wrapper.
369
+ */
370
+ export type CommerceLoader = (props: any, request?: Request) => Promise<any>;
371
+
372
+ /**
373
+ * Context passed through the resolution pipeline.
374
+ * Includes HTTP request info for matcher evaluation and per-request memoization.
375
+ */
376
+ export interface MatcherContext {
377
+ userAgent?: string;
378
+ url?: string;
379
+ path?: string;
380
+ cookies?: Record<string, string>;
381
+ headers?: Record<string, string>;
382
+ request?: Request;
383
+ /**
384
+ * Client-side (SPA) navigation via TanStack `<Link>`. Disables section
385
+ * deferral: deferral is a streaming-SSR optimization, but a client nav
386
+ * receives the server-fn JSON in one shot, so deferral adds a round-trip +
387
+ * skeleton with no benefit (and breaks loaders that need per-request app
388
+ * context — see decocms/deco-start#277). Set by the route loaders.
389
+ */
390
+ isClientNavigation?: boolean;
391
+ }
392
+
393
+ /**
394
+ * Internal resolution context with memoization and error tracking.
395
+ * Created once per resolveDecoPage / resolveValue call tree.
396
+ */
397
+ interface ResolveContext {
398
+ routeParams?: Record<string, string>;
399
+ matcherCtx: MatcherContext;
400
+ memo: Map<string, unknown>;
401
+ depth: number;
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Configuration
406
+ // ---------------------------------------------------------------------------
407
+
408
+ const SKIP_RESOLVE_TYPES = new Set([
409
+ "Deco",
410
+ "htmx/sections/htmx.tsx",
411
+ "website/sections/Analytics/Analytics.tsx",
412
+ "algolia/sections/Analytics/Algolia.tsx",
413
+ "shopify/loaders/proxy.ts",
414
+ "vtex/loaders/proxy.ts",
415
+ "website/loaders/pages.ts",
416
+ "website/loaders/redirects.ts",
417
+ "website/loaders/fonts/googleFonts.ts",
418
+ "commerce/sections/Seo/SeoPDP.tsx",
419
+ "commerce/sections/Seo/SeoPDPV2.tsx",
420
+ "commerce/sections/Seo/SeoPLP.tsx",
421
+ "commerce/sections/Seo/SeoPLPV2.tsx",
422
+ "website/sections/Seo/Seo.tsx",
423
+ "website/sections/Seo/SeoV2.tsx",
424
+ "deco-sites/std/sections/SEO.tsx",
425
+ ]);
426
+
427
+ /** Add a __resolveType that should be skipped during resolution. */
428
+ export function addSkipResolveType(resolveType: string) {
429
+ SKIP_RESOLVE_TYPES.add(resolveType);
430
+ }
431
+
432
+ const MAX_RESOLVE_DEPTH = 20;
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Commerce loaders
436
+ // ---------------------------------------------------------------------------
437
+
438
+ const commerceLoaders: Record<string, CommerceLoader> = G.__deco.commerceLoaders;
439
+
440
+ export function registerCommerceLoader(key: string, loader: CommerceLoader) {
441
+ commerceLoaders[key] = loader;
442
+ }
443
+
444
+ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>) {
445
+ Object.assign(commerceLoaders, loaders);
446
+
447
+ // Auto-register loader + action schemas for the admin manifest.
448
+ // Separate actions (keys containing "/actions/") from loaders.
449
+ const loaderConfigs: LoaderConfig[] = [];
450
+ const actionConfigs: ActionConfig[] = [];
451
+
452
+ for (const key of Object.keys(loaders)) {
453
+ const namespace = key.startsWith("vtex/") ? "vtex" : "site";
454
+ const schema = { type: "object" as const, additionalProperties: true };
455
+
456
+ if (key.includes("/actions/")) {
457
+ actionConfigs.push({ key, title: key, namespace, propsSchema: schema });
458
+ } else {
459
+ loaderConfigs.push({
460
+ key,
461
+ title: key,
462
+ namespace,
463
+ propsSchema: schema,
464
+ tags: inferLoaderTags(key),
465
+ });
466
+ }
467
+ }
468
+
469
+ registerLoaderSchemas(loaderConfigs);
470
+ registerActionSchemas(actionConfigs);
471
+ }
472
+
473
+ function inferLoaderTags(key: string): string[] {
474
+ if (
475
+ key.includes("productList") ||
476
+ key.includes("ProductList") ||
477
+ key.includes("ProductShelf") ||
478
+ key.includes("SearchResult")
479
+ ) {
480
+ return ["product-list"];
481
+ }
482
+ return [];
483
+ }
484
+
485
+ /** Delete a single commerce loader by key. No-op if key is absent. */
486
+ export function unregisterCommerceLoader(key: string): void {
487
+ delete commerceLoaders[key];
488
+ }
489
+
490
+ /** Clear all commerce loaders. Use with care — wipes site-registered entries too. */
491
+ export function clearCommerceLoaders(): void {
492
+ for (const key of Object.keys(commerceLoaders)) delete commerceLoaders[key];
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Custom matchers
497
+ // ---------------------------------------------------------------------------
498
+
499
+ const customMatchers: Record<
500
+ string,
501
+ (rule: Record<string, unknown>, ctx: MatcherContext) => boolean
502
+ > = G.__deco.customMatchers;
503
+
504
+ export function registerMatcher(
505
+ key: string,
506
+ fn: (rule: Record<string, unknown>, ctx: MatcherContext) => boolean,
507
+ ) {
508
+ customMatchers[key] = fn;
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Built-in matchers — registered through the same API as custom matchers
513
+ // ---------------------------------------------------------------------------
514
+
515
+ if (!G.__deco._builtinMatchersRegistered) {
516
+ G.__deco._builtinMatchersRegistered = true;
517
+
518
+ const builtinMatchers: Record<
519
+ string,
520
+ (rule: Record<string, unknown>, ctx: MatcherContext) => boolean
521
+ > = {
522
+ "website/matchers/always.ts": () => true,
523
+ "$live/matchers/MatchAlways.ts": () => true,
524
+ "website/matchers/never.ts": () => false,
525
+ "website/matchers/device.ts": (rule, ctx) => {
526
+ const ua = (ctx.userAgent || "").toLowerCase();
527
+ const isTablet = /ipad|android(?!.*mobile)|tablet/i.test(ua);
528
+ const isMobile =
529
+ !isTablet && /mobile|android|iphone|ipod|webos|blackberry|opera mini|iemobile/i.test(ua);
530
+ const isDesktop = !isMobile && !isTablet;
531
+ // If no flags are set, match everything (permissive default)
532
+ if (!rule.mobile && !rule.tablet && !rule.desktop) return true;
533
+ return (
534
+ !!(rule.mobile && isMobile) || !!(rule.tablet && isTablet) || !!(rule.desktop && isDesktop)
535
+ );
536
+ },
537
+ "website/matchers/random.ts": (rule) => {
538
+ const traffic = typeof rule.traffic === "number" ? rule.traffic : 0.5;
539
+ return Math.random() < traffic;
540
+ },
541
+ "website/matchers/date.ts": (rule) => {
542
+ const now = Date.now();
543
+ const start = typeof rule.start === "string" ? new Date(rule.start).getTime() : 0;
544
+ const end = typeof rule.end === "string" ? new Date(rule.end).getTime() : Infinity;
545
+ return now >= start && now <= end;
546
+ },
547
+ };
548
+
549
+ for (const [key, fn] of Object.entries(builtinMatchers)) {
550
+ // Only register if not already overridden by consumer
551
+ if (!customMatchers[key]) {
552
+ customMatchers[key] = fn;
553
+ }
554
+ }
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Error handling
559
+ // ---------------------------------------------------------------------------
560
+
561
+ export type ResolveErrorHandler = (error: unknown, resolveType: string, context: string) => void;
562
+
563
+ let onResolveError: ResolveErrorHandler = (error, resolveType, context) => {
564
+ console.error(`[CMS] ${context} "${resolveType}" failed:`, error);
565
+ };
566
+
567
+ /** Configure a custom error handler for resolution failures. */
568
+ export function setResolveErrorHandler(handler: ResolveErrorHandler) {
569
+ onResolveError = handler;
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // Dangling reference handling
574
+ // ---------------------------------------------------------------------------
575
+
576
+ export type DanglingReferenceHandler = (resolveType: string) => unknown;
577
+
578
+ let onDanglingReference: DanglingReferenceHandler = (resolveType) => {
579
+ console.warn(`[CMS] Unhandled resolver: ${resolveType}`);
580
+ return null;
581
+ };
582
+
583
+ /** Configure how unresolvable __resolveType references are handled. */
584
+ export function setDanglingReferenceHandler(handler: DanglingReferenceHandler) {
585
+ onDanglingReference = handler;
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Init hook
590
+ // ---------------------------------------------------------------------------
591
+
592
+ export function onBeforeResolve(callback: () => void) {
593
+ G.__deco.initCallback = callback;
594
+ }
595
+
596
+ function ensureInitialized() {
597
+ if (!G.__deco.initialized && G.__deco.initCallback) {
598
+ G.__deco.initCallback();
599
+ G.__deco.initialized = true;
600
+ }
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Matcher evaluation
605
+ // ---------------------------------------------------------------------------
606
+
607
+ export function evaluateMatcher(
608
+ rule: Record<string, unknown> | undefined,
609
+ ctx: MatcherContext,
610
+ ): boolean {
611
+ if (!rule) return true;
612
+
613
+ const resolveType = rule.__resolveType as string | undefined;
614
+ if (!resolveType) return true;
615
+
616
+ const blocks = loadBlocks();
617
+
618
+ // x-deco-matchers-override — Deno resolveChain semantics: a saved matcher
619
+ // block is keyed by its name; an inline rule by `<blockId>@<prop.path>`
620
+ // (e.g. `pages-home-abc123@sections.2.variants.0.rule`). Checked before
621
+ // the saved-block recursion below, which swaps __resolveType for the
622
+ // underlying matcher type and merges into a fresh object, losing both the
623
+ // block name and the rule's identity in the blocks map.
624
+ if (hasMatchersOverride(ctx)) {
625
+ const overrides = getMatchersOverride(ctx);
626
+ const forced =
627
+ (blocks[resolveType] ? overrides[resolveType] : undefined) ??
628
+ overrides[getRuleOverrideId(blocks, rule) ?? ""];
629
+ if (forced !== undefined) return forced;
630
+ }
631
+
632
+ if (blocks[resolveType]) {
633
+ const resolvedRule = blocks[resolveType] as Record<string, unknown>;
634
+ return evaluateMatcher(
635
+ { ...resolvedRule, ...rule, __resolveType: resolvedRule.__resolveType as string },
636
+ ctx,
637
+ );
638
+ }
639
+
640
+ const matcher = customMatchers[resolveType];
641
+ if (matcher) {
642
+ try {
643
+ return matcher(rule, ctx);
644
+ } catch {
645
+ return false;
646
+ }
647
+ }
648
+
649
+ console.warn(`[CMS] Unknown matcher: ${resolveType}, defaulting to false`);
650
+ return false;
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Select (partial field picking)
655
+ // ---------------------------------------------------------------------------
656
+
657
+ function applySelect(value: unknown, select?: string[]): unknown {
658
+ if (!select || !select.length || !value || typeof value !== "object") return value;
659
+ if (Array.isArray(value)) return value.map((item) => applySelect(item, select));
660
+
661
+ const result: Record<string, unknown> = {};
662
+ for (const key of select) {
663
+ if (key in (value as Record<string, unknown>)) {
664
+ result[key] = (value as Record<string, unknown>)[key];
665
+ }
666
+ }
667
+ return result;
668
+ }
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Core resolution
672
+ // ---------------------------------------------------------------------------
673
+
674
+ async function resolveProps(
675
+ obj: Record<string, unknown>,
676
+ rctx: ResolveContext,
677
+ ): Promise<Record<string, unknown>> {
678
+ const entries = Object.entries(obj);
679
+ const resolvedEntries = await Promise.all(
680
+ entries.map(async ([k, v]) => [k, await internalResolve(v, rctx)] as const),
681
+ );
682
+ return Object.fromEntries(resolvedEntries);
683
+ }
684
+
685
+ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<unknown> {
686
+ if (!value || typeof value !== "object") return value;
687
+ if (Array.isArray(value)) {
688
+ return Promise.all(value.map((item) => internalResolve(item, rctx)));
689
+ }
690
+
691
+ const obj = value as Record<string, unknown>;
692
+
693
+ if (!obj.__resolveType) {
694
+ return resolveProps(obj, rctx);
695
+ }
696
+
697
+ const resolveType = obj.__resolveType as string;
698
+
699
+ if (SKIP_RESOLVE_TYPES.has(resolveType)) return null;
700
+
701
+ if (rctx.depth > MAX_RESOLVE_DEPTH) {
702
+ console.error(`[CMS] Max resolution depth (${MAX_RESOLVE_DEPTH}) exceeded at: ${resolveType}`);
703
+ return null;
704
+ }
705
+
706
+ const childCtx: ResolveContext = { ...rctx, depth: rctx.depth + 1 };
707
+
708
+ // "resolved" short-circuit
709
+ if (resolveType === "resolved") return obj.data ?? null;
710
+
711
+ // Lazy section wrapper — unwrap single inner section
712
+ if (resolveType === WELL_KNOWN_TYPES.LAZY) {
713
+ return obj.section ? internalResolve(obj.section, childCtx) : null;
714
+ }
715
+
716
+ // Deferred section wrapper (legacy Fresh/HTMX) — unwrap inner sections array
717
+ if (resolveType === WELL_KNOWN_TYPES.DEFERRED) {
718
+ return obj.sections ? internalResolve(obj.sections, childCtx) : null;
719
+ }
720
+
721
+ // Request param extraction
722
+ if (resolveType === WELL_KNOWN_TYPES.REQUEST_TO_PARAM) {
723
+ const paramName = (obj as any).param as string;
724
+ return rctx.routeParams?.[paramName] ?? null;
725
+ }
726
+
727
+ // Commerce extension wrappers — unwrap to inner data
728
+ if (
729
+ resolveType === WELL_KNOWN_TYPES.COMMERCE_EXT_DETAILS ||
730
+ resolveType === WELL_KNOWN_TYPES.COMMERCE_EXT_LISTING
731
+ ) {
732
+ return obj.data ? internalResolve(obj.data, childCtx) : null;
733
+ }
734
+
735
+ // Multivariate flags
736
+ if (
737
+ resolveType === WELL_KNOWN_TYPES.MULTIVARIATE ||
738
+ resolveType === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION
739
+ ) {
740
+ const variants = obj.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
741
+ if (!variants || variants.length === 0) return null;
742
+
743
+ for (const variant of variants) {
744
+ const rule = variant.rule as Record<string, unknown> | undefined;
745
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
746
+ return internalResolve(variant.value, childCtx);
747
+ }
748
+ }
749
+ return null;
750
+ }
751
+
752
+ // Commerce loaders
753
+ const commerceLoader = commerceLoaders[resolveType];
754
+ if (commerceLoader) {
755
+ const { __resolveType: _, ...loaderProps } = obj;
756
+ const resolvedProps = await resolveProps(loaderProps, childCtx);
757
+
758
+ if (rctx.matcherCtx.path) {
759
+ resolvedProps.__pagePath = rctx.matcherCtx.path;
760
+ }
761
+ if (rctx.matcherCtx.url) {
762
+ resolvedProps.__pageUrl = rctx.matcherCtx.url;
763
+ // Auto-inject URL search params as top-level props so loaders that
764
+ // expect `props.skuId` / `props.q` / `props.page` (the apps-start
765
+ // canonical shape) get them populated on direct entry (Google
766
+ // Shopping deep links, paid ads, email campaigns). Existing values
767
+ // from the CMS block win — URL params are a fallback, not an
768
+ // override.
769
+ //
770
+ // Safe re: cache fragmentation: commerce loaders run through this
771
+ // path without a framework-level cache (the section/page cache
772
+ // layer hashes section.props BEFORE this enrichment in
773
+ // sectionLoaders.ts), so adding query params here does not
774
+ // fragment any cache key.
775
+ if (URL.canParse(rctx.matcherCtx.url)) {
776
+ const url = new URL(rctx.matcherCtx.url);
777
+ for (const [k, v] of url.searchParams.entries()) {
778
+ if (resolvedProps[k] === undefined) resolvedProps[k] = v;
779
+ }
780
+ } else {
781
+ // Loud warning instead of silent swallow: matcherCtx.url should
782
+ // always be a fully-qualified URL (set from `getRequestUrl()` in
783
+ // cmsRoute.ts). If we see a malformed value here, something
784
+ // upstream is wrong — surface it so the caller can fix it.
785
+ console.warn(
786
+ `[CMS] malformed matcherCtx.url for "${resolveType}"; ` +
787
+ `skipping query-param injection: ${rctx.matcherCtx.url}`,
788
+ );
789
+ }
790
+ }
791
+
792
+ try {
793
+ return await commerceLoader(resolvedProps);
794
+ } catch (error) {
795
+ onResolveError(error, resolveType, "Commerce loader");
796
+ return null;
797
+ }
798
+ }
799
+
800
+ // Preview handler — admin sends { __resolveType: "preview", block: "BlockName", ...props }
801
+ // to preview a named block. Redirect resolution to the named block.
802
+ if (resolveType === "preview") {
803
+ const blockName = obj.block as string | undefined;
804
+ if (!blockName) return null;
805
+ const { __resolveType: _, block: _b, ...overrideProps } = obj;
806
+ return internalResolve({ __resolveType: blockName, ...overrideProps }, childCtx);
807
+ }
808
+
809
+ // Named block reference (memoized)
810
+ const blocks = loadBlocks();
811
+ if (blocks[resolveType]) {
812
+ const memoKey = JSON.stringify(obj);
813
+ if (rctx.memo.has(memoKey)) {
814
+ return rctx.memo.get(memoKey);
815
+ }
816
+
817
+ const referencedBlock = blocks[resolveType] as Record<string, unknown>;
818
+ const { __resolveType: _rt, ...restOverrides } = obj;
819
+ const resultPromise = internalResolve({ ...referencedBlock, ...restOverrides }, childCtx);
820
+ rctx.memo.set(memoKey, resultPromise);
821
+
822
+ const result = await resultPromise;
823
+ rctx.memo.set(memoKey, result);
824
+ return result;
825
+ }
826
+
827
+ // Dangling reference — unresolvable __resolveType
828
+ if (resolveType.includes("/loaders/") || resolveType.includes("/actions/")) {
829
+ return onDanglingReference(resolveType);
830
+ }
831
+
832
+ // Unknown type — resolve props but preserve __resolveType (it's a section)
833
+ const { __resolveType: _, ...rest } = obj;
834
+
835
+ // onBeforeResolveProps: let sections transform raw props before resolution.
836
+ // This runs with unresolved props (containing __resolveType refs) so sections
837
+ // can extract metadata that would be lost after resolution (e.g., collection IDs).
838
+ const propsToResolve = await applyOnBeforeResolveProps(resolveType, rest);
839
+
840
+ const resolvedRest = await resolveProps(propsToResolve, childCtx);
841
+ return { __resolveType: resolveType, ...resolvedRest };
842
+ }
843
+
844
+ // ---------------------------------------------------------------------------
845
+ // Nested section normalization
846
+ // ---------------------------------------------------------------------------
847
+
848
+ /**
849
+ * Recursively walks resolved props and converts nested sections
850
+ * from `{ __resolveType: "site/sections/...", ...props }` to
851
+ * `{ component: "site/sections/...", props: {...} }`.
852
+ *
853
+ * This preserves the same `{ Component, props }` shape used by deco-cx/deco (Fresh)
854
+ * so that section code can be ported without API changes. In TanStack, `Component`
855
+ * is the registry key string (not a function ref), and the renderer does the lookup.
856
+ */
857
+ function normalizeNestedSections(value: unknown): unknown {
858
+ if (!value || typeof value !== "object") return value;
859
+
860
+ if (Array.isArray(value)) {
861
+ return value.map(normalizeNestedSections);
862
+ }
863
+
864
+ const obj = value as Record<string, unknown>;
865
+ const rt = obj.__resolveType as string | undefined;
866
+
867
+ if (rt && getSection(rt)) {
868
+ const { __resolveType: _, ...rest } = obj;
869
+ const normalizedProps: Record<string, unknown> = {};
870
+ for (const [k, v] of Object.entries(rest)) {
871
+ normalizedProps[k] = normalizeNestedSections(v);
872
+ }
873
+ return { Component: rt, props: normalizedProps };
874
+ }
875
+
876
+ const result: Record<string, unknown> = {};
877
+ for (const [k, v] of Object.entries(obj)) {
878
+ result[k] = normalizeNestedSections(v);
879
+ }
880
+ return result;
881
+ }
882
+
883
+ // ---------------------------------------------------------------------------
884
+ // Public API
885
+ // ---------------------------------------------------------------------------
886
+
887
+ /**
888
+ * Resolve a value by recursively processing __resolveType references.
889
+ * Supports memoization, commerce loaders, matchers, and flags.
890
+ */
891
+ export async function resolveValue(
892
+ value: unknown,
893
+ routeParams?: Record<string, string>,
894
+ matcherCtx?: MatcherContext,
895
+ options?: { select?: string[] },
896
+ ): Promise<unknown> {
897
+ const rctx: ResolveContext = {
898
+ routeParams,
899
+ matcherCtx: matcherCtx ?? {},
900
+ memo: new Map(),
901
+ depth: 0,
902
+ };
903
+ const result = await internalResolve(value, rctx);
904
+ return options?.select ? applySelect(result, options.select) : result;
905
+ }
906
+
907
+ // ---------------------------------------------------------------------------
908
+ // Layout section CMS-resolve cache
909
+ // Caches the fully-resolved CMS output for layout sections so that
910
+ // commerce loaders (intelligent-search, cross-selling, etc.) inside
911
+ // Header/Footer blocks aren't re-executed on every page navigation.
912
+ // ---------------------------------------------------------------------------
913
+
914
+ const RESOLVE_CACHE_TTL = 5 * 60_000;
915
+
916
+ interface ResolvedSectionsCache {
917
+ sections: ResolvedSection[];
918
+ expiresAt: number;
919
+ }
920
+
921
+ const resolvedLayoutCache = new Map<string, ResolvedSectionsCache>();
922
+ const resolvedLayoutInflight = new Map<string, Promise<ResolvedSection[]>>();
923
+
924
+ function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
925
+ const entry = resolvedLayoutCache.get(blockKey);
926
+ if (!entry) return null;
927
+ if (Date.now() > entry.expiresAt) {
928
+ resolvedLayoutCache.delete(blockKey);
929
+ return null;
930
+ }
931
+ return entry.sections;
932
+ }
933
+
934
+ function setCachedResolvedLayout(blockKey: string, sections: ResolvedSection[]): void {
935
+ resolvedLayoutCache.set(blockKey, {
936
+ sections,
937
+ expiresAt: Date.now() + RESOLVE_CACHE_TTL,
938
+ });
939
+ }
940
+
941
+ /**
942
+ * Resolves a raw section block to ResolvedSection[].
943
+ * Used for both layout and non-layout sections.
944
+ */
945
+ async function resolveRawSection(
946
+ section: unknown,
947
+ rctx: ResolveContext,
948
+ ): Promise<ResolvedSection[]> {
949
+ const resolved = await internalResolve(section, rctx);
950
+ if (!resolved || typeof resolved !== "object") return [];
951
+
952
+ const items = Array.isArray(resolved) ? resolved : [resolved];
953
+ const results: ResolvedSection[] = [];
954
+
955
+ for (const item of items) {
956
+ if (!item || typeof item !== "object") continue;
957
+ const obj = item as Record<string, unknown>;
958
+ if (!obj.__resolveType) continue;
959
+
960
+ const resolveType = obj.__resolveType as string;
961
+ const sectionLoader = getSection(resolveType);
962
+ if (!sectionLoader) {
963
+ console.warn(`[CMS] No component registered for: ${resolveType}`);
964
+ continue;
965
+ }
966
+
967
+ const { __resolveType: _, ...rawProps } = obj;
968
+ const props = normalizeNestedSections(rawProps) as Record<string, unknown>;
969
+ results.push({
970
+ component: resolveType,
971
+ props,
972
+ key: resolveType,
973
+ });
974
+ }
975
+
976
+ return results;
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // Page-level SEO block resolution
981
+ // ---------------------------------------------------------------------------
982
+
983
+ /**
984
+ * Resolve the page-level `seo` field from the CMS page JSON.
985
+ *
986
+ * The CMS stores SEO config in `page.seo` as a separate field from
987
+ * `page.sections`. This field is typically a SeoV2.tsx block (homepage)
988
+ * or a site SEO section like SEOPDP.tsx wrapped in Lazy (PDP).
989
+ *
990
+ * This function always resolves eagerly — SEO metadata must never be
991
+ * deferred because search engine crawlers need it in the initial HTML.
992
+ * Lazy/Deferred wrappers are unwrapped, block references are followed,
993
+ * and commerce loader refs (e.g. PDP jsonLD) are resolved.
994
+ *
995
+ * Returns a ResolvedSection with the final component key and resolved props,
996
+ * or null if the seo field is absent/unresolvable.
997
+ */
998
+ export async function resolvePageSeoBlock(
999
+ seoBlock: Record<string, unknown> | undefined,
1000
+ rctx: ResolveContext,
1001
+ ): Promise<ResolvedSection | null> {
1002
+ if (!seoBlock || typeof seoBlock !== "object") return null;
1003
+
1004
+ // Bot-aware SEO is OPT-IN (`setAsyncRenderingConfig({ botAwareSeo: true })`).
1005
+ // When disabled (default), resolve the SEO block fully for EVERYONE — the
1006
+ // previous behavior — so sites that bump the framework without a lightweight
1007
+ // title fallback are not regressed to a generic `<title>`.
1008
+ //
1009
+ // When enabled: crawlers get the SEO block fully resolved (e.g. a
1010
+ // ProductListingPage for JSON-LD ItemList) — that content exists for indexing;
1011
+ // humans get only the lightweight metadata — SEO props backed by a commerce
1012
+ // loader are skipped (see `stripCommerceLoaderProps`) so SSR doesn't block on
1013
+ // the heavy upstream call and the bulky payload never reaches the human HTML.
1014
+ const botAwareSeo = getAsyncConfig()?.botAwareSeo ?? false;
1015
+ const seoForBot = !botAwareSeo || isEagerRequest(rctx.matcherCtx);
1016
+
1017
+ const blocks = loadBlocks();
1018
+ let current = seoBlock;
1019
+
1020
+ for (let depth = 0; depth < 10; depth++) {
1021
+ const rt = current.__resolveType as string | undefined;
1022
+ if (!rt) return null;
1023
+
1024
+ // Lazy wrapper — always unwrap for SEO (never defer)
1025
+ if (rt === "website/sections/Rendering/Lazy.tsx") {
1026
+ const inner = current.section;
1027
+ if (!inner || typeof inner !== "object") return null;
1028
+ current = inner as Record<string, unknown>;
1029
+ continue;
1030
+ }
1031
+
1032
+ // Deferred wrapper — unwrap
1033
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
1034
+ const inner = current.sections;
1035
+ if (!inner || typeof inner !== "object") return null;
1036
+ if (Array.isArray(inner) && inner.length === 1 && inner[0] && typeof inner[0] === "object") {
1037
+ current = inner[0] as Record<string, unknown>;
1038
+ continue;
1039
+ }
1040
+ return null;
1041
+ }
1042
+
1043
+ // Multivariate flag — evaluate matcher and follow matched variant
1044
+ if (rt === "website/flags/multivariate.ts" || rt === "website/flags/multivariate/section.ts") {
1045
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1046
+ if (!variants?.length) return null;
1047
+ let matched: unknown = null;
1048
+ for (const variant of variants) {
1049
+ const rule = variant.rule as Record<string, unknown> | undefined;
1050
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
1051
+ matched = variant.value;
1052
+ break;
1053
+ }
1054
+ }
1055
+ if (!matched || typeof matched !== "object") return null;
1056
+ current = matched as Record<string, unknown>;
1057
+ continue;
1058
+ }
1059
+
1060
+ // Named block reference — follow the chain
1061
+ const block = blocks[rt] as Record<string, unknown> | undefined;
1062
+ if (block) {
1063
+ const { __resolveType: _, ...overrides } = current;
1064
+ current = { ...block, ...overrides };
1065
+ continue;
1066
+ }
1067
+
1068
+ // Terminal section (site section or framework SEO type).
1069
+ // Resolve all nested prop __resolveType refs (commerce loaders, etc.).
1070
+ const { __resolveType: _, ...rawProps } = current;
1071
+ // For humans, drop SEO props whose value resolves to a commerce loader
1072
+ // (e.g. `jsonLD: { __resolveType: "PLP Loader" }`). This avoids the heavy
1073
+ // SSR fetch and keeps the product payload out of the human HTML. Bots keep
1074
+ // the full props so JSON-LD/rich metadata is still emitted for indexing.
1075
+ const propsToResolve = seoForBot ? rawProps : stripCommerceLoaderProps(rawProps);
1076
+ try {
1077
+ const resolvedProps = await resolveProps(propsToResolve, rctx);
1078
+ return {
1079
+ component: rt,
1080
+ props: resolvedProps as Record<string, unknown>,
1081
+ key: `seo:${rt}`,
1082
+ };
1083
+ } catch (e) {
1084
+ onResolveError(e, rt, "Page SEO resolution");
1085
+ return null;
1086
+ }
1087
+ }
1088
+
1089
+ return null;
1090
+ }
1091
+
1092
+ /**
1093
+ * Does resolving `value` invoke a commerce loader (a heavy upstream data
1094
+ * fetch)? Walks named block references, Lazy/Deferred wrappers, commerce
1095
+ * extension wrappers, and multivariate flags to find a terminal
1096
+ * `__resolveType` registered in `commerceLoaders`.
1097
+ *
1098
+ * Used to keep commerce-backed SEO props (product listings/details for
1099
+ * JSON-LD) out of the human SSR path while preserving them for bots.
1100
+ */
1101
+ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
1102
+ if (depth > 10 || !value || typeof value !== "object" || Array.isArray(value)) return false;
1103
+ const obj = value as Record<string, unknown>;
1104
+ const rt = obj.__resolveType as string | undefined;
1105
+ if (!rt) return false;
1106
+
1107
+ if (commerceLoaders[rt]) return true;
1108
+
1109
+ if (rt === WELL_KNOWN_TYPES.LAZY) return resolvesToCommerceLoader(obj.section, depth + 1);
1110
+ if (rt === WELL_KNOWN_TYPES.DEFERRED) {
1111
+ const inner = obj.sections;
1112
+ const one = Array.isArray(inner) ? inner[0] : inner;
1113
+ return resolvesToCommerceLoader(one, depth + 1);
1114
+ }
1115
+ if (
1116
+ rt === WELL_KNOWN_TYPES.COMMERCE_EXT_DETAILS ||
1117
+ rt === WELL_KNOWN_TYPES.COMMERCE_EXT_LISTING
1118
+ ) {
1119
+ // Extension wrappers carry their inner loader in `data`; they're commerce
1120
+ // by definition, but guard the recursion for clarity.
1121
+ return obj.data ? resolvesToCommerceLoader(obj.data, depth + 1) : true;
1122
+ }
1123
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1124
+ const variants = obj.variants as Array<{ value?: unknown }> | undefined;
1125
+ return Array.isArray(variants)
1126
+ ? variants.some((v) => resolvesToCommerceLoader(v?.value, depth + 1))
1127
+ : false;
1128
+ }
1129
+
1130
+ // Named block reference — follow one level of the chain.
1131
+ const block = loadBlocks()[rt] as Record<string, unknown> | undefined;
1132
+ if (block) return resolvesToCommerceLoader(block, depth + 1);
1133
+
1134
+ return false;
1135
+ }
1136
+
1137
+ /**
1138
+ * Return a copy of `rawProps` with every top-level field that resolves to a
1139
+ * commerce loader removed. Applied to page SEO props for human (non-bot)
1140
+ * requests so the heavy commerce fetch is skipped and its payload is never
1141
+ * serialized into the HTML. Lightweight literal props (title, description,
1142
+ * canonical, …) are preserved.
1143
+ */
1144
+ function stripCommerceLoaderProps(
1145
+ rawProps: Record<string, unknown>,
1146
+ ): Record<string, unknown> {
1147
+ const out: Record<string, unknown> = {};
1148
+ for (const [k, v] of Object.entries(rawProps)) {
1149
+ if (resolvesToCommerceLoader(v)) continue;
1150
+ out[k] = v;
1151
+ }
1152
+ return out;
1153
+ }
1154
+
1155
+ /**
1156
+ * Check if a raw CMS section block will produce a layout section.
1157
+ * Walks the block reference chain (up to 5 levels) to find the final
1158
+ * section component key. Returns the top-level block name for caching.
1159
+ *
1160
+ * Example: {"__resolveType": "Header - 01"} -> block has
1161
+ * __resolveType "site/sections/Header/Header.tsx" -> that's a layout section
1162
+ * -> returns "Header - 01" as the cache key.
1163
+ */
1164
+ function isRawSectionLayout(section: unknown): string | null {
1165
+ if (!section || typeof section !== "object") return null;
1166
+ const obj = section as Record<string, unknown>;
1167
+ const topLevelRt = obj.__resolveType as string | undefined;
1168
+ if (!topLevelRt) return null;
1169
+
1170
+ if (isLayoutSection(topLevelRt)) return topLevelRt;
1171
+
1172
+ const blocks = loadBlocks();
1173
+ let currentRt: string | undefined = topLevelRt;
1174
+ for (let i = 0; i < 5; i++) {
1175
+ const block = blocks[currentRt] as Record<string, unknown> | undefined;
1176
+ if (!block) break;
1177
+ currentRt = block.__resolveType as string | undefined;
1178
+ if (!currentRt) break;
1179
+ if (isLayoutSection(currentRt)) return topLevelRt;
1180
+ }
1181
+
1182
+ return null;
1183
+ }
1184
+
1185
+ /**
1186
+ * Resolve the final section component key by walking block references,
1187
+ * unwrapping Lazy/Deferred wrappers, and evaluating multivariate flags.
1188
+ * Returns null if not determinable.
1189
+ */
1190
+ function resolveFinalSectionKey(section: unknown, matcherCtx?: MatcherContext): string | null {
1191
+ if (!section || typeof section !== "object") return null;
1192
+
1193
+ const blocks = loadBlocks();
1194
+ let current = section as Record<string, unknown>;
1195
+
1196
+ for (let depth = 0; depth < 10; depth++) {
1197
+ const rt = current.__resolveType as string | undefined;
1198
+ if (!rt) return null;
1199
+
1200
+ // Lazy wrapper — unwrap single inner section
1201
+ if (rt === WELL_KNOWN_TYPES.LAZY) {
1202
+ const inner = current.section;
1203
+ if (!inner || typeof inner !== "object") return null;
1204
+ current = inner as Record<string, unknown>;
1205
+ continue;
1206
+ }
1207
+
1208
+ // Deferred wrapper (legacy) — unwrap first inner section if deterministic
1209
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
1210
+ const inner = current.sections;
1211
+ if (!inner || typeof inner !== "object") return null;
1212
+ if (Array.isArray(inner)) {
1213
+ if (inner.length === 1 && inner[0] && typeof inner[0] === "object") {
1214
+ current = inner[0] as Record<string, unknown>;
1215
+ continue;
1216
+ }
1217
+ return null;
1218
+ }
1219
+ // sections is an object (e.g. a flag) — follow it
1220
+ current = inner as Record<string, unknown>;
1221
+ continue;
1222
+ }
1223
+
1224
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1225
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1226
+ if (!variants?.length) return null;
1227
+
1228
+ let matched: unknown = null;
1229
+ for (const variant of variants) {
1230
+ const rule = variant.rule as Record<string, unknown> | undefined;
1231
+ if (evaluateMatcher(rule, matcherCtx ?? {})) {
1232
+ matched = variant.value;
1233
+ break;
1234
+ }
1235
+ }
1236
+ if (!matched || typeof matched !== "object") return null;
1237
+ current = matched as Record<string, unknown>;
1238
+ continue;
1239
+ }
1240
+
1241
+ if (getSection(rt)) return rt;
1242
+
1243
+ const block = blocks[rt] as Record<string, unknown> | undefined;
1244
+ if (block) {
1245
+ const { __resolveType: _, ...overrides } = current;
1246
+ current = { ...block, ...overrides };
1247
+ continue;
1248
+ }
1249
+
1250
+ return rt;
1251
+ }
1252
+
1253
+ return null;
1254
+ }
1255
+
1256
+ /**
1257
+ * Walk the full wrapper chain (block refs, multivariate flags, Lazy, Deferred)
1258
+ * and return true if a deferral wrapper (Lazy.tsx or Deferred.tsx) is found
1259
+ * at any level. This is used by shouldDeferSection to determine if the CMS
1260
+ * editor intended this section to be deferred.
1261
+ */
1262
+ function isCmsDeferralWrapped(section: unknown, matcherCtx?: MatcherContext): boolean {
1263
+ if (!section || typeof section !== "object") return false;
1264
+
1265
+ const blocks = loadBlocks();
1266
+ let current = section as Record<string, unknown>;
1267
+
1268
+ for (let depth = 0; depth < 10; depth++) {
1269
+ const rt = current.__resolveType as string | undefined;
1270
+ if (!rt) return false;
1271
+
1272
+ if (rt === WELL_KNOWN_TYPES.LAZY || rt === WELL_KNOWN_TYPES.DEFERRED) {
1273
+ return true;
1274
+ }
1275
+
1276
+ // Walk through multivariate flags to check the matched variant
1277
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1278
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1279
+ if (!variants?.length) return false;
1280
+
1281
+ let matched: unknown = null;
1282
+ for (const variant of variants) {
1283
+ const rule = variant.rule as Record<string, unknown> | undefined;
1284
+ if (evaluateMatcher(rule, matcherCtx ?? {})) {
1285
+ matched = variant.value;
1286
+ break;
1287
+ }
1288
+ }
1289
+ if (!matched || typeof matched !== "object") return false;
1290
+ current = matched as Record<string, unknown>;
1291
+ continue;
1292
+ }
1293
+
1294
+ // Named block reference — follow the chain
1295
+ const block = blocks[rt] as Record<string, unknown> | undefined;
1296
+ if (block) {
1297
+ const { __resolveType: _, ...overrides } = current;
1298
+ current = { ...block, ...overrides, __resolveType: block.__resolveType as string };
1299
+ continue;
1300
+ }
1301
+
1302
+ return false;
1303
+ }
1304
+
1305
+ return false;
1306
+ }
1307
+
1308
+ /**
1309
+ * Decide whether a section is deferred (rendered client-side on scroll) or
1310
+ * rendered eagerly (SSR).
1311
+ *
1312
+ * The admin is the source of truth: a section the editor marked async ⚡
1313
+ * (wrapped in CMS `Lazy.tsx`/`Deferred.tsx`) is ALWAYS deferred, and a section
1314
+ * the editor left unmarked is ALWAYS eager — independent of its position on the
1315
+ * page and of any `export const eager`/`neverDefer`/`alwaysEager` flag in the
1316
+ * section code. The admin ⚡ check runs first and overrides every code flag.
1317
+ *
1318
+ * The position threshold and code-level eager flags are an explicit per-site
1319
+ * opt-in: they only take effect when a site sets a finite `foldThreshold`
1320
+ * (default {@link DEFAULT_FOLD_THRESHOLD} = `Infinity`, i.e. disabled), and even
1321
+ * then they can only force a NON-⚡ section eager — they never override the
1322
+ * editor's ⚡ choice.
1323
+ *
1324
+ * Exported for unit testing.
1325
+ */
1326
+ export function shouldDeferSection(
1327
+ section: unknown,
1328
+ flatIndex: number,
1329
+ cfg: AsyncRenderingConfig,
1330
+ isBotReq: boolean,
1331
+ matcherCtx?: MatcherContext,
1332
+ ): boolean {
1333
+ // Crawlers always get the full page server-rendered — deferral would hide
1334
+ // content from indexing. This is an SEO guarantee, not a code override of
1335
+ // editorial intent: it makes ⚡ content MORE visible to bots.
1336
+ if (isBotReq) return false;
1337
+
1338
+ if (!section || typeof section !== "object") return false;
1339
+ const obj = section as Record<string, unknown>;
1340
+ const rt = obj.__resolveType as string | undefined;
1341
+ if (!rt) return false;
1342
+
1343
+ const finalKey = resolveFinalSectionKey(section, matcherCtx);
1344
+ if (!finalKey) return false;
1345
+
1346
+ // ── ADMIN IS THE SOURCE OF TRUTH ──────────────────────────────────────────
1347
+ // If the editor marked the section ⚡ (wrapped in CMS Lazy/Deferred at any
1348
+ // nesting level, including multivariate flags), it is deferred —
1349
+ // unconditionally, overriding every code-level flag below. This is the only
1350
+ // thing that drives deferral in the default configuration.
1351
+ if (cfg.respectCmsLazy && isCmsDeferralWrapped(section, matcherCtx)) return true;
1352
+
1353
+ // ── Everything below is OPT-IN ONLY ───────────────────────────────────────
1354
+ // These checks never override the admin (handled above). They are inert with
1355
+ // the default `foldThreshold = Infinity` and only matter when a site
1356
+ // explicitly opts into a finite threshold — where they protect specific
1357
+ // NON-⚡ sections from position-based deferral.
1358
+
1359
+ // Layout sections (Header, Footer, Theme) are shared across pages and their
1360
+ // resolved output is cached; deferring them would flash a skeleton on every
1361
+ // navigation.
1362
+ if (isLayoutSection(finalKey)) return false;
1363
+
1364
+ // `export const neverDefer = true` — keep eager regardless of threshold.
1365
+ if (isNeverDeferSection(finalKey)) return false;
1366
+
1367
+ // `export const eager = true` / alwaysEager — keep eager within the fold.
1368
+ if (flatIndex < cfg.foldThreshold) {
1369
+ if (isEagerSection(finalKey)) return false;
1370
+ if (cfg.alwaysEager.has(finalKey)) return false;
1371
+ }
1372
+
1373
+ // Position-based fallback — disabled by default (foldThreshold = Infinity).
1374
+ if (flatIndex >= cfg.foldThreshold) return true;
1375
+
1376
+ return false;
1377
+ }
1378
+
1379
+ /**
1380
+ * Follow the block reference chain to find the final section component
1381
+ * and collect the CMS props WITHOUT running commerce loaders.
1382
+ * Resolves named block references, Lazy/Deferred wrappers, and multivariate flags.
1383
+ *
1384
+ * For Deferred.tsx with a single inner section, the inner section is returned.
1385
+ * For Deferred.tsx with multiple inner sections, returns null (falls back to eager).
1386
+ */
1387
+ function resolveSectionShallow(
1388
+ section: unknown,
1389
+ matcherCtx?: MatcherContext,
1390
+ ): DeferredSection | null {
1391
+ if (!section || typeof section !== "object") return null;
1392
+
1393
+ const blocks = loadBlocks();
1394
+ let current = section as Record<string, unknown>;
1395
+ const MAX_DEPTH = 10;
1396
+
1397
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
1398
+ const rt = current.__resolveType as string | undefined;
1399
+ if (!rt) return null;
1400
+
1401
+ if (SKIP_RESOLVE_TYPES.has(rt)) return null;
1402
+
1403
+ // Lazy wrapper — unwrap to the inner section
1404
+ if (rt === WELL_KNOWN_TYPES.LAZY) {
1405
+ const inner = current.section;
1406
+ if (!inner || typeof inner !== "object") return null;
1407
+ current = inner as Record<string, unknown>;
1408
+ continue;
1409
+ }
1410
+
1411
+ // Deferred wrapper (legacy) — unwrap if it contains a single section
1412
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
1413
+ const inner = current.sections;
1414
+ if (!inner || typeof inner !== "object") return null;
1415
+ if (Array.isArray(inner)) {
1416
+ if (inner.length === 1 && inner[0] && typeof inner[0] === "object") {
1417
+ current = inner[0] as Record<string, unknown>;
1418
+ continue;
1419
+ }
1420
+ // Multiple inner sections can't be represented as a single DeferredSection
1421
+ return null;
1422
+ }
1423
+ // sections is an object (e.g. a flag) — follow it
1424
+ current = inner as Record<string, unknown>;
1425
+ continue;
1426
+ }
1427
+
1428
+ // Multivariate flags — evaluate matchers and continue with matched variant
1429
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1430
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1431
+ if (!variants?.length) return null;
1432
+
1433
+ let matched: unknown = null;
1434
+ for (const variant of variants) {
1435
+ const rule = variant.rule as Record<string, unknown> | undefined;
1436
+ if (evaluateMatcher(rule, matcherCtx ?? {})) {
1437
+ matched = variant.value;
1438
+ break;
1439
+ }
1440
+ }
1441
+ if (!matched || typeof matched !== "object") return null;
1442
+ current = matched as Record<string, unknown>;
1443
+ continue;
1444
+ }
1445
+
1446
+ // Check if this is a registered section — we found it
1447
+ if (getSection(rt)) {
1448
+ const { __resolveType: _, ...rawProps } = current;
1449
+ return {
1450
+ component: rt,
1451
+ key: rt,
1452
+ index: -1,
1453
+ propsHash: djb2Hex(JSON.stringify(rawProps)),
1454
+ rawProps: rawProps as Record<string, unknown>,
1455
+ };
1456
+ }
1457
+
1458
+ // Named block reference — follow the chain
1459
+ const block = blocks[rt] as Record<string, unknown> | undefined;
1460
+ if (block) {
1461
+ const { __resolveType: _rtOuter, ...overrides } = current;
1462
+ current = { ...block, ...overrides };
1463
+ continue;
1464
+ }
1465
+
1466
+ return null;
1467
+ }
1468
+
1469
+ return null;
1470
+ }
1471
+
1472
+ /**
1473
+ * Resolve outer wrappers (flags, block references) around the sections list
1474
+ * to get the raw section array without resolving each individual section.
1475
+ * This allows the eager/deferred split to happen before section resolution.
1476
+ */
1477
+ export async function resolveSectionsList(
1478
+ value: unknown,
1479
+ rctx: ResolveContext,
1480
+ depth = 0,
1481
+ ): Promise<unknown[]> {
1482
+ if (depth > MAX_RESOLVE_DEPTH) return [];
1483
+ if (!value || typeof value !== "object") return [];
1484
+ if (Array.isArray(value)) return value;
1485
+
1486
+ const obj = value as Record<string, unknown>;
1487
+ const rt = obj.__resolveType as string | undefined;
1488
+
1489
+ // Page-level variant wrapper without __resolveType
1490
+ // (CMS admin wraps ALL sections in a variant object with { variants: [...] })
1491
+ if (!rt && Array.isArray(obj.variants)) {
1492
+ const variants = obj.variants as Array<{ value: unknown; rule?: unknown }>;
1493
+ for (const variant of variants) {
1494
+ const rule = variant.rule as Record<string, unknown> | undefined;
1495
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
1496
+ return resolveSectionsList(variant.value, rctx, depth + 1);
1497
+ }
1498
+ }
1499
+ return [];
1500
+ }
1501
+
1502
+ if (!rt) return [];
1503
+
1504
+ // Multivariate flags — evaluate matchers and recurse into matched variant
1505
+ if (rt === WELL_KNOWN_TYPES.MULTIVARIATE || rt === WELL_KNOWN_TYPES.MULTIVARIATE_SECTION) {
1506
+ const variants = obj.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
1507
+ if (!variants?.length) return [];
1508
+ for (const variant of variants) {
1509
+ const rule = variant.rule as Record<string, unknown> | undefined;
1510
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
1511
+ return resolveSectionsList(variant.value, rctx, depth + 1);
1512
+ }
1513
+ }
1514
+ return [];
1515
+ }
1516
+
1517
+ // Named block reference — follow the chain
1518
+ const blocks = loadBlocks();
1519
+ if (blocks[rt]) {
1520
+ const referencedBlock = blocks[rt] as Record<string, unknown>;
1521
+ const { __resolveType: _rtOuter, ...restOverrides } = obj;
1522
+ return resolveSectionsList({ ...referencedBlock, ...restOverrides }, rctx, depth + 1);
1523
+ }
1524
+
1525
+ // Resolved — unwrap
1526
+ if (rt === "resolved") {
1527
+ const data = obj.data;
1528
+ if (Array.isArray(data)) return data;
1529
+ return [];
1530
+ }
1531
+
1532
+ return [];
1533
+ }
1534
+
1535
+ // ---------------------------------------------------------------------------
1536
+ // Page-level SEO — extracted from registered SEO sections after resolution
1537
+ // ---------------------------------------------------------------------------
1538
+
1539
+ export interface PageSeo {
1540
+ title?: string;
1541
+ description?: string;
1542
+ canonical?: string;
1543
+ image?: string;
1544
+ noIndexing?: boolean;
1545
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1546
+ jsonLDs?: Record<string, any>[];
1547
+ /** Open Graph type — "website" (default), "product", etc. */
1548
+ type?: string;
1549
+ }
1550
+
1551
+ const seoSectionKeys: Set<string> = G.__deco.seoSections ?? (G.__deco.seoSections = new Set());
1552
+
1553
+ /**
1554
+ * Register section component keys whose resolved props contribute page-level
1555
+ * SEO metadata (title, description, canonical, image, jsonLDs, noIndexing).
1556
+ *
1557
+ * After section loaders run, the framework scans these sections and extracts
1558
+ * their SEO fields into `DecoPageResult.seo`, which `cmsRouteConfig` uses
1559
+ * to generate `<head>` metadata (meta tags, OG, canonical, robots).
1560
+ *
1561
+ * JSON-LD structured data stays in the section props for the section
1562
+ * component to render as `<script type="application/ld+json">`.
1563
+ */
1564
+ export function registerSeoSections(keys: string[]): void {
1565
+ for (const k of keys) seoSectionKeys.add(k);
1566
+ }
1567
+
1568
+ /** Check if a section key is registered as an SEO section. */
1569
+ export function isSeoSection(key: string): boolean {
1570
+ return seoSectionKeys.has(key);
1571
+ }
1572
+
1573
+ /**
1574
+ * Pick standard SEO fields from a props object.
1575
+ * Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
1576
+ */
1577
+ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1578
+ const seo: PageSeo = {};
1579
+ if (props.title) seo.title = props.title as string;
1580
+ if (props.description) seo.description = props.description as string;
1581
+ if (props.canonical) seo.canonical = props.canonical as string;
1582
+ if (props.image) seo.image = props.image as string;
1583
+ if (props.noIndexing !== undefined) seo.noIndexing = props.noIndexing as boolean;
1584
+ if (props.type) seo.type = props.type as string;
1585
+ if (Array.isArray(props.jsonLDs) && props.jsonLDs.length) {
1586
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1587
+ seo.jsonLDs = props.jsonLDs as Record<string, any>[];
1588
+ }
1589
+ return seo;
1590
+ }
1591
+
1592
+ /**
1593
+ * Extract SEO metadata from resolved sections registered via
1594
+ * `registerSeoSections`. Later sections override earlier ones
1595
+ * (e.g., a PDP SEO section overrides a generic page SEO).
1596
+ */
1597
+ export function extractSeoFromSections(sections: ResolvedSection[]): PageSeo {
1598
+ const seo: PageSeo = {};
1599
+ for (const section of sections) {
1600
+ if (!seoSectionKeys.has(section.component)) continue;
1601
+ const extracted = extractSeoFromProps(section.props);
1602
+ if (extracted.jsonLDs) {
1603
+ extracted.jsonLDs = [...(seo.jsonLDs ?? []), ...extracted.jsonLDs];
1604
+ }
1605
+ Object.assign(seo, extracted);
1606
+ }
1607
+ return seo;
1608
+ }
1609
+
1610
+ export interface DecoPageResult {
1611
+ name: string;
1612
+ path: string;
1613
+ params: Record<string, string>;
1614
+ /** CMS block key, e.g. "pages-Lista de cupons [APP]-397629" */
1615
+ blockKey?: string;
1616
+ resolvedSections: ResolvedSection[];
1617
+ deferredSections: DeferredSection[];
1618
+ /**
1619
+ * Resolved SEO block from the page-level `seo` field in the CMS JSON.
1620
+ * Contains the section component key and resolved props (with commerce
1621
+ * loader data already fetched). Needs section loader enrichment in
1622
+ * cmsRoute before SEO fields can be extracted.
1623
+ */
1624
+ seoSection?: ResolvedSection | null;
1625
+ }
1626
+
1627
+ export async function resolveDecoPage(
1628
+ targetPath: string,
1629
+ matcherCtx?: MatcherContext,
1630
+ ): Promise<DecoPageResult | null> {
1631
+ const startedAt = performance.now();
1632
+ return withTracing(
1633
+ "deco.cms.resolvePage",
1634
+ async () => {
1635
+ const result = await resolveDecoPageImpl(targetPath, matcherCtx);
1636
+ try {
1637
+ getMeter()?.histogramRecord?.(
1638
+ MetricNames.RESOLVE_DURATION,
1639
+ performance.now() - startedAt,
1640
+ { path: targetPath },
1641
+ );
1642
+ } catch {
1643
+ /* observability never fails the request */
1644
+ }
1645
+ return result;
1646
+ },
1647
+ { "deco.route": targetPath },
1648
+ );
1649
+ }
1650
+
1651
+ async function resolveDecoPageImpl(
1652
+ targetPath: string,
1653
+ matcherCtx?: MatcherContext,
1654
+ ): Promise<DecoPageResult | null> {
1655
+ ensureInitialized();
1656
+
1657
+ const match = findPageByPath(targetPath);
1658
+ if (!match) {
1659
+ console.warn(`[CMS] No page found for path: ${targetPath}`);
1660
+ return null;
1661
+ }
1662
+
1663
+ const { page, params, blockKey } = match;
1664
+ const ctx: MatcherContext = { ...matcherCtx, path: targetPath };
1665
+ const rctx: ResolveContext = { routeParams: params, matcherCtx: ctx, memo: new Map(), depth: 0 };
1666
+
1667
+ let rawSections: unknown[];
1668
+ if (Array.isArray(page.sections)) {
1669
+ rawSections = page.sections;
1670
+ } else {
1671
+ // Resolve outer flag/wrapper to get the section array
1672
+ // without recursively resolving each individual section.
1673
+ const resolved = await resolveSectionsList(page.sections, rctx);
1674
+ rawSections = resolved;
1675
+ }
1676
+
1677
+ const isBotReq = isEagerRequest(matcherCtx);
1678
+ // SPA navigation (TanStack <Link>) receives the server-fn JSON in one shot —
1679
+ // there is no HTTP streaming, so deferral adds a round-trip + skeleton with
1680
+ // no benefit (and breaks loaders that need per-request app context, #277).
1681
+ // Resolve everything eagerly on client nav; SSR/bots keep deferral.
1682
+ const isClientNav = matcherCtx?.isClientNavigation ?? false;
1683
+ const currentAsyncConfig = getAsyncConfig();
1684
+ const useAsync = currentAsyncConfig !== null && !isBotReq && !isClientNav;
1685
+
1686
+ const eagerResults: (ResolvedSection[] | Promise<ResolvedSection[]>)[] = [];
1687
+ const deferredSections: DeferredSection[] = [];
1688
+ let flatIndex = 0;
1689
+
1690
+ for (const section of rawSections) {
1691
+ const currentFlatIndex = flatIndex;
1692
+
1693
+ const shouldDefer =
1694
+ useAsync && shouldDeferSection(section, currentFlatIndex, currentAsyncConfig!, isBotReq, ctx);
1695
+
1696
+ if (shouldDefer) {
1697
+ let deferredOk = false;
1698
+ try {
1699
+ const deferred = resolveSectionShallow(section, ctx);
1700
+ if (deferred) {
1701
+ // Skip sections whose scheduling window has already closed (or
1702
+ // hasn't opened yet). Without this check a LoadingFallback
1703
+ // skeleton is rendered and immediately replaced by nothing once
1704
+ // the component's own scheduling guard returns null — producing
1705
+ // the "skeleton flashes then disappears" effect.
1706
+ const sched = deferred.rawProps?.scheduling as
1707
+ | { start?: string; end?: string }
1708
+ | undefined;
1709
+ if (sched) {
1710
+ const now = Date.now();
1711
+ if (sched.end && now > new Date(sched.end).getTime()) {
1712
+ flatIndex++;
1713
+ continue;
1714
+ }
1715
+ if (sched.start && now < new Date(sched.start).getTime()) {
1716
+ flatIndex++;
1717
+ continue;
1718
+ }
1719
+ }
1720
+
1721
+ deferred.index = currentFlatIndex;
1722
+
1723
+ // Cache rawProps server-side and strip from the deferred object
1724
+ // so they are NOT serialized into the HTML payload.
1725
+ if (deferred.rawProps) {
1726
+ cacheDeferredRawProps(
1727
+ targetPath,
1728
+ deferred.component,
1729
+ currentFlatIndex,
1730
+ deferred.rawProps,
1731
+ );
1732
+ delete deferred.rawProps;
1733
+ }
1734
+
1735
+ deferredSections.push(deferred);
1736
+ deferredOk = true;
1737
+ }
1738
+ } catch (e) {
1739
+ onResolveError(e, "section", "Deferred section resolution");
1740
+ }
1741
+
1742
+ if (!deferredOk) {
1743
+ // Shallow resolution failed — fall back to eager resolution
1744
+ const idx = currentFlatIndex;
1745
+ eagerResults.push(
1746
+ // Clone the wrappers before stamping `index` — layout sections may
1747
+ // be backed by the shared resolved-layout cache, and mutating
1748
+ // `s.index` in place would corrupt that shared object across
1749
+ // concurrent requests (causing sections to sort into the wrong
1750
+ // position, e.g. the footer jumping above other sections).
1751
+ resolveRawSection(section, rctx).then((sections) =>
1752
+ sections.map((s) => ({ ...s, index: idx })),
1753
+ ),
1754
+ );
1755
+ }
1756
+ flatIndex++;
1757
+ } else {
1758
+ // Eager: full resolution (existing logic)
1759
+ const promise = (async (): Promise<ResolvedSection[]> => {
1760
+ try {
1761
+ const layoutKey = isRawSectionLayout(section);
1762
+
1763
+ if (layoutKey) {
1764
+ const cached = getCachedResolvedLayout(layoutKey);
1765
+ if (cached) return cached;
1766
+
1767
+ const inflight = resolvedLayoutInflight.get(layoutKey);
1768
+ if (inflight) return inflight;
1769
+
1770
+ const p = withInflightTimeout(
1771
+ resolveRawSection(section, rctx).then((results) => {
1772
+ setCachedResolvedLayout(layoutKey, results);
1773
+ return results;
1774
+ }),
1775
+ `resolvedLayout ${layoutKey}`,
1776
+ ).finally(() => resolvedLayoutInflight.delete(layoutKey));
1777
+ resolvedLayoutInflight.set(layoutKey, p);
1778
+ return p;
1779
+ }
1780
+
1781
+ return resolveRawSection(section, rctx);
1782
+ } catch (e) {
1783
+ onResolveError(e, "section", "Section resolution");
1784
+ return [];
1785
+ }
1786
+ })();
1787
+
1788
+ const idx = currentFlatIndex;
1789
+ eagerResults.push(
1790
+ // Clone the wrappers before stamping `index`. `promise` may resolve to
1791
+ // the shared resolved-layout cache array (line ~1491 `return cached`)
1792
+ // or the shared in-flight array; mutating `s.index` in place would
1793
+ // corrupt that shared object across concurrent requests, scrambling
1794
+ // section order (e.g. the footer swapping position with top sections).
1795
+ promise.then((sections) => sections.map((s) => ({ ...s, index: idx }))),
1796
+ );
1797
+ flatIndex++;
1798
+ }
1799
+ }
1800
+
1801
+ const allResults = await Promise.all(eagerResults);
1802
+
1803
+ // Resolve page-level SEO block (page.seo field) — always eager.
1804
+ // Runs after sections to benefit from memoized commerce loader results.
1805
+ let seoSection: ResolvedSection | null = null;
1806
+ if (page.seo) {
1807
+ try {
1808
+ seoSection = await resolvePageSeoBlock(page.seo as Record<string, unknown>, rctx);
1809
+ } catch (e) {
1810
+ onResolveError(e, "page.seo", "Page SEO block resolution");
1811
+ }
1812
+ }
1813
+
1814
+ return {
1815
+ name: page.name,
1816
+ path: page.path || targetPath,
1817
+ params,
1818
+ blockKey,
1819
+ resolvedSections: allResults.flat(),
1820
+ deferredSections,
1821
+ seoSection,
1822
+ };
1823
+ }
1824
+
1825
+ /**
1826
+ * Resolve a raw sections array into ResolvedSection[] with the same
1827
+ * optimizations as resolveDecoPage: parallel resolution, layout caching,
1828
+ * in-flight dedup, and memoization.
1829
+ *
1830
+ * Unlike resolveDecoPage, this does NOT look up a page by path — the
1831
+ * caller provides the raw section array directly. Used by the admin
1832
+ * preview renderer where page data comes from the POST body.
1833
+ *
1834
+ * All sections are resolved eagerly (no deferred/async split) since
1835
+ * admin previews need the full rendered output.
1836
+ */
1837
+ export async function resolvePageSections(
1838
+ rawSectionsInput: unknown,
1839
+ matcherCtx?: MatcherContext,
1840
+ ): Promise<ResolvedSection[]> {
1841
+ ensureInitialized();
1842
+
1843
+ const ctx: MatcherContext = matcherCtx ?? {};
1844
+ const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
1845
+
1846
+ let rawSections: unknown[];
1847
+ if (Array.isArray(rawSectionsInput)) {
1848
+ rawSections = rawSectionsInput;
1849
+ } else {
1850
+ rawSections = await resolveSectionsList(rawSectionsInput, rctx);
1851
+ }
1852
+
1853
+ const eagerResults: Promise<ResolvedSection[]>[] = [];
1854
+
1855
+ for (const section of rawSections) {
1856
+ const promise = (async (): Promise<ResolvedSection[]> => {
1857
+ try {
1858
+ const layoutKey = isRawSectionLayout(section);
1859
+
1860
+ if (layoutKey) {
1861
+ const cached = getCachedResolvedLayout(layoutKey);
1862
+ if (cached) return cached;
1863
+
1864
+ const inflight = resolvedLayoutInflight.get(layoutKey);
1865
+ if (inflight) return inflight;
1866
+
1867
+ const p = withInflightTimeout(
1868
+ resolveRawSection(section, rctx).then((results) => {
1869
+ setCachedResolvedLayout(layoutKey, results);
1870
+ return results;
1871
+ }),
1872
+ `resolvedLayout ${layoutKey}`,
1873
+ ).finally(() => resolvedLayoutInflight.delete(layoutKey));
1874
+ resolvedLayoutInflight.set(layoutKey, p);
1875
+ return p;
1876
+ }
1877
+
1878
+ return resolveRawSection(section, rctx);
1879
+ } catch (e) {
1880
+ onResolveError(e, "section", "Preview section resolution");
1881
+ return [];
1882
+ }
1883
+ })();
1884
+
1885
+ eagerResults.push(promise);
1886
+ }
1887
+
1888
+ const allResults = await Promise.all(eagerResults);
1889
+ return allResults.flat();
1890
+ }
1891
+
1892
+ /**
1893
+ * Resolve a single deferred section's raw props into a fully resolved section.
1894
+ * Called by the loadDeferredSection server function when a section scrolls into view.
1895
+ *
1896
+ * This runs the full resolution pipeline on the raw CMS props:
1897
+ * - Resolves nested __resolveType references (commerce loaders, block refs, flags)
1898
+ * - Normalizes nested sections to { Component, props } shape
1899
+ *
1900
+ * After this, the section goes through runSingleSectionLoader for final enrichment.
1901
+ */
1902
+ export async function resolveDeferredSection(
1903
+ component: string,
1904
+ rawProps: Record<string, unknown>,
1905
+ pagePath: string,
1906
+ matcherCtx?: MatcherContext,
1907
+ ): Promise<ResolvedSection | null> {
1908
+ ensureInitialized();
1909
+
1910
+ const ctx: MatcherContext = { ...matcherCtx, path: pagePath };
1911
+ // Recover routeParams from the page match so nested `requestToParam`
1912
+ // resolvers (e.g. `:slug` on PDPs) return the right value.
1913
+ const match = findPageByPath(pagePath);
1914
+ const rctx: ResolveContext = {
1915
+ routeParams: match?.params,
1916
+ matcherCtx: ctx,
1917
+ memo: new Map(),
1918
+ depth: 0,
1919
+ };
1920
+
1921
+ // onBeforeResolveProps: let sections transform raw props before resolution.
1922
+ const propsToResolve = await applyOnBeforeResolveProps(component, rawProps);
1923
+
1924
+ const resolvedProps = await resolveProps(propsToResolve, rctx);
1925
+ const normalizedProps = normalizeNestedSections(resolvedProps) as Record<string, unknown>;
1926
+
1927
+ return {
1928
+ component,
1929
+ props: normalizedProps,
1930
+ key: component,
1931
+ };
1932
+ }
1933
+
1934
+ /**
1935
+ * Resolve AND enrich a deferred section in one call.
1936
+ * Combines CMS prop resolution (resolveDeferredSection) with section loader
1937
+ * enrichment (runSingleSectionLoader) and URL normalization.
1938
+ *
1939
+ * Used by the route loader to create streaming promises for TanStack's
1940
+ * native deferred data pattern — each deferred section becomes an unawaited
1941
+ * promise that TanStack streams via SSR.
1942
+ */
1943
+ export async function resolveDeferredSectionFull(
1944
+ ds: DeferredSection,
1945
+ pagePath: string,
1946
+ request: Request,
1947
+ matcherCtx?: MatcherContext,
1948
+ ): Promise<ResolvedSection | null> {
1949
+ // rawProps may be stripped from the client payload — resolve from cache or page
1950
+ const rawProps =
1951
+ ds.rawProps ??
1952
+ getDeferredRawProps(pagePath, ds.component, ds.index) ??
1953
+ (await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx));
1954
+
1955
+ if (!rawProps) return null;
1956
+
1957
+ const section = await resolveDeferredSection(ds.component, rawProps, pagePath, matcherCtx);
1958
+ if (!section) return null;
1959
+ section.index = ds.index;
1960
+ const enriched = await runSingleSectionLoader(section, request);
1961
+ return normalizeUrlsInObject(enriched);
1962
+ }
1963
+
1964
+ /**
1965
+ * Fallback for deferred rawProps cache miss: re-resolve the page and extract
1966
+ * rawProps for the section at the given index. Expensive but ensures correctness
1967
+ * when the in-memory cache has been evicted (different isolate, TTL expired).
1968
+ */
1969
+ export async function reExtractRawProps(
1970
+ pagePath: string,
1971
+ component: string,
1972
+ sectionIndex: number,
1973
+ matcherCtx?: MatcherContext,
1974
+ ): Promise<Record<string, unknown> | null> {
1975
+ ensureInitialized();
1976
+
1977
+ const match = findPageByPath(pagePath);
1978
+ if (!match) return null;
1979
+
1980
+ const { page } = match;
1981
+ const ctx: MatcherContext = { ...matcherCtx, path: pagePath };
1982
+
1983
+ let rawSections: unknown[];
1984
+ if (Array.isArray(page.sections)) {
1985
+ rawSections = page.sections;
1986
+ } else {
1987
+ const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
1988
+ rawSections = await resolveSectionsList(page.sections, rctx);
1989
+ }
1990
+
1991
+ if (sectionIndex < 0 || sectionIndex >= rawSections.length) return null;
1992
+
1993
+ const section = rawSections[sectionIndex];
1994
+ const shallow = resolveSectionShallow(section, ctx);
1995
+ if (!shallow || shallow.component !== component) return null;
1996
+
1997
+ // Cache for subsequent requests
1998
+ if (shallow.rawProps) {
1999
+ cacheDeferredRawProps(pagePath, component, sectionIndex, shallow.rawProps);
2000
+ }
2001
+
2002
+ return shallow.rawProps ?? null;
2003
+ }