@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
package/src/draft.ts CHANGED
@@ -1,231 +1,45 @@
1
1
  /**
2
- * Draft preview — the Next.js binding.
3
- *
4
- * `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (pointer
5
- * parsing, origin construction, fetch, version cache). This file binds a
6
- * resolved draft to the current request.
7
- *
8
- * ## Why React `cache()` and not AsyncLocalStorage
9
- *
10
- * App Router never enters `RequestContext.run` (that is a TanStack/Workers
11
- * path), and ALS cannot help here anyway: you cannot wrap `ALS.run()` around a
12
- * component's children, because the children render later, outside the call.
13
- * `cache()` gives a per-request memoized value in RSC, which is exactly the
14
- * scope needed — verified concurrently, see `draft.test.ts`.
15
- *
16
- * ## Why this must be awaited by the PAGE, not a layout
17
- *
18
- * A layout's `await` does NOT gate its children: App Router renders a layout
19
- * and its children concurrently, so sections call `loadBlocks()` before a
20
- * layout-level resolve lands, and silently render published content. The
21
- * resolve has to happen somewhere that returns the subtree *after* awaiting —
22
- * i.e. the page component. `ensureDraft()` exists so each site makes one call
23
- * instead of re-deriving that ordering rule.
2
+ * The draft names and headers of a Deco Next.js site: the `?__draft=` link,
3
+ * the cookie that keeps a preview going, the request header the proxy hands
4
+ * the pages, and the response headers of a page rendered from a draft. The
5
+ * proxy reads and writes them (`./drafts.ts`, `./proxy`); the pages read the
6
+ * header (`./server`); `./config/index.cjs` repeats them (`config.test.ts`
7
+ * holds them equal). This package's own copy: it never imports
8
+ * `@decocms/tanstack`. No imports, no state: safe in every bundle.
24
9
  */
25
- import {
26
- isDraftHostAllowed,
27
- resolveDraftDecofile,
28
- setDraftOverrideGetter,
29
- } from "@decocms/blocks/cms";
30
- import { cookies, headers } from "next/headers";
31
- import { cache } from "react";
32
- import { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM } from "./draftConstants";
33
-
34
- // Re-exported for back-compat: these used to live here, but the client badge
35
- // needs DRAFT_PARAM and cannot import this module (it pulls in `next/headers`).
36
- export { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM } from "./draftConstants";
37
10
 
38
11
  /**
39
- * Request-scoped slot.
40
- *
41
- * `cache()` memoizes per-request, so every call within one request gets the
42
- * same object and a different one per request. Mutable by design: the page
43
- * fills it before returning its subtree, and nested sections read it
44
- * synchronously through `loadBlocks()`.
45
- *
46
- * `pointer` is the raw `<host>@<version>` token this render is bound to, kept
47
- * so the UI can surface an explicit "you are in preview" indicator and build a
48
- * shareable link — see {@link getActiveDraftPointer}.
12
+ * The query parameter a draft link carries (`?__draft=<pointer>`, or `off` to
13
+ * end a preview). The proxy reads it after its host check; reading it
14
+ * directly would skip that check.
49
15
  */
50
- const draftSlot = cache((): {
51
- blocks: Record<string, unknown> | null;
52
- pointer: string | null;
53
- } => ({
54
- blocks: null,
55
- pointer: null,
56
- }));
16
+ export const DRAFT_PARAM = "__draft";
57
17
 
58
18
  /**
59
- * Register the request-scoped getter with the runtime.
60
- *
61
- * Idempotent and safe to call at module scope: outside a request `cache()`
62
- * still returns an object, whose `blocks` is null, so `loadBlocks()` sees no
63
- * override and behaves exactly as before.
19
+ * The cookie that keeps a preview going after the first draft link. Like
20
+ * {@link DRAFT_PARAM}, the proxy reads it after its host check.
64
21
  */
