@decocms/nextjs 8.0.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +77 -332
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +35 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +43 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/proxy/index.d.ts +79 -0
  27. package/dist/proxy/index.d.ts.map +1 -0
  28. package/dist/proxy/index.js +92 -0
  29. package/dist/proxy/index.js.map +1 -0
  30. package/dist/server/index.d.ts +46 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +64 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/slot.d.ts +6 -0
  35. package/dist/server/slot.d.ts.map +1 -0
  36. package/dist/server/slot.js +13 -0
  37. package/dist/server/slot.js.map +1 -0
  38. package/dist/studio.d.ts +30 -0
  39. package/dist/studio.d.ts.map +1 -0
  40. package/dist/studio.js +55 -0
  41. package/dist/studio.js.map +1 -0
  42. package/package.json +41 -22
  43. package/src/DraftPreviewIndicator.tsx +17 -16
  44. package/src/StudioBridge.ts +23 -0
  45. package/src/config/index.cjs +78 -0
  46. package/src/config/index.d.cts +27 -0
  47. package/src/draft.ts +30 -210
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -43
  51. package/src/proxy/index.ts +161 -0
  52. package/src/server/index.ts +77 -0
  53. package/src/server/slot.ts +14 -0
  54. package/src/studio.ts +54 -0
  55. package/src/ClientOnlySection.tsx +0 -20
  56. package/src/DecoPageRenderer.test.tsx +0 -37
  57. package/src/DecoPageRenderer.tsx +0 -107
  58. package/src/DecoRootLayout.test.tsx +0 -22
  59. package/src/DecoRootLayout.tsx +0 -82
  60. package/src/DeferredSection.test.tsx +0 -27
  61. package/src/DeferredSection.tsx +0 -59
  62. package/src/DraftPreviewBadge.render.test.tsx +0 -16
  63. package/src/DraftPreviewBadge.test.ts +0 -37
  64. package/src/DraftPreviewBadge.tsx +0 -252
  65. package/src/SectionRenderer.test.tsx +0 -97
  66. package/src/SectionRenderer.tsx +0 -58
  67. package/src/config.cjs +0 -92
  68. package/src/config.d.cts +0 -4
  69. package/src/config.test.ts +0 -86
  70. package/src/createDecoPage.draft.test.tsx +0 -91
  71. package/src/createDecoPage.test.tsx +0 -63
  72. package/src/createDecoPage.tsx +0 -173
  73. package/src/createDecoPreviewPage.test.tsx +0 -71
  74. package/src/createDecoPreviewPage.tsx +0 -108
  75. package/src/decoMark.ts +0 -11
  76. package/src/draft.test.ts +0 -73
  77. package/src/draftConstants.test.ts +0 -21
  78. package/src/draftConstants.ts +0 -15
  79. package/src/draftMiddleware.test.ts +0 -147
  80. package/src/draftMiddleware.ts +0 -136
  81. package/src/routeHandlers.test.ts +0 -243
  82. package/src/routeHandlers.ts +0 -227
  83. package/src/setup.test.ts +0 -253
  84. package/src/setup.ts +0 -180
  85. package/tsconfig.json +0 -7
