@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.
Files changed (85) hide show
  1. package/README.md +77 -332
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +35 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +43 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/proxy/index.d.ts +79 -0
  27. package/dist/proxy/index.d.ts.map +1 -0
  28. package/dist/proxy/index.js +92 -0
  29. package/dist/proxy/index.js.map +1 -0
  30. package/dist/server/index.d.ts +46 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +64 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/slot.d.ts +6 -0
  35. package/dist/server/slot.d.ts.map +1 -0
  36. package/dist/server/slot.js +13 -0
  37. package/dist/server/slot.js.map +1 -0
  38. package/dist/studio.d.ts +30 -0
  39. package/dist/studio.d.ts.map +1 -0
  40. package/dist/studio.js +55 -0
  41. package/dist/studio.js.map +1 -0
  42. package/package.json +41 -22
  43. package/src/DraftPreviewIndicator.tsx +17 -16
  44. package/src/StudioBridge.ts +23 -0
  45. package/src/config/index.cjs +78 -0
  46. package/src/config/index.d.cts +27 -0
  47. package/src/draft.ts +30 -210
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -43
  51. package/src/proxy/index.ts +161 -0
  52. package/src/server/index.ts +77 -0
  53. package/src/server/slot.ts +14 -0
  54. package/src/studio.ts +54 -0
  55. package/src/ClientOnlySection.tsx +0 -20
  56. package/src/DecoPageRenderer.test.tsx +0 -37
  57. package/src/DecoPageRenderer.tsx +0 -107
  58. package/src/DecoRootLayout.test.tsx +0 -22
  59. package/src/DecoRootLayout.tsx +0 -82
  60. package/src/DeferredSection.test.tsx +0 -27
  61. package/src/DeferredSection.tsx +0 -59
  62. package/src/DraftPreviewBadge.render.test.tsx +0 -16
  63. package/src/DraftPreviewBadge.test.ts +0 -37
  64. package/src/DraftPreviewBadge.tsx +0 -252
  65. package/src/SectionRenderer.test.tsx +0 -97
  66. package/src/SectionRenderer.tsx +0 -58
  67. package/src/config.cjs +0 -92
  68. package/src/config.d.cts +0 -4
  69. package/src/config.test.ts +0 -86
  70. package/src/createDecoPage.draft.test.tsx +0 -91
  71. package/src/createDecoPage.test.tsx +0 -63
  72. package/src/createDecoPage.tsx +0 -173
  73. package/src/createDecoPreviewPage.test.tsx +0 -71
  74. package/src/createDecoPreviewPage.tsx +0 -108
  75. package/src/decoMark.ts +0 -11
  76. package/src/draft.test.ts +0 -73
  77. package/src/draftConstants.test.ts +0 -21
  78. package/src/draftConstants.ts +0 -15
  79. package/src/draftMiddleware.test.ts +0 -147
  80. package/src/draftMiddleware.ts +0 -136
  81. package/src/routeHandlers.test.ts +0 -243
  82. package/src/routeHandlers.ts +0 -227
  83. package/src/setup.test.ts +0 -253
  84. package/src/setup.ts +0 -180
  85. package/tsconfig.json +0 -7
