@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.
- package/README.md +77 -332
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +35 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +43 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy/index.d.ts +79 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +92 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +41 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +30 -210
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -43
- package/src/proxy/index.ts +161 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.render.test.tsx +0 -16
- package/src/DraftPreviewBadge.test.ts +0 -37
- package/src/DraftPreviewBadge.tsx +0 -252
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/decoMark.ts +0 -11
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -15
- package/src/draftMiddleware.test.ts +0 -147
- package/src/draftMiddleware.ts +0 -136
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
package/src/routeHandlers.ts
DELETED
|
@@ -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
|
-
}
|