65
- let registered = false;
66
- export function registerDraftOverride(): void {
67
- if (registered) return;
68
- registered = true;
69
- setDraftOverrideGetter(() => draftSlot().blocks);
70
- }
71
-
72
- /** A page's `searchParams` prop, before it is narrowed. */
73
- export type DraftSearchParams = Record<string, string | string[] | undefined>;
74
-
75
- /** First value of a search param that may legitimately repeat. */
76
- function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null {
77
- const raw = searchParams?.[key];
78
- if (Array.isArray(raw)) return raw[0] ?? null;
79
- return raw ?? null;
80
- }
81
-
82
- /**
83
- * The pointer for this request: the query param wins, the cookie carries
84
- * navigation, `off` exits.
85
- *
86
- * Read directly from the page rather than trusted from a request header. The
87
- * param has to take precedence or a save would never surface — Studio
88
- * navigating to a new version would keep rendering whatever older pointer is
89
- * still sitting in the cookie.
90
- */
91
- export function selectDraftPointer(
92
- searchParams: DraftSearchParams | undefined,
93
- cookieValue: string | null | undefined,
94
- ): string | null {
95
- const param = firstParam(searchParams, DRAFT_PARAM);
96
- if (param === "off") return null;
97
- if (param) return param;
98
- return cookieValue ?? null;
99
- }
100
-
101
- /**
102
- * Resolve the request's draft, if any, and bind it for the rest of the render.
103
- *
104
- * Call this from the PAGE, awaited, before returning the subtree:
105
- *
106
- * ```tsx
107
- * export default async function Page({ searchParams }) {
108
- * await ensureDraft(await searchParams);
109
- * return <DecoPageRenderer />;
110
- * }
111
- * ```
112
- *
113
- * Reads the param from the page's own `searchParams` and the cookie via
114
- * `cookies()`. There is deliberately no request header in the path: the page
115
- * owns the decision, so a draft keeps working on routes the middleware matcher
116
- * never sees, and there is one less forgeable input to reason about. (The
117
- * pointer was never a secret — the draft id (the token's authority) is the capability — so a
118
- * client supplying one directly is equivalent to typing the query param.)
119
- *
120
- * Returns whether a draft was bound, so callers can surface an explicit
121
- * "draft unavailable" state instead of silently showing published content —
122
- * the failure mode most likely to mislead someone reviewing their own edits.
123
- */
124
- export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boolean> {
125
- registerDraftOverride();
126
-
127
- const [cookieStore, requestHeaders] = await Promise.all([cookies(), headers()]);
128
-
129
- // Pointer precedence: the page's own `?__draft=` param wins; then the
130
- // middleware-forwarded header (the only source a LAYOUT can see on the entry
131
- // request — see DRAFT_HEADER); then the navigation cookie. This is what lets
132
- // `ensureDraft()` bind from a layout so shell-resolved Header/Footer reflect
133
- // the draft, not just page sections.
134
- const carried = requestHeaders.get(DRAFT_HEADER) ?? cookieStore.get(DRAFT_COOKIE)?.value ?? null;
135
- const pointer = selectDraftPointer(searchParams, carried);
136
- if (!pointer) return false;
137
-
138
- // Host gate, checked only once a pointer exists (headers() is a dynamic
139
- // API): the same build may serve the preview domain and the production
140
- // domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render
141
- // drafts — production stays published no matter what the URL carries.
142
- const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
143
- if (!isDraftHostAllowed(host)) return false;
144
-
145
- const blocks = await resolveDraftDecofile({ pointer });
146
- if (!blocks) return false;
147
-
148
- const slot = draftSlot();
149
- slot.blocks = blocks;
150
- slot.pointer = pointer;
151
- return true;
152
- }
22
+ export const DRAFT_COOKIE = "__deco_draft";
153
23
 
154
24
  /**
155
- * The raw draft pointer bound to this request, or null if the request is not
156
- * rendering a draft.
157
- *
158
- * Synchronous — reads the same request-scoped slot `ensureDraft` fills, so it
159
- * must be called AFTER `ensureDraft` has been awaited in this request (i.e.
160
- * from inside the page subtree, not a concurrently-rendered layout). Powers
161
- * the preview-mode indicator: a bound pointer is the signal that the visitor
162
- * is looking at unpublished content, and it is exactly what a "share this
163
- * draft" link must carry.
25
+ * The request header the proxy sets on the request it hands the pages once it
26
+ * accepted the draft: the pointer, already checked. The proxy removes any
27
+ * copy that arrives from outside, and `bindDraft` repeats the host check, so a
28
+ * client can't send one itself.
164
29
  */
