@decocms/start 0.21.0 → 0.22.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-to-tanstack-migration/SKILL.md +59 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +46 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +18 -1
- package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +16 -49
- package/.cursor/skills/deco-to-tanstack-migration/references/server-functions/README.md +120 -0
- package/package.json +2 -1
- package/src/cms/registry.ts +18 -1
- package/src/hooks/DecoPageRenderer.tsx +11 -1
- package/src/routes/cmsRoute.ts +12 -9
- package/src/sdk/index.ts +5 -0
- package/src/sdk/normalizeUrls.ts +91 -0
|
@@ -15,6 +15,46 @@ Phase-based playbook for converting `deco-sites/*` storefronts from Fresh/Preact
|
|
|
15
15
|
| **@decocms/apps** | `@decocms/apps` | VTEX/Shopify loaders, commerce types, commerce sdk (useOffer, formatPrice, analytics) | Passthrough HTML components, Preact/Fresh refs |
|
|
16
16
|
| **Site repo** | (not published) | All UI: components, hooks, types, routes, styles | No compat/ layer, no aliases beyond `~` |
|
|
17
17
|
|
|
18
|
+
### What Belongs Where
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
@decocms/start (framework)
|
|
22
|
+
├── src/cms/ # Block loading, page resolution, section registry
|
|
23
|
+
│ └── loader.ts # loadBlocks, setBlocks, AsyncLocalStorage for per-request overrides
|
|
24
|
+
├── src/admin/ # Admin protocol: meta, decofile, invoke, render, schema composition
|
|
25
|
+
│ ├── meta.ts # setMetaData() calls composeMeta() at startup; /deco/meta handler
|
|
26
|
+
│ ├── schema.ts # MetaResponse type, composeMeta(), framework block schemas (pages)
|
|
27
|
+
│ ├── render.ts # /live/previews/* for section + page preview (HTML shell)
|
|
28
|
+
│ ├── setup.ts # Client-safe setup (setMetaData, setInvokeLoaders, setRenderShell)
|
|
29
|
+
│ ├── decofile.ts # /.decofile read/reload
|
|
30
|
+
│ ├── invoke.ts # /deco/invoke for loader/action calls
|
|
31
|
+
│ ├── cors.ts # CORS + admin origin validation
|
|
32
|
+
│ └── liveControls.ts # Admin iframe bridge postMessage script
|
|
33
|
+
├── src/sdk/
|
|
34
|
+
│ ├── workerEntry.ts # createDecoWorkerEntry: outermost Cloudflare Worker wrapper
|
|
35
|
+
│ ├── useScript.ts, signal.ts, clx.ts, cachedLoader.ts, instrumentedFetch.ts
|
|
36
|
+
│ └── ...
|
|
37
|
+
├── src/hooks/ # DecoPageRenderer (uses registry, NOT hardcoded map), LiveControls
|
|
38
|
+
├── src/types/ # FnContext, App, AppContext, Section, SectionProps, Resolved
|
|
39
|
+
└── scripts/ # generate-blocks.ts, generate-schema.ts
|
|
40
|
+
|
|
41
|
+
@decocms/apps (commerce)
|
|
42
|
+
├── commerce/types/ # Product, AnalyticsItem, BreadcrumbList, Filter, etc.
|
|
43
|
+
├── commerce/utils/ # mapProductToAnalyticsItem, parseRange, formatRange
|
|
44
|
+
├── commerce/sdk/ # useOffer, useVariantPossibilities, formatPrice, relative, analytics
|
|
45
|
+
├── vtex/ # Client, loaders (actual VTEX API calls)
|
|
46
|
+
└── shopify/ # Client, loaders (actual Shopify API calls)
|
|
47
|
+
|
|
48
|
+
site repo (UI + business logic)
|
|
49
|
+
├── src/components/ # All UI components (Image, Picture, Seo, Theme, etc.)
|
|
50
|
+
├── src/hooks/ # useCart (real VTEX implementation), useUser, useWishlist
|
|
51
|
+
├── src/types/ # widgets.ts (string aliases), vtex.ts (OrderFormItem, etc.)
|
|
52
|
+
├── src/sdk/ # Site-specific contexts, usePlatform, useUI, useSuggestions
|
|
53
|
+
├── src/sections/ # All CMS-renderable sections
|
|
54
|
+
├── src/routes/ # TanStack Router routes
|
|
55
|
+
└── src/server/ # Server functions (invoke.ts — createServerFn wrappers for @decocms/apps)
|
|
56
|
+
```
|
|
57
|
+
|
|
18
58
|
### Architecture Map
|
|
19
59
|
|
|
20
60
|
| Old Stack | New Stack |
|
|
@@ -278,6 +318,24 @@ npm run dev
|
|
|
278
318
|
# 8. Admin — /deco/meta returns JSON, /live/previews works
|
|
279
319
|
```
|
|
280
320
|
|
|
321
|
+
## Quick Start (Full Migration)
|
|
322
|
+
|
|
323
|
+
1. **Scaffold**: `npm create @tanstack/app@latest -- --template cloudflare-workers`
|
|
324
|
+
2. **Copy source**: Move `src/` from deco-site
|
|
325
|
+
3. **Configure Vite**: See `references/vite-config/`
|
|
326
|
+
4. **Install deps**: `npm install @decocms/start @decocms/apps @tanstack/store @tanstack/react-store`
|
|
327
|
+
5. **Link local libs** (if not published): `cd apps-start && npm link && cd ../deco-start && npm link && cd ../my-store && npm link @decocms/apps @decocms/start`
|
|
328
|
+
6. **Rewrite imports** (parallelizable):
|
|
329
|
+
- Preact -> React: `references/imports/`
|
|
330
|
+
- Signals -> TanStack Store: `references/signals/`
|
|
331
|
+
- Deco framework -> inline: `references/deco-framework/`
|
|
332
|
+
- Commerce/widget types -> @decocms/apps + local: `references/commerce/`
|
|
333
|
+
- SDK utilities -> packages: `~/sdk/useOffer` -> `@decocms/apps/commerce/sdk/useOffer`, `~/sdk/useScript` -> `@decocms/start/sdk/useScript`, etc.
|
|
334
|
+
7. **Create local UI components**: Image.tsx, Picture.tsx, Seo.tsx, Theme.tsx in `~/components/ui/`
|
|
335
|
+
8. **Implement platform hooks**: `references/platform-hooks/`
|
|
336
|
+
9. **Build & verify**: `npm run build`
|
|
337
|
+
10. **Final audit**: Zero `from "apps/"`, `from "$store/"`, `from "preact"`, `from "@preact"`, `from "@deco/deco"`, `from "~/sdk/useOffer"`, `from "~/sdk/format"` etc. -- all sdk utilities should come from packages
|
|
338
|
+
|
|
281
339
|
## Key Principles
|
|
282
340
|
|
|
283
341
|
1. **No compat layer anywhere** -- not in `@decocms/start`, not in `@decocms/apps`, not in the site repo
|
|
@@ -492,6 +550,7 @@ The conductor approach that worked (836 errors → 0 across 213 files) treated e
|
|
|
492
550
|
| Vite configuration | `references/vite-config/` |
|
|
493
551
|
| Automation commands | `references/codemod-commands.md` |
|
|
494
552
|
| Admin schema composition | `src/admin/schema.ts` in `@decocms/start` |
|
|
553
|
+
| Server functions (`createServerFn`) | `references/server-functions/` |
|
|
495
554
|
| Common gotchas (45 items) | `references/gotchas.md` |
|
|
496
555
|
| setup.ts template | `templates/setup-ts.md` |
|
|
497
556
|
| vite.config.ts template | `templates/vite-config.md` |
|
|
@@ -126,3 +126,49 @@ grep -r '@deco/deco' src/ --include='*.ts' --include='*.tsx'
|
|
|
126
126
|
grep -r '\$fresh/' src/ --include='*.ts' --include='*.tsx'
|
|
127
127
|
# Both should return ZERO matches
|
|
128
128
|
```
|
|
129
|
+
|
|
130
|
+
## Deferred Sections and CLS Prevention
|
|
131
|
+
|
|
132
|
+
When a CMS page wraps a section in `website/sections/Rendering/Lazy.tsx`, it becomes a `DeferredSection`. The `DeferredSectionWrapper` renders a skeleton via `getSectionOptions(key).loadingFallback`.
|
|
133
|
+
|
|
134
|
+
**Problem**: `getSectionOptions` returns `undefined` if the section module hasn't loaded yet. The module is loaded async in a `useEffect` (`preloadSectionModule`), so on first paint `skeleton = null` — the space is blank and the footer jumps down as sections load (CLS).
|
|
135
|
+
|
|
136
|
+
**Fix**: Use `registerSection` with `loadingFallback` pre-populated in `setup.ts` instead of including the section in the bulk `registerSections` call:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { registerSection, registerSections } from "@decocms/start/cms";
|
|
140
|
+
import PDPSkeleton from "./components/product/PDPSkeleton";
|
|
141
|
+
|
|
142
|
+
registerSections({
|
|
143
|
+
// ... all other sections (NOT the lazy-wrapped one)
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Pre-populate sectionOptions synchronously so DeferredSectionWrapper
|
|
147
|
+
// has loadingFallback on the very first render — no null flash.
|
|
148
|
+
registerSection(
|
|
149
|
+
"site/sections/Product/NotFoundChallenge.tsx",
|
|
150
|
+
() => import("./sections/Product/NotFoundChallenge") as any,
|
|
151
|
+
{ loadingFallback: PDPSkeleton },
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Why `as any`**: `registerSection` expects `Promise<SectionModule>` but the actual module export is wider (includes `LoadingFallback`, `loader`, etc.). The cast is safe.
|
|
156
|
+
|
|
157
|
+
**Composite skeleton**: Create a `PDPSkeleton` that combines all `LoadingFallback` exports from the section's sub-components:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
// src/components/product/PDPSkeleton.tsx
|
|
161
|
+
import { LoadingFallback as MountedPDPSkeleton } from "~/components/product/MountedPDP";
|
|
162
|
+
import { LoadingFallback as ProductDescriptionSkeleton } from "~/components/product/MountedPDP/ProductDescription";
|
|
163
|
+
import { LoadingFallback as ProductFAQSkeleton } from "~/components/product/MountedPDP/ProductFAQ";
|
|
164
|
+
|
|
165
|
+
export default function PDPSkeleton() {
|
|
166
|
+
return (
|
|
167
|
+
<div>
|
|
168
|
+
<MountedPDPSkeleton />
|
|
169
|
+
<ProductDescriptionSkeleton />
|
|
170
|
+
<ProductFAQSkeleton />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Migration Gotchas
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
46 pitfalls discovered during real migrations (espacosmart-storefront, osklen).
|
|
4
4
|
|
|
5
5
|
## 1. Section Loaders Don't Execute
|
|
6
6
|
|
|
@@ -717,3 +717,20 @@ Or in project `.npmrc` with an env var (for CI):
|
|
|
717
717
|
```
|
|
718
718
|
|
|
719
719
|
**Tradeoff with `github:` syntax**: No semver resolution — `npm update` is meaningless. Pin to a tag for stability: `github:decocms/deco-start#v0.14.2`. Without a tag, you get HEAD of the default branch.
|
|
720
|
+
|
|
721
|
+
## 46. `export type { X } from "..."` Does Not Scope `X` for Parameter Annotations
|
|
722
|
+
|
|
723
|
+
Re-exporting a type makes it available to importers but does NOT bring it into scope in the same file:
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
// BROKEN — Props is not in scope
|
|
727
|
+
export type { Props } from "~/components/ui/Foo";
|
|
728
|
+
export default function Foo({ bar }: Props) { } // Error: Cannot find name 'Props'
|
|
729
|
+
|
|
730
|
+
// CORRECT — import separately, then re-export
|
|
731
|
+
import type { Props } from "~/components/ui/Foo";
|
|
732
|
+
export type { Props };
|
|
733
|
+
export default function Foo({ bar }: Props) { }
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
This is a common pattern in section wrapper files (e.g. `src/sections/CountDownApp/CountDownApp.tsx`).
|
|
@@ -18,60 +18,27 @@ The storefront domain (e.g., `my-store.deco.site`) differs from the VTEX checkou
|
|
|
18
18
|
|
|
19
19
|
Use TanStack Start `createServerFn` to create server-side proxy functions that the client hook calls transparently.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
> See `references/server-functions/README.md` for the full pattern and all TypeScript pitfalls.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
import { createServerFn } from "@tanstack/react-start";
|
|
23
|
+
### Server Functions (`src/server/invoke.ts`)
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
const API_KEY = process.env.VTEX_APP_KEY!;
|
|
28
|
-
const API_TOKEN = process.env.VTEX_APP_TOKEN!;
|
|
29
|
-
|
|
30
|
-
export const getOrCreateCart = createServerFn({ method: "GET" })
|
|
31
|
-
.validator((orderFormId: string) => orderFormId)
|
|
32
|
-
.handler(async ({ data: orderFormId }) => {
|
|
33
|
-
const url = orderFormId
|
|
34
|
-
? `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`
|
|
35
|
-
: `https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm`;
|
|
36
|
-
const res = await fetch(url, {
|
|
37
|
-
method: "POST",
|
|
38
|
-
headers: {
|
|
39
|
-
"Content-Type": "application/json",
|
|
40
|
-
"X-VTEX-API-AppKey": API_KEY,
|
|
41
|
-
"X-VTEX-API-AppToken": API_TOKEN,
|
|
42
|
-
},
|
|
43
|
-
body: JSON.stringify({ expectedOrderFormSections: ["items", "totalizers", "shippingData", "clientPreferencesData", "storePreferencesData", "marketingData"] }),
|
|
44
|
-
});
|
|
45
|
-
return res.json();
|
|
46
|
-
});
|
|
25
|
+
Wrap `@decocms/apps` VTEX actions in `createServerFn`. Always use `.inputValidator()` (not `.validator()`) and return `Promise<any>` to bypass `ValidateSerializable`:
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
return res.json();
|
|
27
|
+
```typescript
|
|
28
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
29
|
+
import { getOrCreateCart } from "@decocms/apps/vtex/actions/checkout";
|
|
30
|
+
|
|
31
|
+
// Preferred: wrap @decocms/apps actions (handles auth + API details internally)
|
|
32
|
+
const _getOrCreateCart = createServerFn({ method: "POST" })
|
|
33
|
+
.inputValidator((data: { orderFormId?: string }) => data)
|
|
34
|
+
.handler(async ({ data }): Promise<any> => {
|
|
35
|
+
const result = await getOrCreateCart(data.orderFormId);
|
|
36
|
+
return (result as any).data;
|
|
60
37
|
});
|
|
61
38
|
|
|
62
|
-
export const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const res = await fetch(
|
|
66
|
-
`https://${ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/orderForm/${data.orderFormId}/items/update`,
|
|
67
|
-
{
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: { "Content-Type": "application/json", "X-VTEX-API-AppKey": API_KEY, "X-VTEX-API-AppToken": API_TOKEN },
|
|
70
|
-
body: JSON.stringify({ orderItems: data.items }),
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
return res.json();
|
|
74
|
-
});
|
|
39
|
+
export const invoke = {
|
|
40
|
+
vtex: { actions: { getOrCreateCart: _getOrCreateCart } },
|
|
41
|
+
} as const;
|
|
75
42
|
```
|
|
76
43
|
|
|
77
44
|
### Hook (~/hooks/useCart.ts)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Server Functions (`createServerFn`)
|
|
2
|
+
|
|
3
|
+
All VTEX API calls that need credentials (cart, MasterData, session, newsletter, shipping simulation) must run on the Worker — not on the client — to avoid CORS errors and keep secrets server-side.
|
|
4
|
+
|
|
5
|
+
## Pattern: `src/server/invoke.ts`
|
|
6
|
+
|
|
7
|
+
Create a single file that wraps `@decocms/apps` invoke functions in `createServerFn`. This keeps VTEX credentials inside the Worker and gives type-safe RPC from the browser.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
11
|
+
import {
|
|
12
|
+
getOrCreateCart,
|
|
13
|
+
addItemsToCart,
|
|
14
|
+
updateCartItems,
|
|
15
|
+
addCouponToCart,
|
|
16
|
+
updateOrderFormAttachment,
|
|
17
|
+
simulateCart,
|
|
18
|
+
} from "@decocms/apps/vtex/actions/checkout";
|
|
19
|
+
|
|
20
|
+
// CRITICAL: always chain .inputValidator() before .handler()
|
|
21
|
+
// Without it, ctx.data is typed as `undefined` and every access fails.
|
|
22
|
+
const _getOrCreateCart = createServerFn({ method: "POST" })
|
|
23
|
+
.inputValidator((data: { orderFormId?: string }) => data)
|
|
24
|
+
.handler(async ({ data }): Promise<any> => {
|
|
25
|
+
// Promise<any> bypasses ValidateSerializable on OrderForm.storeId: unknown
|
|
26
|
+
const result = await getOrCreateCart(data.orderFormId);
|
|
27
|
+
return (result as any).data;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const _addItemsToCart = createServerFn({ method: "POST" })
|
|
31
|
+
.inputValidator((data: {
|
|
32
|
+
orderFormId: string;
|
|
33
|
+
orderItems: Array<{ id: string; seller: string; quantity: number }>;
|
|
34
|
+
}) => data)
|
|
35
|
+
.handler(async ({ data }): Promise<any> => {
|
|
36
|
+
const result = await addItemsToCart(data.orderFormId, data.orderItems);
|
|
37
|
+
return (result as any).data;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ... repeat for updateCartItems, addCouponToCart, simulateCart, etc.
|
|
41
|
+
|
|
42
|
+
export const invoke = {
|
|
43
|
+
vtex: {
|
|
44
|
+
actions: {
|
|
45
|
+
getOrCreateCart: _getOrCreateCart,
|
|
46
|
+
addItemsToCart: _addItemsToCart,
|
|
47
|
+
// ...
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
} as const;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Required: `.inputValidator()`
|
|
54
|
+
|
|
55
|
+
Without `.inputValidator()`, TanStack Start types `ctx.data` as `undefined`:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// BROKEN — ctx.data is typed as undefined
|
|
59
|
+
createServerFn({ method: "POST" })
|
|
60
|
+
.handler(async (ctx) => {
|
|
61
|
+
ctx.data.orderFormId; // TS Error: ctx.data is undefined
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// CORRECT
|
|
65
|
+
createServerFn({ method: "POST" })
|
|
66
|
+
.inputValidator((data: { orderFormId: string }) => data)
|
|
67
|
+
.handler(async ({ data }) => {
|
|
68
|
+
data.orderFormId; // typed correctly
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Required: `Promise<any>` Return Type
|
|
73
|
+
|
|
74
|
+
TanStack Start validates that return types are serializable via `ValidateSerializable`. Types with `unknown` fields (e.g. `OrderForm.storeId: unknown`) fail this check at compile time:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Type 'OrderForm' does not satisfy the constraint 'Serializable'.
|
|
78
|
+
Types of property 'storeId' are incompatible.
|
|
79
|
+
Type 'unknown' is not assignable to type 'Serializable'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Fix**: Annotate the handler return as `Promise<any>`:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
.handler(async ({ data }): Promise<any> => {
|
|
86
|
+
return await doSomething(data);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Stripping Non-Serializable Properties
|
|
91
|
+
|
|
92
|
+
Loader results that include function properties must have those functions removed before being returned — Seroval (TanStack's hydration serializer) cannot serialize functions.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
.handler(async ({ data }): Promise<any> => {
|
|
96
|
+
const result = await productReviewsLoader(data);
|
|
97
|
+
// Strip functions before serialization
|
|
98
|
+
const { getProductReview: _r, reviewLikeAction: _l, ...serializable } = result as any;
|
|
99
|
+
return serializable;
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Invoke from `useCart`
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// ~/hooks/useCart.ts
|
|
107
|
+
import { invoke } from "~/server/invoke";
|
|
108
|
+
|
|
109
|
+
const updated = await invoke.vtex.actions.addItemsToCart({
|
|
110
|
+
data: { orderFormId, orderItems },
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Verification
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# No direct VTEX API calls from client components
|
|
118
|
+
rg 'vtexcommercestable\.com\.br' src/ --include='*.tsx'
|
|
119
|
+
# Should return ZERO (all calls go through invoke)
|
|
120
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./sdk/wrapCaughtErrors": "./src/sdk/wrapCaughtErrors.ts",
|
|
32
32
|
"./sdk/csp": "./src/sdk/csp.ts",
|
|
33
33
|
"./sdk/urlUtils": "./src/sdk/urlUtils.ts",
|
|
34
|
+
"./sdk/normalizeUrls": "./src/sdk/normalizeUrls.ts",
|
|
34
35
|
"./sdk/mergeCacheControl": "./src/sdk/mergeCacheControl.ts",
|
|
35
36
|
"./sdk/requestContext": "./src/sdk/requestContext.ts",
|
|
36
37
|
"./sdk/createInvoke": "./src/sdk/createInvoke.ts",
|
package/src/cms/registry.ts
CHANGED
|
@@ -160,7 +160,24 @@ export type SyncSectionEntry =
|
|
|
160
160
|
*/
|
|
161
161
|
export function registerSectionsSync(sections: Record<string, SyncSectionEntry>): void {
|
|
162
162
|
for (const [key, entry] of Object.entries(sections)) {
|
|
163
|
-
const
|
|
163
|
+
const raw = typeof entry === "function" ? entry : (entry as any).default;
|
|
164
|
+
// Accept functions and React wrapper objects (React.memo, forwardRef, lazy)
|
|
165
|
+
const REACT_WRAPPERS = [
|
|
166
|
+
Symbol.for("react.memo"),
|
|
167
|
+
Symbol.for("react.forward_ref"),
|
|
168
|
+
Symbol.for("react.lazy"),
|
|
169
|
+
];
|
|
170
|
+
const component =
|
|
171
|
+
typeof raw === "function" ||
|
|
172
|
+
(raw != null &&
|
|
173
|
+
typeof raw === "object" &&
|
|
174
|
+
REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
175
|
+
? raw
|
|
176
|
+
: undefined;
|
|
177
|
+
if (!component) {
|
|
178
|
+
console.warn(`[registerSectionsSync] "${key}" has no callable default export — skipping`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
164
181
|
syncComponents[key] = component;
|
|
165
182
|
resolvedComponents[key] = component;
|
|
166
183
|
|
|
@@ -209,18 +209,21 @@ export function SectionList({ sections }: { sections: Section[] | null | undefin
|
|
|
209
209
|
interface DeferredSectionWrapperProps {
|
|
210
210
|
deferred: DeferredSection;
|
|
211
211
|
pagePath: string;
|
|
212
|
+
pageUrl?: string;
|
|
212
213
|
loadingFallback?: ReactNode;
|
|
213
214
|
errorFallback?: ReactNode;
|
|
214
215
|
loadFn: (data: {
|
|
215
216
|
component: string;
|
|
216
217
|
rawProps: Record<string, unknown>;
|
|
217
218
|
pagePath: string;
|
|
219
|
+
pageUrl?: string;
|
|
218
220
|
}) => Promise<ResolvedSection | null>;
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
function DeferredSectionWrapper({
|
|
222
224
|
deferred,
|
|
223
225
|
pagePath,
|
|
226
|
+
pageUrl,
|
|
224
227
|
loadingFallback,
|
|
225
228
|
errorFallback,
|
|
226
229
|
loadFn,
|
|
@@ -283,6 +286,7 @@ function DeferredSectionWrapper({
|
|
|
283
286
|
component: deferred.component,
|
|
284
287
|
rawProps: deferred.rawProps,
|
|
285
288
|
pagePath,
|
|
289
|
+
pageUrl,
|
|
286
290
|
})
|
|
287
291
|
.then((result) => {
|
|
288
292
|
if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
|
|
@@ -302,6 +306,7 @@ function DeferredSectionWrapper({
|
|
|
302
306
|
component: deferred.component,
|
|
303
307
|
rawProps: deferred.rawProps,
|
|
304
308
|
pagePath,
|
|
309
|
+
pageUrl,
|
|
305
310
|
})
|
|
306
311
|
.then((result) => {
|
|
307
312
|
if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
|
|
@@ -315,7 +320,7 @@ function DeferredSectionWrapper({
|
|
|
315
320
|
|
|
316
321
|
observer.observe(el);
|
|
317
322
|
return () => observer.disconnect();
|
|
318
|
-
}, [deferred.component, deferred.rawProps, pagePath, section, loadFn]);
|
|
323
|
+
}, [deferred.component, deferred.rawProps, pagePath, pageUrl, section, loadFn]);
|
|
319
324
|
|
|
320
325
|
if (error) {
|
|
321
326
|
const errFallback = loadedOptions?.errorFallback
|
|
@@ -403,12 +408,15 @@ interface Props {
|
|
|
403
408
|
sections: ResolvedSection[];
|
|
404
409
|
deferredSections?: DeferredSection[];
|
|
405
410
|
pagePath?: string;
|
|
411
|
+
/** Original page URL (with query params) — forwarded to deferred section loaders. */
|
|
412
|
+
pageUrl?: string;
|
|
406
413
|
loadingFallback?: ReactNode;
|
|
407
414
|
errorFallback?: ReactNode;
|
|
408
415
|
loadDeferredSectionFn?: (data: {
|
|
409
416
|
component: string;
|
|
410
417
|
rawProps: Record<string, unknown>;
|
|
411
418
|
pagePath: string;
|
|
419
|
+
pageUrl?: string;
|
|
412
420
|
}) => Promise<ResolvedSection | null>;
|
|
413
421
|
}
|
|
414
422
|
|
|
@@ -416,6 +424,7 @@ export function DecoPageRenderer({
|
|
|
416
424
|
sections,
|
|
417
425
|
deferredSections,
|
|
418
426
|
pagePath = "/",
|
|
427
|
+
pageUrl,
|
|
419
428
|
loadingFallback,
|
|
420
429
|
errorFallback,
|
|
421
430
|
loadDeferredSectionFn,
|
|
@@ -436,6 +445,7 @@ export function DecoPageRenderer({
|
|
|
436
445
|
key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
|
|
437
446
|
deferred={item.deferred}
|
|
438
447
|
pagePath={pagePath}
|
|
448
|
+
pageUrl={pageUrl}
|
|
439
449
|
loadingFallback={loadingFallback}
|
|
440
450
|
errorFallback={errorFallback}
|
|
441
451
|
loadFn={loadDeferredSectionFn}
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
detectCacheProfile,
|
|
40
40
|
routeCacheDefaults,
|
|
41
41
|
} from "../sdk/cacheHeaders";
|
|
42
|
+
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
42
43
|
|
|
43
44
|
const isServer = typeof document === "undefined";
|
|
44
45
|
|
|
@@ -86,9 +87,10 @@ async function loadCmsPageInternal(fullPath: string) {
|
|
|
86
87
|
const cacheProfile = detectCacheProfile(basePath);
|
|
87
88
|
return {
|
|
88
89
|
...page,
|
|
89
|
-
resolvedSections: enrichedSections,
|
|
90
|
-
deferredSections: page.deferredSections,
|
|
90
|
+
resolvedSections: normalizeUrlsInObject(enrichedSections),
|
|
91
|
+
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
91
92
|
cacheProfile,
|
|
93
|
+
pageUrl: urlWithSearch,
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -128,8 +130,8 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
128
130
|
|
|
129
131
|
return {
|
|
130
132
|
...page,
|
|
131
|
-
resolvedSections: enrichedSections,
|
|
132
|
-
deferredSections: page.deferredSections,
|
|
133
|
+
resolvedSections: normalizeUrlsInObject(enrichedSections),
|
|
134
|
+
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
133
135
|
};
|
|
134
136
|
});
|
|
135
137
|
|
|
@@ -140,14 +142,15 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
140
142
|
export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
141
143
|
.inputValidator(
|
|
142
144
|
(data: unknown) =>
|
|
143
|
-
data as { component: string; rawProps: Record<string, any>; pagePath: string },
|
|
145
|
+
data as { component: string; rawProps: Record<string, any>; pagePath: string; pageUrl?: string },
|
|
144
146
|
)
|
|
145
147
|
.handler(async (ctx) => {
|
|
146
|
-
const { component, rawProps, pagePath } = ctx.data;
|
|
148
|
+
const { component, rawProps, pagePath, pageUrl } = ctx.data;
|
|
147
149
|
|
|
150
|
+
const serverUrl = getRequestUrl().toString();
|
|
148
151
|
const matcherCtx: MatcherContext = {
|
|
149
152
|
userAgent: getRequestHeader("user-agent") ?? "",
|
|
150
|
-
url:
|
|
153
|
+
url: pageUrl || serverUrl,
|
|
151
154
|
path: pagePath,
|
|
152
155
|
cookies: getCookies(),
|
|
153
156
|
};
|
|
@@ -155,11 +158,11 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
155
158
|
const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
|
|
156
159
|
if (!section) return null;
|
|
157
160
|
|
|
158
|
-
const request = new Request(
|
|
161
|
+
const request = new Request(pageUrl || serverUrl, {
|
|
159
162
|
headers: getRequest().headers,
|
|
160
163
|
});
|
|
161
164
|
const enriched = await runSingleSectionLoader(section, request);
|
|
162
|
-
return enriched;
|
|
165
|
+
return normalizeUrlsInObject(enriched);
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
// ---------------------------------------------------------------------------
|
package/src/sdk/index.ts
CHANGED
|
@@ -27,6 +27,11 @@ export {
|
|
|
27
27
|
} from "./instrumentedFetch";
|
|
28
28
|
export { batchInvoke, createInvokeProxy, type InvokeProxy, invokeQueryOptions } from "./invoke";
|
|
29
29
|
export { createCacheControlCollector, mergeCacheControl } from "./mergeCacheControl";
|
|
30
|
+
export {
|
|
31
|
+
getProductionOrigins,
|
|
32
|
+
normalizeUrlsInObject,
|
|
33
|
+
registerProductionOrigins,
|
|
34
|
+
} from "./normalizeUrls";
|
|
30
35
|
export {
|
|
31
36
|
addRedirects,
|
|
32
37
|
loadRedirects,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL normalization for CMS-resolved props.
|
|
3
|
+
*
|
|
4
|
+
* Strips registered production origins from absolute URLs, converting them to
|
|
5
|
+
* relative paths. This allows staging/preview deployments to work without
|
|
6
|
+
* every CMS-authored link sending users to the production domain.
|
|
7
|
+
*
|
|
8
|
+
* Only affects strings that START with a registered origin + "/" — image CDN
|
|
9
|
+
* URLs, API endpoints on different domains, and non-URL strings are untouched.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // In site's setup.ts:
|
|
14
|
+
* import { registerProductionOrigins } from "@decocms/start/sdk/normalizeUrls";
|
|
15
|
+
* registerProductionOrigins([
|
|
16
|
+
* "https://www.casaevideo.com.br",
|
|
17
|
+
* "https://casaevideo.com.br",
|
|
18
|
+
* ]);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
let origins: string[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register production origins that should be stripped from CMS-resolved URLs.
|
|
26
|
+
* Call once in your site's setup.ts before any page loads.
|
|
27
|
+
*/
|
|
28
|
+
export function registerProductionOrigins(productionOrigins: string[]) {
|
|
29
|
+
origins = productionOrigins.map((o) => o.replace(/\/+$/, ""));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getProductionOrigins(): readonly string[] {
|
|
33
|
+
return origins;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeString(str: string): string {
|
|
37
|
+
for (const origin of origins) {
|
|
38
|
+
if (str.startsWith(origin + "/")) {
|
|
39
|
+
return str.slice(origin.length);
|
|
40
|
+
}
|
|
41
|
+
if (str === origin) {
|
|
42
|
+
return "/";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return str;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deep-walk an object tree and rewrite any string value that starts with a
|
|
50
|
+
* registered production origin to a relative path. Returns the same reference
|
|
51
|
+
* if nothing was changed (structural sharing).
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeUrlsInObject<T>(obj: T): T {
|
|
54
|
+
if (!origins.length) return obj;
|
|
55
|
+
return deepNormalize(obj) as T;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deepNormalize(val: unknown): unknown {
|
|
59
|
+
if (val == null) return val;
|
|
60
|
+
|
|
61
|
+
if (typeof val === "string") {
|
|
62
|
+
return normalizeString(val);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(val)) {
|
|
66
|
+
let changed = false;
|
|
67
|
+
const result = val.map((item) => {
|
|
68
|
+
const normalized = deepNormalize(item);
|
|
69
|
+
if (normalized !== item) changed = true;
|
|
70
|
+
return normalized;
|
|
71
|
+
});
|
|
72
|
+
return changed ? result : val;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof val === "object") {
|
|
76
|
+
// Skip React elements, Dates, RegExps, and other non-plain objects
|
|
77
|
+
const proto = Object.getPrototypeOf(val);
|
|
78
|
+
if (proto !== Object.prototype && proto !== null) return val;
|
|
79
|
+
|
|
80
|
+
let changed = false;
|
|
81
|
+
const result: Record<string, unknown> = {};
|
|
82
|
+
for (const [key, value] of Object.entries(val as Record<string, unknown>)) {
|
|
83
|
+
const normalized = deepNormalize(value);
|
|
84
|
+
result[key] = normalized;
|
|
85
|
+
if (normalized !== value) changed = true;
|
|
86
|
+
}
|
|
87
|
+
return changed ? result : val;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return val;
|
|
91
|
+
}
|