@decocms/tanstack 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.
@@ -0,0 +1,706 @@
1
+ /**
2
+ * CMS Route Helpers
3
+ *
4
+ * Reusable building blocks for CMS catch-all and homepage routes.
5
+ * Since TanStack Router requires routes to be file-based in the site repo,
6
+ * we export helper functions and config that sites use in their route files.
7
+ *
8
+ * @example Site's `src/routes/$.tsx`:
9
+ * ```ts
10
+ * import { createFileRoute, notFound } from "@tanstack/react-router";
11
+ * import { loadCmsPage, cmsRouteConfig } from "@decocms/start/routes";
12
+ * import { DecoPageRenderer } from "@decocms/start/hooks";
13
+ *
14
+ * export const Route = createFileRoute("/$")({
15
+ * ...cmsRouteConfig({
16
+ * siteName: "My Store",
17
+ * defaultTitle: "My Store - Best products",
18
+ * ignoreSearchParams: ["skuId"],
19
+ * }),
20
+ * });
21
+ * ```
22
+ */
23
+
24
+ import { createServerFn } from "@tanstack/react-start";
25
+ import {
26
+ getCookies,
27
+ getRequest,
28
+ getRequestHeader,
29
+ getRequestUrl,
30
+ setResponseHeader,
31
+ } from "@tanstack/react-start/server";
32
+ import { createElement } from "react";
33
+ import {
34
+ extractSeoFromProps,
35
+ extractSeoFromSections,
36
+ getDeferredRawProps,
37
+ getSiteSeo,
38
+ preloadSectionComponents,
39
+ reExtractRawProps,
40
+ resolveDecoPage,
41
+ resolveDeferredSection,
42
+ resolveDeferredSectionFull,
43
+ runSectionLoaders,
44
+ runSingleSectionLoader,
45
+ } from "@decocms/blocks/cms";
46
+ import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "@decocms/blocks/cms";
47
+ import { withTracing } from "@decocms/blocks/middleware/observability";
48
+ import {
49
+ type CacheProfileName,
50
+ cacheHeaders,
51
+ detectCacheProfile,
52
+ routeCacheDefaults,
53
+ } from "@decocms/blocks/sdk/cacheHeaders";
54
+ import { normalizeUrlsInObject } from "@decocms/blocks/sdk/normalizeUrls";
55
+ import { type Device, detectDevice } from "@decocms/blocks/sdk/useDevice";
56
+ import { derivePageUrl, isClientNavigation } from "./pageUrl";
57
+ import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
58
+
59
+ const isServer = typeof document === "undefined";
60
+
61
+ // Section chunk manifest — maps section keys to chunk filenames.
62
+ // Generated by decoVitePlugin's generateBundle hook. Used to emit
63
+ // <link rel="modulepreload"> hints for eager sections.
64
+ let sectionChunkMap: Record<string, string> = {};
65
+
66
+ /**
67
+ * Set the section chunk manifest. Called by the site's worker entry
68
+ * or setup after loading the manifest.
69
+ */
70
+ export function setSectionChunkMap(map: Record<string, string>): void {
71
+ sectionChunkMap = map;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Server function — loads a CMS page, runs section loaders, detects cache
76
+ // ---------------------------------------------------------------------------
77
+
78
+ type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
79
+ const pageInflight = new Map<string, Promise<PageResult>>();
80
+
81
+ async function loadCmsPageInternal(fullPath: string) {
82
+ const [basePath] = fullPath.split("?");
83
+ // On client-side navigation getRequestUrl() is the /_serverFn/... endpoint,
84
+ // not the page being loaded — derivePageUrl rebuilds the real page URL so
85
+ // matcherCtx.url / __pageUrl stay consistent with __pagePath (#280).
86
+ const serverUrl = getRequestUrl();
87
+ const urlWithSearch = derivePageUrl(fullPath, serverUrl);
88
+
89
+ const originRequest = getRequest();
90
+ const matcherCtx: MatcherContext = {
91
+ userAgent: getRequestHeader("user-agent") ?? "",
92
+ url: urlWithSearch,
93
+ path: basePath,
94
+ cookies: getCookies(),
95
+ request: originRequest,
96
+ isClientNavigation: isClientNavigation(fullPath, serverUrl),
97
+ };
98
+ const page = await resolveDecoPage(basePath, matcherCtx);
99
+ if (!page) return null;
100
+
101
+ const request = new Request(urlWithSearch, {
102
+ headers: originRequest.headers,
103
+ });
104
+
105
+ // Resolve page sections and site globals in parallel. Globals are merged
106
+ // into the same `resolvedSections` array so the path through this server
107
+ // function is identical for SSR (F5) and SPA (<Link>) navigations — see
108
+ // #233 for the previous SPA-breakage when `withSiteGlobals` ran client-side
109
+ // and saw an empty client-bundled `blocks.gen.ts`.
110
+ const [enrichedSections, globals] = await Promise.all([
111
+ runSectionLoaders(page.resolvedSections, request),
112
+ resolveSiteGlobals(),
113
+ ]);
114
+
115
+ // Page sections take precedence over globals — dedupe drops any global
116
+ // whose component is already rendered by the page.
117
+ const mergedSections: ResolvedSection[] = [
118
+ ...dedupeGlobals(globals.resolvedSections, enrichedSections),
119
+ ...enrichedSections,
120
+ ];
121
+
122
+ // Pre-import eager section modules so their default exports are cached
123
+ // in resolvedComponents. This ensures SSR renders with direct component
124
+ // refs, and the client hydration can skip React.lazy/Suspense.
125
+ const eagerKeys = mergedSections.map((s) => s.component);
126
+ await preloadSectionComponents(eagerKeys);
127
+
128
+ const cacheProfile = detectCacheProfile(basePath);
129
+ const ua = getRequestHeader("user-agent") ?? "";
130
+ const device = detectDevice(ua);
131
+
132
+ // Build SEO: merge page-level seo block (primary) with section-contributed SEO (secondary)
133
+ const seo = await buildPageSeo(page.seoSection, mergedSections, request);
134
+
135
+ // Destructure seoSection out — it's an internal artifact, not serialized to client
136
+ const { seoSection: _seo, ...pageData } = page;
137
+
138
+ return {
139
+ ...pageData,
140
+ resolvedSections: normalizeUrlsInObject(mergedSections),
141
+ deferredSections: normalizeUrlsInObject(page.deferredSections),
142
+ cacheProfile,
143
+ pageUrl: urlWithSearch,
144
+ pagePath: basePath,
145
+ seo,
146
+ device,
147
+ siteGlobals: { rawRefs: globals.rawRefs },
148
+ };
149
+ }
150
+
151
+ export const loadCmsPage = createServerFn({ method: "GET" })
152
+ .inputValidator((data: unknown) => data as string)
153
+ .handler(async (ctx) => {
154
+ const fullPath = ctx.data;
155
+
156
+ // Use the full path (including query string) as the dedup key.
157
+ // Using basePath only caused /s?q=a and /s?q=b to share one promise,
158
+ // returning wrong/empty results for search and filtered PLPs.
159
+ const existing = pageInflight.get(fullPath);
160
+ if (existing) return existing;
161
+
162
+ const promise = loadCmsPageInternal(fullPath).finally(() => pageInflight.delete(fullPath));
163
+ pageInflight.set(fullPath, promise);
164
+ return promise;
165
+ });
166
+
167
+ /**
168
+ * Same as loadCmsPage but hardcoded to "/" path.
169
+ * Avoids passing data through the server function for the homepage.
170
+ */
171
+ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
172
+ const request = getRequest();
173
+ const ua = getRequestHeader("user-agent") ?? "";
174
+ const serverUrl = getRequestUrl();
175
+ const matcherCtx: MatcherContext = {
176
+ userAgent: ua,
177
+ url: serverUrl.toString(),
178
+ path: "/",
179
+ cookies: getCookies(),
180
+ request,
181
+ isClientNavigation: isClientNavigation("/", serverUrl),
182
+ };
183
+ const page = await resolveDecoPage("/", matcherCtx);
184
+ if (!page) return null;
185
+ const [enrichedSections, globals] = await Promise.all([
186
+ runSectionLoaders(page.resolvedSections, request),
187
+ resolveSiteGlobals(),
188
+ ]);
189
+
190
+ const mergedSections: ResolvedSection[] = [
191
+ ...dedupeGlobals(globals.resolvedSections, enrichedSections),
192
+ ...enrichedSections,
193
+ ];
194
+
195
+ const eagerKeys = mergedSections.map((s) => s.component);
196
+ await preloadSectionComponents(eagerKeys);
197
+
198
+ const device = detectDevice(ua);
199
+ const seo = await buildPageSeo(page.seoSection, mergedSections, request);
200
+
201
+ const { seoSection: _seo, ...pageData } = page;
202
+
203
+ return {
204
+ ...pageData,
205
+ resolvedSections: normalizeUrlsInObject(mergedSections),
206
+ deferredSections: normalizeUrlsInObject(page.deferredSections),
207
+ pagePath: "/",
208
+ pageUrl: serverUrl.toString(),
209
+ seo,
210
+ device,
211
+ siteGlobals: { rawRefs: globals.rawRefs },
212
+ };
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Deferred section loader — resolves + enriches a single section on demand
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /**
220
+ * @deprecated Prefer TanStack native streaming via `deferredPromises` in the
221
+ * route loader. This POST server function is kept for backward compatibility
222
+ * and as a fallback for SPA navigations.
223
+ */
224
+ export const loadDeferredSection = createServerFn({ method: "POST" })
225
+ .inputValidator(
226
+ (data: unknown) =>
227
+ data as {
228
+ component: string;
229
+ /** @deprecated rawProps are now resolved server-side from the deferred props cache. */
230
+ rawProps?: Record<string, any>;
231
+ pagePath: string;
232
+ pageUrl?: string;
233
+ /** Original position in the page section list — preserved for correct SPA ordering. */
234
+ index?: number;
235
+ },
236
+ )
237
+ .handler(async (ctx) => {
238
+ const { component, rawProps: clientRawProps, pagePath, pageUrl, index } = ctx.data;
239
+
240
+ const originRequest = getRequest();
241
+ const serverUrl = getRequestUrl().toString();
242
+ const matcherCtx: MatcherContext = {
243
+ userAgent: getRequestHeader("user-agent") ?? "",
244
+ url: pageUrl || serverUrl,
245
+ path: pagePath,
246
+ cookies: getCookies(),
247
+ request: originRequest,
248
+ };
249
+
250
+ // Resolve rawProps: prefer client-provided (backward compat), then server cache,
251
+ // then re-extract from the page as a last resort (handles cross-isolate cache miss
252
+ // on Cloudflare Workers and TTL expiry for slow-scrolling users).
253
+ const rawProps = clientRawProps
254
+ ?? (index !== undefined ? getDeferredRawProps(pagePath, component, index) : null)
255
+ ?? (index !== undefined
256
+ ? await reExtractRawProps(pagePath, component, index, matcherCtx)
257
+ : null);
258
+
259
+ if (!rawProps) {
260
+ console.warn(`[CMS] Deferred section cache miss: ${component} at index ${index} on ${pagePath}`);
261
+ return null;
262
+ }
263
+
264
+ const section = await withTracing(
265
+ "deco.section.deferred.load",
266
+ () => resolveDeferredSection(component, rawProps, pagePath, matcherCtx),
267
+ {
268
+ "section.name": component,
269
+ "page.path": pagePath,
270
+ ...(typeof index === "number" ? { "section.index": index } : {}),
271
+ },
272
+ );
273
+ if (!section) return null;
274
+
275
+ // Preserve original index for correct ordering in SPA navigation merge
276
+ if (index !== undefined) section.index = index;
277
+
278
+ const request = new Request(pageUrl || serverUrl, {
279
+ headers: originRequest.headers,
280
+ });
281
+ const enriched = await runSingleSectionLoader(section, request);
282
+
283
+ // Signal to the worker entry that this response is safe to edge-cache.
284
+ // Without this header, POST _serverFn responses are passed through
285
+ // without caching (checkout actions, invoke mutations, etc.).
286
+ setResponseHeader("X-Deco-Cacheable", "true");
287
+
288
+ return normalizeUrlsInObject(enriched);
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Pre-wrapped deferred section loader for IntersectionObserver-based rendering
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Convenience wrapper around `loadDeferredSection` that matches the
297
+ * `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
298
+ *
299
+ * Pass this directly to `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
300
+ * to enable IntersectionObserver-based lazy loading of deferred sections.
301
+ */
302
+ export const deferredSectionLoader = async ({
303
+ component,
304
+ rawProps,
305
+ pagePath,
306
+ pageUrl,
307
+ index,
308
+ }: {
309
+ component: string;
310
+ rawProps?: Record<string, unknown>;
311
+ pagePath: string;
312
+ pageUrl?: string;
313
+ index?: number;
314
+ }): Promise<ResolvedSection | null> => {
315
+ return loadDeferredSection({
316
+ data: { component, rawProps, pagePath, pageUrl, index },
317
+ });
318
+ };
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Default pending component — shown during SPA navigation while loader runs
322
+ // ---------------------------------------------------------------------------
323
+
324
+ export function CmsPagePendingFallback() {
325
+ return createElement(
326
+ "div",
327
+ { className: "w-full min-h-[60vh] flex flex-col gap-6 py-8" },
328
+ createElement("div", {
329
+ className: "skeleton animate-pulse w-full rounded",
330
+ style: { aspectRatio: "1440/400", minHeight: 200 },
331
+ }),
332
+ createElement(
333
+ "div",
334
+ { className: "px-4 lg:px-8 flex flex-col gap-4" },
335
+ createElement("div", { className: "skeleton animate-pulse w-48 h-8 rounded" }),
336
+ createElement(
337
+ "div",
338
+ { className: "grid grid-cols-2 lg:grid-cols-4 gap-4" },
339
+ ...Array.from({ length: 4 }, (_, i) =>
340
+ createElement("div", {
341
+ key: i,
342
+ className: "skeleton animate-pulse w-full h-48 lg:h-64 rounded",
343
+ }),
344
+ ),
345
+ ),
346
+ ),
347
+ );
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Route configuration factory
352
+ // ---------------------------------------------------------------------------
353
+
354
+ export interface CmsRouteOptions {
355
+ /** Site name used in page titles (e.g. "Espaço Smart"). */
356
+ siteName: string;
357
+ /** Default page title when CMS page has no name. */
358
+ defaultTitle: string;
359
+ /** Default description for pages without section-contributed SEO. */
360
+ defaultDescription?: string;
361
+ /**
362
+ * Search params to exclude from loader deps.
363
+ * These params won't trigger a server re-fetch when they change.
364
+ * Defaults to `["skuId"]` — variant selection is client-side only.
365
+ */
366
+ ignoreSearchParams?: string[];
367
+ /** Custom pending component shown during SPA navigation. */
368
+ pendingComponent?: () => any;
369
+ /**
370
+ * Delay (ms) before showing the pending component during SPA navigation.
371
+ * If the loader resolves before this threshold, no pending UI is shown.
372
+ * Prevents skeleton flash on fast cache-hit navigations. Default: 200.
373
+ */
374
+ pendingMs?: number;
375
+ /**
376
+ * Minimum display time (ms) for the pending component once shown.
377
+ * Prevents jarring flash when data arrives shortly after the threshold.
378
+ * Default: 300.
379
+ */
380
+ pendingMinMs?: number;
381
+ /**
382
+ * SSR mode for this route.
383
+ * - `true` (default): Full SSR — loader + component render on server.
384
+ * - `'data-only'`: Loader runs on server, component renders on client only.
385
+ * Use for interactive-heavy pages (PDP with zoom/variant selector).
386
+ * - `false`: Everything runs on client only.
387
+ */
388
+ ssr?: boolean | "data-only";
389
+ }
390
+
391
+ type CmsPageLoaderData = {
392
+ name?: string;
393
+ cacheProfile?: CacheProfileName;
394
+ seo?: PageSeo;
395
+ device?: Device;
396
+ resolvedSections?: Array<{ component: string }>;
397
+ } | null;
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Page SEO assembly — merges page.seo block with section-contributed SEO
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * Process the resolved SEO section from page.seo, run its section loader
405
+ * if registered, apply title/description templates, and merge with
406
+ * section-contributed SEO.
407
+ */
408
+ async function buildPageSeo(
409
+ seoSection: ResolvedSection | null | undefined,
410
+ enrichedSections: ResolvedSection[],
411
+ request: Request,
412
+ ): Promise<PageSeo> {
413
+ // Secondary source: SEO sections embedded in the sections array
414
+ const sectionSeo = extractSeoFromSections(enrichedSections);
415
+
416
+ // Site-wide SEO config from the "Site" app block — mirrors ctx.seo in
417
+ // the original deco-cx/deco framework. Provides fallback title,
418
+ // description, and templates when page-level seo doesn't supply them.
419
+ const siteSeo = getSiteSeo();
420
+
421
+ if (!seoSection) {
422
+ // No page.seo block — use site-wide SEO as primary, section-contributed as secondary
423
+ const merged: PageSeo = { ...sectionSeo };
424
+ if (siteSeo.title && !merged.title) merged.title = siteSeo.title;
425
+ if (siteSeo.description && !merged.description) merged.description = siteSeo.description;
426
+ if (siteSeo.image && !merged.image) merged.image = siteSeo.image;
427
+
428
+ // Apply site-level templates even when there is no page-level seo
429
+ // section. Without this, pages whose title comes from a content
430
+ // section (e.g. <h1> + sectionSeo) or from the site default would
431
+ // skip `siteSeo.titleTemplate`, causing prod-vs-worker title drift.
432
+ const titleTemplate = effectiveTemplate(siteSeo.titleTemplate);
433
+ const descTemplate = effectiveTemplate(siteSeo.descriptionTemplate);
434
+ if (titleTemplate && merged.title) {
435
+ merged.title = titleTemplate.replace("%s", merged.title);
436
+ }
437
+ if (descTemplate && merged.description) {
438
+ merged.description = descTemplate.replace("%s", merged.description);
439
+ }
440
+
441
+ return merged;
442
+ }
443
+
444
+ // Run the section loader on the seo section if one is registered
445
+ // (e.g., SEOPDP loader transforms {jsonLD: ProductDetailsPage} → {title, description, ...})
446
+ let enrichedProps = seoSection.props;
447
+ try {
448
+ const enriched = await runSingleSectionLoader(seoSection, request);
449
+ if (enriched) enrichedProps = enriched.props;
450
+ } catch {
451
+ // Section loader failed — use the raw resolved props
452
+ }
453
+
454
+ const pageSeo = extractSeoFromProps(enrichedProps);
455
+
456
+ // Replicate original SeoV2 loader logic: `_title ?? appTitle`
457
+ // When the page's seo block doesn't have a title/description,
458
+ // fall back to the Site app's seo config.
459
+ if (!pageSeo.title && siteSeo.title) pageSeo.title = siteSeo.title;
460
+ if (!pageSeo.description && siteSeo.description) pageSeo.description = siteSeo.description;
461
+ if (!pageSeo.image && siteSeo.image) pageSeo.image = siteSeo.image;
462
+
463
+ // Apply title/description templates.
464
+ // Priority: page-level template → site-level template → no-op.
465
+ // This mirrors the original: `(titleTemplate ?? "").trim().length === 0 ? "%s" : titleTemplate`
466
+ const rawProps = seoSection.props;
467
+ const titleTemplate =
468
+ effectiveTemplate(rawProps.titleTemplate as string | undefined) ??
469
+ effectiveTemplate(siteSeo.titleTemplate);
470
+ const descTemplate =
471
+ effectiveTemplate(rawProps.descriptionTemplate as string | undefined) ??
472
+ effectiveTemplate(siteSeo.descriptionTemplate);
473
+
474
+ if (titleTemplate && pageSeo.title) {
475
+ pageSeo.title = titleTemplate.replace("%s", pageSeo.title);
476
+ }
477
+ if (descTemplate && pageSeo.description) {
478
+ pageSeo.description = descTemplate.replace("%s", pageSeo.description);
479
+ }
480
+
481
+ // Primary source (page.seo) overrides secondary (section-contributed).
482
+ // Only truthy fields in pageSeo override — undefined keys don't clear sectionSeo.
483
+ return { ...sectionSeo, ...pageSeo };
484
+ }
485
+
486
+ /** Returns a non-trivial template string, or undefined for "%s" / empty / blank. */
487
+ function effectiveTemplate(tmpl: string | undefined): string | undefined {
488
+ if (!tmpl || tmpl.trim() === "" || tmpl.trim() === "%s") return undefined;
489
+ return tmpl;
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Head metadata builder
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * Build TanStack Router `head()` metadata from page-level and section-contributed SEO.
498
+ * Emits: title, description, canonical, OG tags, Twitter Card, robots directive.
499
+ */
500
+ function buildHead(
501
+ loaderData: CmsPageLoaderData | undefined,
502
+ siteName: string,
503
+ defaultTitle: string,
504
+ defaultDescription?: string,
505
+ ) {
506
+ const seo = loaderData?.seo;
507
+ const title = seo?.title
508
+ ? seo.title
509
+ : loaderData?.name
510
+ ? `${loaderData.name} | ${siteName}`
511
+ : defaultTitle;
512
+ const description = seo?.description || defaultDescription;
513
+ const image = seo?.image;
514
+ const canonical = seo?.canonical;
515
+ const noIndex = seo?.noIndexing;
516
+
517
+ const meta: Record<string, string>[] = [{ title }];
518
+
519
+ if (description) {
520
+ meta.push({ name: "description", content: description });
521
+ }
522
+
523
+ // Robots — always emit a directive so crawlers see an explicit signal
524
+ meta.push({
525
+ name: "robots",
526
+ content: noIndex
527
+ ? "noindex, nofollow"
528
+ : "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1",
529
+ });
530
+
531
+ // Open Graph
532
+ meta.push({ property: "og:title", content: title });
533
+ if (description) meta.push({ property: "og:description", content: description });
534
+ if (image) meta.push({ property: "og:image", content: image });
535
+ meta.push({ property: "og:type", content: seo?.type || "website" });
536
+ if (canonical) meta.push({ property: "og:url", content: canonical });
537
+
538
+ // Twitter Card
539
+ meta.push({ name: "twitter:card", content: image ? "summary_large_image" : "summary" });
540
+ meta.push({ name: "twitter:title", content: title });
541
+ if (description) meta.push({ name: "twitter:description", content: description });
542
+ if (image) meta.push({ name: "twitter:image", content: image });
543
+
544
+ const links: Record<string, string>[] = [];
545
+ if (canonical) {
546
+ links.push({ rel: "canonical", href: canonical });
547
+ }
548
+
549
+ // Modulepreload hints for eager section chunks
550
+ if (loaderData?.resolvedSections) {
551
+ for (const s of loaderData.resolvedSections) {
552
+ const chunkFile = sectionChunkMap[s.component];
553
+ if (chunkFile) {
554
+ links.push({ rel: "modulepreload", href: `/${chunkFile}` });
555
+ }
556
+ }
557
+ }
558
+
559
+ // JSON-LD structured data — rendered as <script type="application/ld+json"> in <head>
560
+ const scripts: Array<{ type: string; children: string }> = [];
561
+ if (seo?.jsonLDs?.length) {
562
+ for (const jsonLD of seo.jsonLDs) {
563
+ scripts.push({
564
+ type: "application/ld+json",
565
+ children: JSON.stringify(jsonLD),
566
+ });
567
+ }
568
+ }
569
+
570
+ return { meta, links, scripts };
571
+ }
572
+
573
+ /**
574
+ * Returns a TanStack Router route config object for a CMS catch-all route.
575
+ * Spread the result into your `createFileRoute("/$")({...})` call.
576
+ *
577
+ * Includes: loaderDeps, loader, headers, head (with full SEO), staleTime/gcTime.
578
+ * Does NOT include: component, notFoundComponent (site provides these).
579
+ *
580
+ * SEO metadata is extracted from sections registered via `registerSeoSections()`.
581
+ * The `head()` function emits title, description, canonical, OG tags, and robots.
582
+ */
583
+ export function cmsRouteConfig(options: CmsRouteOptions) {
584
+ const {
585
+ siteName,
586
+ defaultTitle,
587
+ defaultDescription,
588
+ ignoreSearchParams = ["skuId"],
589
+ pendingComponent,
590
+ pendingMs = 200,
591
+ pendingMinMs = 300,
592
+ ssr: ssrMode,
593
+ } = options;
594
+
595
+ const ignoreSet = new Set(ignoreSearchParams);
596
+
597
+ return {
598
+ // Catch-all search validation: preserve all URL search params so they
599
+ // reach loaderDeps. Without this, TanStack Router may strip unknown
600
+ // params (e.g. ?q=, filter.*, page, sort) during SPA navigation.
601
+ validateSearch: (search: Record<string, unknown>) =>
602
+ search as Record<string, string>,
603
+
604
+ loaderDeps: ({ search }: { search: Record<string, string> }) => {
605
+ const filtered = Object.fromEntries(
606
+ Object.entries(search ?? {}).filter(([k]) => !ignoreSet.has(k)),
607
+ );
608
+ return {
609
+ search: Object.keys(filtered).length ? filtered : undefined,
610
+ };
611
+ },
612
+
613
+ loader: async ({
614
+ params,
615
+ deps,
616
+ }: {
617
+ params: { _splat?: string };
618
+ deps: { search?: Record<string, string> };
619
+ }) => {
620
+ const basePath = "/" + (params._splat || "");
621
+ const searchStr = deps.search
622
+ ? "?" + new URLSearchParams(deps.search as Record<string, string>).toString()
623
+ : "";
624
+ const page = await loadCmsPage({ data: basePath + searchStr });
625
+ if (!page) return page;
626
+
627
+ if (!isServer && page.resolvedSections) {
628
+ const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
629
+ await preloadSectionComponents(keys);
630
+ }
631
+
632
+ // Deferred sections use client-side IntersectionObserver loading.
633
+ // DecoPageRenderer renders skeletons (LoadingFallback) during SSR and
634
+ // loads full section content via _serverFn when scrolled into view.
635
+ //
636
+ // Previously, SSR streaming via TanStack <Await> resolved ALL deferred
637
+ // sections server-side and streamed them in the initial HTML. This caused
638
+ // the browser to load ALL product shelf images at once (~3x more image
639
+ // requests than the IntersectionObserver path). Client SPA navigation
640
+ // also blocked on await Promise.all() for all deferred sections.
641
+ //
642
+ // The IO path (DeferredSectionWrapper) only fetches section data when
643
+ // the skeleton scrolls into view, matching Fresh/Deno partial behavior
644
+ // and significantly reducing initial image load and TBT.
645
+ return page;
646
+ },
647
+
648
+ ...(pendingComponent ? { pendingComponent } : {}),
649
+ pendingMs,
650
+ pendingMinMs,
651
+ ...(ssrMode !== undefined ? { ssr: ssrMode } : {}),
652
+
653
+ ...routeCacheDefaults("product"),
654
+
655
+ headers: ({ loaderData }: { loaderData?: CmsPageLoaderData }) => {
656
+ const profile = loaderData?.cacheProfile ?? "listing";
657
+ return cacheHeaders(profile);
658
+ },
659
+
660
+ head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
661
+ buildHead(loaderData, siteName, defaultTitle, defaultDescription),
662
+ };
663
+ }
664
+
665
+ /**
666
+ * Returns a TanStack Router route config for the CMS homepage route.
667
+ * Spread into `createFileRoute("/")({...})`.
668
+ *
669
+ * Like `cmsRouteConfig`, emits full SEO head metadata from section-contributed data.
670
+ */
671
+ export function cmsHomeRouteConfig(options: {
672
+ defaultTitle: string;
673
+ defaultDescription?: string;
674
+ /** Site name for OG title composition. Defaults to defaultTitle. */
675
+ siteName?: string;
676
+ pendingComponent?: () => any;
677
+ /** Delay (ms) before showing pending component. Default: 200. */
678
+ pendingMs?: number;
679
+ /** Minimum display time (ms) for pending component. Default: 300. */
680
+ pendingMinMs?: number;
681
+ }) {
682
+ const { defaultTitle, defaultDescription, siteName, pendingMs = 200, pendingMinMs = 300 } = options;
683
+
684
+ return {
685
+ loader: async () => {
686
+ const page = await loadCmsHomePage();
687
+ if (!page) return page;
688
+
689
+ if (!isServer && page.resolvedSections) {
690
+ const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
691
+ await preloadSectionComponents(keys);
692
+ }
693
+
694
+ // Deferred sections use client-side IntersectionObserver loading.
695
+ // See cmsRouteConfig loader for rationale.
696
+ return page;
697
+ },
698
+ ...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
699
+ pendingMs,
700
+ pendingMinMs,
701
+ ...routeCacheDefaults("static"),
702
+ headers: () => cacheHeaders("static"),
703
+ head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
704
+ buildHead(loaderData, siteName ?? defaultTitle, defaultTitle, defaultDescription),
705
+ };
706
+ }