165
- export function getActiveDraftPointer(): string | null {
166
- return draftSlot().pointer;
167
- }
168
-
169
- // ---------------------------------------------------------------------------
170
- // Middleware helper
171
- // ---------------------------------------------------------------------------
172
-
173
- /** What middleware should do with this request. */
174
- export interface DraftMiddlewareDecision {
175
- /** The active pointer, or null. Drives the cache/indexing headers. */
176
- pointer: string | null;
177
- /** Set the cookie to this value (entering draft mode). */
178
- setCookie: string | null;
179
- /** Clear the cookie (leaving draft mode via `?__draft=off`). */
180
- clearCookie: boolean;
181
- }
30
+ export const DRAFT_HEADER = "x-deco-draft";
182
31
 
183
32
  /**
184
- * Decide the draft state for a request, from `?__draft=` and the cookie.
185
- *
186
- * The param is authoritative on entry and the cookie carries subsequent
187
- * in-preview navigation — a param alone dies on the first link click, and a
188
- * cookie alone cannot be relied on: the preview iframe is cross-site, so the
189
- * cookie is third-party and may be blocked outright (Safari ITP) or
190
- * partitioned (Chrome CHIPS). Entry therefore never depends on cookie support.
191
- *
192
- * `?__draft=off` leaves draft mode, so a session can be ended deliberately
193
- * rather than waiting for a cookie to expire.
194
- *
195
- * Pure and framework-free so it can be unit-tested without a Next request; the
196
- * caller applies the decision to its own `NextResponse`.
33
+ * The response headers of a page rendered from a draft: never stored by a
34
+ * browser or a CDN (`Cache-Control: no-store, private`, `Vary: Cookie`) and
35
+ * never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when `pointer` is
36
+ * `null`, so the result of the draft decision applies as is.
197
37
  */
