@decocms/start 0.24.0 → 0.25.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/.cursor/skills/deco-cms-route-config/SKILL.md +181 -50
- package/.cursor/skills/deco-start-architecture/cms-resolution.md +76 -0
- package/.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md +115 -0
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +9 -5
- package/package.json +1 -1
- package/src/admin/invoke.ts +14 -1
- package/src/cms/index.ts +6 -0
- package/src/cms/resolve.ts +195 -0
- package/src/routes/cmsRoute.ts +179 -21
- package/src/routes/index.ts +2 -0
|
@@ -45,44 +45,24 @@ import { createFileRoute } from "@tanstack/react-router";
|
|
|
45
45
|
import { cmsRouteConfig, loadDeferredSection } from "@decocms/start/routes";
|
|
46
46
|
import { DecoPageRenderer } from "@decocms/start/hooks";
|
|
47
47
|
import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
|
|
48
|
-
import type { CacheProfile } from "@decocms/start/sdk/cacheHeaders";
|
|
49
|
-
import { cacheHeaders, routeCacheDefaults } from "@decocms/start/sdk/cacheHeaders";
|
|
50
48
|
|
|
51
49
|
const routeConfig = cmsRouteConfig({
|
|
52
50
|
siteName: "My Store",
|
|
53
51
|
defaultTitle: "My Store - Default Title",
|
|
52
|
+
defaultDescription: "My Store — best products with the best prices.",
|
|
54
53
|
ignoreSearchParams: ["skuId"],
|
|
55
54
|
});
|
|
56
55
|
|
|
57
56
|
type PageData = {
|
|
58
57
|
resolvedSections: ResolvedSection[];
|
|
59
58
|
deferredSections: DeferredSection[];
|
|
60
|
-
cacheProfile: CacheProfile;
|
|
61
59
|
name: string;
|
|
62
60
|
path: string;
|
|
63
61
|
params: Record<string, string>;
|
|
64
62
|
} | null;
|
|
65
63
|
|
|
66
64
|
export const Route = createFileRoute("/$")({
|
|
67
|
-
...
|
|
68
|
-
loaderDeps: routeConfig.loaderDeps,
|
|
69
|
-
loader: routeConfig.loader as any,
|
|
70
|
-
headers: ({ loaderData }) => {
|
|
71
|
-
const data = loaderData as PageData;
|
|
72
|
-
return cacheHeaders(data?.cacheProfile ?? "listing");
|
|
73
|
-
},
|
|
74
|
-
head: ({ loaderData }) => {
|
|
75
|
-
const data = loaderData as PageData;
|
|
76
|
-
return {
|
|
77
|
-
meta: [
|
|
78
|
-
{
|
|
79
|
-
title: data?.name
|
|
80
|
-
? `${data.name} | My Store`
|
|
81
|
-
: "My Store - Default Title",
|
|
82
|
-
},
|
|
83
|
-
],
|
|
84
|
-
};
|
|
85
|
-
},
|
|
65
|
+
...routeConfig,
|
|
86
66
|
component: CmsPage,
|
|
87
67
|
notFoundComponent: NotFoundPage,
|
|
88
68
|
});
|
|
@@ -105,15 +85,17 @@ function CmsPage() {
|
|
|
105
85
|
}
|
|
106
86
|
```
|
|
107
87
|
|
|
108
|
-
**CRITICAL**:
|
|
88
|
+
**CRITICAL**: `cmsRouteConfig` already includes `routeCacheDefaults("product")`, cache headers, and full SEO head metadata. Spread the entire config — do NOT cherry-pick individual fields.
|
|
109
89
|
|
|
110
90
|
### `cmsRouteConfig` Options
|
|
111
91
|
|
|
112
92
|
```typescript
|
|
113
93
|
interface CmsRouteOptions {
|
|
114
|
-
siteName: string;
|
|
115
|
-
defaultTitle: string;
|
|
116
|
-
|
|
94
|
+
siteName: string; // Used in page title: "Page Name | siteName"
|
|
95
|
+
defaultTitle: string; // Fallback title when CMS page has no name
|
|
96
|
+
defaultDescription?: string; // Fallback description when no SEO section contributes one
|
|
97
|
+
ignoreSearchParams?: string[]; // Search params excluded from loaderDeps (default: ["skuId"])
|
|
98
|
+
pendingComponent?: () => any; // Skeleton shown during SPA navigation
|
|
117
99
|
}
|
|
118
100
|
```
|
|
119
101
|
|
|
@@ -160,17 +142,23 @@ The `cacheProfile` is determined by `detectCacheProfile(basePath)` inside `loadC
|
|
|
160
142
|
| `/cart`, `/checkout` | private | none |
|
|
161
143
|
| Everything else | listing | 2 min |
|
|
162
144
|
|
|
163
|
-
### Head/SEO
|
|
145
|
+
### Head/SEO — Automatic from CMS `page.seo` + Section Registry
|
|
164
146
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
147
|
+
The framework's `buildHead()` function generates full `<head>` metadata from two sources:
|
|
148
|
+
|
|
149
|
+
**Primary: `page.seo` field** — The top-level `seo` block in CMS page JSONs is resolved eagerly by `resolvePageSeoBlock()`. Lazy/Deferred wrappers are always unwrapped (SEO must never be deferred for crawlers). Commerce loaders within the seo block are resolved (e.g., PDP product data). Section loaders transform the resolved props into standard SEO fields.
|
|
150
|
+
|
|
151
|
+
**Secondary: Registered SEO sections** — Sections in `page.sections` registered via `registerSeoSections()` contribute SEO as a fallback. Page-level `page.seo` always takes precedence.
|
|
152
|
+
|
|
153
|
+
Generated tags:
|
|
154
|
+
- `<title>` from page.seo → section SEO → page name + siteName → defaultTitle
|
|
155
|
+
- `<meta name="description">` from page.seo → section SEO → defaultDescription
|
|
156
|
+
- `<link rel="canonical">` from page.seo canonical
|
|
157
|
+
- `<meta property="og:*">` Open Graph tags (title, description, image, type, url)
|
|
158
|
+
- `<meta name="twitter:*">` Twitter Card tags
|
|
159
|
+
- `<meta name="robots">` noindex/nofollow when `noIndexing: true`
|
|
160
|
+
|
|
161
|
+
Title/description templates from the CMS (e.g., `"%s | STORE NAME"`) are applied automatically. The `head()` function is built into `cmsRouteConfig` — sites do NOT need to implement their own.
|
|
174
162
|
|
|
175
163
|
---
|
|
176
164
|
|
|
@@ -187,22 +175,20 @@ import { cmsHomeRouteConfig, loadDeferredSection } from "@decocms/start/routes";
|
|
|
187
175
|
import { DecoPageRenderer } from "@decocms/start/hooks";
|
|
188
176
|
import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
|
|
189
177
|
|
|
190
|
-
const homeConfig = cmsHomeRouteConfig({
|
|
191
|
-
defaultTitle: "My Store - Homepage",
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
type HomeData = {
|
|
195
|
-
resolvedSections: ResolvedSection[];
|
|
196
|
-
deferredSections: DeferredSection[];
|
|
197
|
-
} | null;
|
|
198
|
-
|
|
199
178
|
export const Route = createFileRoute("/")({
|
|
200
|
-
...
|
|
179
|
+
...cmsHomeRouteConfig({
|
|
180
|
+
defaultTitle: "My Store - Homepage",
|
|
181
|
+
defaultDescription: "My Store — best products with the best prices.",
|
|
182
|
+
siteName: "My Store",
|
|
183
|
+
}),
|
|
201
184
|
component: HomePage,
|
|
202
185
|
});
|
|
203
186
|
|
|
204
187
|
function HomePage() {
|
|
205
|
-
const data = Route.useLoaderData() as
|
|
188
|
+
const data = Route.useLoaderData() as {
|
|
189
|
+
resolvedSections: ResolvedSection[];
|
|
190
|
+
deferredSections: DeferredSection[];
|
|
191
|
+
} | null;
|
|
206
192
|
if (!data) return null;
|
|
207
193
|
|
|
208
194
|
return (
|
|
@@ -216,13 +202,16 @@ function HomePage() {
|
|
|
216
202
|
}
|
|
217
203
|
```
|
|
218
204
|
|
|
219
|
-
`cmsHomeRouteConfig` already includes `routeCacheDefaults("static")
|
|
205
|
+
`cmsHomeRouteConfig` already includes `routeCacheDefaults("static")`, `cacheHeaders("static")`, and full SEO head metadata. Do NOT add additional cache or head config.
|
|
220
206
|
|
|
221
207
|
### `cmsHomeRouteConfig` Options
|
|
222
208
|
|
|
223
209
|
```typescript
|
|
224
210
|
interface CmsHomeRouteOptions {
|
|
225
211
|
defaultTitle: string;
|
|
212
|
+
defaultDescription?: string; // Fallback description
|
|
213
|
+
siteName?: string; // For OG title composition (defaults to defaultTitle)
|
|
214
|
+
pendingComponent?: () => any;
|
|
226
215
|
}
|
|
227
216
|
```
|
|
228
217
|
|
|
@@ -289,17 +278,30 @@ TanStack Router injects internal properties (`id`, `path`) that conflict if the
|
|
|
289
278
|
```typescript
|
|
290
279
|
// @decocms/start/routes
|
|
291
280
|
export {
|
|
292
|
-
cmsRouteConfig, // Catch-all CMS route config factory
|
|
293
|
-
cmsHomeRouteConfig, // Homepage route config factory
|
|
281
|
+
cmsRouteConfig, // Catch-all CMS route config factory (includes full SEO head)
|
|
282
|
+
cmsHomeRouteConfig, // Homepage route config factory (includes full SEO head)
|
|
294
283
|
loadCmsPage, // Server function for CMS page resolution
|
|
295
284
|
loadCmsHomePage, // Server function for homepage resolution
|
|
285
|
+
loadDeferredSection, // Server function for on-scroll section loading
|
|
296
286
|
type CmsRouteOptions,
|
|
287
|
+
type PageSeo, // SEO data type extracted from sections
|
|
288
|
+
type Device, // Device type: "mobile" | "tablet" | "desktop"
|
|
297
289
|
CmsPage, // Generic CMS page component
|
|
298
290
|
NotFoundPage, // Generic 404 component
|
|
299
291
|
decoMetaRoute, // Admin meta route config
|
|
300
292
|
decoRenderRoute, // Admin render route config
|
|
301
293
|
decoInvokeRoute, // Admin invoke route config
|
|
302
294
|
};
|
|
295
|
+
|
|
296
|
+
// @decocms/start/cms
|
|
297
|
+
export {
|
|
298
|
+
registerSeoSections, // Register section keys that contribute page SEO (secondary source)
|
|
299
|
+
extractSeoFromProps, // Extract SEO fields from any section's props
|
|
300
|
+
extractSeoFromSections, // Extract SEO from registered sections (used internally)
|
|
301
|
+
resolvePageSeoBlock, // Resolve page.seo CMS block eagerly (used internally)
|
|
302
|
+
type PageSeo, // { title, description, canonical, image, noIndexing, jsonLDs, type }
|
|
303
|
+
// ... all existing exports
|
|
304
|
+
};
|
|
303
305
|
```
|
|
304
306
|
|
|
305
307
|
Add to `package.json` exports:
|
|
@@ -346,6 +348,135 @@ The root route contains site-specific elements that should NOT be in the framewo
|
|
|
346
348
|
- Font loading
|
|
347
349
|
- Theme configuration
|
|
348
350
|
- QueryClient setup
|
|
351
|
+
- **Default description and OG site_name/locale** — root-level `head()` should include fallback `<meta name="description">`, `og:site_name`, and `og:locale`. Child routes (from `cmsRouteConfig`) override these when section SEO provides better values.
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// src/routes/__root.tsx
|
|
355
|
+
export const Route = createRootRoute({
|
|
356
|
+
head: () => ({
|
|
357
|
+
meta: [
|
|
358
|
+
{ charSet: "utf-8" },
|
|
359
|
+
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
360
|
+
{ title: "My Store - Default Title" },
|
|
361
|
+
{ name: "description", content: "My Store — default description for all pages." },
|
|
362
|
+
{ property: "og:site_name", content: "My Store" },
|
|
363
|
+
{ property: "og:locale", content: "pt_BR" },
|
|
364
|
+
],
|
|
365
|
+
links: [
|
|
366
|
+
{ rel: "stylesheet", href: appCss },
|
|
367
|
+
{ rel: "icon", href: "/favicon.ico" },
|
|
368
|
+
],
|
|
369
|
+
}),
|
|
370
|
+
component: RootLayout,
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Do NOT include a `Device.Provider` with hardcoded values.** For client-side device detection, use `useSyncExternalStore` + `window.matchMedia`. For server-side, use section loaders via `registerSectionLoaders` (they receive the request and can detect UA).
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## SEO Architecture
|
|
379
|
+
|
|
380
|
+
SEO in @decocms/start works across four layers:
|
|
381
|
+
|
|
382
|
+
### 1. CMS `page.seo` Block (primary source)
|
|
383
|
+
|
|
384
|
+
CMS page JSONs have a top-level `seo` field separate from `sections`. This is the **primary** SEO data source, processed by `resolvePageSeoBlock()` in `resolve.ts`.
|
|
385
|
+
|
|
386
|
+
**Key behavior: Lazy/Deferred wrappers are always unwrapped.** SEO metadata must be in the initial SSR HTML for crawlers. The original Fresh/Deno framework did NOT do this, causing PDP pages to have zero SSR SEO when `page.seo` was wrapped in `Lazy.tsx`. We fix this by design.
|
|
387
|
+
|
|
388
|
+
Resolution pipeline:
|
|
389
|
+
1. Unwrap Lazy/Deferred (unlimited depth)
|
|
390
|
+
2. Follow named block references
|
|
391
|
+
3. Evaluate multivariate flags
|
|
392
|
+
4. Resolve all nested `__resolveType` (commerce loaders for product data)
|
|
393
|
+
5. Return `ResolvedSection` in `DecoPageResult.seoSection`
|
|
394
|
+
|
|
395
|
+
In `cmsRoute.ts`, the seoSection is enriched by its section loader, then:
|
|
396
|
+
- `extractSeoFromProps()` picks title/description/canonical/image/noIndexing/jsonLDs/type
|
|
397
|
+
- `titleTemplate` / `descriptionTemplate` from the CMS block are applied (e.g., `"%s | STORE NAME"`)
|
|
398
|
+
|
|
399
|
+
### 2. Page-Level Meta (framework `head()`)
|
|
400
|
+
|
|
401
|
+
`cmsRouteConfig` and `cmsHomeRouteConfig` generate `<head>` metadata automatically from the merged `PageSeo` object (page.seo primary + sections secondary). Includes title, description, canonical, OG (title, description, image, type, url), Twitter Card, and robots.
|
|
402
|
+
|
|
403
|
+
### 3. Section-Contributed SEO (secondary source, `registerSeoSections`)
|
|
404
|
+
|
|
405
|
+
Sections in `page.sections` that also contribute SEO metadata register themselves in `setup.ts`:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
import { registerSeoSections } from "@decocms/start/cms";
|
|
409
|
+
|
|
410
|
+
registerSeoSections([
|
|
411
|
+
"site/sections/SEOPDP.tsx", // Product structured data + meta
|
|
412
|
+
"site/sections/SEOPLP.tsx", // Category/search meta
|
|
413
|
+
]);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
These sections must have a **section loader** that returns props with SEO fields:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
interface PageSeo {
|
|
420
|
+
title?: string;
|
|
421
|
+
description?: string;
|
|
422
|
+
canonical?: string;
|
|
423
|
+
image?: string;
|
|
424
|
+
noIndexing?: boolean;
|
|
425
|
+
jsonLDs?: Record<string, any>[];
|
|
426
|
+
type?: string; // og:type: "website", "product", etc.
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
After `runSectionLoaders`, the framework scans registered SEO sections and extracts these fields. Page.seo fields take precedence when both sources provide the same field.
|
|
431
|
+
|
|
432
|
+
### 4. Structured Data (section component)
|
|
433
|
+
|
|
434
|
+
JSON-LD (`<script type="application/ld+json">`) is rendered by the section component itself — NOT in `<head>`. The section receives `jsonLDs` in its props and renders them:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// src/components/ui/Seo.tsx
|
|
438
|
+
export default function Seo({ jsonLDs }: Props) {
|
|
439
|
+
if (!jsonLDs?.length) return null;
|
|
440
|
+
return (
|
|
441
|
+
<>
|
|
442
|
+
{jsonLDs.map((jsonLD, i) => (
|
|
443
|
+
<script
|
|
444
|
+
key={i}
|
|
445
|
+
type="application/ld+json"
|
|
446
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLD) }}
|
|
447
|
+
/>
|
|
448
|
+
))}
|
|
449
|
+
</>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### SEO Data Flow
|
|
455
|
+
|
|
456
|
+
```
|
|
457
|
+
CMS Page JSON
|
|
458
|
+
├─ page.sections[] → resolveDecoPage → runSectionLoaders
|
|
459
|
+
│ → extractSeoFromSections() (secondary SEO source)
|
|
460
|
+
│
|
|
461
|
+
└─ page.seo → resolvePageSeoBlock (unwrap Lazy, resolve commerce loaders)
|
|
462
|
+
→ runSingleSectionLoader (SEOPDP transforms jsonLD → title/desc/etc.)
|
|
463
|
+
→ extractSeoFromProps() → apply titleTemplate/descriptionTemplate
|
|
464
|
+
→ PRIMARY PageSeo
|
|
465
|
+
|
|
466
|
+
Merged PageSeo = { ...sectionSeo, ...pageSeo }
|
|
467
|
+
→ cmsRouteConfig head() → emits <title>, <meta>, <link>, OG, Twitter, robots
|
|
468
|
+
→ Section component renders JSON-LD in page body
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Checklist for New Sites
|
|
472
|
+
|
|
473
|
+
1. **`__root.tsx`**: Include fallback `description`, `og:site_name`, `og:locale`
|
|
474
|
+
2. **`$.tsx` / `index.tsx`**: Pass `siteName`, `defaultTitle`, `defaultDescription` to `cmsRouteConfig` / `cmsHomeRouteConfig`
|
|
475
|
+
3. **`setup.ts`**: Register section loaders for any site SEO sections (e.g., SEOPDP) that appear in `page.seo` CMS blocks
|
|
476
|
+
4. **`setup.ts`**: Optionally call `registerSeoSections([...])` for sections in `page.sections` that contribute SEO
|
|
477
|
+
5. **`Seo.tsx`**: Component renders JSON-LD (NOT meta tags — framework handles those)
|
|
478
|
+
6. **Device**: Use `matchMedia` for client-side, section loaders for server-side — NO hardcoded `Device.Provider`
|
|
479
|
+
7. **CMS audit**: Verify PDP `page.seo` blocks are NOT wrapped in `Lazy.tsx` with no inner section — the framework unwraps Lazy, but the inner section must exist
|
|
349
480
|
|
|
350
481
|
---
|
|
351
482
|
|
|
@@ -190,6 +190,82 @@ import { addSkipResolveType } from "@decocms/start/cms";
|
|
|
190
190
|
addSkipResolveType("custom/loaders/myCustomLoader.ts");
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
### Page-Level SEO Resolution (`page.seo`)
|
|
194
|
+
|
|
195
|
+
CMS page JSONs have a top-level `seo` field separate from `sections`:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"name": "Home",
|
|
200
|
+
"path": "/",
|
|
201
|
+
"sections": [ ... ],
|
|
202
|
+
"seo": {
|
|
203
|
+
"__resolveType": "website/sections/Seo/SeoV2.tsx",
|
|
204
|
+
"title": "My Store",
|
|
205
|
+
"description": "Best prices",
|
|
206
|
+
"canonical": "https://example.com/",
|
|
207
|
+
"jsonLDs": [{ "@type": "Organization", ... }],
|
|
208
|
+
"titleTemplate": "%s",
|
|
209
|
+
"descriptionTemplate": "%s",
|
|
210
|
+
"type": "website"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
For PDP pages, `page.seo` is often wrapped in `Lazy.tsx` with a commerce loader:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"seo": {
|
|
220
|
+
"__resolveType": "website/sections/Rendering/Lazy.tsx",
|
|
221
|
+
"section": {
|
|
222
|
+
"__resolveType": "site/sections/SEOPDP.tsx",
|
|
223
|
+
"jsonLD": { "__resolveType": "Intelligent Search PDP Loader" },
|
|
224
|
+
"titleTemplate": "%s | STORE NAME",
|
|
225
|
+
"descriptionTemplate": "%s | STORE NAME"
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`resolvePageSeoBlock()` handles this by:
|
|
232
|
+
1. **Always unwrapping Lazy/Deferred** — SEO must never be deferred for crawlers
|
|
233
|
+
2. Following named block references and multivariate flags
|
|
234
|
+
3. Resolving all nested `__resolveType` props (commerce loaders for PDP product data)
|
|
235
|
+
4. Returning a `ResolvedSection` with the component key and resolved props
|
|
236
|
+
|
|
237
|
+
The result is stored in `DecoPageResult.seoSection` and processed in `cmsRoute.ts`:
|
|
238
|
+
1. Run the registered section loader (e.g., SEOPDP transforms `{jsonLD: ProductDetailsPage}` → `{title, description, canonical, image, jsonLDs}`)
|
|
239
|
+
2. Extract `PageSeo` fields via `extractSeoFromProps()`
|
|
240
|
+
3. Apply `titleTemplate`/`descriptionTemplate` from the CMS config (e.g., `"%s | STORE NAME"`)
|
|
241
|
+
4. Merge with section-contributed SEO (page.seo is primary, sections are secondary)
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
interface PageSeo {
|
|
245
|
+
title?: string;
|
|
246
|
+
description?: string;
|
|
247
|
+
canonical?: string;
|
|
248
|
+
image?: string;
|
|
249
|
+
noIndexing?: boolean;
|
|
250
|
+
jsonLDs?: Record<string, any>[];
|
|
251
|
+
type?: string; // og:type: "website", "product", etc.
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`buildHead()` in `cmsRouteConfig` generates full `<head>` metadata from `PageSeo`: `<title>`, `<meta name="description">`, `<link rel="canonical">`, Open Graph (including `og:url`, `og:type`), Twitter Card, and robots directives.
|
|
256
|
+
|
|
257
|
+
### SEO Section Registry (secondary source)
|
|
258
|
+
|
|
259
|
+
Sections in `page.sections` that also contribute SEO metadata are registered via `registerSeoSections()`:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
registerSeoSections(["site/sections/SEOPDP.tsx"]);
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`extractSeoFromSections()` scans registered sections and extracts SEO fields. This is the **secondary** source — `page.seo` always takes precedence when both are present.
|
|
266
|
+
|
|
267
|
+
Note: PLP and search pages typically lack a `page.seo` field. For these, `cmsRouteConfig` falls back to composing a title from the page name and `siteName` option.
|
|
268
|
+
|
|
193
269
|
### PostHog Matchers (`matchers/posthog.ts`)
|
|
194
270
|
|
|
195
271
|
Server-side PostHog feature flag evaluation:
|
|
@@ -997,11 +997,126 @@ export default defineConfig({
|
|
|
997
997
|
|
|
998
998
|
---
|
|
999
999
|
|
|
1000
|
+
## 25. SEO Architecture — Three Layers
|
|
1001
|
+
|
|
1002
|
+
### Problem
|
|
1003
|
+
|
|
1004
|
+
Sites migrated from Fresh/Deno often have broken SEO:
|
|
1005
|
+
- `Seo.tsx` component returns `null` (dead stub from migration)
|
|
1006
|
+
- Route `head()` functions only set `<title>`, missing description/canonical/OG
|
|
1007
|
+
- No JSON-LD structured data on PDPs
|
|
1008
|
+
- No Open Graph tags for social sharing
|
|
1009
|
+
|
|
1010
|
+
### Solution — Framework + Section + Component
|
|
1011
|
+
|
|
1012
|
+
**Layer 1: Framework head()** (`cmsRouteConfig` / `cmsHomeRouteConfig`)
|
|
1013
|
+
Automatically emits title, description, canonical, OG, Twitter Card, robots from `DecoPageResult.seo`. Requires `defaultDescription` option and `registerSeoSections()` in setup.
|
|
1014
|
+
|
|
1015
|
+
**Layer 2: Section SEO** (`registerSeoSections` + `registerSectionLoaders`)
|
|
1016
|
+
Sections like SEOPDP register as SEO contributors. Their section loader computes title/description/canonical from commerce data (product name, product description, breadcrumb canonical). The framework extracts these into `PageSeo`.
|
|
1017
|
+
|
|
1018
|
+
**Layer 3: Structured data** (section component renders JSON-LD)
|
|
1019
|
+
The `Seo.tsx` component renders `<script type="application/ld+json">` for Product, WebSite, BreadcrumbList schemas. Meta tags are NOT rendered here — the framework handles those via `head()`.
|
|
1020
|
+
|
|
1021
|
+
### Setup Checklist
|
|
1022
|
+
|
|
1023
|
+
```typescript
|
|
1024
|
+
// setup.ts
|
|
1025
|
+
import { registerSeoSections } from "@decocms/start/cms";
|
|
1026
|
+
|
|
1027
|
+
registerSeoSections(["site/sections/SEOPDP.tsx"]);
|
|
1028
|
+
|
|
1029
|
+
// Also register the SEOPDP section loader:
|
|
1030
|
+
registerSectionLoaders({
|
|
1031
|
+
"site/sections/SEOPDP.tsx": async (props, req) => {
|
|
1032
|
+
const mod = await import("./sections/SEOPDP");
|
|
1033
|
+
return mod.loader(props, req, { seo: {} } as any) ?? props;
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
```typescript
|
|
1039
|
+
// routes/$.tsx — spread full config, framework handles SEO
|
|
1040
|
+
const routeConfig = cmsRouteConfig({
|
|
1041
|
+
siteName: "My Store",
|
|
1042
|
+
defaultTitle: "My Store - Default Title",
|
|
1043
|
+
defaultDescription: "My Store — best products...",
|
|
1044
|
+
ignoreSearchParams: ["skuId"],
|
|
1045
|
+
});
|
|
1046
|
+
export const Route = createFileRoute("/$")({ ...routeConfig, component: CmsPage });
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
```typescript
|
|
1050
|
+
// __root.tsx — fallback description and OG globals
|
|
1051
|
+
head: () => ({
|
|
1052
|
+
meta: [
|
|
1053
|
+
{ charSet: "utf-8" },
|
|
1054
|
+
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
1055
|
+
{ title: "My Store - Default Title" },
|
|
1056
|
+
{ name: "description", content: "My Store — default description." },
|
|
1057
|
+
{ property: "og:site_name", content: "My Store" },
|
|
1058
|
+
{ property: "og:locale", content: "pt_BR" },
|
|
1059
|
+
],
|
|
1060
|
+
}),
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
### Anti-Patterns
|
|
1064
|
+
|
|
1065
|
+
- `Seo.tsx` returning `null` — must render JSON-LD at minimum
|
|
1066
|
+
- Custom `head()` in routes that only sets title — use `cmsRouteConfig` which handles full SEO
|
|
1067
|
+
- Hardcoded Device.Provider — use matchMedia for client, section loaders for server
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## 26. Device Detection — No Hardcoded Provider
|
|
1072
|
+
|
|
1073
|
+
### Problem
|
|
1074
|
+
|
|
1075
|
+
Sites often have `<Device.Provider value={{ isMobile: false }}>` in `__root.tsx`, making ALL client-side components think they're on desktop regardless of actual viewport.
|
|
1076
|
+
|
|
1077
|
+
### Solution — Two-Layer Detection
|
|
1078
|
+
|
|
1079
|
+
**Server-side**: Section loaders via `registerSectionLoaders()` detect device from UA and inject `isMobile` / `device` as props. The page result also includes `device` for client use.
|
|
1080
|
+
|
|
1081
|
+
**Client-side**: Use `useSyncExternalStore` + `window.matchMedia` instead of React context:
|
|
1082
|
+
|
|
1083
|
+
```typescript
|
|
1084
|
+
// contexts/device.tsx
|
|
1085
|
+
import { useSyncExternalStore } from "react";
|
|
1086
|
+
|
|
1087
|
+
const MOBILE_QUERY = "(max-width: 767px)";
|
|
1088
|
+
|
|
1089
|
+
function subscribe(cb: () => void) {
|
|
1090
|
+
if (typeof window === "undefined") return () => {};
|
|
1091
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
1092
|
+
mql.addEventListener("change", cb);
|
|
1093
|
+
return () => mql.removeEventListener("change", cb);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function getSnapshot() { return window.matchMedia(MOBILE_QUERY).matches; }
|
|
1097
|
+
function getServerSnapshot() { return false; }
|
|
1098
|
+
|
|
1099
|
+
export const useDevice = () => {
|
|
1100
|
+
const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
1101
|
+
return { isMobile };
|
|
1102
|
+
};
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
### Why matchMedia > UA context
|
|
1106
|
+
|
|
1107
|
+
- Reflects actual viewport, not a server guess
|
|
1108
|
+
- Reactive — updates if user resizes browser
|
|
1109
|
+
- No Provider needed — works anywhere in the component tree
|
|
1110
|
+
- SSR defaults to desktop (false), hydrates to real value
|
|
1111
|
+
|
|
1112
|
+
---
|
|
1113
|
+
|
|
1000
1114
|
## Related Skills
|
|
1001
1115
|
|
|
1002
1116
|
| Skill | Purpose |
|
|
1003
1117
|
|-------|---------|
|
|
1004
1118
|
| `deco-to-tanstack-migration` | Initial migration playbook (imports, signals, architecture) |
|
|
1119
|
+
| `deco-cms-route-config` | Route config, SEO architecture, device detection |
|
|
1005
1120
|
| `deco-tanstack-navigation` | SPA navigation patterns (`<Link>`, `useNavigate`, `loaderDeps`) |
|
|
1006
1121
|
| `deco-islands-migration` | Eliminating the `src/islands/` directory |
|
|
1007
1122
|
| `deco-edge-caching` | Edge caching with `createDecoWorkerEntry` |
|
|
@@ -235,6 +235,7 @@ See: skill `deco-islands-migration`
|
|
|
235
235
|
6. Wire `onBeforeResolve()` → `initVtexFromBlocks()` for VTEX config
|
|
236
236
|
7. Configure `setAsyncRenderingConfig()` with `alwaysEager` for critical sections
|
|
237
237
|
8. Configure admin: `setMetaData()`, `setRenderShell()`, `setInvokeLoaders()`
|
|
238
|
+
9. **Register SEO sections via `registerSeoSections()`** — identify sections that produce title/description/canonical (typically SEOPDP, SEOPLP). Register their loaders in `registerSectionLoaders` too.
|
|
238
239
|
|
|
239
240
|
**Template**: `templates/setup-ts.md`
|
|
240
241
|
|
|
@@ -250,15 +251,17 @@ See: skill `deco-async-rendering-site-guide`
|
|
|
250
251
|
|
|
251
252
|
**Actions**:
|
|
252
253
|
1. Create `src/router.tsx` with scroll restoration
|
|
253
|
-
2. Create `src/routes/__root.tsx` with QueryClient, LiveControls, NavigationProgress, analytics
|
|
254
|
-
3. Create `src/routes/index.tsx` using `cmsHomeRouteConfig()`
|
|
255
|
-
4. Create `src/routes/$.tsx` using `cmsRouteConfig()`
|
|
254
|
+
2. Create `src/routes/__root.tsx` with QueryClient, LiveControls, NavigationProgress, analytics. **Include fallback `description`, `og:site_name`, `og:locale` in root `head()`.** Do NOT include a hardcoded `Device.Provider`.
|
|
255
|
+
3. Create `src/routes/index.tsx` using `cmsHomeRouteConfig({ defaultTitle, defaultDescription, siteName })`
|
|
256
|
+
4. Create `src/routes/$.tsx` using `cmsRouteConfig({ siteName, defaultTitle, defaultDescription })` — spread the full config, do NOT cherry-pick fields. The framework handles SEO head, cache headers, and staleTime/gcTime.
|
|
257
|
+
5. **Create/port `Seo.tsx` component** — must render JSON-LD structured data (NOT return null). Meta tags are handled by the framework's `head()`.
|
|
258
|
+
6. **Port device detection** — use `useSyncExternalStore` + `matchMedia` for client-side. Use `registerSectionLoaders` for server-side UA detection. Do NOT use a hardcoded `Device.Provider`.
|
|
256
259
|
|
|
257
260
|
**Templates**: `templates/root-route.md`, `templates/router.md`
|
|
258
261
|
|
|
259
|
-
**Exit**: Routes compile, CMS pages resolve
|
|
262
|
+
**Exit**: Routes compile, CMS pages resolve, PDPs have JSON-LD + meta description in `<head>`
|
|
260
263
|
|
|
261
|
-
See:
|
|
264
|
+
See: skills `deco-tanstack-navigation`, `deco-cms-route-config`
|
|
262
265
|
|
|
263
266
|
---
|
|
264
267
|
|
|
@@ -566,6 +569,7 @@ The conductor approach that worked (836 errors → 0 across 213 files) treated e
|
|
|
566
569
|
| deco-apps-vtex-porting | Understanding VTEX loader internals (Phase 4-5) |
|
|
567
570
|
| deco-islands-migration | Eliminating islands/ (Phase 6) |
|
|
568
571
|
| deco-async-rendering-site-guide | Lazy wrappers, LoadingFallback (Phase 7, 10) |
|
|
572
|
+
| deco-cms-route-config | Route config, SEO architecture, device detection (Phase 7-8) |
|
|
569
573
|
| deco-tanstack-navigation | Link, prefetch, scroll issues (Phase 8) |
|
|
570
574
|
| deco-edge-caching | Worker caching, cache profiles (Phase 9) |
|
|
571
575
|
| deco-tanstack-hydration-fixes | Hydration mismatches post-migration |
|
package/package.json
CHANGED
package/src/admin/invoke.ts
CHANGED
|
@@ -126,6 +126,13 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
126
126
|
|
|
127
127
|
try {
|
|
128
128
|
const result = await found.handler(body, request);
|
|
129
|
+
// Response passthrough: if the loader/action returns a Response object,
|
|
130
|
+
// forward it as-is (preserving headers like Set-Cookie). This matches
|
|
131
|
+
// deco-cx/deco's invokeToHttpResponse behavior where auth loaders return
|
|
132
|
+
// Response objects with Set-Cookie headers for HttpOnly cookies.
|
|
133
|
+
if (result instanceof Response) {
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
129
136
|
const filtered = selectFields(result, select);
|
|
130
137
|
return new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
|
|
131
138
|
} catch (error) {
|
|
@@ -145,7 +152,13 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
145
152
|
|
|
146
153
|
if (found) {
|
|
147
154
|
try {
|
|
148
|
-
|
|
155
|
+
let result = await found.handler(payload, request);
|
|
156
|
+
// If a loader returns a Response, extract its JSON body for batching.
|
|
157
|
+
// Set-Cookie headers from batch items are not forwarded individually
|
|
158
|
+
// (use single invoke for auth loaders that need cookie passthrough).
|
|
159
|
+
if (result instanceof Response) {
|
|
160
|
+
try { result = await result.json(); } catch { result = null; }
|
|
161
|
+
}
|
|
149
162
|
results[key] = selectFields(result, select);
|
|
150
163
|
} catch (error) {
|
|
151
164
|
results[key] = { error: (error as Error).message };
|
package/src/cms/index.ts
CHANGED
|
@@ -30,19 +30,25 @@ export type {
|
|
|
30
30
|
DecoPageResult,
|
|
31
31
|
DeferredSection,
|
|
32
32
|
MatcherContext,
|
|
33
|
+
PageSeo,
|
|
33
34
|
ResolvedSection,
|
|
34
35
|
ResolveErrorHandler,
|
|
35
36
|
} from "./resolve";
|
|
36
37
|
export {
|
|
37
38
|
addSkipResolveType,
|
|
38
39
|
evaluateMatcher,
|
|
40
|
+
extractSeoFromProps,
|
|
41
|
+
extractSeoFromSections,
|
|
39
42
|
getAsyncRenderingConfig,
|
|
43
|
+
isSeoSection,
|
|
40
44
|
onBeforeResolve,
|
|
41
45
|
registerBotPattern,
|
|
42
46
|
registerCommerceLoader,
|
|
43
47
|
registerCommerceLoaders,
|
|
44
48
|
registerMatcher,
|
|
49
|
+
registerSeoSections,
|
|
45
50
|
resolveDecoPage,
|
|
51
|
+
resolvePageSeoBlock,
|
|
46
52
|
resolveDeferredSection,
|
|
47
53
|
resolveValue,
|
|
48
54
|
setAsyncRenderingConfig,
|
package/src/cms/resolve.ts
CHANGED
|
@@ -615,6 +615,104 @@ async function resolveRawSection(
|
|
|
615
615
|
return results;
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Page-level SEO block resolution
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Resolve the page-level `seo` field from the CMS page JSON.
|
|
624
|
+
*
|
|
625
|
+
* The CMS stores SEO config in `page.seo` as a separate field from
|
|
626
|
+
* `page.sections`. This field is typically a SeoV2.tsx block (homepage)
|
|
627
|
+
* or a site SEO section like SEOPDP.tsx wrapped in Lazy (PDP).
|
|
628
|
+
*
|
|
629
|
+
* This function always resolves eagerly — SEO metadata must never be
|
|
630
|
+
* deferred because search engine crawlers need it in the initial HTML.
|
|
631
|
+
* Lazy/Deferred wrappers are unwrapped, block references are followed,
|
|
632
|
+
* and commerce loader refs (e.g. PDP jsonLD) are resolved.
|
|
633
|
+
*
|
|
634
|
+
* Returns a ResolvedSection with the final component key and resolved props,
|
|
635
|
+
* or null if the seo field is absent/unresolvable.
|
|
636
|
+
*/
|
|
637
|
+
export async function resolvePageSeoBlock(
|
|
638
|
+
seoBlock: Record<string, unknown> | undefined,
|
|
639
|
+
rctx: ResolveContext,
|
|
640
|
+
): Promise<ResolvedSection | null> {
|
|
641
|
+
if (!seoBlock || typeof seoBlock !== "object") return null;
|
|
642
|
+
|
|
643
|
+
const blocks = loadBlocks();
|
|
644
|
+
let current = seoBlock;
|
|
645
|
+
|
|
646
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
647
|
+
const rt = current.__resolveType as string | undefined;
|
|
648
|
+
if (!rt) return null;
|
|
649
|
+
|
|
650
|
+
// Lazy wrapper — always unwrap for SEO (never defer)
|
|
651
|
+
if (rt === "website/sections/Rendering/Lazy.tsx") {
|
|
652
|
+
const inner = current.section;
|
|
653
|
+
if (!inner || typeof inner !== "object") return null;
|
|
654
|
+
current = inner as Record<string, unknown>;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Deferred wrapper — unwrap
|
|
659
|
+
if (rt === "website/sections/Rendering/Deferred.tsx") {
|
|
660
|
+
const inner = current.sections;
|
|
661
|
+
if (!inner || typeof inner !== "object") return null;
|
|
662
|
+
if (Array.isArray(inner) && inner.length === 1 && inner[0] && typeof inner[0] === "object") {
|
|
663
|
+
current = inner[0] as Record<string, unknown>;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Multivariate flag — evaluate matcher and follow matched variant
|
|
670
|
+
if (
|
|
671
|
+
rt === "website/flags/multivariate.ts" ||
|
|
672
|
+
rt === "website/flags/multivariate/section.ts"
|
|
673
|
+
) {
|
|
674
|
+
const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
|
|
675
|
+
if (!variants?.length) return null;
|
|
676
|
+
let matched: unknown = null;
|
|
677
|
+
for (const variant of variants) {
|
|
678
|
+
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
679
|
+
if (evaluateMatcher(rule, rctx.matcherCtx)) {
|
|
680
|
+
matched = variant.value;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!matched || typeof matched !== "object") return null;
|
|
685
|
+
current = matched as Record<string, unknown>;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Named block reference — follow the chain
|
|
690
|
+
const block = blocks[rt] as Record<string, unknown> | undefined;
|
|
691
|
+
if (block) {
|
|
692
|
+
const { __resolveType: _, ...overrides } = current;
|
|
693
|
+
current = { ...block, ...overrides };
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Terminal section (site section or framework SEO type).
|
|
698
|
+
// Resolve all nested prop __resolveType refs (commerce loaders, etc.).
|
|
699
|
+
const { __resolveType: _, ...rawProps } = current;
|
|
700
|
+
try {
|
|
701
|
+
const resolvedProps = await resolveProps(rawProps, rctx);
|
|
702
|
+
return {
|
|
703
|
+
component: rt,
|
|
704
|
+
props: resolvedProps as Record<string, unknown>,
|
|
705
|
+
key: `seo:${rt}`,
|
|
706
|
+
};
|
|
707
|
+
} catch (e) {
|
|
708
|
+
onResolveError(e, rt, "Page SEO resolution");
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
618
716
|
/**
|
|
619
717
|
* Check if a raw CMS section block will produce a layout section.
|
|
620
718
|
* Walks the block reference chain (up to 5 levels) to find the final
|
|
@@ -960,12 +1058,94 @@ async function resolveSectionsList(
|
|
|
960
1058
|
return [];
|
|
961
1059
|
}
|
|
962
1060
|
|
|
1061
|
+
// ---------------------------------------------------------------------------
|
|
1062
|
+
// Page-level SEO — extracted from registered SEO sections after resolution
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
export interface PageSeo {
|
|
1066
|
+
title?: string;
|
|
1067
|
+
description?: string;
|
|
1068
|
+
canonical?: string;
|
|
1069
|
+
image?: string;
|
|
1070
|
+
noIndexing?: boolean;
|
|
1071
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1072
|
+
jsonLDs?: Record<string, any>[];
|
|
1073
|
+
/** Open Graph type — "website" (default), "product", etc. */
|
|
1074
|
+
type?: string;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const seoSectionKeys: Set<string> = G.__deco.seoSections ?? (G.__deco.seoSections = new Set());
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Register section component keys whose resolved props contribute page-level
|
|
1081
|
+
* SEO metadata (title, description, canonical, image, jsonLDs, noIndexing).
|
|
1082
|
+
*
|
|
1083
|
+
* After section loaders run, the framework scans these sections and extracts
|
|
1084
|
+
* their SEO fields into `DecoPageResult.seo`, which `cmsRouteConfig` uses
|
|
1085
|
+
* to generate `<head>` metadata (meta tags, OG, canonical, robots).
|
|
1086
|
+
*
|
|
1087
|
+
* JSON-LD structured data stays in the section props for the section
|
|
1088
|
+
* component to render as `<script type="application/ld+json">`.
|
|
1089
|
+
*/
|
|
1090
|
+
export function registerSeoSections(keys: string[]): void {
|
|
1091
|
+
for (const k of keys) seoSectionKeys.add(k);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Check if a section key is registered as an SEO section. */
|
|
1095
|
+
export function isSeoSection(key: string): boolean {
|
|
1096
|
+
return seoSectionKeys.has(key);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Pick standard SEO fields from a props object.
|
|
1101
|
+
* Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
|
|
1102
|
+
*/
|
|
1103
|
+
export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
|
|
1104
|
+
const seo: PageSeo = {};
|
|
1105
|
+
if (props.title) seo.title = props.title as string;
|
|
1106
|
+
if (props.description) seo.description = props.description as string;
|
|
1107
|
+
if (props.canonical) seo.canonical = props.canonical as string;
|
|
1108
|
+
if (props.image) seo.image = props.image as string;
|
|
1109
|
+
if (props.noIndexing !== undefined) seo.noIndexing = props.noIndexing as boolean;
|
|
1110
|
+
if (props.type) seo.type = props.type as string;
|
|
1111
|
+
if (Array.isArray(props.jsonLDs) && props.jsonLDs.length) {
|
|
1112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1113
|
+
seo.jsonLDs = props.jsonLDs as Record<string, any>[];
|
|
1114
|
+
}
|
|
1115
|
+
return seo;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Extract SEO metadata from resolved sections registered via
|
|
1120
|
+
* `registerSeoSections`. Later sections override earlier ones
|
|
1121
|
+
* (e.g., a PDP SEO section overrides a generic page SEO).
|
|
1122
|
+
*/
|
|
1123
|
+
export function extractSeoFromSections(sections: ResolvedSection[]): PageSeo {
|
|
1124
|
+
const seo: PageSeo = {};
|
|
1125
|
+
for (const section of sections) {
|
|
1126
|
+
if (!seoSectionKeys.has(section.component)) continue;
|
|
1127
|
+
const extracted = extractSeoFromProps(section.props);
|
|
1128
|
+
if (extracted.jsonLDs) {
|
|
1129
|
+
extracted.jsonLDs = [...(seo.jsonLDs ?? []), ...extracted.jsonLDs];
|
|
1130
|
+
}
|
|
1131
|
+
Object.assign(seo, extracted);
|
|
1132
|
+
}
|
|
1133
|
+
return seo;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
963
1136
|
export interface DecoPageResult {
|
|
964
1137
|
name: string;
|
|
965
1138
|
path: string;
|
|
966
1139
|
params: Record<string, string>;
|
|
967
1140
|
resolvedSections: ResolvedSection[];
|
|
968
1141
|
deferredSections: DeferredSection[];
|
|
1142
|
+
/**
|
|
1143
|
+
* Resolved SEO block from the page-level `seo` field in the CMS JSON.
|
|
1144
|
+
* Contains the section component key and resolved props (with commerce
|
|
1145
|
+
* loader data already fetched). Needs section loader enrichment in
|
|
1146
|
+
* cmsRoute before SEO fields can be extracted.
|
|
1147
|
+
*/
|
|
1148
|
+
seoSection?: ResolvedSection | null;
|
|
969
1149
|
}
|
|
970
1150
|
|
|
971
1151
|
export async function resolveDecoPage(
|
|
@@ -1072,12 +1252,27 @@ export async function resolveDecoPage(
|
|
|
1072
1252
|
|
|
1073
1253
|
const allResults = await Promise.all(eagerResults);
|
|
1074
1254
|
|
|
1255
|
+
// Resolve page-level SEO block (page.seo field) — always eager.
|
|
1256
|
+
// Runs after sections to benefit from memoized commerce loader results.
|
|
1257
|
+
let seoSection: ResolvedSection | null = null;
|
|
1258
|
+
if (page.seo) {
|
|
1259
|
+
try {
|
|
1260
|
+
seoSection = await resolvePageSeoBlock(
|
|
1261
|
+
page.seo as Record<string, unknown>,
|
|
1262
|
+
rctx,
|
|
1263
|
+
);
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
onResolveError(e, "page.seo", "Page SEO block resolution");
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1075
1269
|
return {
|
|
1076
1270
|
name: page.name,
|
|
1077
1271
|
path: page.path || targetPath,
|
|
1078
1272
|
params,
|
|
1079
1273
|
resolvedSections: allResults.flat(),
|
|
1080
1274
|
deferredSections,
|
|
1275
|
+
seoSection,
|
|
1081
1276
|
};
|
|
1082
1277
|
}
|
|
1083
1278
|
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -30,8 +30,13 @@ import {
|
|
|
30
30
|
} from "@tanstack/react-start/server";
|
|
31
31
|
import { createElement } from "react";
|
|
32
32
|
import { preloadSectionComponents } from "../cms/registry";
|
|
33
|
-
import type { DeferredSection, MatcherContext, ResolvedSection } from "../cms/resolve";
|
|
34
|
-
import {
|
|
33
|
+
import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "../cms/resolve";
|
|
34
|
+
import {
|
|
35
|
+
extractSeoFromProps,
|
|
36
|
+
extractSeoFromSections,
|
|
37
|
+
resolveDecoPage,
|
|
38
|
+
resolveDeferredSection,
|
|
39
|
+
} from "../cms/resolve";
|
|
35
40
|
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
36
41
|
import {
|
|
37
42
|
type CacheProfile,
|
|
@@ -40,6 +45,7 @@ import {
|
|
|
40
45
|
routeCacheDefaults,
|
|
41
46
|
} from "../sdk/cacheHeaders";
|
|
42
47
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
48
|
+
import { type Device, detectDevice } from "../sdk/useDevice";
|
|
43
49
|
|
|
44
50
|
const isServer = typeof document === "undefined";
|
|
45
51
|
|
|
@@ -87,12 +93,23 @@ async function loadCmsPageInternal(fullPath: string) {
|
|
|
87
93
|
await preloadSectionComponents(eagerKeys);
|
|
88
94
|
|
|
89
95
|
const cacheProfile = detectCacheProfile(basePath);
|
|
96
|
+
const ua = getRequestHeader("user-agent") ?? "";
|
|
97
|
+
const device = detectDevice(ua);
|
|
98
|
+
|
|
99
|
+
// Build SEO: merge page-level seo block (primary) with section-contributed SEO (secondary)
|
|
100
|
+
const seo = await buildPageSeo(page.seoSection, enrichedSections, request);
|
|
101
|
+
|
|
102
|
+
// Destructure seoSection out — it's an internal artifact, not serialized to client
|
|
103
|
+
const { seoSection: _seo, ...pageData } = page;
|
|
104
|
+
|
|
90
105
|
return {
|
|
91
|
-
...
|
|
106
|
+
...pageData,
|
|
92
107
|
resolvedSections: normalizeUrlsInObject(enrichedSections),
|
|
93
108
|
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
94
109
|
cacheProfile,
|
|
95
110
|
pageUrl: urlWithSearch,
|
|
111
|
+
seo,
|
|
112
|
+
device,
|
|
96
113
|
};
|
|
97
114
|
}
|
|
98
115
|
|
|
@@ -116,8 +133,9 @@ export const loadCmsPage = createServerFn({ method: "GET" })
|
|
|
116
133
|
*/
|
|
117
134
|
export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
|
|
118
135
|
const request = getRequest();
|
|
136
|
+
const ua = getRequestHeader("user-agent") ?? "";
|
|
119
137
|
const matcherCtx: MatcherContext = {
|
|
120
|
-
userAgent:
|
|
138
|
+
userAgent: ua,
|
|
121
139
|
url: getRequestUrl().toString(),
|
|
122
140
|
path: "/",
|
|
123
141
|
cookies: getCookies(),
|
|
@@ -130,10 +148,17 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
130
148
|
const eagerKeys = enrichedSections.map((s) => s.component);
|
|
131
149
|
await preloadSectionComponents(eagerKeys);
|
|
132
150
|
|
|
151
|
+
const device = detectDevice(ua);
|
|
152
|
+
const seo = await buildPageSeo(page.seoSection, enrichedSections, request);
|
|
153
|
+
|
|
154
|
+
const { seoSection: _seo, ...pageData } = page;
|
|
155
|
+
|
|
133
156
|
return {
|
|
134
|
-
...
|
|
157
|
+
...pageData,
|
|
135
158
|
resolvedSections: normalizeUrlsInObject(enrichedSections),
|
|
136
159
|
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
160
|
+
seo,
|
|
161
|
+
device,
|
|
137
162
|
};
|
|
138
163
|
});
|
|
139
164
|
|
|
@@ -208,6 +233,8 @@ export interface CmsRouteOptions {
|
|
|
208
233
|
siteName: string;
|
|
209
234
|
/** Default page title when CMS page has no name. */
|
|
210
235
|
defaultTitle: string;
|
|
236
|
+
/** Default description for pages without section-contributed SEO. */
|
|
237
|
+
defaultDescription?: string;
|
|
211
238
|
/**
|
|
212
239
|
* Search params to exclude from loader deps.
|
|
213
240
|
* These params won't trigger a server re-fetch when they change.
|
|
@@ -218,15 +245,148 @@ export interface CmsRouteOptions {
|
|
|
218
245
|
pendingComponent?: () => any;
|
|
219
246
|
}
|
|
220
247
|
|
|
248
|
+
type CmsPageLoaderData = {
|
|
249
|
+
name?: string;
|
|
250
|
+
cacheProfile?: CacheProfile;
|
|
251
|
+
seo?: PageSeo;
|
|
252
|
+
device?: Device;
|
|
253
|
+
} | null;
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Page SEO assembly — merges page.seo block with section-contributed SEO
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Process the resolved SEO section from page.seo, run its section loader
|
|
261
|
+
* if registered, apply title/description templates, and merge with
|
|
262
|
+
* section-contributed SEO.
|
|
263
|
+
*/
|
|
264
|
+
async function buildPageSeo(
|
|
265
|
+
seoSection: ResolvedSection | null | undefined,
|
|
266
|
+
enrichedSections: ResolvedSection[],
|
|
267
|
+
request: Request,
|
|
268
|
+
): Promise<PageSeo> {
|
|
269
|
+
// Secondary source: SEO sections embedded in the sections array
|
|
270
|
+
const sectionSeo = extractSeoFromSections(enrichedSections);
|
|
271
|
+
|
|
272
|
+
if (!seoSection) return sectionSeo;
|
|
273
|
+
|
|
274
|
+
// Run the section loader on the seo section if one is registered
|
|
275
|
+
// (e.g., SEOPDP loader transforms {jsonLD: ProductDetailsPage} → {title, description, ...})
|
|
276
|
+
let enrichedProps = seoSection.props;
|
|
277
|
+
try {
|
|
278
|
+
const enriched = await runSingleSectionLoader(seoSection, request);
|
|
279
|
+
if (enriched) enrichedProps = enriched.props;
|
|
280
|
+
} catch {
|
|
281
|
+
// Section loader failed — use the raw resolved props
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const pageSeo = extractSeoFromProps(enrichedProps);
|
|
285
|
+
|
|
286
|
+
// Apply title/description templates from the SEO block config.
|
|
287
|
+
// SeoV2 blocks carry templates like "%s | CASA & VIDEO" that wrap
|
|
288
|
+
// the computed title. Template "%s" is a no-op.
|
|
289
|
+
const rawProps = seoSection.props;
|
|
290
|
+
const titleTemplate = rawProps.titleTemplate as string | undefined;
|
|
291
|
+
const descTemplate = rawProps.descriptionTemplate as string | undefined;
|
|
292
|
+
if (titleTemplate && titleTemplate !== "%s" && pageSeo.title) {
|
|
293
|
+
pageSeo.title = titleTemplate.replace("%s", pageSeo.title);
|
|
294
|
+
}
|
|
295
|
+
if (descTemplate && descTemplate !== "%s" && pageSeo.description) {
|
|
296
|
+
pageSeo.description = descTemplate.replace("%s", pageSeo.description);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Primary source (page.seo) overrides secondary (section-contributed).
|
|
300
|
+
// Only truthy fields in pageSeo override — undefined keys don't clear sectionSeo.
|
|
301
|
+
return { ...sectionSeo, ...pageSeo };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Head metadata builder
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build TanStack Router `head()` metadata from page-level and section-contributed SEO.
|
|
310
|
+
* Emits: title, description, canonical, OG tags, Twitter Card, robots directive.
|
|
311
|
+
*/
|
|
312
|
+
function buildHead(
|
|
313
|
+
loaderData: CmsPageLoaderData | undefined,
|
|
314
|
+
siteName: string,
|
|
315
|
+
defaultTitle: string,
|
|
316
|
+
defaultDescription?: string,
|
|
317
|
+
) {
|
|
318
|
+
const seo = loaderData?.seo;
|
|
319
|
+
const title = seo?.title
|
|
320
|
+
? seo.title
|
|
321
|
+
: loaderData?.name
|
|
322
|
+
? `${loaderData.name} | ${siteName}`
|
|
323
|
+
: defaultTitle;
|
|
324
|
+
const description = seo?.description || defaultDescription;
|
|
325
|
+
const image = seo?.image;
|
|
326
|
+
const canonical = seo?.canonical;
|
|
327
|
+
const noIndex = seo?.noIndexing;
|
|
328
|
+
|
|
329
|
+
const meta: Record<string, string>[] = [{ title }];
|
|
330
|
+
|
|
331
|
+
if (description) {
|
|
332
|
+
meta.push({ name: "description", content: description });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Robots
|
|
336
|
+
if (noIndex) {
|
|
337
|
+
meta.push({ name: "robots", content: "noindex, nofollow" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Open Graph
|
|
341
|
+
meta.push({ property: "og:title", content: title });
|
|
342
|
+
if (description) meta.push({ property: "og:description", content: description });
|
|
343
|
+
if (image) meta.push({ property: "og:image", content: image });
|
|
344
|
+
meta.push({ property: "og:type", content: seo?.type || "website" });
|
|
345
|
+
if (canonical) meta.push({ property: "og:url", content: canonical });
|
|
346
|
+
|
|
347
|
+
// Twitter Card
|
|
348
|
+
meta.push({ name: "twitter:card", content: image ? "summary_large_image" : "summary" });
|
|
349
|
+
meta.push({ name: "twitter:title", content: title });
|
|
350
|
+
if (description) meta.push({ name: "twitter:description", content: description });
|
|
351
|
+
if (image) meta.push({ name: "twitter:image", content: image });
|
|
352
|
+
|
|
353
|
+
const links: Record<string, string>[] = [];
|
|
354
|
+
if (canonical) {
|
|
355
|
+
links.push({ rel: "canonical", href: canonical });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// JSON-LD structured data — rendered as <script type="application/ld+json"> in <head>
|
|
359
|
+
const scripts: Array<{ type: string; children: string }> = [];
|
|
360
|
+
if (seo?.jsonLDs?.length) {
|
|
361
|
+
for (const jsonLD of seo.jsonLDs) {
|
|
362
|
+
scripts.push({
|
|
363
|
+
type: "application/ld+json",
|
|
364
|
+
children: JSON.stringify(jsonLD),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { meta, links, scripts };
|
|
370
|
+
}
|
|
371
|
+
|
|
221
372
|
/**
|
|
222
373
|
* Returns a TanStack Router route config object for a CMS catch-all route.
|
|
223
374
|
* Spread the result into your `createFileRoute("/$")({...})` call.
|
|
224
375
|
*
|
|
225
|
-
* Includes: loaderDeps, loader, headers, head, staleTime/gcTime.
|
|
376
|
+
* Includes: loaderDeps, loader, headers, head (with full SEO), staleTime/gcTime.
|
|
226
377
|
* Does NOT include: component, notFoundComponent (site provides these).
|
|
378
|
+
*
|
|
379
|
+
* SEO metadata is extracted from sections registered via `registerSeoSections()`.
|
|
380
|
+
* The `head()` function emits title, description, canonical, OG tags, and robots.
|
|
227
381
|
*/
|
|
228
382
|
export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
229
|
-
const {
|
|
383
|
+
const {
|
|
384
|
+
siteName,
|
|
385
|
+
defaultTitle,
|
|
386
|
+
defaultDescription,
|
|
387
|
+
ignoreSearchParams = ["skuId"],
|
|
388
|
+
pendingComponent,
|
|
389
|
+
} = options;
|
|
230
390
|
|
|
231
391
|
const ignoreSet = new Set(ignoreSearchParams);
|
|
232
392
|
|
|
@@ -253,9 +413,6 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
253
413
|
: "";
|
|
254
414
|
const page = await loadCmsPage({ data: basePath + searchStr });
|
|
255
415
|
|
|
256
|
-
// On the client (SPA navigation or initial hydration), pre-import
|
|
257
|
-
// eager section modules BEFORE React renders. This ensures
|
|
258
|
-
// getResolvedComponent() returns a value and we skip React.lazy.
|
|
259
416
|
if (!isServer && page?.resolvedSections) {
|
|
260
417
|
const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
|
|
261
418
|
await preloadSectionComponents(keys);
|
|
@@ -267,29 +424,31 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
267
424
|
|
|
268
425
|
...routeCacheDefaults("product"),
|
|
269
426
|
|
|
270
|
-
headers: ({ loaderData }: { loaderData?:
|
|
427
|
+
headers: ({ loaderData }: { loaderData?: CmsPageLoaderData }) => {
|
|
271
428
|
const profile = loaderData?.cacheProfile ?? "listing";
|
|
272
429
|
return cacheHeaders(profile);
|
|
273
430
|
},
|
|
274
431
|
|
|
275
|
-
head: ({ loaderData }: { loaderData?:
|
|
276
|
-
|
|
277
|
-
{
|
|
278
|
-
title: loaderData?.name ? `${loaderData.name} | ${siteName}` : defaultTitle,
|
|
279
|
-
},
|
|
280
|
-
],
|
|
281
|
-
}),
|
|
432
|
+
head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
|
|
433
|
+
buildHead(loaderData, siteName, defaultTitle, defaultDescription),
|
|
282
434
|
};
|
|
283
435
|
}
|
|
284
436
|
|
|
285
437
|
/**
|
|
286
438
|
* Returns a TanStack Router route config for the CMS homepage route.
|
|
287
439
|
* Spread into `createFileRoute("/")({...})`.
|
|
440
|
+
*
|
|
441
|
+
* Like `cmsRouteConfig`, emits full SEO head metadata from section-contributed data.
|
|
288
442
|
*/
|
|
289
443
|
export function cmsHomeRouteConfig(options: {
|
|
290
444
|
defaultTitle: string;
|
|
445
|
+
defaultDescription?: string;
|
|
446
|
+
/** Site name for OG title composition. Defaults to defaultTitle. */
|
|
447
|
+
siteName?: string;
|
|
291
448
|
pendingComponent?: () => any;
|
|
292
449
|
}) {
|
|
450
|
+
const { defaultTitle, defaultDescription, siteName } = options;
|
|
451
|
+
|
|
293
452
|
return {
|
|
294
453
|
loader: async () => {
|
|
295
454
|
const page = await loadCmsHomePage();
|
|
@@ -302,8 +461,7 @@ export function cmsHomeRouteConfig(options: {
|
|
|
302
461
|
...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
|
|
303
462
|
...routeCacheDefaults("static"),
|
|
304
463
|
headers: () => cacheHeaders("static"),
|
|
305
|
-
head: () =>
|
|
306
|
-
|
|
307
|
-
}),
|
|
464
|
+
head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
|
|
465
|
+
buildHead(loaderData, siteName ?? defaultTitle, defaultTitle, defaultDescription),
|
|
308
466
|
};
|
|
309
467
|
}
|