@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.
Files changed (84) hide show
  1. package/README.md +77 -330
  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 -216
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -45
  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.tsx +0 -16
  63. package/src/SectionRenderer.test.tsx +0 -97
  64. package/src/SectionRenderer.tsx +0 -58
  65. package/src/__conformance__/observability.test.ts +0 -66
  66. package/src/config.cjs +0 -92
  67. package/src/config.d.cts +0 -4
  68. package/src/config.test.ts +0 -86
  69. package/src/createDecoPage.draft.test.tsx +0 -91
  70. package/src/createDecoPage.test.tsx +0 -63
  71. package/src/createDecoPage.tsx +0 -173
  72. package/src/createDecoPreviewPage.test.tsx +0 -71
  73. package/src/createDecoPreviewPage.tsx +0 -108
  74. package/src/draft.test.ts +0 -73
  75. package/src/draftConstants.test.ts +0 -21
  76. package/src/draftConstants.ts +0 -30
  77. package/src/draftMiddleware.test.ts +0 -179
  78. package/src/draftMiddleware.ts +0 -163
  79. package/src/draftShell.test.ts +0 -76
  80. package/src/routeHandlers.test.ts +0 -243
  81. package/src/routeHandlers.ts +0 -227
  82. package/src/setup.test.ts +0 -253
  83. package/src/setup.ts +0 -180
  84. package/tsconfig.json +0 -7
@@ -1,16 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * The preview-mode badge now lives in the runtime core so both bindings share
5
- * one implementation — see `@decocms/blocks/preview`. This module is a thin
6
- * re-export kept for back-compat: sites and `DraftPreviewIndicator` import the
7
- * badge (and the pure `buildShareUrl`/`buildExitUrl` helpers) from here.
8
- * `isFramed` stays internal to `@decocms/blocks/preview` — it was never part of
9
- * this package's public surface.
10
- */
11
- export {
12
- buildExitUrl,
13
- buildShareUrl,
14
- DraftPreviewBadge,
15
- type DraftPreviewBadgeProps,
16
- } from "@decocms/blocks/preview";
@@ -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
- }
@@ -1,66 +0,0 @@
1
- // @vitest-environment node
2
- /**
3
- * Conformance: the Next.js half of caching.mdx ("Upstream data": requests go
4
- * through Next's data cache, no cache of the binding's own) and of
5
- * telemetry.mdx ("What's sent": the bindings add no measurements of their own;
6
- * inbound requests come from Next's own OpenTelemetry setup).
7
- */
8
- import fs from "node:fs";
9
- import path from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { createInstrumentedFetch } from "@decocms/blocks/fetch";
12
- import { afterEach, describe, expect, it, vi } from "vitest";
13
-
14
- const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
-
16
- function sources(): { file: string; text: string }[] {
17
- const out: { file: string; text: string }[] = [];
18
- const walk = (dir: string) => {
19
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
20
- const full = path.join(dir, entry.name);
21
- if (entry.isDirectory()) {
22
- if (entry.name !== "__conformance__" && entry.name !== "__tests__") walk(full);
23
- } else if (/\.(ts|tsx|cjs|js)$/.test(entry.name) && !/\.test\./.test(entry.name)) {
24
- out.push({ file: path.relative(SRC, full), text: fs.readFileSync(full, "utf8") });
25
- }
26
- }
27
- };
28
- walk(SRC);
29
- return out;
30
- }
31
-
32
- afterEach(() => {
33
- vi.unstubAllGlobals();
34
- });
35
-
36
- describe("upstream data on Next.js (caching.mdx)", () => {
37
- it("cache-04: the instrumented fetch goes through the fetch Next patches, with Next's cache options intact", async () => {
38
- const instrumented = createInstrumentedFetch({ provider: "acme-search" });
39
- // Next replaces globalThis.fetch after modules load; the instrumented fetch must use it.
40
- const nextFetch = vi.fn(async () => new Response("{}"));
41
- vi.stubGlobal("fetch", nextFetch);
42
- await instrumented("https://search.example/q", {
43
- operation: "search",
44
- next: { revalidate: 60, tags: ["search"] },
45
- } as RequestInit & { operation: string });
46
- expect(nextFetch).toHaveBeenCalledTimes(1);
47
- const init = (nextFetch.mock.calls[0] as unknown as [unknown, Record<string, unknown>])[1];
48
- expect(init.next).toEqual({ revalidate: 60, tags: ["search"] });
49
- });
50
-
51
- it("cache-04: @decocms/nextjs has no upstream cache of its own", () => {
52
- const own = sources().filter(({ text }) =>
53
- /caches\.default|caches\.open|createFetchCache|fetchWithCache/.test(text),
54
- );
55
- expect(own.map((s) => s.file)).toEqual([]);
56
- });
57
- });
58
-
59
- describe("what the binding adds (telemetry.mdx › What's sent)", () => {
60
- it("tel-09: @decocms/nextjs adds no measurements of its own (no inbound or page-cache metrics)", () => {
61
- const reporters = sources().filter(({ text }) =>
62
- /http\.server\.request\.duration|decocms\.blocks\.telemetry/.test(text),
63
- );
64
- expect(reporters.map((s) => s.file)).toEqual([]);
65
- });
66
- });
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
- });
@@ -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
- });