@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|