@decocms/nextjs 8.1.0-next.0 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -328
- 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 +37 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +45 -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/instrumentation/index.d.ts +74 -0
- package/dist/instrumentation/index.d.ts.map +1 -0
- package/dist/instrumentation/index.js +294 -0
- package/dist/instrumentation/index.js.map +1 -0
- package/dist/proxy/index.d.ts +82 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +91 -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 +53 -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 +32 -215
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -45
- package/src/instrumentation/index.ts +337 -0
- package/src/proxy/index.ts +164 -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.tsx +0 -16
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/__conformance__/observability.test.ts +0 -66
- 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/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -30
- package/src/draftMiddleware.test.ts +0 -179
- package/src/draftMiddleware.ts +0 -163
- package/src/draftShell.test.ts +0 -76
- 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/dist/studio.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Deco Studio runs: the origins its site editor frames a site from and
|
|
3
|
+
* talks to it from (`postMessage`). One list, so the Studio bridge
|
|
4
|
+
* (`StudioBridge`), `withDeco()`'s dev origins and a site's own
|
|
5
|
+
* `frame-ancestors` never drift apart. This package's own copy: it never
|
|
6
|
+
* imports `@decocms/tanstack`.
|
|
7
|
+
*
|
|
8
|
+
* No module state and no framework code: safe in a browser bundle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Studio's origins, in the CSP source form (`https://*.decocms.com` matches
|
|
12
|
+
* any subdomain). Test an origin with {@link isStudioOrigin}: the list holds a
|
|
13
|
+
* wildcard, so `includes()` isn't the check.
|
|
14
|
+
*/
|
|
15
|
+
export declare const STUDIO_ORIGINS: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The `frame-ancestors` sources a site sends so Studio can show it in the
|
|
18
|
+
* site editor: the site itself, {@link STUDIO_ORIGINS}, and v7's
|
|
19
|
+
* `https://*.deco.studio`. For a site that writes its own CSP; add the
|
|
20
|
+
* loopback origins in local development (`http://localhost:*`), where a
|
|
21
|
+
* Studio runs on the same machine.
|
|
22
|
+
*/
|
|
23
|
+
export declare const STUDIO_FRAME_ANCESTORS: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Whether `origin` (a `MessageEvent.origin`, an `Origin` header) is one of
|
|
26
|
+
* {@link STUDIO_ORIGINS}: `https`, the default port, and `decocms.com` or a
|
|
27
|
+
* name under it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStudioOrigin(origin: string | null | undefined): boolean;
|
|
30
|
+
//# sourceMappingURL=studio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"studio.d.ts","sourceRoot":"","sources":["../src/studio.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAK1C,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAIlD,CAAC;AAEH;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAazE"}
|
package/dist/studio.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Deco Studio runs: the origins its site editor frames a site from and
|
|
3
|
+
* talks to it from (`postMessage`). One list, so the Studio bridge
|
|
4
|
+
* (`StudioBridge`), `withDeco()`'s dev origins and a site's own
|
|
5
|
+
* `frame-ancestors` never drift apart. This package's own copy: it never
|
|
6
|
+
* imports `@decocms/tanstack`.
|
|
7
|
+
*
|
|
8
|
+
* No module state and no framework code: safe in a browser bundle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Studio's origins, in the CSP source form (`https://*.decocms.com` matches
|
|
12
|
+
* any subdomain). Test an origin with {@link isStudioOrigin}: the list holds a
|
|
13
|
+
* wildcard, so `includes()` isn't the check.
|
|
14
|
+
*/
|
|
15
|
+
export const STUDIO_ORIGINS = Object.freeze([
|
|
16
|
+
"https://studio.decocms.com",
|
|
17
|
+
"https://studio-stg.decocms.com",
|
|
18
|
+
"https://decocms.com",
|
|
19
|
+
"https://*.decocms.com",
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The `frame-ancestors` sources a site sends so Studio can show it in the
|
|
23
|
+
* site editor: the site itself, {@link STUDIO_ORIGINS}, and v7's
|
|
24
|
+
* `https://*.deco.studio`. For a site that writes its own CSP; add the
|
|
25
|
+
* loopback origins in local development (`http://localhost:*`), where a
|
|
26
|
+
* Studio runs on the same machine.
|
|
27
|
+
*/
|
|
28
|
+
export const STUDIO_FRAME_ANCESTORS = Object.freeze([
|
|
29
|
+
"'self'",
|
|
30
|
+
...STUDIO_ORIGINS,
|
|
31
|
+
"https://*.deco.studio",
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Whether `origin` (a `MessageEvent.origin`, an `Origin` header) is one of
|
|
35
|
+
* {@link STUDIO_ORIGINS}: `https`, the default port, and `decocms.com` or a
|
|
36
|
+
* name under it.
|
|
37
|
+
*/
|
|
38
|
+
export function isStudioOrigin(origin) {
|
|
39
|
+
if (typeof origin !== "string" || origin === "")
|
|
40
|
+
return false;
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(origin);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// An origin is the scheme and host alone: `https://decocms.com/x` or one with
|
|
49
|
+
// credentials isn't one.
|
|
50
|
+
if (url.origin !== origin || url.protocol !== "https:" || url.port !== "")
|
|
51
|
+
return false;
|
|
52
|
+
const host = url.hostname;
|
|
53
|
+
return host === "decocms.com" || host.endsWith(".decocms.com");
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=studio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"studio.js","sourceRoot":"","sources":["../src/studio.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAsB,MAAM,CAAC,MAAM,CAAC;IAC7D,4BAA4B;IAC5B,gCAAgC;IAChC,qBAAqB;IACrB,uBAAuB;CACxB,CAAC,CAAC;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAsB,MAAM,CAAC,MAAM,CAAC;IACrE,QAAQ;IACR,GAAG,cAAc;IACjB,uBAAuB;CACxB,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,MAAiC;IAC9D,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,8EAA8E;IAC9E,yBAAyB;IACzB,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IACxF,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC;IAC1B,OAAO,IAAI,KAAK,aAAa,IAAI,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;AACjE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,48 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
7
7
|
},
|
|
8
|
-
"description": "Deco
|
|
8
|
+
"description": "Deco CMS for any Next.js App Router app: draft previews in the proxy and the pages, the preview badge, the Studio bridge, withDeco() and a next/image loader",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "https://github.com/decocms/blocks.git",
|
|
12
12
|
"directory": "packages/nextjs"
|
|
13
13
|
},
|
|
14
|
-
"
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/__tests__"
|
|
20
|
+
],
|
|
15
21
|
"exports": {
|
|
16
|
-
".":
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"source": "./src/index.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./proxy": {
|
|
28
|
+
"types": "./dist/proxy/index.d.ts",
|
|
29
|
+
"source": "./src/proxy/index.ts",
|
|
30
|
+
"default": "./dist/proxy/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./server": {
|
|
33
|
+
"types": "./dist/server/index.d.ts",
|
|
34
|
+
"source": "./src/server/index.ts",
|
|
35
|
+
"default": "./dist/server/index.js"
|
|
36
|
+
},
|
|
19
37
|
"./config": {
|
|
20
|
-
"types": "./src/config.d.cts",
|
|
21
|
-
"default": "./src/config.cjs"
|
|
38
|
+
"types": "./src/config/index.d.cts",
|
|
39
|
+
"default": "./src/config/index.cjs"
|
|
22
40
|
},
|
|
23
|
-
"./
|
|
41
|
+
"./instrumentation": {
|
|
42
|
+
"types": "./dist/instrumentation/index.d.ts",
|
|
43
|
+
"source": "./src/instrumentation/index.ts",
|
|
44
|
+
"default": "./dist/instrumentation/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./image-loader": {
|
|
47
|
+
"types": "./dist/image-loader/index.d.ts",
|
|
48
|
+
"source": "./src/image-loader/index.ts",
|
|
49
|
+
"default": "./dist/image-loader/index.js"
|
|
50
|
+
}
|
|
24
51
|
},
|
|
25
52
|
"scripts": {
|
|
26
|
-
"build": "tsc",
|
|
53
|
+
"build": "node ../../scripts/tsc.mjs --clean dist -p tsconfig.build.json",
|
|
54
|
+
"prepack": "npm run build",
|
|
27
55
|
"test": "vitest run --root ../.. packages/nextjs/",
|
|
28
|
-
"typecheck": "tsc
|
|
29
|
-
"lint:unused": "knip"
|
|
30
|
-
},
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@decocms/blocks": "8.1.0-next.0",
|
|
33
|
-
"@decocms/blocks-admin": "8.1.0-next.0"
|
|
56
|
+
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
|
|
34
57
|
},
|
|
35
58
|
"peerDependencies": {
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
59
|
+
"@decocms/blocks": "^8.4.0",
|
|
60
|
+
"@opentelemetry/api": "^1.0.0",
|
|
61
|
+
"next": "^16.0.0",
|
|
62
|
+
"react": "^19.0.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@opentelemetry/api": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
39
68
|
},
|
|
40
69
|
"devDependencies": {
|
|
70
|
+
"@decocms/blocks": "8.5.0",
|
|
71
|
+
"@opentelemetry/api": "^1.9.1",
|
|
41
72
|
"@types/react": "^19.0.0",
|
|
42
73
|
"@types/react-dom": "^19.0.0",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
74
|
+
"next": "16.3.8",
|
|
75
|
+
"react": "19.2.7",
|
|
76
|
+
"react-dom": "19.2.7"
|
|
46
77
|
},
|
|
47
78
|
"publishConfig": {
|
|
48
79
|
"registry": "https://registry.npmjs.org",
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The "Preview mode" badge for the request's draft: a Server Component that
|
|
3
|
+
* renders `DraftBadge` (`@decocms/blocks/preview`) only when `bindDraft`
|
|
4
|
+
* bound a draft, so a published page emits nothing.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* nothing at all.
|
|
8
|
-
*
|
|
9
|
-
* Placement rule (inherited from `ensureDraft`): render this INSIDE the page
|
|
10
|
-
* subtree, after `ensureDraft` has been awaited — never from a layout, whose
|
|
11
|
-
* children render concurrently with the page that fills the slot. Sites using
|
|
12
|
-
* `createDecoPage` get it wired automatically; hand-rolled routes drop
|
|
13
|
-
* `<DraftPreviewIndicator />` at the end of their page.
|
|
6
|
+
* Render it inside the page, after `bindDraft` was awaited, never from a
|
|
7
|
+
* layout: a layout renders alongside the page that fills it, so it may run
|
|
8
|
+
* before the page bound its draft.
|
|
14
9
|
*/
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
10
|
+
import { DraftBadge } from "@decocms/blocks/preview";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { activeDraftPointer } from "./server/slot.ts";
|
|
13
|
+
|
|
14
|
+
export interface DraftPreviewIndicatorProps {
|
|
15
|
+
/** Where "Exit preview" goes; default: the current URL with `?__draft=off`. */
|
|
16
|
+
exitHref?: string;
|
|
17
|
+
}
|
|
17
18
|
|
|
18
|
-
export function DraftPreviewIndicator() {
|
|
19
|
-
const pointer =
|
|
19
|
+
export function DraftPreviewIndicator({ exitHref }: DraftPreviewIndicatorProps = {}): ReactNode {
|
|
20
|
+
const pointer = activeDraftPointer();
|
|
20
21
|
if (!pointer) return null;
|
|
21
|
-
return <
|
|
22
|
+
return <DraftBadge pointer={pointer} exitHref={exitHref} />;
|
|
22
23
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StudioBridge` of `@decocms/blocks/preview` with Studio's origins filled in
|
|
3
|
+
* (`STUDIO_ORIGINS`, `./studio.ts`): the package owns where Studio runs, so a
|
|
4
|
+
* site passes only its name. One inline script and no hooks, so a Server
|
|
5
|
+
* Component renders it as is.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
StudioBridge as BridgeScript,
|
|
9
|
+
type StudioBridgeProps as BridgeScriptProps,
|
|
10
|
+
} from "@decocms/blocks/preview";
|
|
11
|
+
import { createElement, type ReactNode } from "react";
|
|
12
|
+
import { STUDIO_ORIGINS } from "./studio.ts";
|
|
13
|
+
|
|
14
|
+
/** `site`, and optionally `editShortcut` and `nonce`; Studio's origins are the package's. */
|
|
15
|
+
export type StudioBridgeProps = Omit<BridgeScriptProps, "trustedOrigins">;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lets Studio's site editor select and edit sections on the page when Studio
|
|
19
|
+
* frames it. Render it once, in the root layout's `<body>`.
|
|
20
|
+
*/
|
|
21
|
+
export function StudioBridge(props: StudioBridgeProps): ReactNode {
|
|
22
|
+
return createElement(BridgeScript, { ...props, trustedOrigins: STUDIO_ORIGINS });
|
|
23
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@decocms/nextjs/config`: `withDeco(nextConfig)` for `next.config`.
|
|
3
|
+
* Plain CommonJS JavaScript, shipped as is, so a CommonJS `next.config.js`
|
|
4
|
+
* can `require()` it; an ESM `next.config.mjs`/`.ts` imports it the same way.
|
|
5
|
+
*
|
|
6
|
+
* It adds two things and nothing about hosting (no rewrites, no image hosts,
|
|
7
|
+
* no CDN rules):
|
|
8
|
+
*
|
|
9
|
+
* 1. `draftHeaders` (default on): v7's two header rules, first, so a draft
|
|
10
|
+
* is never stored or indexed. A rule's `has` entries are ANDed and the two
|
|
11
|
+
* signals never meet: a draft link carries the query (the proxy sets the
|
|
12
|
+
* cookie on that response), every navigation after it the cookie.
|
|
13
|
+
* On a dynamic App Router response Next sets `Cache-Control` and `Vary`
|
|
14
|
+
* itself, so only `X-Robots-Tag` is sure to arrive from here; the proxy's
|
|
15
|
+
* `applyDraft` sets all three on the responses it makes.
|
|
16
|
+
* 2. `studioOrigins` (default on): Studio's origins in `allowedDevOrigins`,
|
|
17
|
+
* so `next dev` serves Studio's local mode its dev assets.
|
|
18
|
+
*
|
|
19
|
+
* The names repeat the package's (`DRAFT_PARAM`, `DRAFT_COOKIE`,
|
|
20
|
+
* `draftResponseHeaders` in `src/draft.ts`, `STUDIO_ORIGINS` in
|
|
21
|
+
* `src/studio.ts`): a CommonJS file can't load an ES module everywhere
|
|
22
|
+
* `next.config` runs. `config.test.ts` holds them equal.
|
|
23
|
+
*/
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
/** `draftResponseHeaders(pointer)` (`src/draft.ts`), as `next.config` header entries. */
|
|
27
|
+
const DRAFT_NO_STORE_HEADERS = [
|
|
28
|
+
{ key: "Cache-Control", value: "no-store, private" },
|
|
29
|
+
{ key: "Vary", value: "Cookie" },
|
|
30
|
+
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** v7's draft rules: the `?__draft=` query, or the `__deco_draft` cookie. */
|
|
34
|
+
const DECO_DRAFT_HEADERS = [
|
|
35
|
+
{
|
|
36
|
+
source: "/:path*",
|
|
37
|
+
has: [{ type: "query", key: "__draft" }],
|
|
38
|
+
headers: DRAFT_NO_STORE_HEADERS,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
source: "/:path*",
|
|
42
|
+
has: [{ type: "cookie", key: "__deco_draft" }],
|
|
43
|
+
headers: DRAFT_NO_STORE_HEADERS,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `STUDIO_ORIGINS` as `allowedDevOrigins` hosts: `https://*.decocms.com`
|
|
49
|
+
* matches any name under `decocms.com` (`isStudioOrigin`), which Next writes
|
|
50
|
+
* as `**.decocms.com`.
|
|
51
|
+
*/
|
|
52
|
+
const STUDIO_DEV_ORIGINS = [
|
|
53
|
+
"studio.decocms.com",
|
|
54
|
+
"studio-stg.decocms.com",
|
|
55
|
+
"decocms.com",
|
|
56
|
+
"**.decocms.com",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function withDeco(nextConfig = {}, options = {}) {
|
|
60
|
+
const { draftHeaders = true, studioOrigins = true } = options;
|
|
61
|
+
const config = { ...nextConfig };
|
|
62
|
+
if (studioOrigins) {
|
|
63
|
+
config.allowedDevOrigins = [
|
|
64
|
+
...new Set([...(nextConfig.allowedDevOrigins ?? []), ...STUDIO_DEV_ORIGINS]),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
if (draftHeaders) {
|
|
68
|
+
const siteHeaders = nextConfig.headers;
|
|
69
|
+
config.headers = async () => [
|
|
70
|
+
// First: a site-wide Cache-Control rule must not win over a draft's.
|
|
71
|
+
...DECO_DRAFT_HEADERS,
|
|
72
|
+
...(siteHeaders ? await siteHeaders() : []),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { withDeco, DECO_DRAFT_HEADERS, STUDIO_DEV_ORIGINS };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
/** A `next.config` header rule. */
|
|
4
|
+
export interface HeaderRule {
|
|
5
|
+
source: string;
|
|
6
|
+
has?: { type: string; key: string; value?: string }[];
|
|
7
|
+
headers: { key: string; value: string }[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WithDecoOptions {
|
|
11
|
+
/** v7's draft header rules (no-store, `Vary: Cookie`, noindex), before the site's own. Default `true`. */
|
|
12
|
+
draftHeaders?: boolean;
|
|
13
|
+
/** Studio's origins in `allowedDevOrigins`. Default `true`. */
|
|
14
|
+
studioOrigins?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The site's `next.config` with Deco's draft header rules and Studio's dev origins. */
|
|
18
|
+
export declare function withDeco<C extends NextConfig>(
|
|
19
|
+
nextConfig?: C,
|
|
20
|
+
options?: WithDecoOptions,
|
|
21
|
+
): C & NextConfig;
|
|
22
|
+
|
|
23
|
+
/** The header rules `withDeco` puts first: a `?__draft=` query, or the `__deco_draft` cookie. */
|
|
24
|
+
export declare const DECO_DRAFT_HEADERS: HeaderRule[];
|
|
25
|
+
|
|
26
|
+
/** `STUDIO_ORIGINS` (`@decocms/nextjs`) in `allowedDevOrigins` form. */
|
|
27
|
+
export declare const STUDIO_DEV_ORIGINS: string[];
|
package/src/draft.ts
CHANGED
|
@@ -1,231 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|
|
22
|
+
export const DRAFT_COOKIE = "__deco_draft";
|
|
81
23
|
|
|
82
24
|
/**
|
|
83
|
-
* The
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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.
|
|
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.
|
|
90
29
|
*/
|
|
91
|
-
export
|
|
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
|
-
}
|
|
30
|
+
export const DRAFT_HEADER = "x-deco-draft";
|
|
100
31
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
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
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
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.
|
|
164
|
-
*/
|
|
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
|
-
}
|
|
32
|
+
/** The URL prefix a drafted request is rewritten onto (`app/%5Fdraft/[[...path]]`). */
|
|
33
|
+
export const DRAFT_ROUTE_PREFIX = "/_draft";
|
|
182
34
|
|
|
183
35
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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`.
|
|
36
|
+
* The response headers of a page rendered from a draft: never stored by a
|
|
37
|
+
* browser or a CDN (`Cache-Control: no-store, private`, `Vary: Cookie`) and
|
|
38
|
+
* never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when `pointer` is
|
|
39
|
+
* `null`, so the result of the draft decision applies as is.
|
|
197
40
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 };
|
|
41
|
+
export function draftResponseHeaders(pointer: string | null | undefined): Record<string, string> {
|
|
42
|
+
if (!pointer) return {};
|
|
43
|
+
return {
|
|
44
|
+
"Cache-Control": "no-store, private",
|
|
45
|
+
Vary: "Cookie",
|
|
46
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
47
|
+
};
|
|
214
48
|
}
|
|
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;
|