198
- export function decideDraft(
199
- url: URL,
200
- cookieValue: string | null | undefined,
201
- ): DraftMiddlewareDecision {
202
- const param = url.searchParams.get(DRAFT_PARAM);
203
-
204
- if (param === "off") {
205
- return { pointer: null, setCookie: null, clearCookie: true };
206
- }
207
- if (param) {
208
- return { pointer: param, setCookie: param, clearCookie: false };
209
- }
210
- if (cookieValue) {
211
- return { pointer: cookieValue, setCookie: null, clearCookie: false };
212
- }
213
- return { pointer: null, setCookie: null, clearCookie: false };
38
+ export function draftResponseHeaders(pointer: string | null | undefined): Record<string, string> {
39
+ if (!pointer) return {};
40
+ return {
41
+ "Cache-Control": "no-store, private",
42
+ Vary: "Cookie",
43
+ "X-Robots-Tag": "noindex, nofollow",
44
+ };
214
45
  }
215
-
216
- /**
217
- * Cookie attributes for the draft pointer.
218
- *
219
- * `SameSite=None; Secure` is mandatory for a cross-site iframe, and
220
- * `Partitioned` (CHIPS) is what keeps it working as browsers wind down
221
- * unpartitioned third-party cookies. Short-lived: a draft session is minutes,
222
- * and a stale pointer would keep pinning an old version.
223
- */
224
- export const DRAFT_COOKIE_OPTIONS = {
225
- httpOnly: true,
226
- secure: true,
227
- sameSite: "none",
228
- partitioned: true,
229
- path: "/",
230
- maxAge: 60 * 30,
231
- } as const;
package/src/drafts.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Which hosts may render drafts: v7's allowlist (`@decocms/blocks` 7.x
3
+ * `cms/draftSource.ts`: `readAllowedHosts`, `isDraftHostAllowed`,
4
+ * `isDraftPreviewEnabled`), ported as is. v7 read its override from the
5
+ * `DECO_ALLOWED_PREVIEW_HOSTS` variable; here it is the `previewHosts` option,
6
+ * and the site passes the same options to the proxy (`decideDraft`) and to
7
+ * its pages (`bindDraft`).
8
+ *
9
+ * - The site's own preview hosts: the ones it declares, in the `CMS` block or
10
+ * as `createCMS({ preview: { hosts } })` (matched as `cms.draftPointer`
11
+ * matches them). A site that declares none has none, as in v7: blocks'
12
+ * own default (every host) doesn't count as a declaration.
13
+ * - The hosts Deco runs the site on, named after the site and always added:
14
+ * `<site>.deco.site` and `<site>.deco-cx.workers.dev`, exact.
15
+ * - `previewHosts: [...]` replaces the site's own list (the inferred hosts are
16
+ * still added); `previewHosts: "none"` turns previews off on every host.
17
+ *
18
+ * Hosts compare exactly, lowercased, port included (`localhost:3000`). The
19
+ * host is the one the visitor asked for: `x-forwarded-host` behind a proxy,
20
+ * else `host`.
21
+ *
22
+ * The draft itself is the capability: a host check only bounds where a draft
23
+ * link works. A request the check refuses gets the release, never an error.
24
+ *
25
+ * No React, no Next.js and no module state: the proxy and the pages share it.
26
+ */
27
+ import { type CMS, parseDraftPointer, type RequestLike } from "@decocms/blocks";
28
+ import { DRAFT_COOKIE, DRAFT_PARAM } from "./draft.ts";
29
+
30
+ export interface PreviewPolicy {
31
+ /** The site's name: `<site>.deco.site` and `<site>.deco-cx.workers.dev` may preview. */
32
+ site?: string;
33
+ /** Replaces the `CMS` block's preview hosts; `"none"` turns previews off everywhere. */
34
+ previewHosts?: readonly string[] | "none";
35
+ }
36
+
37
+ /** The hosts Deco runs `site` on, which may always preview (unless previews are off). */
38
+ export function inferredPreviewHosts(site: string | undefined): string[] {
39
+ const s = (site ?? "").trim().toLowerCase();
40
+ return s ? [`${s}.deco.site`, `${s}.deco-cx.workers.dev`] : [];
41
+ }
42
+
43
+ function overrideHosts(policy: PreviewPolicy): string[] {
44
+ if (policy.previewHosts === "none") return ["none"];
45
+ return (policy.previewHosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean);
46
+ }
47
+
48
+ /**
49
+ * Whether `host` may preview without asking the CMS: `true` (an override or
50
+ * inferred host), `false` (previews off, or the override leaves it out), or
51
+ * `"cms"` when only the site's declared preview hosts can say (none declared:
52
+ * `false`, see {@link declaresPreviewHosts}).
53
+ */
54
+ export function previewHostDecision(host: string, policy: PreviewPolicy): boolean | "cms" {
55
+ const override = overrideHosts(policy);
56
+ if (override.includes("none")) return false;
57
+ const h = host.trim().toLowerCase();
58
+ if (h === "") return false;
59
+ if (inferredPreviewHosts(policy.site).includes(h)) return true;
60
+ if (policy.previewHosts !== undefined) return override.includes(h);
61
+ return "cms";
62
+ }
63
+
64
+ /**
65
+ * Whether any host may preview (v7's `isDraftPreviewEnabled`): a page checks
66
+ * it before it reads the request's headers, so a site with previews off never
67
+ * turns a static page dynamic. `false` for `previewHosts: "none"`; `true` with
68
+ * a site name or an override list; otherwise whether the site declares
69
+ * preview hosts and they allow any.
70
+ */
71
+ export async function isDraftPreviewEnabled(
72
+ cms: Pick<CMS, "settings" | "forRelease">,
73
+ policy: PreviewPolicy,
74
+ ): Promise<boolean> {
75
+ const override = overrideHosts(policy);
76
+ if (override.includes("none")) return false;
77
+ if (inferredPreviewHosts(policy.site).length > 0) return true;
78
+ if (policy.previewHosts !== undefined) return override.length > 0;
79
+ return (await declaresPreviewHosts(cms)) && (await cms.settings()).preview.hosts.length > 0;
80
+ }
81
+
82
+ /**
83
+ * Whether the site declares its own preview hosts: in the `CMS` block, or as
84
+ * `createCMS`'s `preview.hosts`. When it declares none, `cms.settings()`
85
+ * reports blocks' default, every host (`["*"]`), which v7 never had: there,
86
+ * a site without a list previewed only on its Deco hosts. The saved `CMS`
87
+ * block tells an explicit `["*"]` from that default.
88
+ */
89
+ export async function declaresPreviewHosts(
90
+ cms: Pick<CMS, "settings" | "forRelease">,
91
+ ): Promise<boolean> {
92
+ const { hosts } = (await cms.settings()).preview;
93
+ if (hosts.length !== 1 || hosts[0] !== "*") return true;
94
+ try {
95
+ const [block] = await cms.forRelease().resolve<unknown>("CMS", { run: false });
96
+ const preview = isRecord(block) ? block.preview : undefined;
97
+ return isRecord(preview) && ("hosts" in preview || "__resolveType" in preview);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
106
+
107
+ const DRAFT_OFF = "off";
108
+
109
+ function draftParam(request: RequestLike): string | null {
110
+ try {
111
+ return new URL(request.url, "http://localhost").searchParams.get(DRAFT_PARAM);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function readCookie(header: string | null, name: string): string | null {
118
+ if (!header) return null;
119
+ for (const part of header.split(";")) {
120
+ const eq = part.indexOf("=");
121
+ if (eq === -1 || part.slice(0, eq).trim() !== name) continue;
122
+ try {
123
+ return decodeURIComponent(part.slice(eq + 1).trim());
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * The pointer a request carries, as `@decocms/blocks` reads it: `?__draft=`
133
+ * first, then the draft cookie; `null` for `?__draft=off` and for a value
134
+ * that doesn't parse.
135
+ */
136
+ export function readDraftPointer(request: RequestLike): string | null {
137
+ const param = draftParam(request);
138
+ if (param === DRAFT_OFF) return null;
139
+ if (param && parseDraftPointer(param) !== null) return param;
140
+ const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE);
141
+ return cookie && parseDraftPointer(cookie) !== null ? cookie : null;
142
+ }
143
+
144
+ /** Whether the request carries a draft signal at all: without one the CMS is never asked. */
145
+ export function hasDraftSignal(request: RequestLike): boolean {
146
+ return (
147
+ draftParam(request) !== null || readCookie(request.headers.get("cookie"), DRAFT_COOKIE) !== null
148
+ );
149
+ }
150
+
151
+ /** The host the visitor asked for: the first `x-forwarded-host`, else `host`. */
152
+ export function requestHost(headers: { get(name: string): string | null }): string | null {
153
+ const forwarded = headers.get("x-forwarded-host")?.split(",")[0]?.trim();
154
+ const host = forwarded || headers.get("host")?.trim();
155
+ return host || null;
156
+ }
157
+
158
+ /**
159
+ * The request as the draft check should see it: `pathAndQuery` on the host
160
+ * the visitor asked for (`x-forwarded-host` behind a proxy, else `host`), so
161
+ * the host check judges the public hostname rather than the origin's, or
162
+ * `fallbackHost` when the headers name none. `null` when no host is known.
163
+ */
164
+ export function requestLike(
165
+ headers: Headers,
166
+ pathAndQuery: string,
167
+ fallbackHost?: string,
168
+ ): RequestLike | null {
169
+ const host = requestHost(headers) ?? (fallbackHost || null);
170
+ if (!host) return null;
171
+ try {
172
+ return { url: new URL(pathAndQuery, `https://${host}`).href, headers };
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The draft `request` renders under `policy`, or `null` for the release: the
180
+ * pointer it carries, on a host the policy (or, when only it can say, the
181
+ * `CMS` block) allows.
182
+ */
183
+ export async function allowedDraft(
184
+ cms: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
185
+ request: RequestLike,
186
+ policy: PreviewPolicy,
187
+ ): Promise<string | null> {
188
+ const pointer = readDraftPointer(request);
189
+ if (pointer === null) return null;
190
+ let host: string;
191
+ try {
192
+ host = new URL(request.url).host;
193
+ } catch {
194
+ return null;
195
+ }
196
+ const decision = previewHostDecision(host, policy);
197
+ const allowed =
198
+ decision === "cms"
199
+ ? (await declaresPreviewHosts(cms)) && (await cms.draftPointer(request)) !== null
200
+ : decision;
201
+ return allowed ? pointer : null;
202
+ }
203
+
204
+ /**
205
+ * The draft cookie: the same `Set-Cookie` `cms.draftCookie` writes (a test
206
+ * holds the two together). Studio frames the site cross-site, hence
207
+ * `SameSite=None; Partitioned`.
208
+ */
209
+ export function draftCookie(value: string, extra: string[] = []): string {
210
+ return [
211
+ `${DRAFT_COOKIE}=${value}`,
212
+ "Path=/",
213
+ ...extra,
214
+ "HttpOnly",
215
+ "Secure",
216
+ "SameSite=None",
217
+ "Partitioned",
218
+ ].join("; ");
219
+ }
220
+
221
+ /** Whether the request ends a preview (`?__draft=off`): the cookie goes on any host. */
222
+ export function endsPreview(request: RequestLike): boolean {
223
+ return draftParam(request) === DRAFT_OFF;
224
+ }
225
+
226
+ /** Whether the request's own `?__draft=` carries `pointer` (a draft link, not the cookie). */
227
+ export function fromDraftLink(request: RequestLike, pointer: string): boolean {
228
+ return draftParam(request) === pointer;
229
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@decocms/nextjs/image-loader`: a next/image loader over an `ImageUrl` from
3
+ * `@decocms/blocks/image`. The site's `loaderFile` is one line:
4
+ *
5
+ * ```ts
6
+ * // src/image-loader.ts (next.config: images: { loader: "custom", loaderFile: "./src/image-loader.ts" })
7
+ * import { createDecoImageLoader } from "@decocms/nextjs/image-loader";
8
+ * import { imageUrl } from "./imageUrl";
9
+ * export default createDecoImageLoader(imageUrl);
10
+ * ```
11
+ *
12
+ * A source the `ImageUrl` resizes gets its provider's URL; any other source
13
+ * goes to `fallback` (`nextImageOptimizer` sends it through Next's own
14
+ * `/_next/image`), or comes back unchanged. No React, no module state.
15
+ */
16
+ import type { ImageUrl, Quality } from "@decocms/blocks/image";
17
+
18
+ /** What next/image passes a loader (`ImageLoaderProps`), with Deco's named tiers allowed as quality. */
19
+ export interface DecoImageLoaderProps {
20
+ src: string;
21
+ width: number;
22
+ quality?: Quality;
23
+ }
24
+
25
+ export type DecoImageLoader = (props: DecoImageLoaderProps) => string;
26
+
27
+ export interface DecoImageLoaderOptions {
28
+ /** The URL of a source no provider resizes; `null` keeps `src`. Default: `src` unchanged. */
29
+ fallback?: (props: DecoImageLoaderProps) => string | null;
30
+ }
31
+
32
+ const TIER_VALUES = { low: 60, medium: 70, high: 80, original: 100 } as const;
33
+
34
+ /**
35
+ * Next's built-in optimizer (`/_next/image?url=&w=&q=`), for sources no
36
+ * provider resizes: a local `/public` file, another host listed in
37
+ * `images.remotePatterns`.
38
+ */
39
+ export function nextImageOptimizer({ src, width, quality }: DecoImageLoaderProps): string {
40
+ const params = new URLSearchParams();
41
+ params.set("url", src);
42
+ params.set("w", width.toString());
43
+ if (quality) {
44
+ params.set("q", String(typeof quality === "string" ? TIER_VALUES[quality] : quality));
45
+ }
46
+ return `/_next/image?${params.toString()}`;
47
+ }
48
+
49
+ /** The next/image loader for `url`. */
50
+ export function createDecoImageLoader(
51
+ url: ImageUrl,
52
+ options: DecoImageLoaderOptions = {},
53
+ ): DecoImageLoader {
54
+ const { fallback } = options;
55
+ return (props) => {
56
+ const { src, width, quality } = props;
57
+ if (url.isOptimizable(src)) {
58
+ return url(src, quality === undefined ? { width } : { width, quality });
59
+ }
60
+ return fallback?.(props) ?? src;
61
+ };
62
+ }
package/src/index.ts CHANGED
@@ -1,46 +1,32 @@
1
- export { ClientOnlySection } from "./ClientOnlySection";
2
- export { createDecoPage } from "./createDecoPage";
1
+ /**
2
+ * `@decocms/nextjs`: the components a Next.js App Router site adds, and the
3
+ * names and lists the proxy, the pages and `withDeco()` share.
4
+ *
5
+ * - `DraftPreviewIndicator`: the "Preview mode" badge on a page rendered from
6
+ * a draft (`bindDraft` from `@decocms/nextjs/server`), outside Studio's frame.
7
+ * - `StudioBridge`: one line in the root layout, `<StudioBridge site="my-store" />`,
8
+ * lets Studio's site editor drive the page when Studio frames it
9
+ * (`@decocms/blocks/preview`'s, answering `STUDIO_ORIGINS`). It renders an
10
+ * inline script with no React hooks, so a Server Component renders it as is.
11
+ * Outside Studio's frame, "." opens the page in Studio's editor.
12
+ * - `StudioPage`: rendered inside each CMS page, `<StudioPage pathTemplate={page.path} />`,
13
+ * tells the bridge which page "." opens (the root layout doesn't re-render per page).
14
+ * - `DRAFT_PARAM`, `DRAFT_COOKIE`, `DRAFT_HEADER`, `draftResponseHeaders`,
15
+ * `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS`, `isStudioOrigin`: for a site
16
+ * that needs them in its own code (a route that sets its own headers, a CSP
17
+ * it writes itself). The package's own copy: it never imports
18
+ * `@decocms/tanstack`.
19
+ *
20
+ * The proxy (`/proxy`), the pages (`/server`), `next.config` (`/config`) and the
21
+ * image loader (`/image-loader`) are separate subpaths, so the proxy never
22
+ * loads React.
23
+ */
24
+
25
+ export { DraftBadge, StudioPage, type StudioPageInfo } from "@decocms/blocks/preview";
3
26
  export {
4
- type CreateDecoPreviewPageOptions,
5
- createDecoPreviewPage,
6
- type DecoPreviewPageProps,
7
- } from "./createDecoPreviewPage";
8
- export { DecoPageRenderer } from "./DecoPageRenderer";
9
- export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
10
- export { DeferredSectionBoundary } from "./DeferredSection";
11
- export {
12
- buildExitUrl,
13
- buildShareUrl,
14
- DraftPreviewBadge,
15
- type DraftPreviewBadgeProps,
16
- } from "./DraftPreviewBadge";
17
- export { DraftPreviewIndicator } from "./DraftPreviewIndicator";
18
- export {
19
- DRAFT_COOKIE,
20
- DRAFT_COOKIE_OPTIONS,
21
- DRAFT_HEADER,
22
- DRAFT_PARAM,
23
- type DraftMiddlewareDecision,
24
- type DraftSearchParams,
25
- decideDraft,
26
- ensureDraft,
27
- getActiveDraftPointer,
28
- registerDraftOverride,
29
- selectDraftPointer,
30
- } from "./draft";
31
- export {
32
- applyDraft,
33
- DRAFT_ROUTE_PREFIX,
34
- draftRequestHeaders,
35
- prepareDraft,
36
- rewriteToDraftRoute,
37
- } from "./draftMiddleware";
38
- export {
39
- decofileGET,
40
- decofilePOST,
41
- invokePOST,
42
- metaGET,
43
- renderGET,
44
- renderPOST,
45
- } from "./routeHandlers";
46
- export { SectionRenderer } from "./SectionRenderer";
27
+ DraftPreviewIndicator,
28
+ type DraftPreviewIndicatorProps,
29
+ } from "./DraftPreviewIndicator.tsx";
30
+ export { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM, draftResponseHeaders } from "./draft.ts";
31
+ export { StudioBridge, type StudioBridgeProps } from "./StudioBridge.ts";
32
+ export { isStudioOrigin, STUDIO_FRAME_ANCESTORS, STUDIO_ORIGINS } from "./studio.ts";