@decocms/nextjs 8.0.0 → 8.4.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/README.md +77 -332
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +35 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +43 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy/index.d.ts +79 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +92 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +41 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +30 -210
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -43
- package/src/proxy/index.ts +161 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.render.test.tsx +0 -16
- package/src/DraftPreviewBadge.test.ts +0 -37
- package/src/DraftPreviewBadge.tsx +0 -252
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/decoMark.ts +0 -11
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -15
- package/src/draftMiddleware.test.ts +0 -147
- package/src/draftMiddleware.ts +0 -136
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@decocms/nextjs/proxy`: draft previews in a Next.js proxy (`proxy.ts`, or
|
|
3
|
+
* `middleware.ts` before Next 16). No React and no block map: pass the proxy
|
|
4
|
+
* a CMS handle made with an empty block map and the app's options, which
|
|
5
|
+
* shares the app's instance (one per process) without pulling the site's
|
|
6
|
+
* sections into the proxy bundle:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* // src/cms.proxy.ts
|
|
10
|
+
* export const proxyCms = createCMS({ blocks: {}, ...cmsConfig });
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* A site with a proxy of its own calls the pieces (`decideDraft`, then
|
|
14
|
+
* `draftRequestHeaders` on the request it forwards, then `applyDraft` on the
|
|
15
|
+
* response); a site without one exports `draftProxy(...)`.
|
|
16
|
+
*
|
|
17
|
+
* Statically rendered pages can't read a draft, so a drafted request to one
|
|
18
|
+
* is rewritten onto a dynamic draft route (`/_draft/<path>`, mounted as
|
|
19
|
+
* `app/%5Fdraft/...`: a literal `_draft` folder is private in Next and never
|
|
20
|
+
* routes).
|
|
21
|
+
*/
|
|
22
|
+
import type { CMS } from "@decocms/blocks";
|
|
23
|
+
import { type NextRequest, NextResponse } from "next/server.js";
|
|
24
|
+
import { DRAFT_HEADER, draftResponseHeaders } from "../draft.ts";
|
|
25
|
+
import {
|
|
26
|
+
allowedDraft,
|
|
27
|
+
draftCookie,
|
|
28
|
+
endsPreview,
|
|
29
|
+
fromDraftLink,
|
|
30
|
+
hasDraftSignal,
|
|
31
|
+
type PreviewPolicy,
|
|
32
|
+
requestLike,
|
|
33
|
+
} from "../drafts.ts";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
inferredPreviewHosts,
|
|
37
|
+
type PreviewPolicy,
|
|
38
|
+
previewHostDecision,
|
|
39
|
+
requestLike,
|
|
40
|
+
} from "../drafts.ts";
|
|
41
|
+
|
|
42
|
+
/** What the proxy needs from the CMS: the site's declared preview hosts and their check. */
|
|
43
|
+
export type CMSHandle = Pick<CMS, "draftPointer" | "settings" | "forRelease">;
|
|
44
|
+
|
|
45
|
+
/** What the proxy does with a request's draft state. */
|
|
46
|
+
export interface DraftDecision {
|
|
47
|
+
/** The draft this request renders, after the host check; `null` for the release. */
|
|
48
|
+
pointer: string | null;
|
|
49
|
+
/** The `Set-Cookie` the response carries: storing a draft link's pointer, or ending a preview. */
|
|
50
|
+
setCookie: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The URL prefix a drafted request is rewritten onto (`app/%5Fdraft/[[...path]]`). */
|
|
54
|
+
export const DRAFT_ROUTE_PREFIX = "/_draft";
|
|
55
|
+
|
|
56
|
+
const INERT: DraftDecision = Object.freeze({ pointer: null, setCookie: null });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The draft a request renders and the cookie its response sets, under
|
|
60
|
+
* `policy` (the same options the pages pass to `bindDraft`). A request with
|
|
61
|
+
* no `?__draft=` and no draft cookie never reaches the CMS.
|
|
62
|
+
*/
|
|
63
|
+
export async function decideDraft(
|
|
64
|
+
cms: CMSHandle,
|
|
65
|
+
request: NextRequest | Request,
|
|
66
|
+
policy: PreviewPolicy = {},
|
|
67
|
+
): Promise<DraftDecision> {
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
const like = requestLike(request.headers, url.pathname + url.search, url.host);
|
|
70
|
+
if (like === null || !hasDraftSignal(like)) return INERT;
|
|
71
|
+
// Ending a preview is allowed on any host, so a cookie set earlier always goes.
|
|
72
|
+
if (endsPreview(like)) return { pointer: null, setCookie: draftCookie("", ["Max-Age=0"]) };
|
|
73
|
+
const pointer = await allowedDraft(cms, like, policy);
|
|
74
|
+
if (pointer === null) return INERT;
|
|
75
|
+
return {
|
|
76
|
+
pointer,
|
|
77
|
+
setCookie: fromDraftLink(like, pointer) ? draftCookie(encodeURIComponent(pointer)) : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The request headers to forward: the accepted pointer on `x-deco-draft`, so
|
|
83
|
+
* the root layout can bind the draft on the same request (a layout gets no
|
|
84
|
+
* `searchParams`, and on a draft link the cookie is only on the response).
|
|
85
|
+
* A copy that arrives from outside is always removed.
|
|
86
|
+
*/
|
|
87
|
+
export function draftRequestHeaders(
|
|
88
|
+
request: NextRequest | Request,
|
|
89
|
+
decision: DraftDecision,
|
|
90
|
+
): Headers {
|
|
91
|
+
const headers = new Headers(request.headers);
|
|
92
|
+
if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
|
|
93
|
+
else headers.delete(DRAFT_HEADER);
|
|
94
|
+
return headers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether `pathname` is on the draft route: a site answers a direct hit there with its 404. */
|
|
98
|
+
export function isDraftRoutePath(pathname: string): boolean {
|
|
99
|
+
return pathname === DRAFT_ROUTE_PREFIX || pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The rewrite of a drafted request onto the draft route, keeping its path and
|
|
104
|
+
* forwarding the pointer; `null` when the request isn't drafted or is already
|
|
105
|
+
* on the draft route.
|
|
106
|
+
*/
|
|
107
|
+
export function rewriteToDraftRoute(
|
|
108
|
+
request: NextRequest,
|
|
109
|
+
decision: DraftDecision,
|
|
110
|
+
): NextResponse | null {
|
|
111
|
+
if (!decision.pointer) return null;
|
|
112
|
+
const url = request.nextUrl.clone();
|
|
113
|
+
if (isDraftRoutePath(url.pathname)) return null;
|
|
114
|
+
url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
|
|
115
|
+
return NextResponse.rewrite(url, {
|
|
116
|
+
request: { headers: draftRequestHeaders(request, decision) },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The cookie and the response headers of a draft: `Set-Cookie`, and for a
|
|
122
|
+
* drafted response `Cache-Control: no-store, private`, `Vary: Cookie` and
|
|
123
|
+
* `X-Robots-Tag: noindex, nofollow` (`draftResponseHeaders`). With the
|
|
124
|
+
* pointer in a cookie a draft shares its URL with the published page, so a
|
|
125
|
+
* cache keyed on the URL alone would serve it to visitors.
|
|
126
|
+
*/
|
|
127
|
+
export function applyDraft<R extends Response>(response: R, decision: DraftDecision): R {
|
|
128
|
+
if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
|
|
129
|
+
for (const [name, value] of Object.entries(draftResponseHeaders(decision.pointer))) {
|
|
130
|
+
response.headers.set(name, value);
|
|
131
|
+
}
|
|
132
|
+
return response;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface DraftProxyOptions extends PreviewPolicy {
|
|
136
|
+
/**
|
|
137
|
+
* Which drafted requests go to the draft route (a statically rendered
|
|
138
|
+
* page's). Default: every drafted request outside the draft route. Return
|
|
139
|
+
* `false` for a route that is already dynamic and binds the draft itself.
|
|
140
|
+
*/
|
|
141
|
+
rewrite?: (request: NextRequest) => boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The whole draft flow for a site with no proxy of its own:
|
|
146
|
+
* `export const proxy = draftProxy(proxyCms, { site: "my-store" })`.
|
|
147
|
+
*/
|
|
148
|
+
export function draftProxy(
|
|
149
|
+
cms: CMSHandle,
|
|
150
|
+
options: DraftProxyOptions = {},
|
|
151
|
+
): (request: NextRequest) => Promise<NextResponse> {
|
|
152
|
+
const { rewrite = () => true, ...policy } = options;
|
|
153
|
+
return async (request) => {
|
|
154
|
+
const decision = await decideDraft(cms, request, policy);
|
|
155
|
+
const rewritten = rewrite(request) ? rewriteToDraftRoute(request, decision) : null;
|
|
156
|
+
const response =
|
|
157
|
+
rewritten ??
|
|
158
|
+
NextResponse.next({ request: { headers: draftRequestHeaders(request, decision) } });
|
|
159
|
+
return applyDraft(response, decision);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@decocms/nextjs/server`: draft previews in Server Components (pages,
|
|
3
|
+
* layouts, route handlers). Server only: it reads `next/headers`.
|
|
4
|
+
*
|
|
5
|
+
* A page (and, through the `x-deco-draft` header the proxy forwards, the root
|
|
6
|
+
* layout) binds the request's draft before it reads content, then reads
|
|
7
|
+
* through `contentClient(cms)`:
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* export default async function Page({ searchParams }) {
|
|
11
|
+
* await bindDraft(cms, { searchParams: await searchParams, site: "my-store" });
|
|
12
|
+
* const client = contentClient(cms);
|
|
13
|
+
* …
|
|
14
|
+
* return <>{sections}<DraftPreviewIndicator /></>;
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The host check is the proxy's (`decideDraft`), repeated here with the same
|
|
19
|
+
* options, so a route the proxy doesn't match, or a forged `x-deco-draft`,
|
|
20
|
+
* still gets the release on a host that may not preview.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Client, CMS } from "@decocms/blocks";
|
|
24
|
+
import { headers } from "next/headers.js";
|
|
25
|
+
import { DRAFT_HEADER, DRAFT_PARAM } from "../draft.ts";
|
|
26
|
+
import { allowedDraft, isDraftPreviewEnabled, type PreviewPolicy, requestLike } from "../drafts.ts";
|
|
27
|
+
import { activeDraftPointer, draftSlot } from "./slot.ts";
|
|
28
|
+
|
|
29
|
+
export { type PreviewPolicy, requestLike } from "../drafts.ts";
|
|
30
|
+
export { activeDraftPointer };
|
|
31
|
+
|
|
32
|
+
/** A page's `searchParams` prop, awaited. */
|
|
33
|
+
export type SearchParams = Record<string, string | string[] | undefined>;
|
|
34
|
+
|
|
35
|
+
export interface BindDraftOptions extends PreviewPolicy {
|
|
36
|
+
/** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-draft`). */
|
|
37
|
+
searchParams?: SearchParams;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstParam(searchParams: SearchParams | undefined, key: string): string | null {
|
|
41
|
+
const raw = searchParams?.[key];
|
|
42
|
+
return (Array.isArray(raw) ? raw[0] : raw) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Binds the request's draft, if any: the page's own `?__draft=` first (`off`
|
|
47
|
+
* leaves preview), then the pointer the proxy forwarded on `x-deco-draft`,
|
|
48
|
+
* then the draft cookie, each on a host the options allow. Returns whether a
|
|
49
|
+
* draft was bound. With previews off on every host it returns `false`
|
|
50
|
+
* without reading the request, so a static page stays static.
|
|
51
|
+
*/
|
|
52
|
+
export async function bindDraft(
|
|
53
|
+
cms: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
|
|
54
|
+
options: BindDraftOptions = {},
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const { searchParams, ...policy } = options;
|
|
57
|
+
if (!(await isDraftPreviewEnabled(cms, policy))) return false;
|
|
58
|
+
const param = firstParam(searchParams, DRAFT_PARAM);
|
|
59
|
+
if (param === "off") return false;
|
|
60
|
+
const requestHeaders = await headers();
|
|
61
|
+
const forwarded = param ?? requestHeaders.get(DRAFT_HEADER);
|
|
62
|
+
const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
|
|
63
|
+
const request = requestLike(requestHeaders as Headers, `/${query}`);
|
|
64
|
+
const pointer = request ? await allowedDraft(cms, request, policy) : null;
|
|
65
|
+
if (pointer === null) return false;
|
|
66
|
+
draftSlot().pointer = pointer;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The content client for this request: the draft `bindDraft` bound, or the
|
|
72
|
+
* release.
|
|
73
|
+
*/
|
|
74
|
+
export function contentClient(cms: Pick<CMS, "forDraft" | "forRelease">): Client {
|
|
75
|
+
const pointer = activeDraftPointer();
|
|
76
|
+
return pointer ? cms.forDraft(pointer) : cms.forRelease();
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The draft a request renders, held for the request's Server Components.
|
|
3
|
+
* React's `cache()` gives one slot per request in the React Server
|
|
4
|
+
* Components graph, the scope a draft needs; no `next/headers` here, so the
|
|
5
|
+
* root components can read it without binding anything.
|
|
6
|
+
*/
|
|
7
|
+
import { cache } from "react";
|
|
8
|
+
|
|
9
|
+
export const draftSlot = cache((): { pointer: string | null } => ({ pointer: null }));
|
|
10
|
+
|
|
11
|
+
/** The draft pointer bound to this request (`bindDraft`), or `null` when it renders the release. */
|
|
12
|
+
export function activeDraftPointer(): string | null {
|
|
13
|
+
return draftSlot().pointer;
|
|
14
|
+
}
|
package/src/studio.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Deco Studio runs: the origins its site editor frames a site from and
|
|
3
|
+
* talks to it from (`postMessage`). One list, so the Studio bridge
|
|
4
|
+
* (`StudioBridge`), `withDeco()`'s dev origins and a site's own
|
|
5
|
+
* `frame-ancestors` never drift apart. This package's own copy: it never
|
|
6
|
+
* imports `@decocms/tanstack`.
|
|
7
|
+
*
|
|
8
|
+
* No module state and no framework code: safe in a browser bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Studio's origins, in the CSP source form (`https://*.decocms.com` matches
|
|
13
|
+
* any subdomain). Test an origin with {@link isStudioOrigin}: the list holds a
|
|
14
|
+
* wildcard, so `includes()` isn't the check.
|
|
15
|
+
*/
|
|
16
|
+
export const STUDIO_ORIGINS: readonly string[] = Object.freeze([
|
|
17
|
+
"https://studio.decocms.com",
|
|
18
|
+
"https://studio-stg.decocms.com",
|
|
19
|
+
"https://decocms.com",
|
|
20
|
+
"https://*.decocms.com",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `frame-ancestors` sources a site sends so Studio can show it in the
|
|
25
|
+
* site editor: the site itself, {@link STUDIO_ORIGINS}, and v7's
|
|
26
|
+
* `https://*.deco.studio`. For a site that writes its own CSP; add the
|
|
27
|
+
* loopback origins in local development (`http://localhost:*`), where a
|
|
28
|
+
* Studio runs on the same machine.
|
|
29
|
+
*/
|
|
30
|
+
export const STUDIO_FRAME_ANCESTORS: readonly string[] = Object.freeze([
|
|
31
|
+
"'self'",
|
|
32
|
+
...STUDIO_ORIGINS,
|
|
33
|
+
"https://*.deco.studio",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `origin` (a `MessageEvent.origin`, an `Origin` header) is one of
|
|
38
|
+
* {@link STUDIO_ORIGINS}: `https`, the default port, and `decocms.com` or a
|
|
39
|
+
* name under it.
|
|
40
|
+
*/
|
|
41
|
+
export function isStudioOrigin(origin: string | null | undefined): boolean {
|
|
42
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
43
|
+
let url: URL;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(origin);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// An origin is the scheme and host alone: `https://decocms.com/x` or one with
|
|
50
|
+
// credentials isn't one.
|
|
51
|
+
if (url.origin !== origin || url.protocol !== "https:" || url.port !== "") return false;
|
|
52
|
+
const host = url.hostname;
|
|
53
|
+
return host === "decocms.com" || host.endsWith(".decocms.com");
|
|
54
|
+
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import dynamic from "next/dynamic";
|
|
4
|
-
import type { ComponentType } from "react";
|
|
5
|
-
|
|
6
|
-
interface ClientOnlySectionProps {
|
|
7
|
-
loader: () => Promise<{ default: ComponentType<any> }>;
|
|
8
|
-
props: Record<string, unknown>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const cache = new Map<() => Promise<{ default: ComponentType<any> }>, ComponentType<any>>();
|
|
12
|
-
|
|
13
|
-
export function ClientOnlySection({ loader, props }: ClientOnlySectionProps) {
|
|
14
|
-
let Dynamic = cache.get(loader);
|
|
15
|
-
if (!Dynamic) {
|
|
16
|
-
Dynamic = dynamic(loader, { ssr: false });
|
|
17
|
-
cache.set(loader, Dynamic);
|
|
18
|
-
}
|
|
19
|
-
return <Dynamic {...props} />;
|
|
20
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { DecoPageRenderer } from "./DecoPageRenderer";
|
|
5
|
-
|
|
6
|
-
function Hero({ label }: { label?: string }) {
|
|
7
|
-
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
describe("DecoPageRenderer (next)", () => {
|
|
11
|
-
it("renders eager sections in order", async () => {
|
|
12
|
-
registerSectionsSync({
|
|
13
|
-
"site/sections/NextPageA.tsx": { default: Hero },
|
|
14
|
-
"site/sections/NextPageB.tsx": { default: Hero },
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const element = await DecoPageRenderer({
|
|
18
|
-
sections: [
|
|
19
|
-
{
|
|
20
|
-
key: "site/sections/NextPageA.tsx",
|
|
21
|
-
component: "site/sections/NextPageA.tsx",
|
|
22
|
-
props: { label: "first" },
|
|
23
|
-
index: 0,
|
|
24
|
-
} as any,
|
|
25
|
-
{
|
|
26
|
-
key: "site/sections/NextPageB.tsx",
|
|
27
|
-
component: "site/sections/NextPageB.tsx",
|
|
28
|
-
props: { label: "second" },
|
|
29
|
-
index: 1,
|
|
30
|
-
} as any,
|
|
31
|
-
],
|
|
32
|
-
pagePath: "/",
|
|
33
|
-
});
|
|
34
|
-
const html = renderToString(element);
|
|
35
|
-
expect(html.indexOf("hero-first")).toBeLessThan(html.indexOf("hero-second"));
|
|
36
|
-
});
|
|
37
|
-
});
|
package/src/DecoPageRenderer.tsx
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
-
import { resolveDeferredSection } from "@decocms/blocks/cms";
|
|
3
|
-
import { cloneElement, type ReactElement, type ReactNode } from "react";
|
|
4
|
-
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
|
-
import { SectionRenderer } from "./SectionRenderer";
|
|
6
|
-
|
|
7
|
-
interface DecoPageRendererProps {
|
|
8
|
-
sections: ResolvedSection[];
|
|
9
|
-
deferredSections?: DeferredSection[];
|
|
10
|
-
pagePath: string;
|
|
11
|
-
pageUrl?: string;
|
|
12
|
-
loadingFallback?: ReactNode;
|
|
13
|
-
errorFallback?: ReactNode;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type PageItem =
|
|
17
|
-
| { type: "eager"; section: ResolvedSection; sort: number }
|
|
18
|
-
| { type: "deferred"; deferred: DeferredSection; sort: number };
|
|
19
|
-
|
|
20
|
-
function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
|
|
21
|
-
const items: PageItem[] = [];
|
|
22
|
-
resolved.forEach((section, i) =>
|
|
23
|
-
items.push({ type: "eager", section, sort: section.index ?? i }),
|
|
24
|
-
);
|
|
25
|
-
for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
|
|
26
|
-
items.sort((a, b) => a.sort - b.sort);
|
|
27
|
-
return items;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Top-level renderer for a resolved CMS page. Kicks off (but does not await)
|
|
32
|
-
* each deferred section's resolve promise immediately, so they resolve
|
|
33
|
-
* concurrently with the eager sections' own rendering — React's streaming
|
|
34
|
-
* SSR then flushes each <Suspense> boundary as its promise settles.
|
|
35
|
-
*/
|
|
36
|
-
export async function DecoPageRenderer({
|
|
37
|
-
sections,
|
|
38
|
-
deferredSections = [],
|
|
39
|
-
pagePath,
|
|
40
|
-
pageUrl,
|
|
41
|
-
loadingFallback,
|
|
42
|
-
errorFallback,
|
|
43
|
-
}: DecoPageRendererProps) {
|
|
44
|
-
const items = mergeSections(sections, deferredSections);
|
|
45
|
-
|
|
46
|
-
// `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts)
|
|
47
|
-
// takes positional args `(component, rawProps, pagePath, matcherCtx?)` and
|
|
48
|
-
// returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped
|
|
49
|
-
// call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it
|
|
50
|
-
// flows in via `matcherCtx.url`, mirroring how the TanStack route loader
|
|
51
|
-
// builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts).
|
|
52
|
-
// We also stamp `index` onto the resolved section ourselves (mirroring what
|
|
53
|
-
// `resolveDeferredSectionFull` does) so downstream consumers can rely on it.
|
|
54
|
-
const matcherCtx = pageUrl ? { url: pageUrl } : undefined;
|
|
55
|
-
const deferredPromises = new Map<number, Promise<ResolvedSection | null>>();
|
|
56
|
-
for (const d of deferredSections) {
|
|
57
|
-
deferredPromises.set(
|
|
58
|
-
d.index,
|
|
59
|
-
resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
|
|
60
|
-
(resolved) => {
|
|
61
|
-
if (!resolved) return null;
|
|
62
|
-
return { ...resolved, index: d.index };
|
|
63
|
-
},
|
|
64
|
-
),
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Eager sections are resolved via a direct (awaited) function call rather
|
|
69
|
-
// than nested as a `<SectionRenderer />` JSX child. `SectionRenderer` is an
|
|
70
|
-
// async function; Next's real RSC renderer awaits async components nested
|
|
71
|
-
// anywhere in the tree, but that's specific to the RSC streaming renderer —
|
|
72
|
-
// plain `react-dom/server` (used by this package's unit tests, and by any
|
|
73
|
-
// consumer that isn't going through Next's RSC pipeline) throws ("A
|
|
74
|
-
// component suspended while responding to synchronous input") when an async
|
|
75
|
-
// component suspends outside a `<Suspense>` boundary. Deferred sections are
|
|
76
|
-
// deliberately exempt: `DeferredSectionBoundary` wraps its async child in a
|
|
77
|
-
// `<Suspense>`, so letting it suspend there is exactly the intended
|
|
78
|
-
// behavior (renders the fallback synchronously, then streams in later).
|
|
79
|
-
//
|
|
80
|
-
// `deferredPromises` above is already populated before this `Promise.all`
|
|
81
|
-
// runs, so every deferred resolve is in flight concurrently with eager
|
|
82
|
-
// section rendering — nothing here blocks on it.
|
|
83
|
-
const rendered = await Promise.all(
|
|
84
|
-
items.map(async (item): Promise<ReactElement | null> => {
|
|
85
|
-
if (item.type === "eager") {
|
|
86
|
-
const element = await SectionRenderer({
|
|
87
|
-
resolved: item.section,
|
|
88
|
-
errorFallback,
|
|
89
|
-
});
|
|
90
|
-
if (!element) return null;
|
|
91
|
-
return cloneElement(element, { key: `${item.section.key}-${item.section.index}` });
|
|
92
|
-
}
|
|
93
|
-
return (
|
|
94
|
-
<DeferredSectionBoundary
|
|
95
|
-
key={`deferred-${item.deferred.key}-${item.deferred.index}`}
|
|
96
|
-
deferred={item.deferred}
|
|
97
|
-
promise={deferredPromises.get(item.deferred.index)!}
|
|
98
|
-
pagePath={pagePath}
|
|
99
|
-
fallback={loadingFallback}
|
|
100
|
-
errorFallback={errorFallback}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
103
|
-
}),
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
return <>{rendered}</>;
|
|
107
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { renderToString } from "react-dom/server";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { DecoRootLayout } from "./DecoRootLayout";
|
|
4
|
-
|
|
5
|
-
describe("DecoRootLayout (next)", () => {
|
|
6
|
-
it("renders the html shell with LiveControls and the analytics bootstrap script", () => {
|
|
7
|
-
const html = renderToString(
|
|
8
|
-
<DecoRootLayout siteName="test-site">
|
|
9
|
-
<div>page content</div>
|
|
10
|
-
</DecoRootLayout>,
|
|
11
|
-
);
|
|
12
|
-
expect(html).toContain("page content");
|
|
13
|
-
expect(html).toContain("__DECO_STATE");
|
|
14
|
-
// "window.DECO" alone is a false-positive trap: it also appears twice in
|
|
15
|
-
// this component's own inline bootstrap script (buildDecoEventsBootstrap's
|
|
16
|
-
// `window.DECO = window.DECO || {}` and `window.DECO.events = ...`), so it
|
|
17
|
-
// would still pass even if the ANALYTICS_SCRIPT <script> tag were deleted
|
|
18
|
-
// entirely. "IntersectionObserver" only exists inside ANALYTICS_SCRIPT
|
|
19
|
-
// (packages/blocks/src/sdk/analytics.ts) — it genuinely pins that script.
|
|
20
|
-
expect(html).toContain("IntersectionObserver");
|
|
21
|
-
});
|
|
22
|
-
});
|
package/src/DecoRootLayout.tsx
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { LiveControls } from "@decocms/blocks/hooks";
|
|
2
|
-
import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
|
|
5
|
-
function buildDecoEventsBootstrap(account?: string): string {
|
|
6
|
-
const accountJson = JSON.stringify(account ?? "");
|
|
7
|
-
return `
|
|
8
|
-
window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
|
|
9
|
-
window.DECO = window.DECO || {};
|
|
10
|
-
window.DECO.events = window.DECO.events || {
|
|
11
|
-
_q: [],
|
|
12
|
-
_subs: [],
|
|
13
|
-
dispatch: function(e) {
|
|
14
|
-
this._q.push(e);
|
|
15
|
-
for (var i = 0; i < this._subs.length; i++) {
|
|
16
|
-
try { this._subs[i](e); } catch(err) { console.error('[DECO.events]', err); }
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
subscribe: function(fn) {
|
|
20
|
-
this._subs.push(fn);
|
|
21
|
-
for (var i = 0; i < this._q.length; i++) {
|
|
22
|
-
try { fn(this._q[i]); } catch(err) {}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
window.dataLayer = window.dataLayer || [];
|
|
27
|
-
`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface DecoRootLayoutProps {
|
|
31
|
-
/** Language attribute for the <html> tag. Default: "en" */
|
|
32
|
-
lang?: string;
|
|
33
|
-
/** DaisyUI data-theme attribute. Default: "light" */
|
|
34
|
-
dataTheme?: string;
|
|
35
|
-
/** Site name for LiveControls (admin iframe communication). Required. */
|
|
36
|
-
siteName: string;
|
|
37
|
-
/** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
|
|
38
|
-
account?: string;
|
|
39
|
-
/** CSS class for <body>. Default: "bg-base-200 text-base-content" */
|
|
40
|
-
bodyClassName?: string;
|
|
41
|
-
children?: ReactNode;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Root layout for app/layout.tsx — the Next.js analogue of
|
|
46
|
-
* `@decocms/tanstack`'s `DecoRootLayout`.
|
|
47
|
-
*
|
|
48
|
-
* The bootstrap/analytics <script> tags run once per navigation session
|
|
49
|
-
* because Next.js App Router doesn't remount a shared layout.tsx across
|
|
50
|
-
* same-layout navigations — the same property TanStack's <ScriptOnce>
|
|
51
|
-
* provides explicitly, here implicit in the framework's own layout model.
|
|
52
|
-
* That's why a plain inline <script> (rather than a ScriptOnce-like helper)
|
|
53
|
-
* is sufficient here.
|
|
54
|
-
*
|
|
55
|
-
* No <head>/<HeadContent> equivalent: Next.js App Router injects
|
|
56
|
-
* `generateMetadata`'s output automatically, and a root layout.tsx doesn't
|
|
57
|
-
* render its own <head> element by convention.
|
|
58
|
-
*
|
|
59
|
-
* No NavigationProgress/StableOutlet port in v1 — those are
|
|
60
|
-
* TanStack-Router-specific SPA-navigation UX polish (a loading bar during
|
|
61
|
-
* client-side nav, height-preserved outlet), not part of the core
|
|
62
|
-
* CMS-rendering feature set this plan scopes.
|
|
63
|
-
*/
|
|
64
|
-
export function DecoRootLayout({
|
|
65
|
-
lang = "en",
|
|
66
|
-
dataTheme = "light",
|
|
67
|
-
siteName,
|
|
68
|
-
account,
|
|
69
|
-
bodyClassName = "bg-base-200 text-base-content",
|
|
70
|
-
children,
|
|
71
|
-
}: DecoRootLayoutProps) {
|
|
72
|
-
return (
|
|
73
|
-
<html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
|
|
74
|
-
<body className={bodyClassName} suppressHydrationWarning>
|
|
75
|
-
<script dangerouslySetInnerHTML={{ __html: buildDecoEventsBootstrap(account) }} />
|
|
76
|
-
{children}
|
|
77
|
-
<LiveControls site={siteName} />
|
|
78
|
-
<script dangerouslySetInnerHTML={{ __html: ANALYTICS_SCRIPT }} />
|
|
79
|
-
</body>
|
|
80
|
-
</html>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
|
-
|
|
6
|
-
function Hero({ label }: { label?: string }) {
|
|
7
|
-
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
describe("DeferredSectionBoundary (next)", () => {
|
|
11
|
-
it("renders the fallback while the promise is pending, per Suspense semantics", () => {
|
|
12
|
-
const KEY = "site/sections/NextDeferredA.tsx";
|
|
13
|
-
registerSectionsSync({ [KEY]: { default: Hero } });
|
|
14
|
-
|
|
15
|
-
const neverResolves = new Promise<any>(() => {});
|
|
16
|
-
const html = renderToString(
|
|
17
|
-
<DeferredSectionBoundary
|
|
18
|
-
deferred={{ key: KEY, component: KEY, index: 0 } as any}
|
|
19
|
-
promise={neverResolves}
|
|
20
|
-
pagePath="/"
|
|
21
|
-
fallback={<div>loading</div>}
|
|
22
|
-
/>,
|
|
23
|
-
);
|
|
24
|
-
expect(html).toContain("loading");
|
|
25
|
-
expect(html).not.toContain("hero-");
|
|
26
|
-
});
|
|
27
|
-
});
|
package/src/DeferredSection.tsx
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
-
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
3
|
-
import { type ReactNode, Suspense } from "react";
|
|
4
|
-
import { SectionRenderer } from "./SectionRenderer";
|
|
5
|
-
|
|
6
|
-
interface DeferredSectionBoundaryProps {
|
|
7
|
-
deferred: DeferredSection;
|
|
8
|
-
promise: Promise<ResolvedSection | null>;
|
|
9
|
-
pagePath: string;
|
|
10
|
-
fallback?: ReactNode;
|
|
11
|
-
errorFallback?: ReactNode;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function ResolvedDeferredSection({
|
|
15
|
-
promise,
|
|
16
|
-
errorFallback,
|
|
17
|
-
}: {
|
|
18
|
-
promise: Promise<ResolvedSection | null>;
|
|
19
|
-
errorFallback?: ReactNode;
|
|
20
|
-
}) {
|
|
21
|
-
const resolved = await promise;
|
|
22
|
-
if (!resolved) return null;
|
|
23
|
-
return <SectionRenderer resolved={resolved} errorFallback={errorFallback} />;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Deferred/streamed section — the RSC-native analogue of DecoPageRenderer's
|
|
28
|
-
* <Suspense><Await promise={promise}>{...}</Await></Suspense>. React 19
|
|
29
|
-
* Server Components can await a promise directly inside an async component,
|
|
30
|
-
* so there is no need for TanStack's <Await> wrapper — <Suspense> here is
|
|
31
|
-
* the same React primitive TanStack also uses, just without the extra
|
|
32
|
-
* unwrapping component.
|
|
33
|
-
*/
|
|
34
|
-
export function DeferredSectionBoundary({
|
|
35
|
-
deferred,
|
|
36
|
-
promise,
|
|
37
|
-
pagePath: _pagePath,
|
|
38
|
-
fallback,
|
|
39
|
-
errorFallback,
|
|
40
|
-
}: DeferredSectionBoundaryProps) {
|
|
41
|
-
const sectionId = deferred.key
|
|
42
|
-
.replace(/\//g, "-")
|
|
43
|
-
.replace(/\.tsx$/, "")
|
|
44
|
-
.replace(/^site-sections-/, "");
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<SectionErrorBoundary sectionKey={deferred.key} fallback={errorFallback}>
|
|
48
|
-
<Suspense
|
|
49
|
-
fallback={
|
|
50
|
-
<section id={sectionId} data-manifest-key={deferred.key} data-deferred="true">
|
|
51
|
-
{fallback}
|
|
52
|
-
</section>
|
|
53
|
-
}
|
|
54
|
-
>
|
|
55
|
-
<ResolvedDeferredSection promise={promise} errorFallback={errorFallback} />
|
|
56
|
-
</Suspense>
|
|
57
|
-
</SectionErrorBoundary>
|
|
58
|
-
);
|
|
59
|
-
}
|