@capacms/sdk 1.0.0-next.7 → 1.0.0-next.9
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/CHANGELOG.md +64 -1
- package/README.md +292 -29
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +510 -0
- package/dist/esm/overlay/protocol.d.ts +134 -0
- package/dist/esm/overlay/protocol.js +173 -0
- package/dist/esm/package.json +4 -0
- package/dist/next/client.d.ts +6 -6
- package/dist/next/client.js +2 -1
- package/dist/next/field-names.js +1 -1
- package/dist/next/graphql/introspection.d.ts +1 -1
- package/dist/next/graphql/introspection.js +1 -1
- package/dist/next/graphql/plan.js +2 -2
- package/dist/next/key-family.d.ts +9 -12
- package/dist/next/key-family.js +11 -19
- package/dist/nextjs/index.d.ts +149 -9
- package/dist/nextjs/index.js +207 -15
- package/dist/nextjs/overlay.d.ts +26 -1
- package/dist/nextjs/overlay.js +49 -6
- package/dist/overlay/index.d.ts +14 -2
- package/dist/overlay/index.js +168 -45
- package/package.json +12 -4
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live preview protocol, site side. Pure: no DOM, no globals, so it can be
|
|
3
|
+
* tested under plain `node --test`.
|
|
4
|
+
*
|
|
5
|
+
* The Capa admin frames the site and the two talk over `postMessage`. Every
|
|
6
|
+
* message is a plain object `{ source, v: 1, type, ...fields }`:
|
|
7
|
+
*
|
|
8
|
+
* admin -> site (source "capa-admin")
|
|
9
|
+
* hello {} sent on the frame's load
|
|
10
|
+
* highlight { entryId, field: string | null } entryId "" clears
|
|
11
|
+
* outline { on: boolean } outline every tagged element
|
|
12
|
+
* refresh {} re-render the draft
|
|
13
|
+
*
|
|
14
|
+
* site -> admin (source "capa")
|
|
15
|
+
* ready { path, entries } after hello, and after every navigation
|
|
16
|
+
* select { entryId, field } a tagged element was clicked
|
|
17
|
+
* hover { entryId, field } | { entryId: "", field: null }
|
|
18
|
+
* visible { entryId, field } the tagged element at the centre of the
|
|
19
|
+
* viewport changed (throttled, on scroll)
|
|
20
|
+
*
|
|
21
|
+
* `visible` arrived in 1.0.0-next.2 and is OPTIONAL: it is still `v: 1`,
|
|
22
|
+
* because an admin that does not know it drops an unknown type, and an older
|
|
23
|
+
* overlay simply never sends it. Additive messages keep the version; only a
|
|
24
|
+
* change to an existing shape would move it.
|
|
25
|
+
*
|
|
26
|
+
* The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
|
|
27
|
+
* this package). If you change one side, change the other.
|
|
28
|
+
*/
|
|
29
|
+
export const PROTOCOL_VERSION = 1;
|
|
30
|
+
export const ADMIN_SOURCE = "capa-admin";
|
|
31
|
+
export const SITE_SOURCE = "capa";
|
|
32
|
+
/**
|
|
33
|
+
* `https://admin.example.com/` and `https://admin.example.com` are one origin,
|
|
34
|
+
* and a configured value that is not a URL at all is dropped rather than
|
|
35
|
+
* compared as a string, because `"*"` must never mean "anyone".
|
|
36
|
+
*/
|
|
37
|
+
export function normaliseOrigins(origins) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const raw of origins) {
|
|
40
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const origin = new URL(raw.trim()).origin;
|
|
44
|
+
if (origin !== "null" && !out.includes(origin))
|
|
45
|
+
out.push(origin);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Not a URL: never an origin anybody can send from.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The one gate every incoming message goes through. A message is accepted only
|
|
55
|
+
* when all of these hold, and is `null` otherwise:
|
|
56
|
+
*
|
|
57
|
+
* - it came from the window that framed this page (`parent`), not from a
|
|
58
|
+
* popup, a sibling frame or this page itself
|
|
59
|
+
* - its origin is one the site configured as its admin
|
|
60
|
+
* - it says it is from the admin, speaks version 1, and has a known type
|
|
61
|
+
* whose fields have the right shapes
|
|
62
|
+
*
|
|
63
|
+
* `allowedOrigins` is expected already normalised (`normaliseOrigins`).
|
|
64
|
+
*/
|
|
65
|
+
export function acceptMessage(event, allowedOrigins, parent) {
|
|
66
|
+
if (!event || event.source !== parent || parent == null)
|
|
67
|
+
return null;
|
|
68
|
+
if (typeof event.origin !== "string" || !allowedOrigins.includes(event.origin))
|
|
69
|
+
return null;
|
|
70
|
+
const data = event.data;
|
|
71
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
72
|
+
return null;
|
|
73
|
+
const m = data;
|
|
74
|
+
if (m.source !== ADMIN_SOURCE || m.v !== PROTOCOL_VERSION)
|
|
75
|
+
return null;
|
|
76
|
+
switch (m.type) {
|
|
77
|
+
case "hello":
|
|
78
|
+
return { source: ADMIN_SOURCE, v: 1, type: "hello" };
|
|
79
|
+
case "refresh":
|
|
80
|
+
return { source: ADMIN_SOURCE, v: 1, type: "refresh" };
|
|
81
|
+
case "outline":
|
|
82
|
+
if (typeof m.on !== "boolean")
|
|
83
|
+
return null;
|
|
84
|
+
return { source: ADMIN_SOURCE, v: 1, type: "outline", on: m.on };
|
|
85
|
+
case "highlight":
|
|
86
|
+
if (typeof m.entryId !== "string")
|
|
87
|
+
return null;
|
|
88
|
+
if (m.field !== null && typeof m.field !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
return { source: ADMIN_SOURCE, v: 1, type: "highlight", entryId: m.entryId, field: m.field };
|
|
91
|
+
default:
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function readyMessage(path, entries) {
|
|
96
|
+
return { source: SITE_SOURCE, v: 1, type: "ready", path, entries };
|
|
97
|
+
}
|
|
98
|
+
export function selectMessage(entryId, field) {
|
|
99
|
+
return { source: SITE_SOURCE, v: 1, type: "select", entryId, field };
|
|
100
|
+
}
|
|
101
|
+
export function hoverMessage(entryId, field) {
|
|
102
|
+
return entryId === ""
|
|
103
|
+
? { source: SITE_SOURCE, v: 1, type: "hover", entryId: "", field: null }
|
|
104
|
+
: { source: SITE_SOURCE, v: 1, type: "hover", entryId, field };
|
|
105
|
+
}
|
|
106
|
+
export function visibleMessage(entryId, field) {
|
|
107
|
+
return { source: SITE_SOURCE, v: 1, type: "visible", entryId, field };
|
|
108
|
+
}
|
|
109
|
+
/** Above any element height, so a box that misses the centre line never outranks one that crosses it. */
|
|
110
|
+
const MISS_FLOOR = 1e9;
|
|
111
|
+
/**
|
|
112
|
+
* The tagged element a reader is looking at: the one that contains the
|
|
113
|
+
* viewport's horizontal centre line, and when several do (a field inside a
|
|
114
|
+
* card that is also tagged) the SMALLEST, because the innermost element is
|
|
115
|
+
* the specific one. When none crosses the line, the nearest one that is at
|
|
116
|
+
* least partly on screen. Null when nothing tagged is on screen at all.
|
|
117
|
+
*
|
|
118
|
+
* `edge` is where the page is scrolled to. A heading near the top of a page
|
|
119
|
+
* can never reach the centre line, because the page cannot scroll above its
|
|
120
|
+
* top; at the top the topmost visible element is the one being read, and at
|
|
121
|
+
* the bottom the bottommost. The same rule a table of contents' scroll spy
|
|
122
|
+
* uses.
|
|
123
|
+
*
|
|
124
|
+
* Pure, so the admin's "follow the page" can be tested without a DOM.
|
|
125
|
+
*/
|
|
126
|
+
export function pickCentred(boxes, viewportHeight, edge = null) {
|
|
127
|
+
const centre = viewportHeight / 2;
|
|
128
|
+
let best = null;
|
|
129
|
+
let bestScore = Infinity;
|
|
130
|
+
for (const b of boxes) {
|
|
131
|
+
if (!b.entryId || !b.field)
|
|
132
|
+
continue;
|
|
133
|
+
if (b.bottom <= 0 || b.top >= viewportHeight || b.bottom <= b.top)
|
|
134
|
+
continue;
|
|
135
|
+
const height = b.bottom - b.top;
|
|
136
|
+
let score;
|
|
137
|
+
if (edge === "top") {
|
|
138
|
+
// Topmost first; of two that start together, the smaller (inner) one.
|
|
139
|
+
score = b.top * MISS_FLOOR + height;
|
|
140
|
+
}
|
|
141
|
+
else if (edge === "bottom") {
|
|
142
|
+
score = (viewportHeight - b.bottom) * MISS_FLOOR + height;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// Crossing the line scores by height (smaller wins) and always beats a
|
|
146
|
+
// box that misses it, which scores by its distance from the line on top
|
|
147
|
+
// of a floor no height can reach.
|
|
148
|
+
score =
|
|
149
|
+
b.top <= centre && b.bottom >= centre
|
|
150
|
+
? height
|
|
151
|
+
: MISS_FLOOR + Math.min(Math.abs(b.top - centre), Math.abs(b.bottom - centre));
|
|
152
|
+
}
|
|
153
|
+
if (score < bestScore) {
|
|
154
|
+
best = b;
|
|
155
|
+
bestScore = score;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return best;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Which edge a scroll position is at, for `pickCentred`. A page that does not
|
|
162
|
+
* scroll at all is at neither: nothing moves, and the centre rule stands.
|
|
163
|
+
*/
|
|
164
|
+
export function scrollEdge(scrollY, viewportHeight, scrollHeight) {
|
|
165
|
+
const max = scrollHeight - viewportHeight;
|
|
166
|
+
if (max <= 1)
|
|
167
|
+
return null;
|
|
168
|
+
if (scrollY <= 1)
|
|
169
|
+
return "top";
|
|
170
|
+
if (scrollY >= max - 1)
|
|
171
|
+
return "bottom";
|
|
172
|
+
return null;
|
|
173
|
+
}
|
package/dist/next/client.d.ts
CHANGED
|
@@ -9,9 +9,9 @@ export interface CapaNextConfig {
|
|
|
9
9
|
baseUrl: string;
|
|
10
10
|
/**
|
|
11
11
|
* A `cap_` key, or the legacy key a site already holds: `pk_`, `sk_`, or
|
|
12
|
-
* an older key with no prefix. A legacy key reads through every read call
|
|
13
|
-
* and warns once per process;
|
|
14
|
-
* only.
|
|
12
|
+
* an older key with no prefix. A legacy key reads through every read call,
|
|
13
|
+
* `preview()` included, and warns once per process; the draft clients of
|
|
14
|
+
* `@capacms/sdk/nextjs` take a `cap_` key only.
|
|
15
15
|
*/
|
|
16
16
|
apiKey: string;
|
|
17
17
|
version: string;
|
|
@@ -210,7 +210,7 @@ export interface EntriesResource {
|
|
|
210
210
|
* One suggestion drawn from a page's own reads.
|
|
211
211
|
*
|
|
212
212
|
* Computed on the API, never here. Three clients want this answer (this SDK,
|
|
213
|
-
* the Capa admin and `@
|
|
213
|
+
* the Capa admin and `@capacms/mcp`), and a second implementation of "this page
|
|
214
214
|
* over-fetches" would drift from the first the moment a threshold moved.
|
|
215
215
|
*/
|
|
216
216
|
export interface PageInsight {
|
|
@@ -401,8 +401,8 @@ export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions =
|
|
|
401
401
|
* Returns the claim, or NULL when the token is invalid or expired, because
|
|
402
402
|
* both mean the same thing to a preview route: do not enable draft mode.
|
|
403
403
|
* Every other failure throws, so a Capa outage does not look like a bad link.
|
|
404
|
-
*
|
|
405
|
-
*
|
|
404
|
+
* Takes any key the site holds, a legacy `pk_`, `sk_` or unprefixed key
|
|
405
|
+
* included: Capa answers a token minted for another tenant as invalid.
|
|
406
406
|
*/
|
|
407
407
|
preview(token: string, options?: CallOptions): Promise<PreviewClaim | null>;
|
|
408
408
|
me<T = Record<string, unknown>>(options?: CallOptions): Promise<T>;
|
package/dist/next/client.js
CHANGED
|
@@ -501,7 +501,8 @@ function createClient(config) {
|
|
|
501
501
|
return readSchema(options.signal);
|
|
502
502
|
},
|
|
503
503
|
async preview(token, options = {}) {
|
|
504
|
-
|
|
504
|
+
// Any key the site holds, legacy included: the API checks the token
|
|
505
|
+
// belongs to the key's own tenant (see key-family.ts).
|
|
505
506
|
const query = new URLSearchParams();
|
|
506
507
|
query.set("token", token);
|
|
507
508
|
try {
|
package/dist/next/field-names.js
CHANGED
|
@@ -28,7 +28,7 @@ exports.readPath = readPath;
|
|
|
28
28
|
*
|
|
29
29
|
* The API's own writer is `writeName` in `@capa/shared`. This package ships
|
|
30
30
|
* with no runtime dependencies, so the rule is copied here and in
|
|
31
|
-
* `@
|
|
31
|
+
* `@capacms/mcp`, and test/fixtures/field-names.json pins all three to the same
|
|
32
32
|
* vectors.
|
|
33
33
|
*/
|
|
34
34
|
const system_keys_1 = require("./system-keys");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* One request carries both the standard introspection selection and the
|
|
6
6
|
* `version` root field, so a schema summary always says which platform version
|
|
7
|
-
* it describes. Kept byte-stable: `@
|
|
7
|
+
* it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
|
|
8
8
|
* persisted copy of it is one hash for every client.
|
|
9
9
|
*
|
|
10
10
|
* Deprecated arguments and input fields are asked for too, with their
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* One request carries both the standard introspection selection and the
|
|
7
7
|
* `version` root field, so a schema summary always says which platform version
|
|
8
|
-
* it describes. Kept byte-stable: `@
|
|
8
|
+
* it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
|
|
9
9
|
* persisted copy of it is one hash for every client.
|
|
10
10
|
*
|
|
11
11
|
* Deprecated arguments and input fields are asked for too, with their
|
|
@@ -80,7 +80,7 @@ function didYouMean(wanted, candidates) {
|
|
|
80
80
|
/**
|
|
81
81
|
* The refusal for a model the key reads that GraphQL leaves out (N1): REST
|
|
82
82
|
* serves it, so the message names that read and why, and suggests no other
|
|
83
|
-
* model. `@
|
|
83
|
+
* model. `@capacms/mcp` says the same, pinned by a test.
|
|
84
84
|
*/
|
|
85
85
|
function restOnlyModel(namespace, restOnly) {
|
|
86
86
|
// N1 leaves models out when their type names would collide, as these
|
|
@@ -176,7 +176,7 @@ function checkSort(value, model, where) {
|
|
|
176
176
|
// A spec written as REST writes a read (`author.name` or `author(name)` for a
|
|
177
177
|
// relation's fields, `*` for every field, `-publishedAt` for a sort) is
|
|
178
178
|
// refused with what to write instead, spelled out, since the same read is one
|
|
179
|
-
// edit away. @
|
|
179
|
+
// edit away. @capacms/mcp refuses them in the same words, pinned by
|
|
180
180
|
// test/fixtures/graphql-rest-forms.json.
|
|
181
181
|
/** A REST sort key as a sort value: `-publishedAt` is `publishedAt_DESC`, `author.name` is `author__name_ASC`. */
|
|
182
182
|
function restSortValue(value) {
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
|
|
3
3
|
*
|
|
4
4
|
* A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* the draft clients take to read drafts. Every other key is legacy, which is
|
|
6
|
+
* the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
|
|
7
|
+
* `sk_` keys, and the unprefixed keys older tenants were minted, all reach
|
|
8
|
+
* `/api/` with the grants their permission gives. So every read call takes
|
|
9
|
+
* them, `preview()` included: `GET /api/preview` asks for `instance:read`,
|
|
10
|
+
* which every legacy permission grants, and refuses a token minted for another
|
|
11
|
+
* tenant. The first client built with one warns once per process, naming the
|
|
12
|
+
* key to mint.
|
|
11
13
|
*
|
|
12
14
|
* The `cap_` prefix is the whole rule, case-sensitive, as on the API:
|
|
13
15
|
* `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
|
|
14
|
-
* `@
|
|
16
|
+
* `@capacms/mcp` applies the same rule, and both packages are tested against one
|
|
15
17
|
* vector file, `test/fixtures/key-family.json`.
|
|
16
18
|
*/
|
|
17
19
|
export type KeyFamily = "cap" | "legacy";
|
|
@@ -19,11 +21,6 @@ export type KeyFamily = "cap" | "legacy";
|
|
|
19
21
|
export declare function keyFamily(apiKey: unknown): KeyFamily | null;
|
|
20
22
|
/** Warn about a legacy key once per process: a site builds a client per request. */
|
|
21
23
|
export declare function warnLegacyKeyOnce(apiKey: string): void;
|
|
22
|
-
/**
|
|
23
|
-
* Throws for a legacy key: verifying a preview token takes a `cap_` key. A
|
|
24
|
-
* value that is no key at all is `resolveNextConfig`'s to refuse, by name.
|
|
25
|
-
*/
|
|
26
|
-
export declare function requirePreviewKey(apiKey: string): void;
|
|
27
24
|
/**
|
|
28
25
|
* Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
|
|
29
26
|
* where the key came from (`CAPA_DRAFT_KEY`, `the draft config`), so the
|
package/dist/next/key-family.js
CHANGED
|
@@ -3,22 +3,23 @@
|
|
|
3
3
|
* key-family.ts — which keys `@capacms/sdk/next` takes, and for what.
|
|
4
4
|
*
|
|
5
5
|
* A `cap_` key is the `/api/` key: scoped, hashed at rest, and the only kind
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* the draft clients take to read drafts. Every other key is legacy, which is
|
|
7
|
+
* the API's rule (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and
|
|
8
|
+
* `sk_` keys, and the unprefixed keys older tenants were minted, all reach
|
|
9
|
+
* `/api/` with the grants their permission gives. So every read call takes
|
|
10
|
+
* them, `preview()` included: `GET /api/preview` asks for `instance:read`,
|
|
11
|
+
* which every legacy permission grants, and refuses a token minted for another
|
|
12
|
+
* tenant. The first client built with one warns once per process, naming the
|
|
13
|
+
* key to mint.
|
|
12
14
|
*
|
|
13
15
|
* The `cap_` prefix is the whole rule, case-sensitive, as on the API:
|
|
14
16
|
* `CAP_live_…` is not a key Capa mints, so it is looked up as a legacy key.
|
|
15
|
-
* `@
|
|
17
|
+
* `@capacms/mcp` applies the same rule, and both packages are tested against one
|
|
16
18
|
* vector file, `test/fixtures/key-family.json`.
|
|
17
19
|
*/
|
|
18
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
21
|
exports.keyFamily = keyFamily;
|
|
20
22
|
exports.warnLegacyKeyOnce = warnLegacyKeyOnce;
|
|
21
|
-
exports.requirePreviewKey = requirePreviewKey;
|
|
22
23
|
exports.requireDraftKey = requireDraftKey;
|
|
23
24
|
exports.__resetKeyWarningForTests = __resetKeyWarningForTests;
|
|
24
25
|
const LEGACY_PREFIX = /^(pk|sk)_/;
|
|
@@ -46,17 +47,8 @@ function warnLegacyKeyOnce(apiKey) {
|
|
|
46
47
|
if (warned)
|
|
47
48
|
return;
|
|
48
49
|
warned = true;
|
|
49
|
-
console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads work as they do with a cap_ key. ` +
|
|
50
|
-
`Draft
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Throws for a legacy key: verifying a preview token takes a `cap_` key. A
|
|
54
|
-
* value that is no key at all is `resolveNextConfig`'s to refuse, by name.
|
|
55
|
-
*/
|
|
56
|
-
function requirePreviewKey(apiKey) {
|
|
57
|
-
if (!isLegacy(apiKey))
|
|
58
|
-
return;
|
|
59
|
-
throw new TypeError(`@capacms/sdk/next: preview needs a cap_ key, and this client holds a ${legacyKind(apiKey)}. Mint a cap_ key ${MINT}`);
|
|
50
|
+
console.warn(`@capacms/sdk/next: this client reads with a ${legacyKind(apiKey)}. Reads and preview links work as they do with a cap_ key. ` +
|
|
51
|
+
`Draft reads need a cap_ key: mint one ${MINT}`);
|
|
60
52
|
}
|
|
61
53
|
/**
|
|
62
54
|
* Throws for a legacy key: a draft read takes a `cap_` key. `holder` names
|
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -111,6 +111,10 @@ export type { PreviewClaim };
|
|
|
111
111
|
* clickable. It never switches the site to draft data.
|
|
112
112
|
*/
|
|
113
113
|
export declare const EDIT_PARAM = "capa-edit";
|
|
114
|
+
/** The query parameter a Capa preview link carries: `?capa-preview=<token>`. */
|
|
115
|
+
export declare const PREVIEW_PARAM = "capa-preview";
|
|
116
|
+
/** The query parameter of the editor's Published view: `?capa-view=published`. */
|
|
117
|
+
export declare const VIEW_PARAM = "capa-view";
|
|
114
118
|
/**
|
|
115
119
|
* The request header `resolveEditRequest` sets once a `capa-edit` token has
|
|
116
120
|
* been verified, for `editMode()` to read. Any copy a browser sent is removed
|
|
@@ -157,6 +161,8 @@ export interface EditRequest {
|
|
|
157
161
|
headers: Headers;
|
|
158
162
|
/** `EDIT_CACHE_CONTROL` in edit mode, otherwise null. Set it on the response. */
|
|
159
163
|
cacheControl: string | null;
|
|
164
|
+
/** `DRAFT_ROBOTS_TAG` in edit mode, otherwise null. Set it on the response as `X-Robots-Tag`. */
|
|
165
|
+
robotsTag: string | null;
|
|
160
166
|
}
|
|
161
167
|
/**
|
|
162
168
|
* The middleware half of edit mode.
|
|
@@ -165,6 +171,7 @@ export interface EditRequest {
|
|
|
165
171
|
* const edit = await resolveEditRequest(request, publishedClient());
|
|
166
172
|
* const response = NextResponse.next({ request: { headers: edit.headers } });
|
|
167
173
|
* if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
|
|
174
|
+
* if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
|
|
168
175
|
* return response;
|
|
169
176
|
* }
|
|
170
177
|
*
|
|
@@ -323,29 +330,149 @@ export declare function graphql<D extends keyof CapaDocuments>(document: D, ...r
|
|
|
323
330
|
export declare function graphql<TData = Record<string, unknown>, TVariables = Record<string, unknown>, D extends string = string>(document: NotARecordedDocument<D, TypedDocument<TData, TVariables> | D>, ...rest: VariablesThenOptions<TVariables, NextGraphQLOptions>): Promise<GraphQLResult<TData>>;
|
|
324
331
|
/** Only a path on this site: never `//elsewhere.example` or a full URL. */
|
|
325
332
|
export declare function safeSitePath(value: string | null | undefined): string;
|
|
333
|
+
/** The Capa admin's origin: what frames a draft in the editor, unless a site names another. */
|
|
334
|
+
export declare const CAPA_ADMIN_ORIGIN = "https://app.capacms.com";
|
|
335
|
+
/** `X-Robots-Tag` on every draft and edit-mode response: a draft is never indexed, nor its links followed. */
|
|
336
|
+
export declare const DRAFT_ROBOTS_TAG = "noindex, nofollow";
|
|
337
|
+
/** How long the draft cookie lasts, in seconds: one hour, as a preview token does. */
|
|
338
|
+
export declare const DRAFT_COOKIE_MAX_AGE = 3600;
|
|
339
|
+
/**
|
|
340
|
+
* `frame-ancestors 'self' <origins>`, the CSP directive that lets the Capa
|
|
341
|
+
* editor frame a draft and nothing else frame it. Each origin is a scheme and
|
|
342
|
+
* a host, with a port when it has one, and is written as the URL parser
|
|
343
|
+
* normalises it, so no `;` or quote can reach the policy. Anything else, a
|
|
344
|
+
* path or a query included, throws a `TypeError`.
|
|
345
|
+
*/
|
|
346
|
+
export declare function frameAncestors(adminOrigins?: readonly string[]): string;
|
|
347
|
+
/**
|
|
348
|
+
* The headers every draft response carries: `X-Robots-Tag: noindex, nofollow`,
|
|
349
|
+
* and `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
|
|
350
|
+
* (or the `adminOrigins` given). Throws for an origin that is not one.
|
|
351
|
+
*/
|
|
352
|
+
export declare function draftHeaders(options?: {
|
|
353
|
+
adminOrigins?: readonly string[];
|
|
354
|
+
}): Record<string, string>;
|
|
355
|
+
/** One rule of `next.config`'s `headers()`, in the shape Next takes. */
|
|
356
|
+
export interface NextHeaderRule {
|
|
357
|
+
source: string;
|
|
358
|
+
has: Array<{
|
|
359
|
+
type: "cookie" | "query";
|
|
360
|
+
key: string;
|
|
361
|
+
}>;
|
|
362
|
+
headers: Array<{
|
|
363
|
+
key: string;
|
|
364
|
+
value: string;
|
|
365
|
+
}>;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Rules for `next.config`'s `headers()` that send `draftHeaders` on draft
|
|
369
|
+
* responses only:
|
|
370
|
+
*
|
|
371
|
+
* // next.config.mjs
|
|
372
|
+
* import { capaHeaders } from "@capacms/sdk/nextjs";
|
|
373
|
+
* export default { async headers() { return [...capaHeaders()]; } };
|
|
374
|
+
*
|
|
375
|
+
* A rule matches a request carrying the draft cookie, or a `capa-preview`,
|
|
376
|
+
* `capa-edit` or `capa-view` query. A visitor's request carries none of
|
|
377
|
+
* them, so its response, cached or not, is exactly what it was. Next checks
|
|
378
|
+
* the cookie by name, not by value, so a forged cookie only adds these
|
|
379
|
+
* headers to the forger's own response.
|
|
380
|
+
*
|
|
381
|
+
* A site that sends its own `frame-ancestors` or `X-Frame-Options` must leave
|
|
382
|
+
* them off draft responses (`missing: [{ type: "cookie", key: DRAFT_COOKIE }]`
|
|
383
|
+
* on its rule): a browser applies every CSP it is sent, so the strictest wins.
|
|
384
|
+
*/
|
|
385
|
+
export declare function capaHeaders(options?: {
|
|
386
|
+
adminOrigins?: readonly string[];
|
|
387
|
+
}): NextHeaderRule[];
|
|
388
|
+
/** The part of Next's `cookies()` store (`next/headers`) the draft-cookie helpers use. */
|
|
389
|
+
export interface DraftCookieJar {
|
|
390
|
+
get(name: string): {
|
|
391
|
+
value: string;
|
|
392
|
+
} | undefined;
|
|
393
|
+
set(cookie: {
|
|
394
|
+
name: string;
|
|
395
|
+
value: string;
|
|
396
|
+
path: string;
|
|
397
|
+
httpOnly: boolean;
|
|
398
|
+
secure: boolean;
|
|
399
|
+
sameSite: "none";
|
|
400
|
+
partitioned: boolean;
|
|
401
|
+
maxAge?: number;
|
|
402
|
+
expires?: Date;
|
|
403
|
+
}): unknown;
|
|
404
|
+
}
|
|
405
|
+
/** Next's `cookies` from `next/headers`, or anything shaped like it. */
|
|
406
|
+
export type CookiesFn = () => DraftCookieJar | Promise<DraftCookieJar>;
|
|
407
|
+
/**
|
|
408
|
+
* Re-set the cookie `draftMode().enable()` just set, so the Capa editor can
|
|
409
|
+
* use it: `HttpOnly; Secure; SameSite=None; Partitioned; Path=/` and
|
|
410
|
+
* `Max-Age` one hour (`maxAge` seconds). Next sets it with no `Max-Age`, so
|
|
411
|
+
* it unlocks every draft on the site until the browser closes, and without
|
|
412
|
+
* `Partitioned`, which Safari 26.2 and later need to send a cookie into a
|
|
413
|
+
* cross-site frame. Call it after `enable()`, in the same route handler.
|
|
414
|
+
* Resolves false, and sets nothing, when there is no draft cookie to re-set.
|
|
415
|
+
*/
|
|
416
|
+
export declare function frameDraftCookie(cookies: CookiesFn, options?: {
|
|
417
|
+
maxAge?: number;
|
|
418
|
+
}): Promise<boolean>;
|
|
419
|
+
/**
|
|
420
|
+
* Delete the draft cookie `frameDraftCookie` set. A partitioned cookie is
|
|
421
|
+
* deleted only by a `Set-Cookie` that is partitioned too, which
|
|
422
|
+
* `draftMode().disable()` is not. Call it after `disable()`; it replaces
|
|
423
|
+
* `disable()`'s own deletion, since a response sets one cookie per name. A
|
|
424
|
+
* draft cookie set without `Partitioned`, before a site used
|
|
425
|
+
* `frameDraftCookie`, ends when the browser closes, as it always did.
|
|
426
|
+
*/
|
|
427
|
+
export declare function clearDraftCookie(cookies: CookiesFn): Promise<void>;
|
|
326
428
|
/**
|
|
327
429
|
* `app/api/capa/preview/route.ts`:
|
|
328
430
|
*
|
|
329
|
-
* import { draftMode } from "next/headers";
|
|
330
|
-
*
|
|
331
|
-
* export const GET = createPreviewRoute({ draftMode, redirect });
|
|
431
|
+
* import { cookies, draftMode } from "next/headers";
|
|
432
|
+
* export const GET = createPreviewRoute({ draftMode, cookies });
|
|
332
433
|
*
|
|
333
434
|
* Checks the token with Capa (the site never holds the signing key), turns
|
|
334
435
|
* draft mode on and lands on the entry's page. A bad or expired token lands on
|
|
335
436
|
* the page without draft mode and `?preview=expired`; Capa unreachable gives
|
|
336
|
-
* `?preview=unavailable`.
|
|
437
|
+
* `?preview=unavailable`. The token is checked with any key the site holds,
|
|
438
|
+
* its legacy key included (`getPublishedClient`, from `CAPA_KEY`).
|
|
439
|
+
*
|
|
440
|
+
* Given `cookies`, the draft cookie is re-set by `frameDraftCookie`: one hour,
|
|
441
|
+
* and sent inside the editor's frame. With no `redirect`, the route answers
|
|
442
|
+
* with its own 307, marked `X-Robots-Tag: noindex, nofollow`,
|
|
443
|
+
* `Referrer-Policy: no-referrer` (the token is in the URL),
|
|
444
|
+
* `Cache-Control: private, no-store` and the editor's `frame-ancestors`.
|
|
445
|
+
* Given Next's `redirect`, it is called instead, as before, and the redirect
|
|
446
|
+
* carries none of those.
|
|
337
447
|
*/
|
|
338
448
|
export declare function createPreviewRoute(input: {
|
|
339
449
|
draftMode: DraftModeFn;
|
|
340
|
-
|
|
450
|
+
/** Next's `cookies`. Given, the draft cookie lasts `maxAge` seconds and works in the editor's frame. */
|
|
451
|
+
cookies?: CookiesFn;
|
|
452
|
+
/** Next's `redirect`. Leave it out for a redirect that carries the draft headers. */
|
|
453
|
+
redirect?: (url: string) => never | void;
|
|
341
454
|
client?: () => Pick<CapaNextClient, "preview">;
|
|
342
|
-
/** Runs after draft mode is enabled, before the redirect (cookie tweaks). */
|
|
455
|
+
/** Runs after draft mode is enabled and the cookie re-set, before the redirect (cookie tweaks). */
|
|
343
456
|
onEnable?: () => void | Promise<void>;
|
|
457
|
+
/** The origins allowed to frame a draft. `[CAPA_ADMIN_ORIGIN]` when left out. */
|
|
458
|
+
adminOrigins?: readonly string[];
|
|
459
|
+
/** Seconds the draft cookie lasts, given `cookies`. `DRAFT_COOKIE_MAX_AGE` (one hour) when left out. */
|
|
460
|
+
maxAge?: number;
|
|
344
461
|
}): (request: Request) => Promise<Response | void>;
|
|
345
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* `app/api/capa/exit/route.ts`:
|
|
464
|
+
*
|
|
465
|
+
* import { cookies, draftMode } from "next/headers";
|
|
466
|
+
* export const GET = exitPreviewRoute({ draftMode, cookies });
|
|
467
|
+
*
|
|
468
|
+
* Turns draft mode off and lands on `?path=`. Given `cookies`, the framed
|
|
469
|
+
* cookie `createPreviewRoute` set is deleted too. With no `redirect`, the route
|
|
470
|
+
* answers with its own 307, marked noindex and never cached.
|
|
471
|
+
*/
|
|
346
472
|
export declare function exitPreviewRoute(input: {
|
|
347
473
|
draftMode: DraftModeFn;
|
|
348
|
-
|
|
474
|
+
cookies?: CookiesFn;
|
|
475
|
+
redirect?: (url: string) => never | void;
|
|
349
476
|
}): (request: Request) => Promise<Response | void>;
|
|
350
477
|
interface MiddlewareRequest {
|
|
351
478
|
url: string;
|
|
@@ -381,7 +508,20 @@ interface NextResponseLike {
|
|
|
381
508
|
* - `?capa-view=published` renders without the draft cookie, for the editor's
|
|
382
509
|
* Published view;
|
|
383
510
|
* - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
|
|
384
|
-
* - every edit-mode response is `private, no-store
|
|
511
|
+
* - every edit-mode response is `private, no-store`, and it and every request
|
|
512
|
+
* carrying `capa-edit` or `capa-view` is `X-Robots-Tag: noindex, nofollow`.
|
|
513
|
+
*
|
|
514
|
+
* Give it a `matcher` so a visitor's request never runs it (Next reads
|
|
515
|
+
* `config` from the file itself, so it is written out there):
|
|
516
|
+
*
|
|
517
|
+
* export const config = {
|
|
518
|
+
* matcher: [
|
|
519
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
|
|
520
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
|
|
521
|
+
* { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
|
|
522
|
+
* { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
|
|
523
|
+
* ],
|
|
524
|
+
* };
|
|
385
525
|
*/
|
|
386
526
|
export declare function capaMiddleware(input: {
|
|
387
527
|
NextResponse: NextResponseLike;
|