@decocms/nextjs 7.28.1 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- 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/DraftPreviewBadge.render.test.tsx +16 -0
- package/src/DraftPreviewBadge.test.ts +37 -0
- package/src/DraftPreviewBadge.tsx +252 -0
- package/src/DraftPreviewIndicator.tsx +22 -0
- package/src/SectionRenderer.test.tsx +1 -1
- package/src/SectionRenderer.tsx +2 -6
- package/src/config.cjs +56 -1
- package/src/config.test.ts +39 -0
- package/src/createDecoPage.draft.test.tsx +91 -0
- package/src/createDecoPage.test.tsx +2 -4
- package/src/createDecoPage.tsx +74 -8
- package/src/decoMark.ts +11 -0
- package/src/draft.test.ts +73 -0
- package/src/draft.ts +225 -0
- package/src/draftConstants.test.ts +21 -0
- package/src/draftConstants.ts +15 -0
- package/src/draftMiddleware.test.ts +147 -0
- package/src/draftMiddleware.ts +136 -0
- package/src/index.ts +25 -0
- package/src/setup.ts +23 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-preview wiring in `createDecoPage`.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `createDecoPage.test.tsx` because this file has to mock
|
|
5
|
+
* `next/headers`, and that mock would otherwise apply to the plain
|
|
6
|
+
* (non-draft) cases too — which are worth keeping honest about never
|
|
7
|
+
* touching a dynamic API at all.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { registerSections, setBlocks } from "@decocms/blocks/cms";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
// The gate must stop us reaching cookies() at all when the feature is off.
|
|
15
|
+
const cookiesMock = vi.fn(async () => ({ get: () => undefined }));
|
|
16
|
+
vi.mock("next/headers", () => ({
|
|
17
|
+
cookies: () => cookiesMock(),
|
|
18
|
+
headers: async () => new Headers(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// `connection()` is Next's runtime opt-out from static rendering; outside a
|
|
22
|
+
// request scope it throws, so it is stubbed to a no-op here.
|
|
23
|
+
const connectionMock = vi.fn(async () => {});
|
|
24
|
+
vi.mock("next/server", () => ({ connection: () => connectionMock() }));
|
|
25
|
+
|
|
26
|
+
const { createDecoPage } = await import("./createDecoPage");
|
|
27
|
+
|
|
28
|
+
function Hero({ label }: { label?: string }) {
|
|
29
|
+
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function seedPublishedHome() {
|
|
33
|
+
registerSections({ "site/sections/DraftHero.tsx": async () => ({ default: Hero }) });
|
|
34
|
+
setBlocks({
|
|
35
|
+
"pages-home": {
|
|
36
|
+
path: "/",
|
|
37
|
+
sections: [{ __resolveType: "site/sections/DraftHero.tsx", label: "published" }],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
cookiesMock.mockClear();
|
|
44
|
+
connectionMock.mockClear();
|
|
45
|
+
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
46
|
+
delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("createDecoPage — draft preview gate", () => {
|
|
50
|
+
it("never reads cookies() when the feature is off", async () => {
|
|
51
|
+
// Load-bearing, not a micro-optimisation: cookies() is a dynamic API, so
|
|
52
|
+
// calling it unconditionally would opt EVERY page of EVERY site out of
|
|
53
|
+
// static/ISR rendering the moment this package is upgraded.
|
|
54
|
+
seedPublishedHome();
|
|
55
|
+
|
|
56
|
+
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
57
|
+
const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
|
|
58
|
+
|
|
59
|
+
expect(html).toContain("hero-published");
|
|
60
|
+
expect(cookiesMock).not.toHaveBeenCalled();
|
|
61
|
+
expect(connectionMock).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("stays off when the flag is set but no origin suffix is configured", async () => {
|
|
65
|
+
// Both halves are required — a bare flag with nowhere to fetch from is
|
|
66
|
+
// inert, mirroring Fast Deploy's opt-in.
|
|
67
|
+
process.env.DECO_DRAFT_PREVIEW = "1";
|
|
68
|
+
seedPublishedHome();
|
|
69
|
+
|
|
70
|
+
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
71
|
+
const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
|
|
72
|
+
|
|
73
|
+
expect(html).toContain("hero-published");
|
|
74
|
+
expect(cookiesMock).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reads cookies() once the feature is fully configured", async () => {
|
|
78
|
+
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
|
|
79
|
+
seedPublishedHome();
|
|
80
|
+
|
|
81
|
+
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
82
|
+
const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
|
|
83
|
+
|
|
84
|
+
// No draft pointer on the request, so the page still renders published —
|
|
85
|
+
// enabling the feature must not change what an ordinary visitor sees.
|
|
86
|
+
expect(html).toContain("hero-published");
|
|
87
|
+
expect(cookiesMock).toHaveBeenCalled();
|
|
88
|
+
// Nothing was bound, so no need to force the page dynamic.
|
|
89
|
+
expect(connectionMock).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -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
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { cache } from "react";
|
|
2
|
-
import { notFound } from "next/navigation";
|
|
3
|
-
import type { Metadata } from "next";
|
|
4
1
|
import {
|
|
2
|
+
type DecoPageResult,
|
|
5
3
|
extractSeoFromProps,
|
|
6
4
|
extractSeoFromSections,
|
|
7
|
-
|
|
8
|
-
type DecoPageResult,
|
|
5
|
+
isDraftPreviewEnabled,
|
|
9
6
|
type PageSeo,
|
|
7
|
+
resolveDecoPage,
|
|
10
8
|
} from "@decocms/blocks/cms";
|
|
9
|
+
import type { Metadata } from "next";
|
|
10
|
+
import { notFound } from "next/navigation";
|
|
11
|
+
import { connection } from "next/server";
|
|
12
|
+
import { cache } from "react";
|
|
11
13
|
import { DecoPageRenderer } from "./DecoPageRenderer";
|
|
14
|
+
import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
|
|
15
|
+
import { type DraftSearchParams, ensureDraft } from "./draft";
|
|
12
16
|
|
|
13
17
|
interface CreateDecoPageOptions {
|
|
14
18
|
siteName: string;
|
|
@@ -16,6 +20,12 @@ interface CreateDecoPageOptions {
|
|
|
16
20
|
|
|
17
21
|
interface PageProps {
|
|
18
22
|
params: Promise<{ slug?: string[] }>;
|
|
23
|
+
/**
|
|
24
|
+
* Next always supplies this; optional here so existing callers (and unit
|
|
25
|
+
* tests) that construct props by hand keep compiling. Draft preview reads
|
|
26
|
+
* `?__draft=` from it — see bindDraftOnce.
|
|
27
|
+
*/
|
|
28
|
+
searchParams?: Promise<DraftSearchParams>;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
function pathFromSlug(slug: string[] | undefined): string {
|
|
@@ -67,8 +77,48 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
|
67
77
|
|
|
68
78
|
const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Bind this request's draft decofile, at most once, BEFORE anything resolves
|
|
82
|
+
* a page.
|
|
83
|
+
*
|
|
84
|
+
* Ordering is load-bearing twice over:
|
|
85
|
+
*
|
|
86
|
+
* 1. `resolveDecoPage` calls `loadBlocks()`, so a draft bound afterwards
|
|
87
|
+
* would resolve the page against published content and silently render
|
|
88
|
+
* the wrong thing.
|
|
89
|
+
* 2. `resolveForPath` is `cache()`d per request and Next may run
|
|
90
|
+
* `generateMetadata` and the page body concurrently — whichever resolves
|
|
91
|
+
* first wins for both. So this must be awaited at the top of BOTH, not
|
|
92
|
+
* just the page. `cache()` here makes the second call free.
|
|
93
|
+
*
|
|
94
|
+
* Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a
|
|
95
|
+
* plain env read, not a dynamic API —
|
|
96
|
+
* so sites that never opt in behave exactly as before. That gate is doing
|
|
97
|
+
* real work: `cookies()` and `searchParams` are both dynamic, and touching
|
|
98
|
+
* them unconditionally would opt EVERY page out of static/ISR rendering.
|
|
99
|
+
*
|
|
100
|
+
* KNOWN COST: with the flag on, every page served by this route becomes
|
|
101
|
+
* dynamic, because reading the pointer requires `cookies()`. Acceptable
|
|
102
|
+
* while the feature is opt-in; the fix, when it needs to run on a
|
|
103
|
+
* statically-rendered production site, is for middleware to rewrite draft
|
|
104
|
+
* requests onto a separate dynamic route so ordinary traffic keeps its cache.
|
|
105
|
+
*/
|
|
106
|
+
const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise<boolean> => {
|
|
107
|
+
if (!isDraftPreviewEnabled()) return false;
|
|
108
|
+
|
|
109
|
+
const bound = await ensureDraft(searchParams);
|
|
110
|
+
|
|
111
|
+
// A draft render must never be cached — ISR or the Full Route Cache would
|
|
112
|
+
// hand unpublished content to a real visitor. `revalidate` is a static
|
|
113
|
+
// export and cannot vary per request, so opt out at runtime instead.
|
|
114
|
+
if (bound) await connection();
|
|
115
|
+
|
|
116
|
+
return bound;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
|
71
120
|
const { slug } = await params;
|
|
121
|
+
await bindDraftOnce(await searchParams);
|
|
72
122
|
const page = await resolveForPath(pathFromSlug(slug));
|
|
73
123
|
if (!page) return {};
|
|
74
124
|
|
|
@@ -81,9 +131,11 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
|
81
131
|
};
|
|
82
132
|
}
|
|
83
133
|
|
|
84
|
-
async function Page({ params }: PageProps) {
|
|
134
|
+
async function Page({ params, searchParams }: PageProps) {
|
|
85
135
|
const { slug } = await params;
|
|
86
136
|
const pathname = pathFromSlug(slug);
|
|
137
|
+
// Before resolveForPath — see bindDraftOnce.
|
|
138
|
+
const isDraft = await bindDraftOnce(await searchParams);
|
|
87
139
|
const page = await resolveForPath(pathname);
|
|
88
140
|
if (!page) notFound();
|
|
89
141
|
|
|
@@ -96,11 +148,25 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
|
96
148
|
// suspends outside a <Suspense> boundary. Awaiting it here directly
|
|
97
149
|
// keeps both paths working, matching the same convention
|
|
98
150
|
// DecoPageRenderer itself uses for SectionRenderer.
|
|
99
|
-
|
|
151
|
+
const content = await DecoPageRenderer({
|
|
100
152
|
sections: page.resolvedSections,
|
|
101
153
|
deferredSections: page.deferredSections,
|
|
102
154
|
pagePath: pathname,
|
|
103
155
|
});
|
|
156
|
+
|
|
157
|
+
// In draft mode, float the preview-mode badge over the page so the
|
|
158
|
+
// reviewer can never mistake unpublished content for what is live.
|
|
159
|
+
// `DraftPreviewIndicator` self-gates on the request-scoped pointer; the
|
|
160
|
+
// `isDraft` short-circuit keeps the extra element out of every published
|
|
161
|
+
// render. Rendered here (page subtree, after bindDraftOnce) — not a layout,
|
|
162
|
+
// whose children render concurrently — so the pointer slot is already set.
|
|
163
|
+
if (!isDraft) return content;
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
{content}
|
|
167
|
+
<DraftPreviewIndicator />
|
|
168
|
+
</>
|
|
169
|
+
);
|
|
104
170
|
}
|
|
105
171
|
|
|
106
172
|
return { generateMetadata, default: Page };
|
package/src/decoMark.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deco brand mark, inlined as a data URI so the draft-preview badge is fully
|
|
3
|
+
* self-contained: it renders inside an arbitrary consumer site with no asset
|
|
4
|
+
* pipeline, no `<img src>` that could 404, and no dependency on the site
|
|
5
|
+
* shipping the logo. Only loaded when a draft is active (the badge is inert
|
|
6
|
+
* otherwise), so the ~21KB cost never touches ordinary production traffic.
|
|
7
|
+
*
|
|
8
|
+
* Source: the deco "d↗" logo (pos-d-logo). Swap this constant to rebrand.
|
|
9
|
+
*/
|
|
10
|
+
export const DECO_MARK_DATA_URI =
|
|
11
|
+
"data:image/png;base64,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";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { DRAFT_COOKIE_OPTIONS, DRAFT_PARAM, decideDraft } from "./draft";
|
|
4
|
+
|
|
5
|
+
function urlWith(param?: string): URL {
|
|
6
|
+
const url = new URL("https://site.example/some/page");
|
|
7
|
+
if (param !== undefined) url.searchParams.set(DRAFT_PARAM, param);
|
|
8
|
+
return url;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("decideDraft", () => {
|
|
12
|
+
it("enters draft mode from the param and sets the cookie", () => {
|
|
13
|
+
expect(decideDraft(urlWith("abc@v1"), null)).toEqual({
|
|
14
|
+
pointer: "abc@v1",
|
|
15
|
+
setCookie: "abc@v1",
|
|
16
|
+
clearCookie: false,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("carries the draft across navigation via the cookie alone", () => {
|
|
21
|
+
// The param is gone — this is the in-preview link click that a
|
|
22
|
+
// param-only design would silently drop back to published.
|
|
23
|
+
expect(decideDraft(urlWith(), "abc@v1")).toEqual({
|
|
24
|
+
pointer: "abc@v1",
|
|
25
|
+
setCookie: null,
|
|
26
|
+
clearCookie: false,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("lets the param override a stale cookie", () => {
|
|
31
|
+
// Studio navigating to a newer version must win over the older pointer
|
|
32
|
+
// still sitting in the cookie, or a save would never be reflected.
|
|
33
|
+
expect(decideDraft(urlWith("abc@v2"), "abc@v1")).toEqual({
|
|
34
|
+
pointer: "abc@v2",
|
|
35
|
+
setCookie: "abc@v2",
|
|
36
|
+
clearCookie: false,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("leaves draft mode on ?__draft=off, even with a cookie set", () => {
|
|
41
|
+
expect(decideDraft(urlWith("off"), "abc@v1")).toEqual({
|
|
42
|
+
pointer: null,
|
|
43
|
+
setCookie: null,
|
|
44
|
+
clearCookie: true,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is inert for an ordinary request", () => {
|
|
49
|
+
expect(decideDraft(urlWith(), null)).toEqual({
|
|
50
|
+
pointer: null,
|
|
51
|
+
setCookie: null,
|
|
52
|
+
clearCookie: false,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("DRAFT_COOKIE_OPTIONS", () => {
|
|
58
|
+
it("is set up to survive a cross-site preview iframe", () => {
|
|
59
|
+
// The preview iframe is cross-site (Studio embeds the production origin),
|
|
60
|
+
// so the cookie is third-party. Without SameSite=None+Secure it is never
|
|
61
|
+
// sent; without Partitioned (CHIPS) it is dropped as browsers wind down
|
|
62
|
+
// unpartitioned third-party cookies. These attributes are load-bearing,
|
|
63
|
+
// not incidental.
|
|
64
|
+
expect(DRAFT_COOKIE_OPTIONS.sameSite).toBe("none");
|
|
65
|
+
expect(DRAFT_COOKIE_OPTIONS.secure).toBe(true);
|
|
66
|
+
expect(DRAFT_COOKIE_OPTIONS.partitioned).toBe(true);
|
|
67
|
+
expect(DRAFT_COOKIE_OPTIONS.httpOnly).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is short-lived so a stale pointer can't pin an old version", () => {
|
|
71
|
+
expect(DRAFT_COOKIE_OPTIONS.maxAge).toBeLessThanOrEqual(60 * 60);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/draft.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft preview — the Next.js binding.
|
|
3
|
+
*
|
|
4
|
+
* `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (pointer
|
|
5
|
+
* parsing, origin construction, fetch, version cache). This file binds a
|
|
6
|
+
* resolved draft to the current request.
|
|
7
|
+
*
|
|
8
|
+
* ## Why React `cache()` and not AsyncLocalStorage
|
|
9
|
+
*
|
|
10
|
+
* App Router never enters `RequestContext.run` (that is a TanStack/Workers
|
|
11
|
+
* path), and ALS cannot help here anyway: you cannot wrap `ALS.run()` around a
|
|
12
|
+
* component's children, because the children render later, outside the call.
|
|
13
|
+
* `cache()` gives a per-request memoized value in RSC, which is exactly the
|
|
14
|
+
* scope needed — verified concurrently, see `draft.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* ## Why this must be awaited by the PAGE, not a layout
|
|
17
|
+
*
|
|
18
|
+
* A layout's `await` does NOT gate its children: App Router renders a layout
|
|
19
|
+
* and its children concurrently, so sections call `loadBlocks()` before a
|
|
20
|
+
* layout-level resolve lands, and silently render published content. The
|
|
21
|
+
* resolve has to happen somewhere that returns the subtree *after* awaiting —
|
|
22
|
+
* i.e. the page component. `ensureDraft()` exists so each site makes one call
|
|
23
|
+
* instead of re-deriving that ordering rule.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
isDraftHostAllowed,
|
|
27
|
+
resolveDraftDecofile,
|
|
28
|
+
setDraftOverrideGetter,
|
|
29
|
+
} from "@decocms/blocks/cms";
|
|
30
|
+
import { cookies, headers } from "next/headers";
|
|
31
|
+
import { cache } from "react";
|
|
32
|
+
import { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
|
|
33
|
+
|
|
34
|
+
// Re-exported for back-compat: these used to live here, but the client badge
|
|
35
|
+
// needs DRAFT_PARAM and cannot import this module (it pulls in `next/headers`).
|
|
36
|
+
export { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Request-scoped slot.
|
|
40
|
+
*
|
|
41
|
+
* `cache()` memoizes per-request, so every call within one request gets the
|
|
42
|
+
* same object and a different one per request. Mutable by design: the page
|
|
43
|
+
* fills it before returning its subtree, and nested sections read it
|
|
44
|
+
* synchronously through `loadBlocks()`.
|
|
45
|
+
*
|
|
46
|
+
* `pointer` is the raw `<host>@<version>` token this render is bound to, kept
|
|
47
|
+
* so the UI can surface an explicit "you are in preview" indicator and build a
|
|
48
|
+
* shareable link — see {@link getActiveDraftPointer}.
|
|
49
|
+
*/
|
|
50
|
+
const draftSlot = cache((): {
|
|
51
|
+
blocks: Record<string, unknown> | null;
|
|
52
|
+
pointer: string | null;
|
|
53
|
+
} => ({
|
|
54
|
+
blocks: null,
|
|
55
|
+
pointer: null,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register the request-scoped getter with the runtime.
|
|
60
|
+
*
|
|
61
|
+
* Idempotent and safe to call at module scope: outside a request `cache()`
|
|
62
|
+
* still returns an object, whose `blocks` is null, so `loadBlocks()` sees no
|
|
63
|
+
* override and behaves exactly as before.
|
|
64
|
+
*/
|
|
65
|
+
let registered = false;
|
|
66
|
+
export function registerDraftOverride(): void {
|
|
67
|
+
if (registered) return;
|
|
68
|
+
registered = true;
|
|
69
|
+
setDraftOverrideGetter(() => draftSlot().blocks);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A page's `searchParams` prop, before it is narrowed. */
|
|
73
|
+
export type DraftSearchParams = Record<string, string | string[] | undefined>;
|
|
74
|
+
|
|
75
|
+
/** First value of a search param that may legitimately repeat. */
|
|
76
|
+
function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null {
|
|
77
|
+
const raw = searchParams?.[key];
|
|
78
|
+
if (Array.isArray(raw)) return raw[0] ?? null;
|
|
79
|
+
return raw ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The pointer for this request: the query param wins, the cookie carries
|
|
84
|
+
* navigation, `off` exits.
|
|
85
|
+
*
|
|
86
|
+
* Read directly from the page rather than trusted from a request header. The
|
|
87
|
+
* param has to take precedence or a save would never surface — Studio
|
|
88
|
+
* navigating to a new version would keep rendering whatever older pointer is
|
|
89
|
+
* still sitting in the cookie.
|
|
90
|
+
*/
|
|
91
|
+
export function selectDraftPointer(
|
|
92
|
+
searchParams: DraftSearchParams | undefined,
|
|
93
|
+
cookieValue: string | null | undefined,
|
|
94
|
+
): string | null {
|
|
95
|
+
const param = firstParam(searchParams, DRAFT_PARAM);
|
|
96
|
+
if (param === "off") return null;
|
|
97
|
+
if (param) return param;
|
|
98
|
+
return cookieValue ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the request's draft, if any, and bind it for the rest of the render.
|
|
103
|
+
*
|
|
104
|
+
* Call this from the PAGE, awaited, before returning the subtree:
|
|
105
|
+
*
|
|
106
|
+
* ```tsx
|
|
107
|
+
* export default async function Page({ searchParams }) {
|
|
108
|
+
* await ensureDraft(await searchParams);
|
|
109
|
+
* return <DecoPageRenderer />;
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* Reads the param from the page's own `searchParams` and the cookie via
|
|
114
|
+
* `cookies()`. There is deliberately no request header in the path: the page
|
|
115
|
+
* owns the decision, so a draft keeps working on routes the middleware matcher
|
|
116
|
+
* never sees, and there is one less forgeable input to reason about. (The
|
|
117
|
+
* pointer was never a secret — the draft id (the token's authority) is the capability — so a
|
|
118
|
+
* client supplying one directly is equivalent to typing the query param.)
|
|
119
|
+
*
|
|
120
|
+
* Returns whether a draft was bound, so callers can surface an explicit
|
|
121
|
+
* "draft unavailable" state instead of silently showing published content —
|
|
122
|
+
* the failure mode most likely to mislead someone reviewing their own edits.
|
|
123
|
+
*/
|
|
124
|
+
export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boolean> {
|
|
125
|
+
registerDraftOverride();
|
|
126
|
+
|
|
127
|
+
const cookieStore = await cookies();
|
|
128
|
+
const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value);
|
|
129
|
+
if (!pointer) return false;
|
|
130
|
+
|
|
131
|
+
// Host gate, checked only once a pointer exists (headers() is a dynamic
|
|
132
|
+
// API): the same build may serve the preview domain and the production
|
|
133
|
+
// domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render
|
|
134
|
+
// drafts — production stays published no matter what the URL carries.
|
|
135
|
+
const requestHeaders = await headers();
|
|
136
|
+
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
|
|
137
|
+
if (!isDraftHostAllowed(host)) return false;
|
|
138
|
+
|
|
139
|
+
const blocks = await resolveDraftDecofile({ pointer });
|
|
140
|
+
if (!blocks) return false;
|
|
141
|
+
|
|
142
|
+
const slot = draftSlot();
|
|
143
|
+
slot.blocks = blocks;
|
|
144
|
+
slot.pointer = pointer;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The raw draft pointer bound to this request, or null if the request is not
|
|
150
|
+
* rendering a draft.
|
|
151
|
+
*
|
|
152
|
+
* Synchronous — reads the same request-scoped slot `ensureDraft` fills, so it
|
|
153
|
+
* must be called AFTER `ensureDraft` has been awaited in this request (i.e.
|
|
154
|
+
* from inside the page subtree, not a concurrently-rendered layout). Powers
|
|
155
|
+
* the preview-mode indicator: a bound pointer is the signal that the visitor
|
|
156
|
+
* is looking at unpublished content, and it is exactly what a "share this
|
|
157
|
+
* draft" link must carry.
|
|
158
|
+
*/
|
|
159
|
+
export function getActiveDraftPointer(): string | null {
|
|
160
|
+
return draftSlot().pointer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Middleware helper
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** What middleware should do with this request. */
|
|
168
|
+
export interface DraftMiddlewareDecision {
|
|
169
|
+
/** The active pointer, or null. Drives the cache/indexing headers. */
|
|
170
|
+
pointer: string | null;
|
|
171
|
+
/** Set the cookie to this value (entering draft mode). */
|
|
172
|
+
setCookie: string | null;
|
|
173
|
+
/** Clear the cookie (leaving draft mode via `?__draft=off`). */
|
|
174
|
+
clearCookie: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decide the draft state for a request, from `?__draft=` and the cookie.
|
|
179
|
+
*
|
|
180
|
+
* The param is authoritative on entry and the cookie carries subsequent
|
|
181
|
+
* in-preview navigation — a param alone dies on the first link click, and a
|
|
182
|
+
* cookie alone cannot be relied on: the preview iframe is cross-site, so the
|
|
183
|
+
* cookie is third-party and may be blocked outright (Safari ITP) or
|
|
184
|
+
* partitioned (Chrome CHIPS). Entry therefore never depends on cookie support.
|
|
185
|
+
*
|
|
186
|
+
* `?__draft=off` leaves draft mode, so a session can be ended deliberately
|
|
187
|
+
* rather than waiting for a cookie to expire.
|
|
188
|
+
*
|
|
189
|
+
* Pure and framework-free so it can be unit-tested without a Next request; the
|
|
190
|
+
* caller applies the decision to its own `NextResponse`.
|
|
191
|
+
*/
|
|
192
|
+
export function decideDraft(
|
|
193
|
+
url: URL,
|
|
194
|
+
cookieValue: string | null | undefined,
|
|
195
|
+
): DraftMiddlewareDecision {
|
|
196
|
+
const param = url.searchParams.get(DRAFT_PARAM);
|
|
197
|
+
|
|
198
|
+
if (param === "off") {
|
|
199
|
+
return { pointer: null, setCookie: null, clearCookie: true };
|
|
200
|
+
}
|
|
201
|
+
if (param) {
|
|
202
|
+
return { pointer: param, setCookie: param, clearCookie: false };
|
|
203
|
+
}
|
|
204
|
+
if (cookieValue) {
|
|
205
|
+
return { pointer: cookieValue, setCookie: null, clearCookie: false };
|
|
206
|
+
}
|
|
207
|
+
return { pointer: null, setCookie: null, clearCookie: false };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cookie attributes for the draft pointer.
|
|
212
|
+
*
|
|
213
|
+
* `SameSite=None; Secure` is mandatory for a cross-site iframe, and
|
|
214
|
+
* `Partitioned` (CHIPS) is what keeps it working as browsers wind down
|
|
215
|
+
* unpartitioned third-party cookies. Short-lived: a draft session is minutes,
|
|
216
|
+
* and a stale pointer would keep pinning an old version.
|
|
217
|
+
*/
|
|
218
|
+
export const DRAFT_COOKIE_OPTIONS = {
|
|
219
|
+
httpOnly: true,
|
|
220
|
+
secure: true,
|
|
221
|
+
sameSite: "none",
|
|
222
|
+
partitioned: true,
|
|
223
|
+
path: "/",
|
|
224
|
+
maxAge: 60 * 30,
|
|
225
|
+
} as const;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DRAFT_COOKIE_NAME, DRAFT_QUERY_PARAM } from "@decocms/blocks/cms";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The draft cookie/param names are wire protocol shared across a package
|
|
8
|
+
* boundary: the client-safe copy here (the badge imports it without pulling
|
|
9
|
+
* `next/headers` into the client bundle) MUST stay identical to the
|
|
10
|
+
* framework-agnostic owner in `@decocms/blocks`, which `/deco/invoke` reads to
|
|
11
|
+
* bind the same draft. If these drift, invoke silently serves published content
|
|
12
|
+
* while the page shows the draft.
|
|
13
|
+
*/
|
|
14
|
+
describe("draft constants parity with @decocms/blocks", () => {
|
|
15
|
+
it("cookie name matches", () => {
|
|
16
|
+
expect(DRAFT_COOKIE).toBe(DRAFT_COOKIE_NAME);
|
|
17
|
+
});
|
|
18
|
+
it("query param matches", () => {
|
|
19
|
+
expect(DRAFT_PARAM).toBe(DRAFT_QUERY_PARAM);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-preview constants with ZERO server imports.
|
|
3
|
+
*
|
|
4
|
+
* `draft.ts` statically imports `next/headers`, so anything reachable from a
|
|
5
|
+
* `"use client"` file must not import from it — that would pull `next/headers`
|
|
6
|
+
* into the client bundle and fail the build. The client-side badge
|
|
7
|
+
* (`DraftPreviewBadge`) needs `DRAFT_PARAM` to build its links, so the shared
|
|
8
|
+
* constants live here and `draft.ts` re-exports them for its own callers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Query param that enters draft mode (and, with `off`, leaves it). */
|
|
12
|
+
export const DRAFT_PARAM = "__draft";
|
|
13
|
+
|
|
14
|
+
/** Cookie that carries the pointer across in-preview navigation. */
|
|
15
|
+
export const DRAFT_COOKIE = "__deco_draft";
|