@@ -1,227 +0,0 @@
1
- /**
2
- * Admin Route Handlers for Next.js App Router.
3
- *
4
- * IMPORT THESE FROM `@decocms/nextjs/routeHandlers` IN ROUTE FILES — never
5
- * from the `@decocms/nextjs` root barrel. Route handlers (app router
6
- * route.ts files) evaluate their whole module graph against React's
7
- * react-server build and IGNORE `"use client"` directives (there is no
8
- * client graph to move a module into). The root barrel also exports the
9
- * render components (SectionRenderer, DecoRootLayout, ...), whose graph
10
- * reaches `@decocms/blocks/hooks` — files with module-scope client-React
11
- * usage (e.g. `class ... extends Component`) that the react-server build
12
- * does not export. Importing the root barrel from a route file therefore
13
- * crashes at module evaluation ("...createContext is not a function" /
14
- * "Class extends value undefined is not a constructor") before the handler
15
- * ever runs. This subpath keeps the route-handler graph free of all
16
- * component code.
17
- */
18
- import {
19
- corsHeaders,
20
- handleDecofileRead,
21
- handleDecofileReload,
22
- handleInvoke,
23
- handleMeta,
24
- handleRender,
25
- } from "@decocms/blocks-admin";
26
-
27
- /**
28
- * Stamp the admin CORS headers onto a handler response. The Studio is a
29
- * cross-origin browser client (decocms.com / admin.deco.cx fetching the
30
- * site's /live/_meta, /.decofile, ...), and its ETag flow sends
31
- * `If-None-Match` — a non-simple header, so requests preflight. On
32
- * TanStack this stamping lives in workerEntry/adminRoutes; on Next.js it
33
- * lives HERE, since these handlers/dispatcher are the only admin surface.
34
- * Response headers can be immutable (e.g. a cached/replayed body) — fall
35
- * back to cloning.
36
- */
37
- function withCors(request: Request, response: Response): Response {
38
- const cors = corsHeaders(request);
39
- try {
40
- for (const [k, v] of Object.entries(cors)) response.headers.set(k, v);
41
- return response;
42
- } catch {
43
- const headers = new Headers(response.headers);
44
- for (const [k, v] of Object.entries(cors)) headers.set(k, v);
45
- return new Response(response.body, {
46
- status: response.status,
47
- statusText: response.statusText,
48
- headers,
49
- });
50
- }
51
- }
52
-
53
- /** Answer a CORS preflight for the admin protocol. */
54
- function preflight(request: Request): Response {
55
- return new Response(null, { status: 204, headers: corsHeaders(request) });
56
- }
57
-
58
- /** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs/routeHandlers"` */
59
- export async function metaGET(request: Request): Promise<Response> {
60
- return withCors(request, handleMeta(request));
61
- }
62
-
63
- /** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
64
- * segments can't literally start with a dot; see Task 9's fixture for the
65
- * rewrite-rule workaround). */
66
- export async function decofileGET(request: Request): Promise<Response> {
67
- return withCors(request, await handleDecofileRead());
68
- }
69
-
70
- export async function decofilePOST(request: Request): Promise<Response> {
71
- return withCors(request, await handleDecofileReload(request));
72
- }
73
-
74
- /** For app/deco/invoke/[...key]/route.ts */
75
- export async function invokePOST(request: Request): Promise<Response> {
76
- return withCors(request, await handleInvoke(request));
77
- }
78
-
79
- /** For app/live/previews/[...path]/route.ts */
80
- export async function renderGET(request: Request): Promise<Response> {
81
- return withCors(request, await handleRender(request));
82
- }
83
-
84
- export async function renderPOST(request: Request): Promise<Response> {
85
- return withCors(request, await handleRender(request));
86
- }
87
-
88
- export interface DecoRouteHandlersOptions {
89
- /**
90
- * Site bootstrap, awaited before every admin request — pass the
91
- * ensureSetup returned by createNextSetup (@decocms/nextjs/setup).
92
- */
93
- setup?: () => Promise<void>;
94
- }
95
-
96
- const PREVIEW_PAGE_PATH = "/deco/preview";
97
-
98
- /**
99
- * Single catch-all dispatcher for the whole Studio admin protocol. Mount
100
- * at `app/deco/[[...deco]]/route.ts` and wrap next.config with
101
- * `withDeco()` (@decocms/nextjs/config), whose rewrites map the protocol
102
- * URLs Next can't express as segments (`/.decofile`, `/live/_meta`,
103
- * `/live/previews/*`) into `/deco/*`:
104
- *
105
- * ```ts
106
- * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
107
- * import { ensureSetup } from "../../../deco/setup";
108
- * export const dynamic = "force-dynamic";
109
- * export const { GET, POST, OPTIONS } = createDecoRouteHandlers({
110
- * setup: ensureSetup,
111
- * });
112
- * ```
113
- *
114
- * `resolveAction` accepts BOTH the rewrite's public source path (e.g.
115
- * `/.decofile`, `/live/_meta`, `/live/previews/*`) AND the rewrite's own
116
- * destination path (`/deco/*`) — not just the latter. This is load-bearing:
117
- * verified empirically against a real `next build && next start` (both
118
- * dev and production servers) that a Next.js App Router route handler
119
- * reached via a `next.config.js`-level `rewrites()` entry sees
120
- * `request.url` (and `NextRequest.nextUrl.pathname`) as the ORIGINAL,
121
- * pre-rewrite path the client requested — rewrites are transparent to the
122
- * client and, in this respect, to the handler too. Only a *direct* request
123
- * to `/deco/*` (bypassing the rewrite, e.g. `/deco/invoke/*`, which has no
124
- * public alias) arrives with a `/deco/`-prefixed pathname. Matching only
125
- * the `/deco/*` form (as an earlier version of this dispatcher did) makes
126
- * every rewritten protocol URL 404 — the dispatcher never sees a
127
- * `/deco/decofile`-shaped path in that case, it sees `/.decofile` verbatim.
128
- */
129
- function resolveAction(pathname: string): string {
130
- if (pathname.startsWith("/deco/")) return pathname.slice("/deco/".length);
131
- if (pathname === "/.decofile") return "decofile";
132
- if (pathname === "/live/_meta") return "meta";
133
- if (pathname === "/live/previews") return "previews";
134
- if (pathname.startsWith("/live/previews/")) {
135
- return `previews/${pathname.slice("/live/previews/".length)}`;
136
- }
137
- return pathname;
138
- }
139
-
140
- export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}): {
141
- GET(request: Request): Promise<Response>;
142
- POST(request: Request): Promise<Response>;
143
- OPTIONS(request: Request): Promise<Response>;
144
- } {
145
- async function dispatch(request: Request): Promise<Response> {
146
- // CORS preflight: Studio fetches cross-origin and its ETag flow sends
147
- // If-None-Match (non-simple header), so browsers preflight every meta
148
- // request. Answer before setup — preflights carry no work and must not
149
- // pay (or fail on) the site bootstrap.
150
- if (request.method === "OPTIONS") return preflight(request);
151
- await options.setup?.();
152
-
153
- const url = new URL(request.url);
154
- const action = resolveAction(url.pathname);
155
-
156
- if (action === "decofile") {
157
- return request.method === "POST" ? handleDecofileReload(request) : handleDecofileRead();
158
- }
159
- if (action === "meta") {
160
- // GET only: this is a read-only schema endpoint, no legitimate POST use.
161
- if (request.method !== "GET") {
162
- return new Response(JSON.stringify({ error: "Method not allowed: meta is GET-only" }), {
163
- status: 405,
164
- headers: { "Content-Type": "application/json", Allow: "GET" },
165
- });
166
- }
167
- return handleMeta(request);
168
- }
169
- if (action === "render") return handleRender(request);
170
- if (action.startsWith("invoke/")) {
171
- // POST only: handleInvoke has no auth of its own and falls back to a
172
- // `?props=<json>` query string for GET requests. A GET is a CORS
173
- // "simple request" (no preflight), so an unauthenticated
174
- // `<img src="https://site/deco/invoke/site/actions/...?props=...">`
175
- // on a third-party page would be able to trigger mutating VTEX
176
- // actions cross-site (CSRF). The per-route `invokePOST` export this
177
- // dispatcher replaces was POST-only for exactly this reason — keep
178
- // that restriction here.
179
- if (request.method !== "POST") {
180
- return new Response(
181
- JSON.stringify({ error: "Method not allowed: invoke is POST-only (CSRF protection)" }),
182
- {
183
- status: 405,
184
- headers: { "Content-Type": "application/json", Allow: "POST" },
185
- },
186
- );
187
- }
188
- return handleInvoke(request);
189
- }
190
- if (action === "previews" || action.startsWith("previews/")) {
191
- if (request.method === "GET") {
192
- const rest = action === "previews" ? "" : action.slice("previews/".length);
193
- const target = new URL(url);
194
- target.pathname = rest ? `${PREVIEW_PAGE_PATH}/${rest}` : PREVIEW_PAGE_PATH;
195
- return Response.redirect(target, 307);
196
- }
197
-
198
- // handleRender parses the literal "/live/previews/" prefix.
199
- // `resolveAction` above already normalizes BOTH a direct
200
- // `/deco/previews/*` hit and a rewritten `/live/previews/*` hit down
201
- // to this same `action` shape, so rebuilding unconditionally here is
202
- // a no-op in the rewrite case (rebuilt === original) and the
203
- // necessary reconstruction in the direct-hit case.
204
- const rest = action === "previews" ? "" : action.slice("previews/".length);
205
- const rebuilt = new URL(url);
206
- rebuilt.pathname = `/live/previews/${rest}`;
207
- // `request` (a Request) is passed as the `init` argument here, not a
208
- // plain object — the Request-as-init form clones headers/method/body
209
- // (including the body *stream*) without needing an explicit `duplex`
210
- // option. Rewriting this as `new Request(rebuilt, { ...request })` or
211
- // any plain-object init would throw "duplex option is required" for
212
- // POST bodies with a body stream — see the test below that posts a
213
- // JSON body through this branch and asserts it still parses.
214
- return handleRender(new Request(rebuilt, request));
215
- }
216
- return new Response(JSON.stringify({ error: `Unknown deco route: ${url.pathname}` }), {
217
- status: 404,
218
- headers: { "Content-Type": "application/json" },
219
- });
220
- }
221
-
222
- async function dispatchWithCors(request: Request): Promise<Response> {
223
- return withCors(request, await dispatch(request));
224
- }
225
-
226
- return { GET: dispatchWithCors, POST: dispatchWithCors, OPTIONS: dispatchWithCors };
227
- }
package/src/setup.test.ts DELETED
@@ -1,253 +0,0 @@
1
- // @vitest-environment node
2
- //
3
- // createSiteSetup (via @decocms/blocks/setup) only calls setBlocks() when
4
- // `typeof document === "undefined"` — it's the server-only half of the
5
- // Vite dual-environment split. Next.js Route Handlers run server-side with
6
- // no DOM, which is exactly what createNextSetup targets, so this suite runs
7
- // under vitest's "node" environment rather than the package default
8
- // (jsdom, used by the component-rendering tests in this same package) to
9
- // match that real invocation context.
10
- import * as fs from "node:fs";
11
- import * as os from "node:os";
12
- import * as path from "node:path";
13
- import * as cms from "@decocms/blocks/cms";
14
- import { listRegisteredSections, loadBlocks, setBlocks } from "@decocms/blocks/cms";
15
- import * as loadDecofileDirectoryModule from "@decocms/blocks/cms/loadDecofileDirectory";
16
- import { getProductionOrigins } from "@decocms/blocks/sdk/normalizeUrls";
17
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
-
19
- // Mirrors routeHandlers.test.ts in this same package: @decocms/blocks-admin
20
- // is mocked (rather than exercised for real) because it's a heavier graph
21
- // than the CMS core — see this file's `@example` JSDoc note in setup.ts.
22
- // The mock applies to every test in this file; existing tests that pass
23
- // `meta` don't assert on setMetaData's *internal* effects (only that the
24
- // `meta` loader itself was invoked), so swapping the real setter for a
25
- // stub doesn't change what they verify.
26
- const adminMocks = vi.hoisted(() => ({
27
- setMetaData: vi.fn(),
28
- setRenderShell: vi.fn(),
29
- setPreviewWrapper: vi.fn(),
30
- }));
31
- vi.mock("@decocms/blocks-admin", () => adminMocks);
32
-
33
- import { createNextSetup } from "./setup";
34
-
35
- describe("createNextSetup", () => {
36
- beforeEach(() => {
37
- setBlocks({});
38
- vi.clearAllMocks();
39
- });
40
-
41
- it("returns a memoized ensureSetup that registers blocks, sections, meta", async () => {
42
- const meta = vi.fn().mockResolvedValue({
43
- major: 1,
44
- version: "test",
45
- namespace: "site",
46
- site: "test",
47
- manifest: { blocks: { sections: {} } },
48
- schema: { definitions: {}, root: {} },
49
- platform: "test",
50
- cloudProvider: "test",
51
- });
52
- const ensureSetup = createNextSetup({
53
- blocksDir: false,
54
- blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } },
55
- sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
56
- meta,
57
- });
58
-
59
- await ensureSetup();
60
- await ensureSetup(); // memoized — meta loader must run once
61
-
62
- expect(meta).toHaveBeenCalledTimes(1);
63
- expect(loadBlocks().myBlock).toBeDefined();
64
- expect(listRegisteredSections()).toContain("site/sections/Hero.tsx");
65
- });
66
-
67
- it("applies section conventions when provided", async () => {
68
- const ensureSetup = createNextSetup({
69
- blocksDir: false,
70
- sections: { "./sections/Footer.tsx": async () => ({ default: () => null }) },
71
- conventions: { meta: { "site/sections/Footer.tsx": { layout: true } } },
72
- });
73
- await ensureSetup();
74
- const { isLayoutSection } = await import("@decocms/blocks/cms");
75
- expect(isLayoutSection("site/sections/Footer.tsx")).toBe(true);
76
- });
77
-
78
- it("clears the memo on a rejected bootstrap so the next call retries", async () => {
79
- const meta = vi
80
- .fn()
81
- .mockRejectedValueOnce(new Error("transient fetch failure"))
82
- .mockResolvedValueOnce({
83
- major: 1,
84
- version: "test",
85
- namespace: "site",
86
- site: "test",
87
- manifest: { blocks: { sections: {} } },
88
- schema: { definitions: {}, root: {} },
89
- platform: "test",
90
- cloudProvider: "test",
91
- });
92
- const ensureSetup = createNextSetup({
93
- blocksDir: false,
94
- sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
95
- meta,
96
- });
97
-
98
- await expect(ensureSetup()).rejects.toThrow("transient fetch failure");
99
- await expect(ensureSetup()).resolves.toBeUndefined();
100
- expect(meta).toHaveBeenCalledTimes(2);
101
- });
102
-
103
- describe("blocksDir", () => {
104
- let tmpDir: string;
105
-
106
- beforeEach(() => {
107
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "next-setup-blocksdir-"));
108
- });
109
-
110
- afterEach(() => {
111
- fs.rmSync(tmpDir, { recursive: true, force: true });
112
- });
113
-
114
- it("loads a real string blocksDir path (tmp dir with one JSON decofile)", async () => {
115
- fs.writeFileSync(
116
- path.join(tmpDir, "myBlock.json"),
117
- JSON.stringify({ __resolveType: "site/sections/Hero.tsx" }),
118
- );
119
-
120
- const ensureSetup = createNextSetup({
121
- blocksDir: tmpDir,
122
- sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
123
- });
124
- await ensureSetup();
125
-
126
- expect(loadBlocks().myBlock).toEqual({
127
- __resolveType: "site/sections/Hero.tsx",
128
- });
129
- });
130
-
131
- it("manifest wiring (blocks + blocksDir: false) is pure — no filesystem read", async () => {
132
- // The documented static-import-manifest path: the site passes the
133
- // manifest's default export as `blocks` and disables the directory
134
- // scan. Bootstrap must never touch the fs — same namespace-spy
135
- // technique as the setResolveErrorHandler spies below.
136
- const loadDirSpy = vi.spyOn(loadDecofileDirectoryModule, "loadDecofileDirectory");
137
-
138
- const ensureSetup = createNextSetup({
139
- blocksDir: false,
140
- blocks: { "pages-Home%20(principal)-287364": { path: "/" } },
141
- sections: {},
142
- });
143
- await ensureSetup();
144
-
145
- expect(loadDirSpy).not.toHaveBeenCalled();
146
- expect(loadBlocks()["pages-Home%20(principal)-287364"]).toEqual({ path: "/" });
147
- loadDirSpy.mockRestore();
148
- });
149
-
150
- it("options.blocks wins over blocksDir on an overlapping key (merge precedence)", async () => {
151
- fs.writeFileSync(path.join(tmpDir, "shared.json"), JSON.stringify({ source: "dir" }));
152
-
153
- const ensureSetup = createNextSetup({
154
- blocksDir: tmpDir,
155
- blocks: { shared: { source: "override" } },
156
- sections: {},
157
- });
158
- await ensureSetup();
159
-
160
- expect(loadBlocks().shared).toEqual({ source: "override" });
161
- });
162
- });
163
-
164
- it("reaches blocks-admin's setRenderShell, setPreviewWrapper, and setMetaData with the given args", async () => {
165
- const meta = vi.fn().mockResolvedValue({ schema: { definitions: {}, root: {} } });
166
- const renderShell = { css: "https://cdn.example.com/admin.css", fonts: ["Inter"] };
167
- const PreviewWrapper = () => null;
168
-
169
- const ensureSetup = createNextSetup({
170
- blocksDir: false,
171
- sections: {},
172
- meta,
173
- renderShell,
174
- previewWrapper: PreviewWrapper,
175
- });
176
- await ensureSetup();
177
-
178
- expect(adminMocks.setMetaData).toHaveBeenCalledWith(await meta.mock.results[0]!.value);
179
- expect(adminMocks.setRenderShell).toHaveBeenCalledWith(renderShell);
180
- expect(adminMocks.setPreviewWrapper).toHaveBeenCalledWith(PreviewWrapper);
181
- });
182
-
183
- it("does not touch blocks-admin's setters when meta/renderShell/previewWrapper are all omitted", async () => {
184
- const ensureSetup = createNextSetup({
185
- blocksDir: false,
186
- sections: {},
187
- });
188
- await ensureSetup();
189
-
190
- expect(adminMocks.setMetaData).not.toHaveBeenCalled();
191
- expect(adminMocks.setRenderShell).not.toHaveBeenCalled();
192
- expect(adminMocks.setPreviewWrapper).not.toHaveBeenCalled();
193
- });
194
-
195
- it("passes productionOrigins, customMatchers, onResolveError, and onDanglingReference through to createSiteSetup", async () => {
196
- const matcher = vi.fn();
197
- const onResolveError = vi.fn();
198
- const onDanglingReference = vi.fn();
199
-
200
- // onResolveError / onDanglingReference are installed via module-scope
201
- // setters (setResolveErrorHandler / setDanglingReferenceHandler) with no
202
- // public getter to read the currently-installed handler back — spy on
203
- // the real setters on the @decocms/blocks/cms module namespace.
204
- // createSiteSetup (called by createNextSetup) imports these same two
205
- // names from the identical resolved module ("./cms/index" internally,
206
- // "@decocms/blocks/cms" here — same file per package.json's export
207
- // map), and Vitest's SSR module transform makes named exports mutable
208
- // properties on a shared namespace object, so spying here intercepts
209
- // the call made from inside createSiteSetup too.
210
- const resolveErrorSpy = vi.spyOn(cms, "setResolveErrorHandler");
211
- const danglingRefSpy = vi.spyOn(cms, "setDanglingReferenceHandler");
212
-
213
- const ensureSetup = createNextSetup({
214
- blocksDir: false,
215
- sections: {},
216
- productionOrigins: ["https://www.example.com"],
217
- customMatchers: [matcher],
218
- onResolveError,
219
- onDanglingReference,
220
- });
221
- await ensureSetup();
222
-
223
- // Cheapest direct observation, per the option's own doc comment: each
224
- // customMatchers thunk is called exactly once during setup.
225
- expect(matcher).toHaveBeenCalledTimes(1);
226
- // productionOrigins has a real, cheap getter — assert the registered
227
- // value directly rather than only inferring it went through.
228
- expect(getProductionOrigins()).toEqual(["https://www.example.com"]);
229
- expect(resolveErrorSpy).toHaveBeenCalledWith(onResolveError);
230
- expect(danglingRefSpy).toHaveBeenCalledWith(onDanglingReference);
231
-
232
- resolveErrorSpy.mockRestore();
233
- danglingRefSpy.mockRestore();
234
- });
235
-
236
- it("calls extend with the merged, loaded blocks", async () => {
237
- const extend = vi.fn();
238
- const ensureSetup = createNextSetup({
239
- blocksDir: false,
240
- blocks: { myBlock: { __resolveType: "site/sections/Hero.tsx" } },
241
- sections: { "./sections/Hero.tsx": async () => ({ default: () => null }) },
242
- extend,
243
- });
244
- await ensureSetup();
245
-
246
- expect(extend).toHaveBeenCalledTimes(1);
247
- expect(extend).toHaveBeenCalledWith(
248
- expect.objectContaining({
249
- myBlock: { __resolveType: "site/sections/Hero.tsx" },
250
- }),
251
- );
252
- });
253
- });
package/src/setup.ts DELETED
@@ -1,180 +0,0 @@
1
- /**
2
- * One-call site bootstrap for Next.js — the App Router sibling of the
3
- * Vite flow (`createSiteSetup` + `createAdminSetup` + import.meta.glob).
4
- * Next has no import.meta.glob and no Vite plugin, so this composes the
5
- * same framework pieces from a generated section registry
6
- * (`generate-sections --registry`) and a filesystem decofile directory.
7
- *
8
- * ROUTE-HANDLER-SAFE: this module (and everything it imports eagerly) must
9
- * never reach module-scope client-React — it is imported by route files
10
- * via the site's setup module. Admin setters are imported lazily for the
11
- * same reason createAdminSetup keeps meta lazy: they're only needed when
12
- * an admin request actually arrives... and because @decocms/blocks-admin
13
- * is a heavier graph than the CMS core.
14
- *
15
- * @example site's `src/deco/setup.ts` — recommended wiring: the static-import
16
- * blocks manifest (`generate-blocks-manifest`) as the block source, so the
17
- * bundler's module graph owns CMS content (dev hot-reload on block edits, no
18
- * `outputFileTracingIncludes` for `.deco/blocks` in deploys).
19
- * ```ts
20
- * import { createNextSetup } from "@decocms/nextjs/setup";
21
- * // Generators default to `.deco/`; `deco/*` is a tsconfig path alias for
22
- * // `.deco/*` (see the package README) since `src/deco/setup.ts` isn't
23
- * // adjacent to the site-root `.deco/` directory.
24
- * import blocks from "deco/blocksManifest.gen";
25
- * import { sectionImports, sectionMeta, syncComponents } from "deco/sections.gen";
26
- *
27
- * export const ensureSetup = createNextSetup({
28
- * blocks,
29
- * blocksDir: false, // manifest IS the directory — skip the runtime fs read
30
- * sections: sectionImports,
31
- * conventions: { meta: sectionMeta, syncComponents },
32
- * meta: () => import("deco/meta.gen.json").then((m) => m.default),
33
- * });
34
- * ```
35
- * With `blocksDir: false` + `blocks`, this bootstrap is pure — no filesystem
36
- * access. Sites that skip the manifest can instead rely on the default
37
- * `blocksDir: ".deco/blocks"` runtime read (no dev reload on content edits;
38
- * deploys must ship the directory alongside the server bundle).
39
- */
40
- import type { ApplySectionConventionsInput } from "@decocms/blocks/cms";
41
- import { applySectionConventions, loadBlocks, setDraftPreviewHosts } from "@decocms/blocks/cms";
42
- import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory";
43
- import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup";
44
-
45
- export interface NextSetupOptions {
46
- /**
47
- * Directory of decofile JSON snapshots, relative to the site root, read
48
- * at bootstrap time with a plain fs scan. Pass `false` to skip filesystem
49
- * loading entirely (blocks come from `blocks`) — with `blocks` set this
50
- * makes the bootstrap pure, which is the recommended manifest wiring (see
51
- * the module-level example).
52
- * @default ".deco/blocks"
53
- */
54
- blocksDir?: string | false;
55
-
56
- /**
57
- * Extra/override blocks, merged OVER the directory's blocks. Pass the
58
- * default export of a generated static-import manifest
59
- * (`generate-blocks-manifest` → `.deco/blocksManifest.gen.ts`) together
60
- * with `blocksDir: false` to make the manifest the sole block source.
61
- */
62
- blocks?: Record<string, unknown>;
63
-
64
- /**
65
- * Lazy section registry — `sectionImports` from
66
- * `generate-sections --registry` (keys `./sections/X.tsx`).
67
- */
68
- sections: Record<string, () => Promise<any>>;
69
-
70
- /** `{ meta: sectionMeta, syncComponents, loadingFallbacks }` from sections.gen.ts (`.deco/sections.gen.ts` by default). */
71
- conventions?: Omit<ApplySectionConventionsInput, "sectionGlob">;
72
-
73
- /** Lazy admin meta schema: `() => import("deco/meta.gen.json").then(m => m.default)` (`.deco/meta.gen.json` by default). */
74
- meta?: () => Promise<unknown>;
75
-
76
- /** Admin preview shell (CSS/font URLs) — see blocks-admin setRenderShell. */
77
- renderShell?: { css?: string; fonts?: string[] };
78
-
79
- /** Admin preview wrapper component. */
80
- previewWrapper?: React.ComponentType<any>;
81
-
82
- productionOrigins?: SiteSetupOptions["productionOrigins"];
83
- customMatchers?: SiteSetupOptions["customMatchers"];
84
- onResolveError?: SiteSetupOptions["onResolveError"];
85
- onDanglingReference?: SiteSetupOptions["onDanglingReference"];
86
-
87
- /**
88
- * Site-specific wiring that must run after the core setup (section
89
- * loaders, SEO keys for legacy decofiles, curated post-processing).
90
- * Receives the loaded blocks.
91
- */
92
- extend?: (blocks: Record<string, unknown>) => void | Promise<void>;
93
- }
94
-
95
- /**
96
- * Returns a memoized `ensureSetup` function. A successful bootstrap is
97
- * cached for the lifetime of the module (warm serverless instance); a
98
- * *rejected* bootstrap is NOT cached — the memo is cleared on failure so
99
- * the next call retries from scratch, while the triggering call still
100
- * rejects with the original error.
101
- *
102
- * "Lifetime of the module" is exactly what makes the static-import manifest
103
- * hot-reload in `next dev`: the site's setup module imports
104
- * `blocksManifest.gen.ts`, which statically imports every block JSON, so
105
- * editing one invalidates the server module graph up through the setup
106
- * module. Next re-evaluates it, `createNextSetup` runs again with the fresh
107
- * JSON, and this memo is rebuilt from scratch — that module-graph reset IS
108
- * the designed dev reload mechanism for CMS content (measured at ~120–165ms
109
- * per edit on a 500-block site). With the default `blocksDir` fs read
110
- * instead, nothing imports the JSON, so edits invalidate nothing and dev
111
- * serves stale content until a full restart.
112
- */
113
- /**
114
- * Install the site block's `previewHosts` as the draft-preview allowlist.
115
- *
116
- * Reads the BASE blocks handed to setup — never `loadBlocks()` at request
117
- * time, where the draft override is merged in: an allowlist readable through
118
- * the override could be rewritten by the very draft it gates.
119
- * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
120
- * this list when set.
121
- */
122
- function installPreviewHosts(blocks: Record<string, unknown> | undefined): void {
123
- const site = blocks?.site as { previewHosts?: unknown } | undefined;
124
- if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
125
- }
126
-
127
- export function createNextSetup(options: NextSetupOptions): () => Promise<void> {
128
- let setupPromise: Promise<void> | null = null;
129
-
130
- // Draft preview opt-in from the repo: read SYNCHRONOUSLY at createNextSetup
131
- // time (module evaluation), not inside the lazy ensureSetup — pages call
132
- // `ensureDraft` BEFORE they resolve CMS content, so hosts installed lazily
133
- // would arrive after the gate already said no on the first request.
134
- installPreviewHosts(options.blocks);
135
-
136
- return function ensureSetup(): Promise<void> {
137
- setupPromise ??= (async () => {
138
- const dirBlocks =
139
- options.blocksDir === false
140
- ? {}
141
- : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks");
142
- const blocks = { ...dirBlocks, ...options.blocks };
143
- // Covers the blocksDir mode, where blocks only exist after the fs read.
144
- installPreviewHosts(blocks);
145
-
146
- createSiteSetup({
147
- sections: options.sections,
148
- blocks,
149
- productionOrigins: options.productionOrigins,
150
- customMatchers: options.customMatchers,
151
- onResolveError: options.onResolveError,
152
- onDanglingReference: options.onDanglingReference,
153
- });
154
-
155
- if (options.conventions) {
156
- applySectionConventions({
157
- ...options.conventions,
158
- sectionGlob: options.sections,
159
- });
160
- }
161
-
162
- if (options.meta || options.renderShell || options.previewWrapper) {
163
- const admin = await import("@decocms/blocks-admin");
164
- if (options.meta) admin.setMetaData((await options.meta()) as never);
165
- if (options.renderShell) admin.setRenderShell(options.renderShell);
166
- if (options.previewWrapper) admin.setPreviewWrapper(options.previewWrapper);
167
- }
168
-
169
- await options.extend?.(loadBlocks());
170
- })().catch((error) => {
171
- // A failed bootstrap must not poison the warm instance: clear the memo
172
- // so the next request retries (transient fs/fetch failures are the
173
- // common case in serverless cold starts). The error still propagates
174
- // to THIS caller so the triggering request fails loudly.
175
- setupPromise = null;
176
- throw error;
177
- });
178
- return setupPromise;
179
- };
180
- }
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist"
5
- },
6
- "include": ["src/**/*"]
7
- }