@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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": false
4
+ }
@@ -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; `preview()` and draft reads take a `cap_` key
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 `@capa/mcp`), and a second implementation of "this page
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
- * Needs a `cap_` key: a client built with a legacy key throws a
405
- * `TypeError` here before any request.
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>;
@@ -501,7 +501,8 @@ function createClient(config) {
501
501
  return readSchema(options.signal);
502
502
  },
503
503
  async preview(token, options = {}) {
504
- (0, key_family_1.requirePreviewKey)(resolved.apiKey);
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 {
@@ -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
- * `@capa/mcp`, and test/fixtures/field-names.json pins all three to the same
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: `@capa/mcp` sends the same text, and a
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: `@capa/mcp` sends the same text, and a
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. `@capa/mcp` says the same, pinned by a test.
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. @capa/mcp refuses them in the same words, pinned by
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
- * a draft preview takes, both to verify a preview token and to read drafts.
6
- * Every other key is legacy, which is the API's rule
7
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
8
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
9
- * grants their permission gives. So every read call takes them, and the first
10
- * client built with one warns once per process, naming the key to mint.
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
- * `@capa/mcp` applies the same rule, and both packages are tested against one
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
@@ -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
- * a draft preview takes, both to verify a preview token and to read drafts.
7
- * Every other key is legacy, which is the API's rule
8
- * (`apps/api/src/api-next/auth/key-format.ts`): the `pk_` and `sk_` keys, and
9
- * the unprefixed keys older tenants were minted, all reach `/api/` with the
10
- * grants their permission gives. So every read call takes them, and the first
11
- * client built with one warns once per process, naming the key to mint.
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
- * `@capa/mcp` applies the same rule, and both packages are tested against one
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 previews need a cap_ key: mint one ${MINT}`);
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
@@ -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
- * import { redirect } from "next/navigation";
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
- redirect: (url: string) => never | void;
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
- /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
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
- redirect: (url: string) => never | void;
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;