@decocms/nextjs 8.1.0-next.0 → 8.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -330
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +35 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +43 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy/index.d.ts +79 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +92 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +41 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +30 -216
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -45
- package/src/proxy/index.ts +161 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.tsx +0 -16
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/__conformance__/observability.test.ts +0 -66
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -30
- package/src/draftMiddleware.test.ts +0 -179
- package/src/draftMiddleware.ts +0 -163
- package/src/draftShell.test.ts +0 -76
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
package/src/createDecoPage.tsx
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type DecoPageResult,
|
|
3
|
-
extractSeoFromProps,
|
|
4
|
-
extractSeoFromSections,
|
|
5
|
-
isDraftPreviewEnabled,
|
|
6
|
-
type PageSeo,
|
|
7
|
-
resolveDecoPage,
|
|
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";
|
|
13
|
-
import { DecoPageRenderer } from "./DecoPageRenderer";
|
|
14
|
-
import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
|
|
15
|
-
import { type DraftSearchParams, ensureDraft } from "./draft";
|
|
16
|
-
|
|
17
|
-
interface CreateDecoPageOptions {
|
|
18
|
-
siteName: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface PageProps {
|
|
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>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function pathFromSlug(slug: string[] | undefined): string {
|
|
32
|
-
return `/${(slug ?? []).join("/")}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Merge page-level SEO (the `seo` block resolved into `DecoPageResult.seoSection`)
|
|
37
|
-
* with section-contributed SEO (sections registered via `registerSeoSections`
|
|
38
|
-
* whose resolved props also carry SEO fields — e.g. a PDP SEO section). Page
|
|
39
|
-
* level fields win on conflict, mirroring `buildPageSeo` in the TanStack
|
|
40
|
-
* binding (packages/tanstack/src/routes/cmsRoute.ts:408-484).
|
|
41
|
-
*
|
|
42
|
-
* Deliberately narrower than that binding's version: it does NOT run
|
|
43
|
-
* `seoSection` through its own section loader (so commerce-loader-backed
|
|
44
|
-
* jsonLD on the seo block won't resolve here) and does NOT fall back to
|
|
45
|
-
* site-wide SEO defaults or apply title/description templates. Those are
|
|
46
|
-
* out of scope for this minimal page.tsx wiring.
|
|
47
|
-
*/
|
|
48
|
-
function buildSeo(page: DecoPageResult): PageSeo {
|
|
49
|
-
const sectionSeo = extractSeoFromSections(page.resolvedSections);
|
|
50
|
-
const pageSeo = page.seoSection ? extractSeoFromProps(page.seoSection.props) : {};
|
|
51
|
-
return { ...sectionSeo, ...pageSeo };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Creates the { generateMetadata, default } pair a site spreads into
|
|
56
|
-
* app/[[...slug]]/page.tsx. Mirrors `@decocms/tanstack`'s `cmsRouteConfig`.
|
|
57
|
-
*
|
|
58
|
-
* `resolveForPath` is wrapped in React's `cache()` so `generateMetadata` and
|
|
59
|
-
* the page body share one `resolveDecoPage` call per request instead of
|
|
60
|
-
* resolving twice — the same pattern a production Next.js site's own
|
|
61
|
-
* `resolveCmsPageByPath` already used against the old /next tier. This relies
|
|
62
|
-
* on Next's RSC renderer establishing a per-request cache boundary that
|
|
63
|
-
* `cache()` memoizes against: calling the returned functions directly outside
|
|
64
|
-
* that renderer (e.g. in a plain unit test) will NOT dedupe, since there is
|
|
65
|
-
* no active cache boundary for `cache()` to key off — verified empirically
|
|
66
|
-
* against this repo's `react` version. The functions still return correct,
|
|
67
|
-
* independent results either way; only the single-request sharing is
|
|
68
|
-
* untestable outside Next's own pipeline.
|
|
69
|
-
*/
|
|
70
|
-
export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
71
|
-
// `siteName` is unused here. `MatcherContext` (packages/blocks/src/cms/resolve.ts)
|
|
72
|
-
// has no siteName field, so there's nothing to thread it into `resolveDecoPage`.
|
|
73
|
-
// It's kept in the options shape to mirror `cmsRouteConfig({ siteName })`'s call
|
|
74
|
-
// signature and as the extension point for Task 7's root layout
|
|
75
|
-
// (LiveControls/analytics wiring) — genuinely unused in this file.
|
|
76
|
-
void siteName;
|
|
77
|
-
|
|
78
|
-
const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
|
|
79
|
-
|
|
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> {
|
|
120
|
-
const { slug } = await params;
|
|
121
|
-
await bindDraftOnce(await searchParams);
|
|
122
|
-
const page = await resolveForPath(pathFromSlug(slug));
|
|
123
|
-
if (!page) return {};
|
|
124
|
-
|
|
125
|
-
const seo = buildSeo(page);
|
|
126
|
-
return {
|
|
127
|
-
title: seo.title,
|
|
128
|
-
description: seo.description,
|
|
129
|
-
alternates: seo.canonical ? { canonical: seo.canonical } : undefined,
|
|
130
|
-
robots: seo.noIndexing ? { index: false, follow: false } : undefined,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function Page({ params, searchParams }: PageProps) {
|
|
135
|
-
const { slug } = await params;
|
|
136
|
-
const pathname = pathFromSlug(slug);
|
|
137
|
-
// Before resolveForPath — see bindDraftOnce.
|
|
138
|
-
const isDraft = await bindDraftOnce(await searchParams);
|
|
139
|
-
const page = await resolveForPath(pathname);
|
|
140
|
-
if (!page) notFound();
|
|
141
|
-
|
|
142
|
-
// Call DecoPageRenderer directly (await its result) rather than nesting
|
|
143
|
-
// it as `<DecoPageRenderer .../>` JSX. DecoPageRenderer.tsx documents why:
|
|
144
|
-
// it's an async function, and Next's real RSC renderer awaits async
|
|
145
|
-
// components anywhere in the tree — but react-dom/server's synchronous
|
|
146
|
-
// renderer (used by this package's unit tests, and by any consumer not
|
|
147
|
-
// going through Next's RSC pipeline) throws when an async component
|
|
148
|
-
// suspends outside a <Suspense> boundary. Awaiting it here directly
|
|
149
|
-
// keeps both paths working, matching the same convention
|
|
150
|
-
// DecoPageRenderer itself uses for SectionRenderer.
|
|
151
|
-
const content = await DecoPageRenderer({
|
|
152
|
-
sections: page.resolvedSections,
|
|
153
|
-
deferredSections: page.deferredSections,
|
|
154
|
-
pagePath: pathname,
|
|
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
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return { generateMetadata, default: Page };
|
|
173
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
|
|
3
|
-
import { registerSection, setBlocks } from "@decocms/blocks/cms";
|
|
4
|
-
import { setRenderShell } from "@decocms/blocks-admin";
|
|
5
|
-
import { renderToString } from "react-dom/server";
|
|
6
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import { createDecoPreviewPage } from "./createDecoPreviewPage";
|
|
8
|
-
import * as nextjs from "./index";
|
|
9
|
-
|
|
10
|
-
vi.mock("next/headers", () => ({
|
|
11
|
-
headers: vi.fn(
|
|
12
|
-
async () =>
|
|
13
|
-
new Headers({
|
|
14
|
-
host: "store.test",
|
|
15
|
-
"x-forwarded-proto": "https",
|
|
16
|
-
"user-agent": "preview-test",
|
|
17
|
-
}),
|
|
18
|
-
),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const HERO = "site/sections/PreviewHero.tsx";
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
setBlocks({
|
|
25
|
-
"Preview Hero": { __resolveType: HERO, label: "rsc" },
|
|
26
|
-
});
|
|
27
|
-
registerSection(HERO, async () => ({
|
|
28
|
-
default: ({ label }: { label?: string }) => <h1>{`hero-${label}`}</h1>,
|
|
29
|
-
}));
|
|
30
|
-
setRenderShell({
|
|
31
|
-
css: "/preview.css",
|
|
32
|
-
fonts: ["/font.css"],
|
|
33
|
-
theme: "light",
|
|
34
|
-
bodyClass: "preview-body",
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("createDecoPreviewPage", () => {
|
|
39
|
-
it("is exposed by the Next.js binding", () => {
|
|
40
|
-
expect((nextjs as Record<string, unknown>).createDecoPreviewPage).toBeTypeOf("function");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("resolves and renders a section through the RSC-aware SectionRenderer", async () => {
|
|
44
|
-
const setup = vi.fn(async () => {});
|
|
45
|
-
const Page = createDecoPreviewPage({ setup });
|
|
46
|
-
const element = await Page({
|
|
47
|
-
params: Promise.resolve({ path: ["Preview Hero"] }),
|
|
48
|
-
searchParams: Promise.resolve({ deviceHint: "desktop" }),
|
|
49
|
-
});
|
|
50
|
-
const html = renderToString(element);
|
|
51
|
-
|
|
52
|
-
expect(setup).toHaveBeenCalledOnce();
|
|
53
|
-
expect(html).toContain("hero-rsc");
|
|
54
|
-
expect(html).toContain('data-manifest-key="site/sections/PreviewHero.tsx"');
|
|
55
|
-
expect(html).toContain('href="/preview.css"');
|
|
56
|
-
expect(html).toContain('href="/font.css"');
|
|
57
|
-
expect(html).toContain('data-theme="light"');
|
|
58
|
-
expect(html).toContain("preview-body");
|
|
59
|
-
expect(html).toContain("editor::inject");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("renders a visible diagnostic for an unknown section", async () => {
|
|
63
|
-
const Page = createDecoPreviewPage();
|
|
64
|
-
const element = await Page({
|
|
65
|
-
params: Promise.resolve({ path: ["Missing"] }),
|
|
66
|
-
searchParams: Promise.resolve({}),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
expect(renderToString(element)).toContain("Unknown section: Missing");
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { LIVE_CONTROLS_SCRIPT, resolvePreviewRequest } from "@decocms/blocks-admin";
|
|
2
|
-
import { getPreviewWrapper, getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
|
|
3
|
-
import { headers } from "next/headers";
|
|
4
|
-
import { cloneElement, createElement, type ReactElement, type ReactNode } from "react";
|
|
5
|
-
import { SectionRenderer } from "./SectionRenderer";
|
|
6
|
-
|
|
7
|
-
export interface CreateDecoPreviewPageOptions {
|
|
8
|
-
setup?: () => Promise<void>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface DecoPreviewPageProps {
|
|
12
|
-
params: Promise<{ path?: string[] }>;
|
|
13
|
-
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function appendSearchParams(
|
|
17
|
-
target: URLSearchParams,
|
|
18
|
-
source: Record<string, string | string[] | undefined>,
|
|
19
|
-
) {
|
|
20
|
-
for (const [key, value] of Object.entries(source)) {
|
|
21
|
-
if (Array.isArray(value)) {
|
|
22
|
-
for (const item of value) target.append(key, item);
|
|
23
|
-
} else if (value !== undefined) {
|
|
24
|
-
target.set(key, value);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function buildPreviewRequest(props: DecoPreviewPageProps): Promise<Request> {
|
|
30
|
-
const [params, searchParams, requestHeaders] = await Promise.all([
|
|
31
|
-
props.params,
|
|
32
|
-
props.searchParams,
|
|
33
|
-
headers(),
|
|
34
|
-
]);
|
|
35
|
-
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
|
|
36
|
-
const protocol = requestHeaders.get("x-forwarded-proto") ?? "http";
|
|
37
|
-
const encodedPath = (params.path ?? []).map(encodeURIComponent).join("/");
|
|
38
|
-
const url = new URL(`/live/previews/${encodedPath}`, `${protocol}://${host}`);
|
|
39
|
-
appendSearchParams(url.searchParams, searchParams);
|
|
40
|
-
return new Request(url, { headers: new Headers(requestHeaders) });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function PreviewResources() {
|
|
44
|
-
const { cssHref, fontHrefs } = getRenderShellConfig();
|
|
45
|
-
return (
|
|
46
|
-
<>
|
|
47
|
-
{fontHrefs.map((href) => (
|
|
48
|
-
<link key={href} rel="stylesheet" href={href} />
|
|
49
|
-
))}
|
|
50
|
-
{cssHref ? <link rel="stylesheet" href={cssHref} /> : null}
|
|
51
|
-
</>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function PreviewFrame({ children }: { children: ReactNode }) {
|
|
56
|
-
const { themeName, bodyClass } = getRenderShellConfig();
|
|
57
|
-
const Wrapper = getPreviewWrapper();
|
|
58
|
-
const content = Wrapper ? createElement(Wrapper, null, children) : children;
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<>
|
|
62
|
-
<PreviewResources />
|
|
63
|
-
<div data-theme={themeName || "light"} className={bodyClass || undefined}>
|
|
64
|
-
{content}
|
|
65
|
-
</div>
|
|
66
|
-
<script dangerouslySetInnerHTML={{ __html: LIVE_CONTROLS_SCRIPT }} />
|
|
67
|
-
</>
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function PreviewDiagnostic({ children }: { children: ReactNode }) {
|
|
72
|
-
return <div style={{ padding: 20, color: "red" }}>{children}</div>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function createDecoPreviewPage(options: CreateDecoPreviewPageOptions = {}) {
|
|
76
|
-
return async function DecoPreviewPage(props: DecoPreviewPageProps) {
|
|
77
|
-
await options.setup?.();
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const request = await buildPreviewRequest(props);
|
|
81
|
-
const resolution = await resolvePreviewRequest(request);
|
|
82
|
-
if (resolution.type === "unknown") {
|
|
83
|
-
return (
|
|
84
|
-
<PreviewFrame>
|
|
85
|
-
<PreviewDiagnostic>{`Unknown section: ${resolution.component}`}</PreviewDiagnostic>
|
|
86
|
-
</PreviewFrame>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const rendered = await Promise.all(
|
|
91
|
-
resolution.sections.map(async (section, index): Promise<ReactElement | null> => {
|
|
92
|
-
const element = await SectionRenderer({ resolved: section });
|
|
93
|
-
return element
|
|
94
|
-
? cloneElement(element, { key: `${section.key}-${section.index ?? index}` })
|
|
95
|
-
: null;
|
|
96
|
-
}),
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
return <PreviewFrame>{rendered}</PreviewFrame>;
|
|
100
|
-
} catch (error) {
|
|
101
|
-
return (
|
|
102
|
-
<PreviewFrame>
|
|
103
|
-
<PreviewDiagnostic>{`Render error: ${(error as Error).message}`}</PreviewDiagnostic>
|
|
104
|
-
</PreviewFrame>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
}
|
package/src/draft.test.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
});
|
package/src/draftConstants.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
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";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Request header the middleware forwards the active pointer on, so the whole
|
|
19
|
-
* RSC tree — including the root layout / app shell — can bind the draft on the
|
|
20
|
-
* SAME request. The page reads `?__draft=` from its own `searchParams`, but a
|
|
21
|
-
* layout never receives `searchParams` and, on the entry request, the cookie
|
|
22
|
-
* has only just been set on the *response* (not yet readable). Forwarding the
|
|
23
|
-
* pointer as a request header closes both gaps so `ensureDraft()` works from a
|
|
24
|
-
* layout, letting shell-resolved Header/Footer reflect the draft too.
|
|
25
|
-
*
|
|
26
|
-
* Not a new trust surface: the pointer was never a secret (the draft id is the
|
|
27
|
-
* capability, host-scoping bounds blast radius — see `draftSource`), so a
|
|
28
|
-
* forwarded/forged header is equivalent to typing the query param.
|
|
29
|
-
*/
|
|
30
|
-
export const DRAFT_HEADER = "x-deco-draft";
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { DRAFT_COOKIE } from "./draft";
|
|
5
|
-
import {
|
|
6
|
-
applyDraft,
|
|
7
|
-
draftRequestHeaders,
|
|
8
|
-
prepareDraft,
|
|
9
|
-
rewriteToDraftRoute,
|
|
10
|
-
} from "./draftMiddleware";
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
// The middleware is host-gated; these tests run as the allowed host.
|
|
14
|
-
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
|
|
15
|
-
});
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
function request(url: string, cookie?: string): NextRequest {
|
|
21
|
-
const req = new NextRequest(new URL(url), {
|
|
22
|
-
headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
|
|
23
|
-
});
|
|
24
|
-
return req;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("prepareDraft", () => {
|
|
28
|
-
it("reads the pointer from the param", () => {
|
|
29
|
-
expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
|
|
30
|
-
"abc.localhost@v1",
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("reads the pointer from the cookie on a plain navigation", () => {
|
|
35
|
-
expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
|
|
36
|
-
"abc.localhost@v1",
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("passes through when no env override is set — the page gate is authoritative", () => {
|
|
41
|
-
// The site-block hosts live in the server-components graph; middleware
|
|
42
|
-
// can't see them, so without the env kill switch it must not block.
|
|
43
|
-
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
44
|
-
const req = request("https://anything.example/p?__draft=abc.localhost@v1");
|
|
45
|
-
expect(prepareDraft(req).pointer).toBe("abc.localhost@v1");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("is inert on a host outside the allowlist — the production domain", () => {
|
|
49
|
-
// Same build, different Host: no cookie, no rewrite, nothing touched.
|
|
50
|
-
const req = request("https://acme.com.br/p?__draft=abc@v1");
|
|
51
|
-
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "acme.vtex.app";
|
|
52
|
-
expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
|
|
53
|
-
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("does not read a client-supplied draft header — the decision is param/cookie only", () => {
|
|
57
|
-
// The forwarded `x-deco-draft` header (read by `ensureDraft` in the layout)
|
|
58
|
-
// is an OUTPUT of the middleware decision, never an input to it: prepareDraft
|
|
59
|
-
// still decides purely from the param + cookie. A client that forges the
|
|
60
|
-
// header directly can't manufacture a decision here (and `draftRequestHeaders`
|
|
61
|
-
// strips it when not previewing).
|
|
62
|
-
const req = new NextRequest(new URL("https://site.example/p"), {
|
|
63
|
-
headers: { "x-deco-draft": "attacker.localhost@v1" },
|
|
64
|
-
});
|
|
65
|
-
expect(prepareDraft(req).pointer).toBeNull();
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("draftRequestHeaders", () => {
|
|
70
|
-
it("forwards the active pointer on x-deco-draft so a layout can bind it", () => {
|
|
71
|
-
const req = request("https://site.example/p?__draft=abc.localhost@v1");
|
|
72
|
-
const headers = draftRequestHeaders(req, prepareDraft(req));
|
|
73
|
-
expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("forwards the cookie-carried pointer on plain navigation", () => {
|
|
77
|
-
const req = request("https://site.example/p", "abc.localhost@v1");
|
|
78
|
-
const headers = draftRequestHeaders(req, prepareDraft(req));
|
|
79
|
-
expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("strips a client-forged header when the request is not previewing", () => {
|
|
83
|
-
// Direct-to-origin request carrying a forged header but no real pointer:
|
|
84
|
-
// the forwarded headers must not carry it downstream.
|
|
85
|
-
const req = new NextRequest(new URL("https://site.example/p"), {
|
|
86
|
-
headers: { "x-deco-draft": "attacker.localhost@v1" },
|
|
87
|
-
});
|
|
88
|
-
const headers = draftRequestHeaders(req, prepareDraft(req));
|
|
89
|
-
expect(headers.get("x-deco-draft")).toBeNull();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("applyDraft", () => {
|
|
94
|
-
it("sets a partitioned cross-site cookie when entering draft mode", () => {
|
|
95
|
-
const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
|
|
96
|
-
const res = applyDraft(NextResponse.next(), decision);
|
|
97
|
-
|
|
98
|
-
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
99
|
-
expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
|
|
100
|
-
// The preview iframe is cross-site, so these attributes are what make the
|
|
101
|
-
// cookie survive at all — not hardening extras.
|
|
102
|
-
expect(setCookie).toMatch(/SameSite=None/i);
|
|
103
|
-
expect(setCookie).toMatch(/Secure/i);
|
|
104
|
-
expect(setCookie).toMatch(/Partitioned/i);
|
|
105
|
-
expect(setCookie).toMatch(/HttpOnly/i);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("marks a draft response uncacheable and unindexable", () => {
|
|
109
|
-
// With the pointer in a cookie, draft and published share a URL — a CDN
|
|
110
|
-
// keyed on URL alone would serve unpublished content to a real visitor.
|
|
111
|
-
const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
|
|
112
|
-
const res = applyDraft(NextResponse.next(), decision);
|
|
113
|
-
|
|
114
|
-
expect(res.headers.get("cache-control")).toBe("no-store, private");
|
|
115
|
-
expect(res.headers.get("vary")).toBe("Cookie");
|
|
116
|
-
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("leaves an ordinary response completely untouched", () => {
|
|
120
|
-
const decision = prepareDraft(request("https://site.example/p"));
|
|
121
|
-
const res = applyDraft(NextResponse.next(), decision);
|
|
122
|
-
|
|
123
|
-
expect(res.headers.get("cache-control")).toBeNull();
|
|
124
|
-
expect(res.headers.get("vary")).toBeNull();
|
|
125
|
-
expect(res.headers.get("x-robots-tag")).toBeNull();
|
|
126
|
-
expect(res.headers.get("set-cookie")).toBeNull();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("clears the cookie on ?__draft=off", () => {
|
|
130
|
-
const decision = prepareDraft(
|
|
131
|
-
request("https://site.example/p?__draft=off", "abc.localhost@v1"),
|
|
132
|
-
);
|
|
133
|
-
expect(decision.clearCookie).toBe(true);
|
|
134
|
-
|
|
135
|
-
const res = applyDraft(NextResponse.next(), decision);
|
|
136
|
-
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
137
|
-
// Deletion is a set with an immediate expiry.
|
|
138
|
-
expect(setCookie).toContain(DRAFT_COOKIE);
|
|
139
|
-
expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i);
|
|
140
|
-
// And the response is not treated as a draft render.
|
|
141
|
-
expect(res.headers.get("cache-control")).toBeNull();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe("rewriteToDraftRoute", () => {
|
|
146
|
-
it("rewrites a drafted request onto the dynamic draft route", () => {
|
|
147
|
-
const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
|
|
148
|
-
const res = rewriteToDraftRoute(req, prepareDraft(req));
|
|
149
|
-
const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
|
|
150
|
-
expect(target.pathname).toBe("/_draft/blog/hello");
|
|
151
|
-
// The pointer must survive: the draft page reads it from searchParams.
|
|
152
|
-
expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("rewrites a cookie-only navigation too", () => {
|
|
156
|
-
const req = request("https://site.example/blog/hello", "abc.localhost@v1");
|
|
157
|
-
const res = rewriteToDraftRoute(req, prepareDraft(req));
|
|
158
|
-
expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
|
|
159
|
-
"/_draft/blog/hello",
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("leaves an ordinary request alone — ISR must not be disturbed", () => {
|
|
164
|
-
// The whole point: only drafted requests go dynamic. A shopper's request
|
|
165
|
-
// must reach the real, statically rendered route untouched.
|
|
166
|
-
const req = request("https://site.example/blog/hello");
|
|
167
|
-
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
|
|
171
|
-
const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
|
|
172
|
-
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("does not rewrite when leaving draft mode", () => {
|
|
176
|
-
const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
|
|
177
|
-
expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
|
|
178
|
-
});
|
|
179
|
-
});
|