@@ -1,16 +0,0 @@
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
- });
@@ -1,37 +0,0 @@
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
- });
@@ -1,252 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
1
- import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
2
- import { renderToString } from "react-dom/server";
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
4
- import { SectionRenderer } from "./SectionRenderer";
5
-
6
- function Hero({ label }: { label?: string }) {
7
- return <h1>{`hero-${label ?? "none"}`}</h1>;
8
- }
9
-
10
- // The test environment runs under jsdom (see root vitest.config.ts), where
11
- // `window` is defined. next/dynamic's internals branch on
12
- // `typeof window === "undefined"` to decide server vs. client behavior, so
13
- // under jsdom it always takes the client path — `{ ssr: false }` vs.
14
- // `{ ssr: true }` produce IDENTICAL rendered output here (both effectively
15
- // render `null` during a synchronous renderToString call, since the loader's
16
- // promise can't resolve in time regardless of the ssr option). Asserting on
17
- // rendered HTML would therefore pass even if ClientOnlySection passed
18
- // `{ ssr: true }` by mistake — it exercises no real server/window-undefined
19
- // code path in this environment.
20
- //
21
- // Instead we verify the actual contract ClientOnlySection is responsible
22
- // for: that it calls next/dynamic with `{ ssr: false }`. We spy on the
23
- // `next/dynamic` module import via `vi.mock` + `vi.hoisted`.
24
- const { dynamicSpy } = vi.hoisted(() => ({ dynamicSpy: vi.fn() }));
25
-
26
- vi.mock("next/dynamic", () => ({
27
- default: (loader: unknown, options: unknown) => {
28
- dynamicSpy(loader, options);
29
- return function DynamicStub() {
30
- return null;
31
- };
32
- },
33
- }));
34
-
35
- beforeEach(() => {
36
- dynamicSpy.mockClear();
37
- });
38
-
39
- describe("SectionRenderer (next)", () => {
40
- it("renders a sync-registered section directly", async () => {
41
- const KEY = "site/sections/NextSyncA.tsx";
42
- registerSectionsSync({ [KEY]: { default: Hero } });
43
-
44
- const element = await SectionRenderer({
45
- resolved: { key: KEY, component: KEY, props: { label: "a" } } as any,
46
- });
47
- const html = renderToString(element);
48
- expect(html).toContain("hero-a");
49
- });
50
-
51
- it("awaits a code-split (non-sync) section's loader", async () => {
52
- const KEY = "site/sections/NextLazyA.tsx";
53
- registerSections({ [KEY]: () => Promise.resolve({ default: Hero }) });
54
-
55
- const element = await SectionRenderer({
56
- resolved: { key: KEY, component: KEY, props: { label: "b" } } as any,
57
- });
58
- const html = renderToString(element);
59
- expect(html).toContain("hero-b");
60
- });
61
-
62
- it("routes a clientOnly-registered section through next/dynamic with ssr:false", async () => {
63
- const KEY = "site/sections/NextClientOnlyA.tsx";
64
- const loader = () => Promise.resolve({ default: Hero });
65
- registerSection(KEY, loader, { clientOnly: true });
66
-
67
- const element = await SectionRenderer({
68
- resolved: { key: KEY, component: KEY, props: { label: "c" } } as any,
69
- });
70
- const html = renderToString(element);
71
-
72
- // The actual contract: ClientOnlySection must call next/dynamic with
73
- // { ssr: false } for this section's loader. This fails if the
74
- // implementation is changed to pass { ssr: true } (or omits the option).
75
- expect(dynamicSpy).toHaveBeenCalledWith(loader, { ssr: false });
76
-
77
- // The section shell (wrapping <section> + manifest key) still renders
78
- // server-side regardless of the dynamic child's ssr option.
79
- expect(html).toContain(`data-manifest-key="${KEY}"`);
80
- });
81
-
82
- it("warns and renders nothing for an unregistered section", async () => {
83
- const KEY = "site/sections/NextMissingA.tsx";
84
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
85
-
86
- const element = await SectionRenderer({
87
- resolved: { key: KEY, component: KEY, props: {} } as any,
88
- });
89
-
90
- expect(element).toBeNull();
91
- expect(warnSpy).toHaveBeenCalledWith(
92
- expect.stringContaining(`No component registered for: ${KEY}`),
93
- );
94
-
95
- warnSpy.mockRestore();
96
- });
97
- });
@@ -1,58 +0,0 @@
1
- import type { ResolvedSection } from "@decocms/blocks/cms";
2
- import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms";
3
- import { SectionErrorBoundary } from "@decocms/blocks/hooks";
4
- import { createElement, type ReactNode } from "react";
5
- import { ClientOnlySection } from "./ClientOnlySection";
6
-
7
- interface SectionRendererProps {
8
- resolved: ResolvedSection;
9
- errorFallback?: ReactNode;
10
- }
11
-
12
- /**
13
- * Renders one resolved section. Runs inside an async Server Component tree,
14
- * so code-split sections are loaded via a plain await — no client-side
15
- * React.lazy/Suspense needed for content in the initial response.
16
- */
17
- export async function SectionRenderer({ resolved, errorFallback }: SectionRendererProps) {
18
- const sectionId = resolved.key
19
- .replace(/\//g, "-")
20
- .replace(/\.tsx$/, "")
21
- .replace(/^site-sections-/, "");
22
-
23
- const options = getSectionOptions(resolved.component);
24
- const errFallback = options?.errorFallback
25
- ? createElement(options.errorFallback, { error: new Error("") })
26
- : errorFallback;
27
-
28
- const isClientOnly = options?.clientOnly === true;
29
- const SyncComp = getSyncComponent(resolved.component);
30
- let content: ReactNode;
31
-
32
- if (isClientOnly) {
33
- const loader = getSection(resolved.component);
34
- if (!loader) {
35
- console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
36
- return null;
37
- }
38
- content = <ClientOnlySection loader={loader} props={resolved.props} />;
39
- } else if (SyncComp) {
40
- content = createElement(SyncComp, resolved.props);
41
- } else {
42
- const loader = getSection(resolved.component);
43
- if (!loader) {
44
- console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
45
- return null;
46
- }
47
- const mod = await loader();
48
- content = createElement(mod.default, resolved.props);
49
- }
50
-
51
- return (
52
- <section id={sectionId} data-manifest-key={resolved.key}>
53
- <SectionErrorBoundary sectionKey={resolved.key} fallback={errFallback}>
54
- {content}
55
- </SectionErrorBoundary>
56
- </section>
57
- );
58
- }
package/src/config.cjs DELETED
@@ -1,92 +0,0 @@
1
- /**
2
- * next.config wrapper for Deco sites. CommonJS on purpose: next.config.js
3
- * is CJS in most sites and this package is "type": "module", so a .js
4
- * file here would be ESM and unrequireable on Node < 22.
5
- *
6
- * Adds:
7
- * 1. Rewrites for the Studio-protocol URLs Next cannot express as route
8
- * segments — `/.decofile` (segments can't start with a dot) and
9
- * `/live/_meta` (`_`-prefixed segments are Next "private folders",
10
- * silently excluded from routing) — plus `/live/previews/*`, all
11
- * funneled to `/deco/*` where a single catch-all route
12
- * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves
13
- * the whole protocol.
14
- * 2. transpilePackages for the raw-TS @decocms packages.
15
- * 3. Response headers that keep a draft-preview render out of every cache.
16
- */
17
- const DECO_REWRITES = [
18
- { source: "/.decofile", destination: "/deco/decofile" },
19
- { source: "/live/_meta", destination: "/deco/meta" },
20
- { source: "/live/previews/:path*", destination: "/deco/previews/:path*" },
21
- ];
22
-
23
- const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
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
-
72
- function withDeco(nextConfig = {}) {
73
- const userRewrites = nextConfig.rewrites;
74
- const userHeaders = nextConfig.headers;
75
- return {
76
- ...nextConfig,
77
- transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
78
- async rewrites() {
79
- const user = typeof userRewrites === "function" ? await userRewrites() : (userRewrites ?? []);
80
- if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
81
- return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
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
- },
89
- };
90
- }
91
-
92
- module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS };
package/src/config.d.cts DELETED
@@ -1,4 +0,0 @@
1
- import type { NextConfig } from "next";
2
-
3
- export declare const DECO_REWRITES: Array<{ source: string; destination: string }>;
4
- export declare function withDeco(nextConfig?: NextConfig): NextConfig;
@@ -1,86 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { DECO_REWRITES, withDeco } from "./config.cjs";
3
-
4
- // withDeco()'s declared return type is the standard (loosely typed) Next.js
5
- // NextConfig, whose `rewrites`/`transpilePackages` are optional and
6
- // union-typed. The runtime object always has them (withDeco always sets
7
- // them), so tests assert that with `!` / casts rather than loosening the
8
- // public declaration.
9
- describe("withDeco", () => {
10
- it("adds rewrites and transpilePackages to a bare config", async () => {
11
- const cfg = withDeco({});
12
- expect(cfg.transpilePackages).toEqual(
13
- expect.arrayContaining(["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]),
14
- );
15
- expect(await cfg.rewrites!()).toEqual(DECO_REWRITES);
16
- });
17
-
18
- it("prepends deco rewrites to a user's array-returning rewrites()", async () => {
19
- const cfg = withDeco({
20
- rewrites: async () => [{ source: "/a", destination: "/b" }],
21
- });
22
- const out = (await cfg.rewrites!()) as Array<{ source: string; destination: string }>;
23
- expect(out.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES);
24
- expect(out.at(-1)).toEqual({ source: "/a", destination: "/b" });
25
- });
26
-
27
- it("merges into a user's object-form rewrites via beforeFiles", async () => {
28
- const cfg = withDeco({
29
- rewrites: async () => ({
30
- beforeFiles: [{ source: "/x", destination: "/y" }],
31
- afterFiles: [],
32
- fallback: [],
33
- }),
34
- });
35
- const out = (await cfg.rewrites!()) as {
36
- beforeFiles: Array<{ source: string; destination: string }>;
37
- };
38
- expect(out.beforeFiles.slice(0, DECO_REWRITES.length)).toEqual(DECO_REWRITES);
39
- expect(out.beforeFiles.at(-1)).toEqual({ source: "/x", destination: "/y" });
40
- });
41
-
42
- it("dedupes transpilePackages", () => {
43
- const cfg = withDeco({ transpilePackages: ["@decocms/blocks", "other"] });
44
- expect(cfg.transpilePackages!.filter((p: string) => p === "@decocms/blocks")).toHaveLength(1);
45
- expect(cfg.transpilePackages).toContain("other");
46
- });
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
- });