@decocms/nextjs 7.28.0-beta.2 → 7.28.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/DecoPageRenderer.test.tsx +13 -3
- package/src/DecoPageRenderer.tsx +11 -7
- package/src/DecoRootLayout.tsx +1 -1
- package/src/DeferredSection.test.tsx +1 -1
- package/src/DeferredSection.tsx +1 -1
- package/src/SectionRenderer.test.tsx +1 -1
- package/src/SectionRenderer.tsx +2 -6
- package/src/createDecoPage.draft.test.tsx +2 -2
- package/src/createDecoPage.test.tsx +2 -4
- package/src/createDecoPage.tsx +1 -1
- package/src/draft.ts +2 -2
- package/src/draftMiddleware.test.ts +21 -15
- package/src/draftMiddleware.ts +2 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "7.28.0-beta.
|
|
3
|
+
"version": "7.28.0-beta.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"lint:unused": "knip"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@decocms/blocks": "7.28.0-beta.
|
|
33
|
-
"@decocms/blocks-admin": "7.28.0-beta.
|
|
32
|
+
"@decocms/blocks": "7.28.0-beta.3",
|
|
33
|
+
"@decocms/blocks-admin": "7.28.0-beta.3"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"next": ">=15.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
1
2
|
import { renderToString } from "react-dom/server";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
4
4
|
import { DecoPageRenderer } from "./DecoPageRenderer";
|
|
5
5
|
|
|
6
6
|
function Hero({ label }: { label?: string }) {
|
|
@@ -16,8 +16,18 @@ describe("DecoPageRenderer (next)", () => {
|
|
|
16
16
|
|
|
17
17
|
const element = await DecoPageRenderer({
|
|
18
18
|
sections: [
|
|
19
|
-
{
|
|
20
|
-
|
|
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,
|
|
21
31
|
],
|
|
22
32
|
pagePath: "/",
|
|
23
33
|
});
|
package/src/DecoPageRenderer.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
2
|
import { resolveDeferredSection } from "@decocms/blocks/cms";
|
|
3
|
-
import { SectionRenderer } from "./SectionRenderer";
|
|
4
|
-
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
3
|
import { cloneElement, type ReactElement, type ReactNode } from "react";
|
|
4
|
+
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
|
+
import { SectionRenderer } from "./SectionRenderer";
|
|
6
6
|
|
|
7
7
|
interface DecoPageRendererProps {
|
|
8
8
|
sections: ResolvedSection[];
|
|
@@ -19,7 +19,9 @@ type PageItem =
|
|
|
19
19
|
|
|
20
20
|
function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
|
|
21
21
|
const items: PageItem[] = [];
|
|
22
|
-
resolved.forEach((section, i) =>
|
|
22
|
+
resolved.forEach((section, i) =>
|
|
23
|
+
items.push({ type: "eager", section, sort: section.index ?? i }),
|
|
24
|
+
);
|
|
23
25
|
for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
|
|
24
26
|
items.sort((a, b) => a.sort - b.sort);
|
|
25
27
|
return items;
|
|
@@ -54,10 +56,12 @@ export async function DecoPageRenderer({
|
|
|
54
56
|
for (const d of deferredSections) {
|
|
55
57
|
deferredPromises.set(
|
|
56
58
|
d.index,
|
|
57
|
-
resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
|
|
60
|
+
(resolved) => {
|
|
61
|
+
if (!resolved) return null;
|
|
62
|
+
return { ...resolved, index: d.index };
|
|
63
|
+
},
|
|
64
|
+
),
|
|
61
65
|
);
|
|
62
66
|
}
|
|
63
67
|
|
package/src/DecoRootLayout.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
1
|
import { LiveControls } from "@decocms/blocks/hooks";
|
|
3
2
|
import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
function buildDecoEventsBootstrap(account?: string): string {
|
|
6
6
|
const accountJson = JSON.stringify(account ?? "");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
1
2
|
import { renderToString } from "react-dom/server";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
4
4
|
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
5
|
|
|
6
6
|
function Hero({ label }: { label?: string }) {
|
package/src/DeferredSection.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Suspense, type ReactNode } from "react";
|
|
2
1
|
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
3
2
|
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
3
|
+
import { type ReactNode, Suspense } from "react";
|
|
4
4
|
import { SectionRenderer } from "./SectionRenderer";
|
|
5
5
|
|
|
6
6
|
interface DeferredSectionBoundaryProps {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
|
|
1
2
|
import { renderToString } from "react-dom/server";
|
|
2
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
|
|
4
4
|
import { SectionRenderer } from "./SectionRenderer";
|
|
5
5
|
|
|
6
6
|
function Hero({ label }: { label?: string }) {
|
package/src/SectionRenderer.tsx
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import { createElement, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
3
|
-
getSection,
|
|
4
|
-
getSectionOptions,
|
|
5
|
-
getSyncComponent,
|
|
6
|
-
} from "@decocms/blocks/cms";
|
|
7
1
|
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
+
import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms";
|
|
8
3
|
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
4
|
+
import { createElement, type ReactNode } from "react";
|
|
9
5
|
import { ClientOnlySection } from "./ClientOnlySection";
|
|
10
6
|
|
|
11
7
|
interface SectionRendererProps {
|
|
@@ -42,7 +42,7 @@ function seedPublishedHome() {
|
|
|
42
42
|
afterEach(() => {
|
|
43
43
|
cookiesMock.mockClear();
|
|
44
44
|
connectionMock.mockClear();
|
|
45
|
-
delete process.env.
|
|
45
|
+
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
46
46
|
delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES;
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -75,7 +75,7 @@ describe("createDecoPage — draft preview gate", () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
it("reads cookies() once the feature is fully configured", async () => {
|
|
78
|
-
process.env.
|
|
78
|
+
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
|
|
79
79
|
seedPublishedHome();
|
|
80
80
|
|
|
81
81
|
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { registerSections, setBlocks } from "@decocms/blocks/cms";
|
|
1
2
|
import { renderToString } from "react-dom/server";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { registerSections, setBlocks } from "@decocms/blocks/cms";
|
|
4
4
|
import { createDecoPage } from "./createDecoPage";
|
|
5
5
|
|
|
6
6
|
function Hero({ label }: { label?: string }) {
|
|
@@ -58,8 +58,6 @@ describe("createDecoPage (next)", () => {
|
|
|
58
58
|
|
|
59
59
|
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
60
60
|
|
|
61
|
-
await expect(
|
|
62
|
-
Page({ params: Promise.resolve({ slug: ["missing"] }) }),
|
|
63
|
-
).rejects.toThrow();
|
|
61
|
+
await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow();
|
|
64
62
|
});
|
|
65
63
|
});
|
package/src/createDecoPage.tsx
CHANGED
|
@@ -90,7 +90,7 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
|
90
90
|
* first wins for both. So this must be awaited at the top of BOTH, not
|
|
91
91
|
* just the page. `cache()` here makes the second call free.
|
|
92
92
|
*
|
|
93
|
-
* Gated on `isDraftPreviewEnabled()` (
|
|
93
|
+
* Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a
|
|
94
94
|
* plain env read, not a dynamic API —
|
|
95
95
|
* so sites that never opt in behave exactly as before. That gate is doing
|
|
96
96
|
* real work: `cookies()` and `searchParams` are both dynamic, and touching
|
package/src/draft.ts
CHANGED
|
@@ -106,7 +106,7 @@ export function selectDraftPointer(
|
|
|
106
106
|
* `cookies()`. There is deliberately no request header in the path: the page
|
|
107
107
|
* owns the decision, so a draft keeps working on routes the middleware matcher
|
|
108
108
|
* never sees, and there is one less forgeable input to reason about. (The
|
|
109
|
-
* pointer was never a secret — the
|
|
109
|
+
* pointer was never a secret — the draft id (the token's authority) is the capability — so a
|
|
110
110
|
* client supplying one directly is equivalent to typing the query param.)
|
|
111
111
|
*
|
|
112
112
|
* Returns whether a draft was bound, so callers can surface an explicit
|
|
@@ -122,7 +122,7 @@ export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boo
|
|
|
122
122
|
|
|
123
123
|
// Host gate, checked only once a pointer exists (headers() is a dynamic
|
|
124
124
|
// API): the same build may serve the preview domain and the production
|
|
125
|
-
// domain, and only hosts named in
|
|
125
|
+
// domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render
|
|
126
126
|
// drafts — production stays published no matter what the URL carries.
|
|
127
127
|
const requestHeaders = await headers();
|
|
128
128
|
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
|
|
@@ -6,10 +6,10 @@ import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware
|
|
|
6
6
|
|
|
7
7
|
beforeEach(() => {
|
|
8
8
|
// The middleware is host-gated; these tests run as the allowed host.
|
|
9
|
-
process.env.
|
|
9
|
+
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
|
|
10
10
|
});
|
|
11
11
|
afterEach(() => {
|
|
12
|
-
delete process.env.
|
|
12
|
+
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
function request(url: string, cookie?: string): NextRequest {
|
|
@@ -21,17 +21,21 @@ function request(url: string, cookie?: string): NextRequest {
|
|
|
21
21
|
|
|
22
22
|
describe("prepareDraft", () => {
|
|
23
23
|
it("reads the pointer from the param", () => {
|
|
24
|
-
expect(prepareDraft(request("https://site.example/p?__draft=abc@v1")).pointer).toBe(
|
|
24
|
+
expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
|
|
25
|
+
"abc.localhost@v1",
|
|
26
|
+
);
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
it("reads the pointer from the cookie on a plain navigation", () => {
|
|
28
|
-
expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe(
|
|
30
|
+
expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
|
|
31
|
+
"abc.localhost@v1",
|
|
32
|
+
);
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
it("is inert on a host outside the allowlist — the production domain", () => {
|
|
32
36
|
// Same build, different Host: no cookie, no rewrite, nothing touched.
|
|
33
37
|
const req = request("https://fila.com.br/p?__draft=abc@v1");
|
|
34
|
-
process.env.
|
|
38
|
+
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app";
|
|
35
39
|
expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
|
|
36
40
|
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
37
41
|
});
|
|
@@ -40,7 +44,7 @@ describe("prepareDraft", () => {
|
|
|
40
44
|
// There is no request-header path any more; the page reads searchParams
|
|
41
45
|
// and cookies itself, so a forged header is simply not an input.
|
|
42
46
|
const req = new NextRequest(new URL("https://site.example/p"), {
|
|
43
|
-
headers: { "x-deco-draft": "attacker@v1" },
|
|
47
|
+
headers: { "x-deco-draft": "attacker.localhost@v1" },
|
|
44
48
|
});
|
|
45
49
|
expect(prepareDraft(req).pointer).toBeNull();
|
|
46
50
|
});
|
|
@@ -48,11 +52,11 @@ describe("prepareDraft", () => {
|
|
|
48
52
|
|
|
49
53
|
describe("applyDraft", () => {
|
|
50
54
|
it("sets a partitioned cross-site cookie when entering draft mode", () => {
|
|
51
|
-
const decision = prepareDraft(request("https://site.example/p?__draft=abc@v1"));
|
|
55
|
+
const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
|
|
52
56
|
const res = applyDraft(NextResponse.next(), decision);
|
|
53
57
|
|
|
54
58
|
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
55
|
-
expect(setCookie).toContain(`${DRAFT_COOKIE}=abc%40v1`);
|
|
59
|
+
expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
|
|
56
60
|
// The preview iframe is cross-site, so these attributes are what make the
|
|
57
61
|
// cookie survive at all — not hardening extras.
|
|
58
62
|
expect(setCookie).toMatch(/SameSite=None/i);
|
|
@@ -64,7 +68,7 @@ describe("applyDraft", () => {
|
|
|
64
68
|
it("marks a draft response uncacheable and unindexable", () => {
|
|
65
69
|
// With the pointer in a cookie, draft and published share a URL — a CDN
|
|
66
70
|
// keyed on URL alone would serve unpublished content to a real visitor.
|
|
67
|
-
const decision = prepareDraft(request("https://site.example/p", "abc@v1"));
|
|
71
|
+
const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
|
|
68
72
|
const res = applyDraft(NextResponse.next(), decision);
|
|
69
73
|
|
|
70
74
|
expect(res.headers.get("cache-control")).toBe("no-store, private");
|
|
@@ -83,7 +87,9 @@ describe("applyDraft", () => {
|
|
|
83
87
|
});
|
|
84
88
|
|
|
85
89
|
it("clears the cookie on ?__draft=off", () => {
|
|
86
|
-
const decision = prepareDraft(
|
|
90
|
+
const decision = prepareDraft(
|
|
91
|
+
request("https://site.example/p?__draft=off", "abc.localhost@v1"),
|
|
92
|
+
);
|
|
87
93
|
expect(decision.clearCookie).toBe(true);
|
|
88
94
|
|
|
89
95
|
const res = applyDraft(NextResponse.next(), decision);
|
|
@@ -98,16 +104,16 @@ describe("applyDraft", () => {
|
|
|
98
104
|
|
|
99
105
|
describe("rewriteToDraftRoute", () => {
|
|
100
106
|
it("rewrites a drafted request onto the dynamic draft route", () => {
|
|
101
|
-
const req = request("https://site.example/blog/hello?__draft=abc@v1");
|
|
107
|
+
const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
|
|
102
108
|
const res = rewriteToDraftRoute(req, prepareDraft(req));
|
|
103
109
|
const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
|
|
104
110
|
expect(target.pathname).toBe("/_draft/blog/hello");
|
|
105
111
|
// The pointer must survive: the draft page reads it from searchParams.
|
|
106
|
-
expect(target.searchParams.get("__draft")).toBe("abc@v1");
|
|
112
|
+
expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
it("rewrites a cookie-only navigation too", () => {
|
|
110
|
-
const req = request("https://site.example/blog/hello", "abc@v1");
|
|
116
|
+
const req = request("https://site.example/blog/hello", "abc.localhost@v1");
|
|
111
117
|
const res = rewriteToDraftRoute(req, prepareDraft(req));
|
|
112
118
|
expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
|
|
113
119
|
"/_draft/blog/hello",
|
|
@@ -122,12 +128,12 @@ describe("rewriteToDraftRoute", () => {
|
|
|
122
128
|
});
|
|
123
129
|
|
|
124
130
|
it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
|
|
125
|
-
const req = request("https://site.example/_draft/blog/hello?__draft=abc@v1");
|
|
131
|
+
const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
|
|
126
132
|
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
it("does not rewrite when leaving draft mode", () => {
|
|
130
|
-
const req = request("https://site.example/blog/hello?__draft=off", "abc@v1");
|
|
136
|
+
const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
|
|
131
137
|
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
132
138
|
});
|
|
133
139
|
});
|
package/src/draftMiddleware.ts
CHANGED
|
@@ -40,13 +40,11 @@ import {
|
|
|
40
40
|
const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false };
|
|
41
41
|
|
|
42
42
|
export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
|
|
43
|
-
// Host gate first: on a host not named in
|
|
43
|
+
// Host gate first: on a host not named in DECO_ALLOWED_PREVIEW_HOSTS the
|
|
44
44
|
// decision is inert — no cookie written, no rewrite, no headers touched —
|
|
45
45
|
// so the production domain behaves as if the feature didn't exist.
|
|
46
46
|
const host =
|
|
47
|
-
request.headers.get("x-forwarded-host") ??
|
|
48
|
-
request.headers.get("host") ??
|
|
49
|
-
request.nextUrl.host;
|
|
47
|
+
request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
|
|
50
48
|
if (!isDraftHostAllowed(host)) return INERT;
|
|
51
49
|
return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
|
|
52
50
|
}
|