@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"./config": {
|
|
20
20
|
"types": "./src/config.d.cts",
|
|
21
21
|
"default": "./src/config.cjs"
|
|
22
|
-
}
|
|
22
|
+
},
|
|
23
|
+
"./middleware": "./src/draftMiddleware.ts"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc",
|
|
@@ -28,8 +29,8 @@
|
|
|
28
29
|
"lint:unused": "knip"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@decocms/blocks": "
|
|
32
|
-
"@decocms/blocks-admin": "
|
|
32
|
+
"@decocms/blocks": "8.0.0",
|
|
33
|
+
"@decocms/blocks-admin": "8.0.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
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 {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { DraftPreviewBadge } from "./DraftPreviewBadge";
|
|
5
|
+
|
|
6
|
+
describe("DraftPreviewBadge (render)", () => {
|
|
7
|
+
it("surfaces the preview-mode label so a reviewer can't mistake a draft for live", () => {
|
|
8
|
+
const html = renderToString(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
|
|
9
|
+
expect(html).toContain("Preview mode");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("embeds the deco mark inline (no external asset that could 404 on a consumer site)", () => {
|
|
13
|
+
const html = renderToString(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
|
|
14
|
+
expect(html).toContain("data:image/png;base64,");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildExitUrl, buildShareUrl } from "./DraftPreviewBadge";
|
|
4
|
+
|
|
5
|
+
describe("buildShareUrl", () => {
|
|
6
|
+
it("pins the exact draft version so a recipient sees the same working tree", () => {
|
|
7
|
+
expect(buildShareUrl("https://fila.vtex.app/pdp/shoe", "abc.localhost:60534@v7")).toBe(
|
|
8
|
+
"https://fila.vtex.app/pdp/shoe?__draft=abc.localhost%3A60534%40v7",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("preserves existing query params, only setting the draft token", () => {
|
|
13
|
+
expect(buildShareUrl("https://fila.vtex.app/plp?sort=price", "h@v1")).toBe(
|
|
14
|
+
"https://fila.vtex.app/plp?sort=price&__draft=h%40v1",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("replaces a stale token already in the URL rather than appending a second", () => {
|
|
19
|
+
expect(buildShareUrl("https://fila.vtex.app/p?__draft=h@old", "h@new")).toBe(
|
|
20
|
+
"https://fila.vtex.app/p?__draft=h%40new",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("buildExitUrl", () => {
|
|
26
|
+
it("sets the ?__draft=off exit sentinel", () => {
|
|
27
|
+
expect(buildExitUrl("https://fila.vtex.app/pdp/shoe")).toBe(
|
|
28
|
+
"https://fila.vtex.app/pdp/shoe?__draft=off",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("overrides an active token with off (leaves preview from anywhere)", () => {
|
|
33
|
+
expect(buildExitUrl("https://fila.vtex.app/p?__draft=h@v1")).toBe(
|
|
34
|
+
"https://fila.vtex.app/p?__draft=off",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preview-mode indicator.
|
|
5
|
+
*
|
|
6
|
+
* The draft-preview flow (see `draft.ts`) carries the pointer in a cookie
|
|
7
|
+
* across in-preview navigation, so after the first click the URL no longer
|
|
8
|
+
* shows `?__draft=` — a reviewer can forget they are looking at unpublished
|
|
9
|
+
* content and mistake it for what is live. This badge is the always-on signal:
|
|
10
|
+
* whenever a draft is bound it floats over the page, and clicking it offers the
|
|
11
|
+
* two things a reviewer actually needs — leave preview, or hand the exact draft
|
|
12
|
+
* version to someone else.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately self-contained: `"use client"`, inline styles (no dependency on
|
|
15
|
+
* the host site's CSS/Tailwind), and a very high z-index, so it renders the
|
|
16
|
+
* same on any consumer site. Only mounted when a draft is active
|
|
17
|
+
* (`DraftPreviewIndicator` gates it), so it never costs ordinary traffic.
|
|
18
|
+
*/
|
|
19
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
20
|
+
import { DECO_MARK_DATA_URI } from "./decoMark";
|
|
21
|
+
import { DRAFT_PARAM } from "./draftConstants";
|
|
22
|
+
|
|
23
|
+
export interface DraftPreviewBadgeProps {
|
|
24
|
+
/** The `<host>@<version>` token this render is bound to. */
|
|
25
|
+
pointer: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* URL that re-enters this exact draft version — what "Share Preview Link"
|
|
30
|
+
* copies. Pins the version (the token carries the content ETag), so the
|
|
31
|
+
* recipient sees the same working-tree state, not merely "some draft".
|
|
32
|
+
*
|
|
33
|
+
* Pure and exported for tests: builds off a full href so it needs no DOM.
|
|
34
|
+
*/
|
|
35
|
+
export function buildShareUrl(href: string, pointer: string): string {
|
|
36
|
+
const url = new URL(href);
|
|
37
|
+
url.searchParams.set(DRAFT_PARAM, pointer);
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* URL that leaves preview mode — `?__draft=off`, the exit sentinel the
|
|
43
|
+
* middleware/page pair understands (it clears the cookie and renders
|
|
44
|
+
* published). Pure and exported for tests.
|
|
45
|
+
*/
|
|
46
|
+
export function buildExitUrl(href: string): string {
|
|
47
|
+
const url = new URL(href);
|
|
48
|
+
url.searchParams.set(DRAFT_PARAM, "off");
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DECO_GREEN = "#0b3d1e";
|
|
53
|
+
/** Toast background — the deco lime. Text sits on it in dark green for contrast. */
|
|
54
|
+
const DECO_LIME = "#d0ec1a";
|
|
55
|
+
|
|
56
|
+
/** Leaving preview: an arrow back to the published site. */
|
|
57
|
+
function ExitIcon() {
|
|
58
|
+
return (
|
|
59
|
+
<svg
|
|
60
|
+
width={14}
|
|
61
|
+
height={14}
|
|
62
|
+
viewBox="0 0 24 24"
|
|
63
|
+
fill="none"
|
|
64
|
+
stroke="currentColor"
|
|
65
|
+
strokeWidth={2}
|
|
66
|
+
strokeLinecap="round"
|
|
67
|
+
strokeLinejoin="round"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
>
|
|
70
|
+
<path d="M19 12H5" />
|
|
71
|
+
<path d="M12 19l-7-7 7-7" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Sharing the draft: the classic three-node share glyph. */
|
|
77
|
+
function ShareIcon() {
|
|
78
|
+
return (
|
|
79
|
+
<svg
|
|
80
|
+
width={14}
|
|
81
|
+
height={14}
|
|
82
|
+
viewBox="0 0 24 24"
|
|
83
|
+
fill="none"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
strokeWidth={2}
|
|
86
|
+
strokeLinecap="round"
|
|
87
|
+
strokeLinejoin="round"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
>
|
|
90
|
+
<circle cx={18} cy={5} r={3} />
|
|
91
|
+
<circle cx={6} cy={12} r={3} />
|
|
92
|
+
<circle cx={18} cy={19} r={3} />
|
|
93
|
+
<path d="M8.59 13.51l6.83 3.98" />
|
|
94
|
+
<path d="M15.41 6.51l-6.82 3.98" />
|
|
95
|
+
</svg>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function DraftPreviewBadge({ pointer }: DraftPreviewBadgeProps) {
|
|
100
|
+
const [open, setOpen] = useState(false);
|
|
101
|
+
const [copied, setCopied] = useState(false);
|
|
102
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
103
|
+
|
|
104
|
+
// Dismiss on outside click or Escape — standard popover behaviour, and it
|
|
105
|
+
// keeps the badge from trapping focus on a reviewer's own page.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open) return;
|
|
108
|
+
const onPointerDown = (e: MouseEvent) => {
|
|
109
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
|
110
|
+
};
|
|
111
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
112
|
+
if (e.key === "Escape") setOpen(false);
|
|
113
|
+
};
|
|
114
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
115
|
+
document.addEventListener("keydown", onKeyDown);
|
|
116
|
+
return () => {
|
|
117
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
118
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
119
|
+
};
|
|
120
|
+
}, [open]);
|
|
121
|
+
|
|
122
|
+
const exitPreview = useCallback(() => {
|
|
123
|
+
window.location.href = buildExitUrl(window.location.href);
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const sharePreviewLink = useCallback(async () => {
|
|
127
|
+
const link = buildShareUrl(window.location.href, pointer);
|
|
128
|
+
try {
|
|
129
|
+
await navigator.clipboard.writeText(link);
|
|
130
|
+
setCopied(true);
|
|
131
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
132
|
+
} catch {
|
|
133
|
+
// Clipboard blocked (insecure context / permissions): fall back to a
|
|
134
|
+
// prompt so the reviewer can still copy the link by hand rather than
|
|
135
|
+
// silently doing nothing.
|
|
136
|
+
window.prompt("Copy preview link:", link);
|
|
137
|
+
}
|
|
138
|
+
}, [pointer]);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
ref={rootRef}
|
|
143
|
+
data-deco-preview-badge=""
|
|
144
|
+
style={{
|
|
145
|
+
position: "fixed",
|
|
146
|
+
bottom: 16,
|
|
147
|
+
left: 16,
|
|
148
|
+
zIndex: 2147483647,
|
|
149
|
+
fontFamily:
|
|
150
|
+
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
151
|
+
fontSize: 13,
|
|
152
|
+
lineHeight: 1.4,
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{open ? (
|
|
156
|
+
<div
|
|
157
|
+
role="menu"
|
|
158
|
+
style={{
|
|
159
|
+
position: "absolute",
|
|
160
|
+
bottom: "calc(100% + 8px)",
|
|
161
|
+
left: 0,
|
|
162
|
+
minWidth: 200,
|
|
163
|
+
background: "#fff",
|
|
164
|
+
color: "#1a1a1a",
|
|
165
|
+
borderRadius: 12,
|
|
166
|
+
boxShadow: "0 8px 30px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.05)",
|
|
167
|
+
overflow: "hidden",
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<MenuItem onClick={exitPreview} label="Exit preview" icon={<ExitIcon />} />
|
|
171
|
+
<div style={{ height: 1, background: "rgba(0,0,0,0.07)" }} />
|
|
172
|
+
<MenuItem
|
|
173
|
+
onClick={sharePreviewLink}
|
|
174
|
+
label={copied ? "Copied!" : "Share preview"}
|
|
175
|
+
icon={<ShareIcon />}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
) : null}
|
|
179
|
+
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
aria-expanded={open}
|
|
183
|
+
aria-label="Preview mode"
|
|
184
|
+
onClick={() => setOpen((v) => !v)}
|
|
185
|
+
style={{
|
|
186
|
+
display: "inline-flex",
|
|
187
|
+
alignItems: "center",
|
|
188
|
+
gap: 8,
|
|
189
|
+
padding: "7px 12px 7px 8px",
|
|
190
|
+
background: DECO_LIME,
|
|
191
|
+
color: DECO_GREEN,
|
|
192
|
+
border: "none",
|
|
193
|
+
borderRadius: 9999,
|
|
194
|
+
cursor: "pointer",
|
|
195
|
+
fontSize: 13,
|
|
196
|
+
fontWeight: 600,
|
|
197
|
+
boxShadow: "0 4px 14px rgba(0,0,0,0.25)",
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<img
|
|
201
|
+
src={DECO_MARK_DATA_URI}
|
|
202
|
+
alt=""
|
|
203
|
+
width={18}
|
|
204
|
+
height={18}
|
|
205
|
+
style={{ display: "block", borderRadius: 4 }}
|
|
206
|
+
/>
|
|
207
|
+
<span>Preview mode</span>
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function MenuItem({
|
|
214
|
+
onClick,
|
|
215
|
+
label,
|
|
216
|
+
icon,
|
|
217
|
+
}: {
|
|
218
|
+
onClick: () => void;
|
|
219
|
+
label: string;
|
|
220
|
+
icon: ReactNode;
|
|
221
|
+
}) {
|
|
222
|
+
return (
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
role="menuitem"
|
|
226
|
+
onClick={onClick}
|
|
227
|
+
style={{
|
|
228
|
+
display: "flex",
|
|
229
|
+
alignItems: "center",
|
|
230
|
+
gap: 10,
|
|
231
|
+
width: "100%",
|
|
232
|
+
textAlign: "left",
|
|
233
|
+
padding: "10px 14px",
|
|
234
|
+
background: "transparent",
|
|
235
|
+
border: "none",
|
|
236
|
+
color: "inherit",
|
|
237
|
+
cursor: "pointer",
|
|
238
|
+
fontSize: 13,
|
|
239
|
+
whiteSpace: "nowrap",
|
|
240
|
+
}}
|
|
241
|
+
onMouseEnter={(e) => {
|
|
242
|
+
e.currentTarget.style.background = "rgba(0,0,0,0.05)";
|
|
243
|
+
}}
|
|
244
|
+
onMouseLeave={(e) => {
|
|
245
|
+
e.currentTarget.style.background = "transparent";
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<span style={{ display: "inline-flex", flexShrink: 0, color: DECO_GREEN }}>{icon}</span>
|
|
249
|
+
<span>{label}</span>
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side gate for the preview-mode badge.
|
|
3
|
+
*
|
|
4
|
+
* A Server Component (no `"use client"`) so it can read the request-scoped
|
|
5
|
+
* draft pointer via `getActiveDraftPointer()` and mount the client badge ONLY
|
|
6
|
+
* when a draft is actually bound — an unconfigured/published request renders
|
|
7
|
+
* nothing at all.
|
|
8
|
+
*
|
|
9
|
+
* Placement rule (inherited from `ensureDraft`): render this INSIDE the page
|
|
10
|
+
* subtree, after `ensureDraft` has been awaited — never from a layout, whose
|
|
11
|
+
* children render concurrently with the page that fills the slot. Sites using
|
|
12
|
+
* `createDecoPage` get it wired automatically; hand-rolled routes drop
|
|
13
|
+
* `<DraftPreviewIndicator />` at the end of their page.
|
|
14
|
+
*/
|
|
15
|
+
import { DraftPreviewBadge } from "./DraftPreviewBadge";
|
|
16
|
+
import { getActiveDraftPointer } from "./draft";
|
|
17
|
+
|
|
18
|
+
export function DraftPreviewIndicator() {
|
|
19
|
+
const pointer = getActiveDraftPointer();
|
|
20
|
+
if (!pointer) return null;
|
|
21
|
+
return <DraftPreviewBadge pointer={pointer} />;
|
|
22
|
+
}
|
|
@@ -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 {
|
package/src/config.cjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves
|
|
13
13
|
* the whole protocol.
|
|
14
14
|
* 2. transpilePackages for the raw-TS @decocms packages.
|
|
15
|
+
* 3. Response headers that keep a draft-preview render out of every cache.
|
|
15
16
|
*/
|
|
16
17
|
const DECO_REWRITES = [
|
|
17
18
|
{ source: "/.decofile", destination: "/deco/decofile" },
|
|
@@ -21,8 +22,56 @@ const DECO_REWRITES = [
|
|
|
21
22
|
|
|
22
23
|
const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A draft render must never be cached or indexed: with the pointer in a cookie
|
|
27
|
+
* it shares a URL with the published page, so a shared cache keyed on URL alone
|
|
28
|
+
* would serve unpublished content to a real visitor.
|
|
29
|
+
*
|
|
30
|
+
* Two rules, not one: `has` entries within a rule are ANDed, and the two
|
|
31
|
+
* signals never coincide. On ENTRY the pointer is in the query and the cookie
|
|
32
|
+
* has not been set yet (middleware sets it in the response); on every
|
|
33
|
+
* navigation after that it is the cookie and the query is gone.
|
|
34
|
+
*
|
|
35
|
+
* MEASURED LIMIT — do not assume `no-store` is in force. On a *dynamic* App
|
|
36
|
+
* Router response Next reserves `Cache-Control` and `Vary` and overwrites
|
|
37
|
+
* whatever we set, from here AND from middleware: the wire shows
|
|
38
|
+
* `Cache-Control: no-cache, must-revalidate` and
|
|
39
|
+
* `Vary: rsc, next-router-state-tree, …`. Verified that these rules do match
|
|
40
|
+
* (a marker header on the same rules came through on both signals and was
|
|
41
|
+
* correctly absent otherwise) — it is those two header names specifically that
|
|
42
|
+
* Next owns. `X-Robots-Tag` does get through.
|
|
43
|
+
*
|
|
44
|
+
* Why that is still safe: `no-cache` is not "don't cache", it is "never reuse
|
|
45
|
+
* without revalidating with the origin", so a compliant shared cache cannot
|
|
46
|
+
* serve a stored draft to a different visitor — it has to ask us first, and we
|
|
47
|
+
* answer per-request. That also covers the loss of `Vary: Cookie`. The residual
|
|
48
|
+
* risk is a cache that ignores `no-cache`; closing that needs a CDN-level rule,
|
|
49
|
+
* which is a deployment concern rather than something this package can set.
|
|
50
|
+
* `Cache-Control`/`Vary` are kept here so the intent is explicit and so they
|
|
51
|
+
* apply anywhere Next is not overriding them.
|
|
52
|
+
*/
|
|
53
|
+
const DRAFT_NO_STORE_HEADERS = [
|
|
54
|
+
{ key: "Cache-Control", value: "no-store, private" },
|
|
55
|
+
{ key: "Vary", value: "Cookie" },
|
|
56
|
+
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const DECO_DRAFT_HEADERS = [
|
|
60
|
+
{
|
|
61
|
+
source: "/:path*",
|
|
62
|
+
has: [{ type: "query", key: "__draft" }],
|
|
63
|
+
headers: DRAFT_NO_STORE_HEADERS,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
source: "/:path*",
|
|
67
|
+
has: [{ type: "cookie", key: "__deco_draft" }],
|
|
68
|
+
headers: DRAFT_NO_STORE_HEADERS,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
24
72
|
function withDeco(nextConfig = {}) {
|
|
25
73
|
const userRewrites = nextConfig.rewrites;
|
|
74
|
+
const userHeaders = nextConfig.headers;
|
|
26
75
|
return {
|
|
27
76
|
...nextConfig,
|
|
28
77
|
transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
|
|
@@ -31,7 +80,13 @@ function withDeco(nextConfig = {}) {
|
|
|
31
80
|
if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
|
|
32
81
|
return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
|
|
33
82
|
},
|
|
83
|
+
async headers() {
|
|
84
|
+
const user = typeof userHeaders === "function" ? await userHeaders() : (userHeaders ?? []);
|
|
85
|
+
// Draft rules first: a site's own catch-all Cache-Control must not win
|
|
86
|
+
// over the one thing standing between a draft and a shared cache.
|
|
87
|
+
return [...DECO_DRAFT_HEADERS, ...user];
|
|
88
|
+
},
|
|
34
89
|
};
|
|
35
90
|
}
|
|
36
91
|
|
|
37
|
-
module.exports = { withDeco, DECO_REWRITES };
|
|
92
|
+
module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS };
|
package/src/config.test.ts
CHANGED
|
@@ -45,3 +45,42 @@ describe("withDeco", () => {
|
|
|
45
45
|
expect(cfg.transpilePackages).toContain("other");
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("withDeco draft headers", () => {
|
|
50
|
+
it("adds no-store/no-index rules for both draft signals", async () => {
|
|
51
|
+
const headers = await withDeco({}).headers!();
|
|
52
|
+
// Two rules, not one: `has` entries within a rule are ANDed, and the query
|
|
53
|
+
// (entry) and cookie (navigation) signals never coincide.
|
|
54
|
+
expect(headers).toHaveLength(2);
|
|
55
|
+
expect(headers[0].has).toEqual([{ type: "query", key: "__draft" }]);
|
|
56
|
+
expect(headers[1].has).toEqual([{ type: "cookie", key: "__deco_draft" }]);
|
|
57
|
+
|
|
58
|
+
for (const rule of headers) {
|
|
59
|
+
const byKey = Object.fromEntries(rule.headers.map((h) => [h.key, h.value]));
|
|
60
|
+
// `no-store` is what actually stops a shared cache serving unpublished
|
|
61
|
+
// content to a real visitor — the pointer being a cookie means draft and
|
|
62
|
+
// published share a URL.
|
|
63
|
+
expect(byKey["Cache-Control"]).toBe("no-store, private");
|
|
64
|
+
expect(byKey.Vary).toBe("Cookie");
|
|
65
|
+
expect(byKey["X-Robots-Tag"]).toBe("noindex, nofollow");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps a site's own headers, with the draft rules taking precedence", async () => {
|
|
70
|
+
const cfg = withDeco({
|
|
71
|
+
headers: async () => [
|
|
72
|
+
{ source: "/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const headers = await cfg.headers!();
|
|
76
|
+
expect(headers).toHaveLength(3);
|
|
77
|
+
// A site's catch-all cache policy must not out-rank the draft rules.
|
|
78
|
+
expect(headers[0].has).toBeDefined();
|
|
79
|
+
expect(headers[1].has).toBeDefined();
|
|
80
|
+
expect(headers[2].headers[0].value).toBe("public, max-age=3600");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("leaves a site without headers() untouched apart from the draft rules", async () => {
|
|
84
|
+
expect(await withDeco({}).headers!()).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|