@decocms/nextjs 8.1.0-next.0 → 8.5.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 (89) hide show
  1. package/README.md +97 -328
  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 +37 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +45 -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/instrumentation/index.d.ts +74 -0
  27. package/dist/instrumentation/index.d.ts.map +1 -0
  28. package/dist/instrumentation/index.js +294 -0
  29. package/dist/instrumentation/index.js.map +1 -0
  30. package/dist/proxy/index.d.ts +82 -0
  31. package/dist/proxy/index.d.ts.map +1 -0
  32. package/dist/proxy/index.js +91 -0
  33. package/dist/proxy/index.js.map +1 -0
  34. package/dist/server/index.d.ts +46 -0
  35. package/dist/server/index.d.ts.map +1 -0
  36. package/dist/server/index.js +64 -0
  37. package/dist/server/index.js.map +1 -0
  38. package/dist/server/slot.d.ts +6 -0
  39. package/dist/server/slot.d.ts.map +1 -0
  40. package/dist/server/slot.js +13 -0
  41. package/dist/server/slot.js.map +1 -0
  42. package/dist/studio.d.ts +30 -0
  43. package/dist/studio.d.ts.map +1 -0
  44. package/dist/studio.js +55 -0
  45. package/dist/studio.js.map +1 -0
  46. package/package.json +53 -22
  47. package/src/DraftPreviewIndicator.tsx +17 -16
  48. package/src/StudioBridge.ts +23 -0
  49. package/src/config/index.cjs +78 -0
  50. package/src/config/index.d.cts +27 -0
  51. package/src/draft.ts +32 -215
  52. package/src/drafts.ts +229 -0
  53. package/src/image-loader/index.ts +62 -0
  54. package/src/index.ts +31 -45
  55. package/src/instrumentation/index.ts +337 -0
  56. package/src/proxy/index.ts +164 -0
  57. package/src/server/index.ts +77 -0
  58. package/src/server/slot.ts +14 -0
  59. package/src/studio.ts +54 -0
  60. package/src/ClientOnlySection.tsx +0 -20
  61. package/src/DecoPageRenderer.test.tsx +0 -37
  62. package/src/DecoPageRenderer.tsx +0 -107
  63. package/src/DecoRootLayout.test.tsx +0 -22
  64. package/src/DecoRootLayout.tsx +0 -82
  65. package/src/DeferredSection.test.tsx +0 -27
  66. package/src/DeferredSection.tsx +0 -59
  67. package/src/DraftPreviewBadge.tsx +0 -16
  68. package/src/SectionRenderer.test.tsx +0 -97
  69. package/src/SectionRenderer.tsx +0 -58
  70. package/src/__conformance__/observability.test.ts +0 -66
  71. package/src/config.cjs +0 -92
  72. package/src/config.d.cts +0 -4
  73. package/src/config.test.ts +0 -86
  74. package/src/createDecoPage.draft.test.tsx +0 -91
  75. package/src/createDecoPage.test.tsx +0 -63
  76. package/src/createDecoPage.tsx +0 -173
  77. package/src/createDecoPreviewPage.test.tsx +0 -71
  78. package/src/createDecoPreviewPage.tsx +0 -108
  79. package/src/draft.test.ts +0 -73
  80. package/src/draftConstants.test.ts +0 -21
  81. package/src/draftConstants.ts +0 -30
  82. package/src/draftMiddleware.test.ts +0 -179
  83. package/src/draftMiddleware.ts +0 -163
  84. package/src/draftShell.test.ts +0 -76
  85. package/src/routeHandlers.test.ts +0 -243
  86. package/src/routeHandlers.ts +0 -227
  87. package/src/setup.test.ts +0 -253
  88. package/src/setup.ts +0 -180
  89. package/tsconfig.json +0 -7
@@ -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
- });
@@ -1,91 +0,0 @@
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,63 +0,0 @@
1
- import { registerSections, setBlocks } from "@decocms/blocks/cms";
2
- import { renderToString } from "react-dom/server";
3
- import { describe, expect, it } from "vitest";
4
- import { createDecoPage } from "./createDecoPage";
5
-
6
- function Hero({ label }: { label?: string }) {
7
- return <h1>{`hero-${label ?? "none"}`}</h1>;
8
- }
9
-
10
- describe("createDecoPage (next)", () => {
11
- it("resolves and renders the page for the current path", async () => {
12
- // registerSections (the async-loader registry, packages/blocks/src/cms/registry.ts)
13
- // — NOT registerSectionsSync — is what resolveDecoPage's pipeline reads:
14
- // resolveRawSection() gates each raw CMS section through getSection(),
15
- // which only consults the registry registerSections populates.
16
- // registerSectionsSync populates a separate `syncComponents` map that
17
- // SectionRenderer checks first for already-rendered pages, but a section
18
- // registered ONLY via registerSectionsSync is silently dropped (with a
19
- // "[CMS] No component registered for: ..." warning) during CMS
20
- // resolution itself — confirmed by running this test against the plan's
21
- // original registerSectionsSync-based sketch.
22
- registerSections({ "site/sections/CreateDecoPageHero.tsx": async () => ({ default: Hero }) });
23
- // Block key MUST start with "pages-" — that's the prefix getAllPages()
24
- // (packages/blocks/src/cms/loader.ts) filters on. The plan's sketch used
25
- // "pages/home.json", which getAllPages() would silently skip, so
26
- // resolveDecoPage would return null and this test would fail even with a
27
- // correct implementation.
28
- setBlocks({
29
- "pages-home": {
30
- path: "/",
31
- sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
32
- seo: {
33
- __resolveType: "website/sections/Seo/SeoV2.tsx",
34
- title: "Home title",
35
- description: "Home description",
36
- },
37
- },
38
- });
39
-
40
- const { generateMetadata, default: Page } = createDecoPage({ siteName: "test-site" });
41
-
42
- const element = await Page({ params: Promise.resolve({ slug: [] }) });
43
- const html = renderToString(element);
44
- expect(html).toContain("hero-home");
45
-
46
- const metadata = await generateMetadata({ params: Promise.resolve({ slug: [] }) });
47
- expect(metadata.title).toBe("Home title");
48
- expect(metadata.description).toBe("Home description");
49
- });
50
-
51
- it("calls next/navigation notFound() when no page matches the path", async () => {
52
- setBlocks({
53
- "pages-home": {
54
- path: "/",
55
- sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
56
- },
57
- });
58
-
59
- const { default: Page } = createDecoPage({ siteName: "test-site" });
60
-
61
- await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow();
62
- });
63
- });
@@ -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
- });