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