@capacms/sdk 1.0.0-next.3 → 1.0.0-next.4

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 CHANGED
@@ -75,6 +75,52 @@ await capa.entries.list("articles", {
75
75
  Unknown filter operators throw a local `TypeError` before any request is sent.
76
76
  Per-call `{ signal }` is forwarded to `fetch`.
77
77
 
78
+ ### Flat responses: each related entry once
79
+
80
+ By default an expanded relation is nested where you selected it, so twenty
81
+ articles by one author carry that author twenty times. Pass `shape: "flat"` and
82
+ every relation comes back as a `{ id, model }` reference, with each expanded
83
+ entry once in `included`, keyed by model namespace and then id:
84
+
85
+ ```ts
86
+ const select = ["title", { author: ["name"] }] as const satisfies Select<Article>;
87
+ const flat = await capa.entries.list<Article, typeof select>("articles", {
88
+ select,
89
+ shape: "flat",
90
+ });
91
+
92
+ flat.data[0].fields.author; // { id: "…", model: "authors" }
93
+ flat.included.authors[authorId].fields; // { name: "Ada Vale" }, typed as Author
94
+ ```
95
+
96
+ `included` is typed by the select: the union of the entry types it expands, at
97
+ any depth. A select written as a plain string types it as
98
+ `Record<string, unknown>`. `get` takes `shape: "flat"` the same way.
99
+ `iterate` reads the tree shape only.
100
+
101
+ `inflate` turns a flat result back into the tree result, deep-equal to what the
102
+ same request without `shape` returns:
103
+
104
+ ```ts
105
+ import { inflate } from "@capacms/sdk/next";
106
+
107
+ const tree = inflate(flat); // { data, page, meta, cacheTags }, no included
108
+ ```
109
+
110
+ - It walks the select the request sent. A result from this client carries it
111
+ (`flat.select`); for a body you fetched yourself, pass it:
112
+ `inflate(body, "title,author(name)")`.
113
+ - It returns copies. The same author under twenty articles is twenty equal,
114
+ independent objects, as when a tree body is parsed. The input is not changed.
115
+ - Cycles end where the select ends: `a` related to `b` related to `a` is
116
+ inflated to the depth you wrote and no further.
117
+ - An entry reached by two paths holds the union of what they selected, and one
118
+ value per field. If two paths expand the same array relation with different
119
+ `limit` or `sort`, the first one wins, and `inflate` cannot tell them apart.
120
+ Every other request round-trips exactly.
121
+ - In edit mode, included entries are marked, and so is every copy `inflate`
122
+ makes of them.
123
+
78
124
  ### Errors
79
125
 
80
126
  ```ts
@@ -192,6 +238,15 @@ const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" });
192
238
  const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) });
193
239
  ```
194
240
 
241
+ Send `path` beside it, the concrete path being rendered, and Capa can list the
242
+ real URLs an entry appears on, not only the route patterns:
243
+
244
+ ```ts
245
+ await capa.entries.list("articles", { page: "/blog/[slug]", path: `/blog/${slug}` });
246
+ ```
247
+
248
+ `path` goes out as `Capa-Path`, only when `page` is also set.
249
+
195
250
  `routeOf` turns a Next route file into the page string: `/blog/[slug]`. It
196
251
  drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name
197
252
  and the extension. Write the string out by hand if you prefer; `routeOf` exists
@@ -341,22 +396,86 @@ editor sees the path without a link to open it.
341
396
 
342
397
  ## Live preview
343
398
 
399
+ ### Quick start (Next.js)
400
+
401
+ Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`) and `CAPA_DRAFT_KEY` (`cap_test_`), then:
402
+
403
+ ```ts
404
+ // middleware.ts
405
+ import { NextResponse } from "next/server";
406
+ import { capaMiddleware } from "@capacms/sdk/nextjs";
407
+ export const middleware = capaMiddleware({ NextResponse });
408
+
409
+ // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
410
+ import { draftMode } from "next/headers";
411
+ import { redirect } from "next/navigation";
412
+ import { createPreviewRoute } from "@capacms/sdk/nextjs";
413
+ export const GET = createPreviewRoute({ draftMode, redirect });
414
+
415
+ // in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
416
+ ```
417
+
418
+ In Capa, set the project's preview URL to your site. Add the overlay from step 2
419
+ below and editors can click your page. The sections below explain each piece.
420
+
344
421
  The Capa editor can show your site beside the form: focus a field and its spot
345
422
  on the page is outlined, click the page and the editor jumps to the field, save
346
423
  and the draft re-renders in place. It needs three things on your side. A
347
424
  complete, runnable example is `examples/sdk-demo` in this repository.
348
425
 
349
- ### 1. Tag what an editor can click
426
+ ### 1. Turn on edit mode and tag what an editor can click
427
+
428
+ Edit mode is on when Next draft mode is on, or when the request carries a
429
+ `capa-edit` token the Capa editor's Published view sends. Work it out once per
430
+ request and build the client with it:
431
+
432
+ ```ts
433
+ // lib/capa.ts
434
+ import { draftMode, headers } from "next/headers";
435
+ import { createClient } from "@capacms/sdk/next";
436
+ import { editMode } from "@capacms/sdk/nextjs";
437
+
438
+ export async function capa() {
439
+ const draft = (await draftMode()).isEnabled;
440
+ return createClient({
441
+ baseUrl, version: "2026-10-01",
442
+ apiKey: draft ? process.env.CAPA_DRAFT_KEY! : process.env.CAPA_KEY!,
443
+ editMode: await editMode({ draftMode, headers }),
444
+ });
445
+ }
446
+ ```
350
447
 
351
- `capaAttrs(entry, field, enabled)` from `@capacms/sdk/next` returns the two
352
- attributes the overlay looks for. `field` is the field's namespace, which is its
353
- key in `entry.fields`, and it autocompletes when the entry is typed. Pass the
354
- draft flag as `enabled` so a published page ships no entry ids.
448
+ Then tag fields with `capaAttrs(entry, field)` from `@capacms/sdk/next`. `field`
449
+ is the field's namespace, its key in `entry.fields`, and it autocompletes when
450
+ the entry is typed. There is no flag to pass: an entry read by an edit-mode
451
+ client carries a hidden mark (related entries too), and `capaAttrs` tags only
452
+ marked entries. A visitor's page therefore ships no `data-capa-` attributes.
355
453
 
356
454
  ```tsx
357
455
  import { capaAttrs } from "@capacms/sdk/next";
358
456
 
359
- <h1 {...capaAttrs(article, "title", isDraft)}>{article.fields.title}</h1>
457
+ <h1 {...capaAttrs(article, "title")}>{article.fields.title}</h1>
458
+ ```
459
+
460
+ The mark does not survive a spread copy or being passed to a client component,
461
+ so tag in the server component that read the entry. `capaAttrs(entry, field,
462
+ true)` still forces the tags on and `false` forces them off.
463
+
464
+ To accept `capa-edit`, verify it in middleware. The token is checked with Capa,
465
+ any forged `x-capa-edit` header is removed, and edit-mode responses are marked
466
+ `private, no-store`:
467
+
468
+ ```ts
469
+ // middleware.ts
470
+ import { NextResponse, type NextRequest } from "next/server";
471
+ import { resolveEditRequest } from "@capacms/sdk/nextjs";
472
+
473
+ export async function middleware(request: NextRequest) {
474
+ const edit = await resolveEditRequest(request, publishedClient());
475
+ const response = NextResponse.next({ request: { headers: edit.headers } });
476
+ if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
477
+ return response;
478
+ }
360
479
  ```
361
480
 
362
481
  ### 2. Start the overlay in draft mode
@@ -375,7 +494,8 @@ export function CapaOverlay({ adminOrigins }: { adminOrigins: string[] }) {
375
494
  }
376
495
  ```
377
496
 
378
- Render it from your root layout only when `(await draftMode()).isEnabled`.
497
+ Render it from your root layout only in edit mode
498
+ (`await editMode({ draftMode, headers })`), so a visitor never downloads it.
379
499
  `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
380
500
  does nothing at all, and inside one it only listens to a parent window at one of
381
501
  `adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position
@@ -1,15 +1,25 @@
1
1
  /**
2
2
  * The attributes that make a rendered field clickable in Capa's live preview.
3
3
  *
4
- * <h1 {...capaAttrs(entry, "title", isDraft)}>{entry.fields.title}</h1>
4
+ * <h1 {...capaAttrs(entry, "title")}>{entry.fields.title}</h1>
5
5
  *
6
6
  * `field` is the field's namespace, which is the key it has in `entry.fields`:
7
7
  * the API renders every field under its namespace, and the Capa editor tags
8
8
  * each field row with that same namespace, so one string names the field on
9
9
  * both sides of the preview frame.
10
10
  *
11
- * Pass `enabled = false` outside draft mode and the element carries nothing, so
12
- * a published page never ships entry ids in its markup.
11
+ * EDIT MODE DECIDES, NOT THE CALLER. An entry read by a client created with
12
+ * `editMode: true` carries a hidden edit mark (`markEditEntries`), and
13
+ * `capaAttrs` tags only marked entries. So a published page, read with a normal
14
+ * client, ships no entry ids in its markup without the site passing anything,
15
+ * and the same component in the Capa editor is clickable. The mark is a
16
+ * non-enumerable symbol: it does not show up in JSON, logs or a spread copy, and
17
+ * it does not survive being passed to a client component as a prop, which is
18
+ * the safe direction to fail in.
19
+ *
20
+ * Pass `enabled` to override: `true` tags an unmarked entry, `false` tags
21
+ * nothing. Sites written before edit mode passed `isDraft` here and keep
22
+ * working unchanged.
13
23
  */
14
24
  export type CapaAttrs = {
15
25
  "data-capa-entry": string;
@@ -18,7 +28,32 @@ export type CapaAttrs = {
18
28
  "data-capa-entry"?: undefined;
19
29
  "data-capa-field"?: undefined;
20
30
  };
31
+ /** `Symbol.for`, so two copies of the SDK in one bundle agree on the mark. */
32
+ export declare const CAPA_EDIT: unique symbol;
33
+ /** Whether an entry was read in edit mode. */
34
+ export declare function isEditEntry(entry: unknown): boolean;
35
+ /**
36
+ * Mark every entry inside `value`, related entries included, so a click on an
37
+ * author inside an article opens the author. Walks arrays and plain objects,
38
+ * never the same object twice. Returns `value` for chaining.
39
+ */
40
+ export declare function markEditEntries<T>(value: T): T;
21
41
  export declare function capaAttrs<T = Record<string, unknown>>(entry: {
22
42
  id: string;
23
43
  fields?: T;
24
44
  }, field: Extract<keyof T, string>, enabled?: boolean): CapaAttrs;
45
+ /**
46
+ * Typed attributes for every field of one entry (M5):
47
+ *
48
+ * const a = fieldAttrs(article);
49
+ * <h1 {...a.title}>…</h1> // a.titel is a compile error
50
+ *
51
+ * Same rule as `capaAttrs`: empty unless the entry was read in edit mode, or
52
+ * `enabled` says otherwise.
53
+ */
54
+ export declare function fieldAttrs<T = Record<string, unknown>>(entry: {
55
+ id: string;
56
+ fields?: T;
57
+ }, enabled?: boolean): {
58
+ readonly [K in Extract<keyof T, string>]: CapaAttrs;
59
+ };
@@ -1,8 +1,71 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CAPA_EDIT = void 0;
4
+ exports.isEditEntry = isEditEntry;
5
+ exports.markEditEntries = markEditEntries;
3
6
  exports.capaAttrs = capaAttrs;
4
- function capaAttrs(entry, field, enabled = true) {
7
+ exports.fieldAttrs = fieldAttrs;
8
+ /** `Symbol.for`, so two copies of the SDK in one bundle agree on the mark. */
9
+ exports.CAPA_EDIT = Symbol.for("capacms.edit");
10
+ /** Whether an entry was read in edit mode. */
11
+ function isEditEntry(entry) {
12
+ return (typeof entry === "object" &&
13
+ entry !== null &&
14
+ entry[exports.CAPA_EDIT] === true);
15
+ }
16
+ function looksLikeEntry(value) {
17
+ return typeof value.id === "string" && typeof value.fields === "object" && value.fields !== null;
18
+ }
19
+ /**
20
+ * Mark every entry inside `value`, related entries included, so a click on an
21
+ * author inside an article opens the author. Walks arrays and plain objects,
22
+ * never the same object twice. Returns `value` for chaining.
23
+ */
24
+ function markEditEntries(value) {
25
+ const seen = new Set();
26
+ const visit = (node) => {
27
+ if (typeof node !== "object" || node === null || seen.has(node))
28
+ return;
29
+ seen.add(node);
30
+ if (Array.isArray(node)) {
31
+ for (const item of node)
32
+ visit(item);
33
+ return;
34
+ }
35
+ const record = node;
36
+ if (looksLikeEntry(record) && Object.isExtensible(record)) {
37
+ Object.defineProperty(record, exports.CAPA_EDIT, {
38
+ value: true,
39
+ enumerable: false,
40
+ configurable: true,
41
+ });
42
+ }
43
+ for (const key of Object.keys(record))
44
+ visit(record[key]);
45
+ };
46
+ visit(value);
47
+ return value;
48
+ }
49
+ function capaAttrs(entry, field, enabled = isEditEntry(entry)) {
5
50
  if (!enabled)
6
51
  return {};
7
52
  return { "data-capa-entry": entry.id, "data-capa-field": field };
8
53
  }
54
+ /**
55
+ * Typed attributes for every field of one entry (M5):
56
+ *
57
+ * const a = fieldAttrs(article);
58
+ * <h1 {...a.title}>…</h1> // a.titel is a compile error
59
+ *
60
+ * Same rule as `capaAttrs`: empty unless the entry was read in edit mode, or
61
+ * `enabled` says otherwise.
62
+ */
63
+ function fieldAttrs(entry, enabled = isEditEntry(entry)) {
64
+ return new Proxy({}, {
65
+ get(_target, key) {
66
+ if (typeof key !== "string")
67
+ return undefined;
68
+ return enabled ? { "data-capa-entry": entry.id, "data-capa-field": key } : {};
69
+ },
70
+ });
71
+ }
@@ -1,4 +1,4 @@
1
- import type { Select } from "./select-types";
1
+ import type { ExpandedTargets, Select } from "./select-types";
2
2
  export interface CapaNextConfig {
3
3
  baseUrl: string;
4
4
  apiKey: string;
@@ -34,6 +34,21 @@ export interface CapaNextConfig {
34
34
  * Config only, never per call: one build has one schema.
35
35
  */
36
36
  schemaChecksum?: string;
37
+ /**
38
+ * Read in edit mode: every entry this client returns carries the hidden edit
39
+ * mark, so `capaAttrs` tags it for the Capa editor. Leave it off for visitors
40
+ * and a published page ships no entry ids. `@capacms/sdk/nextjs` works it out
41
+ * per request with `editMode()`.
42
+ *
43
+ * Changes nothing on the wire. The same request is sent either way.
44
+ */
45
+ editMode?: boolean;
46
+ /**
47
+ * The concrete path being rendered (`/blog/hello`), sent as `Capa-Path`
48
+ * beside `Capa-Page` (`/blog/[slug]`). Telemetry only, like `page`: it is how
49
+ * Capa lists every real URL an entry appears on. Usually set per call.
50
+ */
51
+ path?: string;
37
52
  /** Injected for tests, non-standard runtimes, and framework fetch wrappers. */
38
53
  fetch?: typeof fetch;
39
54
  }
@@ -53,6 +68,12 @@ export interface CallOptions {
53
68
  * writing the code.
54
69
  */
55
70
  page?: string;
71
+ /**
72
+ * The concrete path THIS read renders (`/blog/hello`). Overrides `path` on the
73
+ * config. Sent as `Capa-Path` only alongside a `Capa-Page`, since a path with
74
+ * no page says nothing Capa can group.
75
+ */
76
+ path?: string;
56
77
  }
57
78
  export interface Entry<T = Record<string, unknown>> {
58
79
  id: string;
@@ -97,8 +118,17 @@ export type FilterOperator = "eq" | "ne" | "in" | "nin" | "lt" | "lte" | "gt" |
97
118
  export type FilterScalar = string | number | boolean | null;
98
119
  export type FilterValue = FilterScalar | readonly FilterScalar[];
99
120
  export type Filter = Record<string, Partial<Record<FilterOperator, FilterValue>>>;
121
+ /**
122
+ * How expanded relations come back (`?shape=`). `tree`, the default, nests each
123
+ * one inline where it was selected. `flat` answers every relation as a
124
+ * `{ id, model }` reference and every expanded entry ONCE, in `included`, so
125
+ * twenty articles by one author carry that author once. `inflate(result)`
126
+ * turns a flat result back into the tree one.
127
+ */
128
+ export type ResponseShape = "tree" | "flat";
100
129
  export interface ListOptions<T = Record<string, unknown>> extends CallOptions {
101
130
  select?: Select<T> | string;
131
+ shape?: "tree";
102
132
  filter?: Filter;
103
133
  where?: Record<string, unknown>;
104
134
  sort?: readonly string[];
@@ -109,10 +139,36 @@ export interface ListOptions<T = Record<string, unknown>> extends CallOptions {
109
139
  }
110
140
  export interface GetOptions<T = Record<string, unknown>> extends CallOptions {
111
141
  select?: Select<T> | string;
142
+ shape?: "tree";
143
+ }
144
+ /** `list` with `shape: "flat"`. `S` is the select, which types `included`. */
145
+ export type FlatListOptions<T, S extends Select<T> | string = Select<T>> = Omit<ListOptions<T>, "select" | "shape"> & {
146
+ select?: S;
147
+ shape: "flat";
148
+ };
149
+ /** `get` with `shape: "flat"`. */
150
+ export type FlatGetOptions<T, S extends Select<T> | string = Select<T>> = Omit<GetOptions<T>, "select" | "shape"> & {
151
+ select?: S;
152
+ shape: "flat";
153
+ };
154
+ /**
155
+ * `included` of a flat read: model namespace, then entry id, then the entry.
156
+ * Typed by the select: the union of every entry type it expands.
157
+ */
158
+ export type Included<I> = Record<string, Record<string, Entry<I>>>;
159
+ interface FlatExtras<T, S> {
160
+ included: Included<[ExpandedTargets<T, S>] extends [never] ? never : ExpandedTargets<T, S>>;
161
+ /** The select this read sent, which is what `inflate` walks. Absent when none was sent. */
162
+ select?: string;
112
163
  }
164
+ export type FlatPage<T, S = Select<T>> = Page<Entry<T>> & FlatExtras<T, S>;
165
+ export type FlatSingle<T, S = Select<T>> = Single<Entry<T>> & FlatExtras<T, S>;
113
166
  export interface EntriesResource {
167
+ list<T = Record<string, unknown>, S extends Select<T> | string = Select<T>>(namespace: string, options: FlatListOptions<T, S>): Promise<FlatPage<T, S>>;
114
168
  list<T = Record<string, unknown>>(namespace: string, options?: ListOptions<T>): Promise<Page<Entry<T>>>;
169
+ get<T = Record<string, unknown>, S extends Select<T> | string = Select<T>>(namespace: string, id: string, options: FlatGetOptions<T, S>): Promise<FlatSingle<T, S> | null>;
115
170
  get<T = Record<string, unknown>>(namespace: string, id: string, options?: GetOptions<T>): Promise<Single<Entry<T>> | null>;
171
+ /** Tree only: it yields entries one at a time, which is what `included` exists to avoid. */
116
172
  iterate<T = Record<string, unknown>>(namespace: string, options?: ListOptions<T>): AsyncGenerator<Entry<T>, void, undefined>;
117
173
  }
118
174
  /**
@@ -5,6 +5,7 @@ exports.isCapaError = isCapaError;
5
5
  exports.resolveNextConfig = resolveNextConfig;
6
6
  exports.serializeSelect = serializeSelect;
7
7
  exports.createClient = createClient;
8
+ const attrs_1 = require("./attrs");
8
9
  /**
9
10
  * What a `Capa-Schema` value may look like: hex, 8 to 64 characters.
10
11
  *
@@ -60,6 +61,7 @@ function resolveNextConfig(config) {
60
61
  // Checked here as well as per call, so a bad page on the config throws where
61
62
  // the client is built rather than on whichever read happens to run first.
62
63
  resolvePage(value.page, undefined);
64
+ resolvePath(value.path, undefined);
63
65
  // THROWS rather than dropping, the same rule `page` follows and for the same
64
66
  // reason: the API must never 400 a running site over a telemetry header, so
65
67
  // it ignores what it cannot store, and the SDK is the place a wrong value
@@ -120,6 +122,24 @@ function resolvePage(configPage, callPage) {
120
122
  }
121
123
  return value;
122
124
  }
125
+ /**
126
+ * What a `Capa-Path` value may look like: a site path, query and hash cut off.
127
+ * COPIED from `PAGE_PATH_PATTERN` in `apps/api/src/api-next/page-header.ts`.
128
+ */
129
+ const PAGE_PATH = /^\/[^\s?#]{0,1023}$/;
130
+ function resolvePath(configPath, callPath) {
131
+ const value = callPath !== undefined ? callPath : configPath;
132
+ if (value === undefined || value === null)
133
+ return undefined;
134
+ if (typeof value !== "string") {
135
+ throw new TypeError(`@capacms/sdk/next: path must be a string such as "/blog/hello".`);
136
+ }
137
+ const bare = value.split("#")[0].split("?")[0];
138
+ if (!PAGE_PATH.test(bare)) {
139
+ throw new TypeError(`@capacms/sdk/next: path must be the concrete path being rendered, such as "/blog/hello". Got ${JSON.stringify(value)}.`);
140
+ }
141
+ return bare;
142
+ }
123
143
  const NAME = /^[A-Za-z0-9_-]+$/;
124
144
  function selectName(value, context) {
125
145
  if (typeof value !== "string" || !NAME.test(value)) {
@@ -225,10 +245,21 @@ function filterValue(operator, value) {
225
245
  }
226
246
  return String(value);
227
247
  }
248
+ /** `shape` is sent only when it is `flat`, so a tree read's URL is the one it always was. */
249
+ function shapeOf(options) {
250
+ const shape = options.shape;
251
+ if (shape === undefined || shape === "tree")
252
+ return undefined;
253
+ if (shape === "flat")
254
+ return "flat";
255
+ throw new TypeError(`@capacms/sdk/next: shape must be "tree" or "flat". Got ${JSON.stringify(shape)}.`);
256
+ }
228
257
  function listQuery(options) {
229
258
  const query = new URLSearchParams();
230
259
  if (options.select !== undefined)
231
260
  query.set("select", serializeSelect(options.select));
261
+ if (shapeOf(options) === "flat")
262
+ query.set("shape", "flat");
232
263
  if (options.filter !== undefined) {
233
264
  for (const [path, operations] of Object.entries(options.filter)) {
234
265
  for (const [rawOperator, value] of Object.entries(operations)) {
@@ -294,7 +325,7 @@ function cacheTags(response) {
294
325
  return value.split(/\s+/).filter(Boolean);
295
326
  }
296
327
  function createRequester(config) {
297
- return async function request(path, query = new URLSearchParams(), signal, page) {
328
+ return async function request(path, query = new URLSearchParams(), signal, page, pagePath) {
298
329
  const url = new URL(config.baseUrl + path);
299
330
  query.forEach((value, key) => url.searchParams.append(key, value));
300
331
  const headers = {
@@ -308,6 +339,8 @@ function createRequester(config) {
308
339
  // byte-identical requests to the ones it sent before this option existed.
309
340
  if (page !== undefined)
310
341
  headers["Capa-Page"] = page;
342
+ if (page !== undefined && pagePath !== undefined)
343
+ headers["Capa-Path"] = pagePath;
311
344
  // Same rule, and on EVERY call rather than only the entries reads: the
312
345
  // stamp describes the build, so a page whose only Capa call is `me()`
313
346
  // still reports which schema it was generated from.
@@ -327,24 +360,43 @@ function createRequester(config) {
327
360
  throw errorFromEnvelope(response.status, body, requestId);
328
361
  if (!body || typeof body !== "object")
329
362
  throw unparseable(response.status, requestId);
363
+ if (config.editMode === true) {
364
+ // `included` holds a flat read's related entries, which are marked
365
+ // exactly as they are when the tree nests them inside `data`.
366
+ const { data, included } = body;
367
+ (0, attrs_1.markEditEntries)(data);
368
+ if (included !== undefined)
369
+ (0, attrs_1.markEditEntries)(included);
370
+ }
330
371
  return { body: body, cacheTags: cacheTags(response) };
331
372
  };
332
373
  }
333
374
  function createClient(config) {
334
375
  const resolved = resolveNextConfig(config);
335
376
  const request = createRequester(resolved);
377
+ /**
378
+ * A flat read's result carries the select it sent, so `inflate(result)`
379
+ * needs nothing else. A tree read's result is exactly what it always was.
380
+ */
381
+ const withSelect = (result, options) => {
382
+ if (shapeOf(options) !== "flat" || options.select === undefined)
383
+ return result;
384
+ return { ...result, select: serializeSelect(options.select) };
385
+ };
336
386
  const entries = {
337
387
  async list(namespace, options = {}) {
338
- const result = await request(`/api/entries/${encodeURIComponent(namespace)}`, listQuery(options), options.signal, resolvePage(resolved.page, options.page));
339
- return { ...result.body, cacheTags: result.cacheTags };
388
+ const result = await request(`/api/entries/${encodeURIComponent(namespace)}`, listQuery(options), options.signal, resolvePage(resolved.page, options.page), resolvePath(resolved.path, options.path));
389
+ return withSelect({ ...result.body, cacheTags: result.cacheTags }, options);
340
390
  },
341
391
  async get(namespace, id, options = {}) {
342
392
  const query = new URLSearchParams();
343
393
  if (options.select !== undefined)
344
394
  query.set("select", serializeSelect(options.select));
395
+ if (shapeOf(options) === "flat")
396
+ query.set("shape", "flat");
345
397
  try {
346
- const result = await request(`/api/entries/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`, query, options.signal, resolvePage(resolved.page, options.page));
347
- return { ...result.body, cacheTags: result.cacheTags };
398
+ const result = await request(`/api/entries/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`, query, options.signal, resolvePage(resolved.page, options.page), resolvePath(resolved.path, options.path));
399
+ return withSelect({ ...result.body, cacheTags: result.cacheTags }, options);
348
400
  }
349
401
  catch (error) {
350
402
  if (error instanceof CapaError &&
@@ -357,6 +409,9 @@ function createClient(config) {
357
409
  }
358
410
  },
359
411
  async *iterate(namespace, options = {}) {
412
+ if (shapeOf(options) === "flat") {
413
+ throw new TypeError("@capacms/sdk/next: iterate reads the tree shape only. Use list with shape: \"flat\" and page.next.");
414
+ }
360
415
  let after = options.after;
361
416
  for (;;) {
362
417
  const page = await entries.list(namespace, { ...options, after });
@@ -417,10 +472,10 @@ function createClient(config) {
417
472
  }
418
473
  },
419
474
  async me(options = {}) {
420
- return (await request("/api/me", new URLSearchParams(), options.signal, resolvePage(resolved.page, options.page))).body;
475
+ return (await request("/api/me", new URLSearchParams(), options.signal, resolvePage(resolved.page, options.page), resolvePath(resolved.path, options.path))).body;
421
476
  },
422
477
  async versions(options = {}) {
423
- return (await request("/api/versions", new URLSearchParams(), options.signal, resolvePage(resolved.page, options.page))).body;
478
+ return (await request("/api/versions", new URLSearchParams(), options.signal, resolvePage(resolved.page, options.page), resolvePath(resolved.path, options.path))).body;
424
479
  },
425
480
  };
426
481
  }
@@ -1,5 +1,7 @@
1
1
  export { CapaError, createClient, isCapaError, LAYOUT_PAGE, resolveNextConfig, serializeSelect, } from "./client";
2
- export { capaAttrs } from "./attrs";
2
+ export { CAPA_EDIT, capaAttrs, fieldAttrs, isEditEntry, markEditEntries } from "./attrs";
3
+ export { inflate } from "./inflate";
4
+ export type { FlatResponse } from "./inflate";
3
5
  export type { CapaAttrs } from "./attrs";
4
- export type { CallOptions, CapaNextClient, CapaNextConfig, EntriesResource, Entry, Filter, FilterOperator, FilterScalar, FilterValue, GetOptions, ListOptions, Page, PageDetail, PageInfo, PageSummary, PagesListOptions, PagesResource, PreviewClaim, ResponseMeta, Single, } from "./client";
5
- export type { CapaRelation, CapaRelationList, RelationSelectOptions, Select, SelectItem, SelectSort, } from "./select-types";
6
+ export type { CallOptions, CapaNextClient, CapaNextConfig, EntriesResource, Entry, Filter, FilterOperator, FlatGetOptions, FlatListOptions, FlatPage, FlatSingle, Included, FilterScalar, FilterValue, GetOptions, ListOptions, Page, PageDetail, PageInfo, PageSummary, PagesListOptions, PagesResource, PreviewClaim, ResponseMeta, ResponseShape, Single, } from "./client";
7
+ export type { ExpandedTargets, CapaRelation, CapaRelationList, RelationSelectOptions, Select, SelectItem, SelectSort, } from "./select-types";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.capaAttrs = exports.serializeSelect = exports.resolveNextConfig = exports.LAYOUT_PAGE = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
3
+ exports.inflate = exports.markEditEntries = exports.isEditEntry = exports.fieldAttrs = exports.capaAttrs = exports.CAPA_EDIT = exports.serializeSelect = exports.resolveNextConfig = exports.LAYOUT_PAGE = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "CapaError", { enumerable: true, get: function () { return client_1.CapaError; } });
6
6
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
@@ -9,4 +9,10 @@ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function
9
9
  Object.defineProperty(exports, "resolveNextConfig", { enumerable: true, get: function () { return client_1.resolveNextConfig; } });
10
10
  Object.defineProperty(exports, "serializeSelect", { enumerable: true, get: function () { return client_1.serializeSelect; } });
11
11
  var attrs_1 = require("./attrs");
12
+ Object.defineProperty(exports, "CAPA_EDIT", { enumerable: true, get: function () { return attrs_1.CAPA_EDIT; } });
12
13
  Object.defineProperty(exports, "capaAttrs", { enumerable: true, get: function () { return attrs_1.capaAttrs; } });
14
+ Object.defineProperty(exports, "fieldAttrs", { enumerable: true, get: function () { return attrs_1.fieldAttrs; } });
15
+ Object.defineProperty(exports, "isEditEntry", { enumerable: true, get: function () { return attrs_1.isEditEntry; } });
16
+ Object.defineProperty(exports, "markEditEntries", { enumerable: true, get: function () { return attrs_1.markEditEntries; } });
17
+ var inflate_1 = require("./inflate");
18
+ Object.defineProperty(exports, "inflate", { enumerable: true, get: function () { return inflate_1.inflate; } });
@@ -0,0 +1,33 @@
1
+ /** The flat body, or an SDK result wrapping one. */
2
+ export interface FlatResponse {
3
+ data: unknown;
4
+ included: Record<string, Record<string, unknown>>;
5
+ /** The select the request sent, when the SDK made it. */
6
+ select?: string;
7
+ }
8
+ /** One level of a select: `*` or not, and the names it wrote, in order. */
9
+ interface Level {
10
+ star: boolean;
11
+ items: Array<{
12
+ name: string;
13
+ expand: Level | null;
14
+ }>;
15
+ }
16
+ /**
17
+ * The select grammar, read only as far as `inflate` needs it: names, nesting
18
+ * and `*`. Modifiers (`limit:`, `sort:`, `after:`) decide which rows the API
19
+ * returned, which the response already reflects, so they are skipped. The API
20
+ * validated the select before answering, so this parser trusts its shape and
21
+ * throws a `TypeError` only on text it cannot split at all.
22
+ */
23
+ export declare function parseSelectLevels(select: string): Level;
24
+ /**
25
+ * The tree shape of a flat response: `data` with every expansion nested back
26
+ * in, `included` and `select` removed, everything else (`page`, `meta`,
27
+ * `cacheTags`) carried over as it was.
28
+ *
29
+ * `select` is what the request sent; it defaults to `response.select`, which
30
+ * an SDK flat read fills in. A request with no select expanded nothing.
31
+ */
32
+ export declare function inflate<R extends FlatResponse>(response: R, select?: string): Omit<R, "included" | "select">;
33
+ export {};
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseSelectLevels = parseSelectLevels;
4
+ exports.inflate = inflate;
5
+ /**
6
+ * inflate.ts — turn a `shape=flat` response back into the `shape=tree` one.
7
+ *
8
+ * A flat response carries every expanded entry once, in `included`, keyed by
9
+ * model namespace and then id, and every relation as a `{ id, model }`
10
+ * reference. `inflate` walks the SAME select the request sent and puts each
11
+ * referenced entry back where the tree would have nested it, holding exactly
12
+ * the system keys and fields that path selected. The result is deep-equal to
13
+ * the `shape=tree` body for the same request (amendment 16 of the api-next spec
14
+ * names the one exception).
15
+ *
16
+ * WHY IT NEEDS THE SELECT. An included entry holds the UNION of every path that
17
+ * reached it, and an entry in `data` also carries what any relation path asked
18
+ * of it. Only the select says which of those fields belong at which place in
19
+ * the tree, and which references were expanded. A result from this SDK's
20
+ * `shape: "flat"` read carries the select it sent (`response.select`), so
21
+ * `inflate(response)` is enough; for a body fetched by hand, pass the select
22
+ * you sent as the second argument. A request that sent no select expanded
23
+ * nothing, and `inflate` returns its data unchanged.
24
+ *
25
+ * COPIES, NEVER SHARED INSTANCES. Every entry in the result is a new object,
26
+ * and so is every value inside it: the same author reached from twenty
27
+ * articles comes back as twenty equal, independent objects, exactly as the
28
+ * tree response parses. Mutating one never changes another, the input is never
29
+ * modified, and `JSON.stringify` of the result cannot meet a cycle.
30
+ *
31
+ * CYCLES END WHERE THE SELECT ENDS. `a` related to `b` related to `a` is
32
+ * inflated to the depth the select wrote (at most four levels, the API's cap)
33
+ * and no further: the walk follows the select, never the references, so it
34
+ * cannot loop. A reference the select did not expand stays a reference.
35
+ */
36
+ const attrs_1 = require("./attrs");
37
+ /** The system keys, in the order the API prints them. */
38
+ const SYSTEM_KEYS = [
39
+ "id",
40
+ "model",
41
+ "status",
42
+ "createdAt",
43
+ "updatedAt",
44
+ "publishedAt",
45
+ "version",
46
+ "folder",
47
+ "tags",
48
+ ];
49
+ const ALWAYS_KEYS = new Set(["id", "model", "status"]);
50
+ const SYSTEM_KEY_SET = new Set(SYSTEM_KEYS);
51
+ /**
52
+ * The select grammar, read only as far as `inflate` needs it: names, nesting
53
+ * and `*`. Modifiers (`limit:`, `sort:`, `after:`) decide which rows the API
54
+ * returned, which the response already reflects, so they are skipped. The API
55
+ * validated the select before answering, so this parser trusts its shape and
56
+ * throws a `TypeError` only on text it cannot split at all.
57
+ */
58
+ function parseSelectLevels(select) {
59
+ let pos = 0;
60
+ const fail = () => {
61
+ throw new TypeError(`@capacms/sdk/next: inflate could not read the select ${JSON.stringify(select)}.`);
62
+ };
63
+ const level = () => {
64
+ const out = { star: false, items: [] };
65
+ for (;;) {
66
+ const start = pos;
67
+ while (pos < select.length && !",()".includes(select[pos]))
68
+ pos += 1;
69
+ const token = select.slice(start, pos);
70
+ if (select[pos] === "(") {
71
+ if (token === "")
72
+ fail();
73
+ pos += 1;
74
+ const inner = level();
75
+ if (select[pos] !== ")")
76
+ fail();
77
+ pos += 1;
78
+ out.items.push({ name: token, expand: inner });
79
+ }
80
+ else if (token === "*") {
81
+ out.star = true;
82
+ }
83
+ else if (token.includes(":")) {
84
+ // A modifier: `limit:2`, `sort:-name`, `after:<cursor>`.
85
+ }
86
+ else if (token !== "") {
87
+ out.items.push({ name: token, expand: null });
88
+ }
89
+ else {
90
+ fail();
91
+ }
92
+ if (select[pos] !== ",")
93
+ return out;
94
+ pos += 1;
95
+ }
96
+ };
97
+ const root = level();
98
+ if (pos !== select.length)
99
+ fail();
100
+ return root;
101
+ }
102
+ // ----------------------------------------------------------------- walk ----
103
+ function clone(value) {
104
+ if (Array.isArray(value))
105
+ return value.map(clone);
106
+ if (value && typeof value === "object") {
107
+ const out = {};
108
+ for (const [key, inner] of Object.entries(value))
109
+ out[key] = clone(inner);
110
+ return out;
111
+ }
112
+ return value;
113
+ }
114
+ function isReference(value) {
115
+ return (!!value &&
116
+ typeof value === "object" &&
117
+ !Array.isArray(value) &&
118
+ typeof value.id === "string" &&
119
+ !("fields" in value));
120
+ }
121
+ function resolve(index, ref) {
122
+ if (ref.missing)
123
+ return null;
124
+ const fromIncluded = ref.model ? index.included[ref.model]?.[ref.id] : undefined;
125
+ if (fromIncluded && typeof fromIncluded === "object")
126
+ return fromIncluded;
127
+ return index.data.get(ref.id) ?? null;
128
+ }
129
+ /**
130
+ * One entry as the tree holds it at a place whose select is `level`. `null`
131
+ * is "no select was sent", which is every key and every field, unexpanded.
132
+ */
133
+ function project(entry, level, index) {
134
+ const fields = (entry.fields ?? {});
135
+ const all = level === null || level.star;
136
+ // A name is a FIELD when the entry has a field of that name, otherwise a
137
+ // system key: a model field shadows a system key of the same name (spec
138
+ // 3.3), and a path that selected the field is exactly what put it here.
139
+ const wanted = new Set(ALWAYS_KEYS);
140
+ if (level) {
141
+ for (const item of level.items) {
142
+ if (!(item.name in fields) && SYSTEM_KEY_SET.has(item.name))
143
+ wanted.add(item.name);
144
+ }
145
+ }
146
+ const out = {};
147
+ for (const key of SYSTEM_KEYS) {
148
+ if (!(key in entry))
149
+ continue;
150
+ if (all || wanted.has(key))
151
+ out[key] = clone(entry[key]);
152
+ }
153
+ const expansions = new Map();
154
+ const names = [];
155
+ if (level) {
156
+ for (const item of level.items) {
157
+ if (item.expand)
158
+ expansions.set(item.name, item.expand);
159
+ if (item.name in fields && !all)
160
+ names.push(item.name);
161
+ }
162
+ }
163
+ if (all)
164
+ names.push(...Object.keys(fields));
165
+ const outFields = {};
166
+ for (const name of names) {
167
+ const expand = expansions.get(name);
168
+ outFields[name] = expand ? expandValue(fields[name], expand, index) : clone(fields[name]);
169
+ }
170
+ out.fields = outFields;
171
+ if ((0, attrs_1.isEditEntry)(entry)) {
172
+ Object.defineProperty(out, attrs_1.CAPA_EDIT, { value: true, enumerable: false, configurable: true });
173
+ }
174
+ return out;
175
+ }
176
+ /** A reference, or an array relation's `{ items, pageInfo }`, put back in place. */
177
+ function expandValue(value, level, index) {
178
+ if (isReference(value)) {
179
+ const target = resolve(index, value);
180
+ return target ? project(target, level, index) : clone(value);
181
+ }
182
+ if (value && typeof value === "object" && Array.isArray(value.items)) {
183
+ const list = value;
184
+ const out = {};
185
+ for (const [key, inner] of Object.entries(list)) {
186
+ out[key] =
187
+ key === "items"
188
+ ? list.items.map((item) => expandValue(item, level, index))
189
+ : clone(inner);
190
+ }
191
+ return out;
192
+ }
193
+ return clone(value);
194
+ }
195
+ /**
196
+ * The tree shape of a flat response: `data` with every expansion nested back
197
+ * in, `included` and `select` removed, everything else (`page`, `meta`,
198
+ * `cacheTags`) carried over as it was.
199
+ *
200
+ * `select` is what the request sent; it defaults to `response.select`, which
201
+ * an SDK flat read fills in. A request with no select expanded nothing.
202
+ */
203
+ function inflate(response, select) {
204
+ if (!response || typeof response !== "object" || !("included" in response)) {
205
+ throw new TypeError("@capacms/sdk/next: inflate takes a shape=flat response, which carries included.");
206
+ }
207
+ const text = select ?? response.select;
208
+ const level = text === undefined || text === null ? null : parseSelectLevels(text);
209
+ const rows = Array.isArray(response.data) ? response.data : [response.data];
210
+ const index = { included: response.included ?? {}, data: new Map() };
211
+ for (const row of rows) {
212
+ if (row && typeof row === "object" && typeof row.id === "string") {
213
+ index.data.set(row.id, row);
214
+ }
215
+ }
216
+ const inflateRow = (row) => row && typeof row === "object" ? project(row, level, index) : row;
217
+ const out = {};
218
+ for (const [key, value] of Object.entries(response)) {
219
+ if (key === "included" || key === "select")
220
+ continue;
221
+ if (key === "data") {
222
+ out.data = Array.isArray(value) ? value.map(inflateRow) : inflateRow(value);
223
+ }
224
+ else {
225
+ out[key] = value;
226
+ }
227
+ }
228
+ return out;
229
+ }
@@ -36,4 +36,21 @@ export type SelectItem<T> = "*" | ScalarKeys<T> | {
36
36
  }[RelationKeys<T>];
37
37
  /** A typed form of the `/api/entries` `select` grammar. */
38
38
  export type Select<T> = ReadonlyArray<SelectItem<T>>;
39
+ type Depth = [never, 0, 1, 2, 3, 4];
40
+ /** The target types one select item expands, and everything below them. */
41
+ type ItemTargets<T, I, D extends number> = [D] extends [never] ? never : I extends string ? never : {
42
+ [K in Extract<keyof I, RelationKeys<T>>]: RelationTarget<T[K]> | ValueTargets<RelationTarget<T[K]>, I[K], Depth[D]>;
43
+ }[Extract<keyof I, RelationKeys<T>>];
44
+ type ValueTargets<R, V, D extends number> = V extends "*" ? never : V extends {
45
+ select: infer S;
46
+ } ? ExpandedTargetsAt<R, S, D> : ExpandedTargetsAt<R, V, D>;
47
+ type ExpandedTargetsAt<T, S, D extends number> = S extends ReadonlyArray<infer I> ? ItemTargets<T, I, D> : never;
48
+ /**
49
+ * Every entry type a select EXPANDS, at any depth: what `included` can hold
50
+ * for a `shape: "flat"` read. `[{ author: ["name"] }]` on an article is
51
+ * `Author`; a select given as a string cannot be read by the type system and is
52
+ * `Record<string, unknown>`. Bounded at the API's four levels, so a model that
53
+ * relates to itself does not recurse forever.
54
+ */
55
+ export type ExpandedTargets<T, S> = S extends string ? Record<string, unknown> : [ExpandedTargetsAt<T, S, 4>] extends [never] ? never : ExpandedTargetsAt<T, S, 4>;
39
56
  export {};
@@ -52,3 +52,177 @@ export declare function preview(token: string, client: CapaNextClient): Promise<
52
52
  export declare function pagesFor(client: CapaNextClient): PagesResource;
53
53
  export { LAYOUT_PAGE };
54
54
  export type { PreviewClaim };
55
+ /**
56
+ * The query parameter that turns edit mode on for one request without draft
57
+ * content: `?capa-edit=<token>`, where the token is a Capa preview token. The
58
+ * Capa editor's Published view sends it so the published page is still
59
+ * clickable. It never switches the site to draft data.
60
+ */
61
+ export declare const EDIT_PARAM = "capa-edit";
62
+ /**
63
+ * The request header `resolveEditRequest` sets once a `capa-edit` token has
64
+ * been verified, for `editMode()` to read. Any copy a browser sent is removed
65
+ * first, so it cannot be forged from outside.
66
+ */
67
+ export declare const EDIT_HEADER = "x-capa-edit";
68
+ /** The cookie Next's `draftMode().enable()` sets. */
69
+ export declare const DRAFT_COOKIE = "__prerender_bypass";
70
+ /** What every edit-mode response must send: never cached, never shared. */
71
+ export declare const EDIT_CACHE_CONTROL = "private, no-store";
72
+ interface HeaderReader {
73
+ get(name: string): string | null;
74
+ }
75
+ /**
76
+ * Whether this request renders in edit mode: Next draft mode is on, or the
77
+ * request carried a verified `capa-edit` token (see `resolveEditRequest`).
78
+ *
79
+ * Pass Next's own functions; this package imports nothing from `next`:
80
+ *
81
+ * import { draftMode, headers } from "next/headers";
82
+ * const edit = await editMode({ draftMode, headers });
83
+ * const client = createClient({ ...config, editMode: edit });
84
+ *
85
+ * A client built with `editMode: edit` marks what it reads, and `capaAttrs`
86
+ * then tags those entries and only those.
87
+ */
88
+ export declare function editMode(input: {
89
+ draftMode: () => {
90
+ isEnabled: boolean;
91
+ } | Promise<{
92
+ isEnabled: boolean;
93
+ }>;
94
+ headers: () => HeaderReader | Promise<HeaderReader>;
95
+ }): Promise<boolean>;
96
+ export interface EditRequest {
97
+ /** True when the page must render in edit mode. */
98
+ edit: boolean;
99
+ /** True when a `capa-edit` token was presented and Capa accepted it. */
100
+ verified: boolean;
101
+ /**
102
+ * The request headers to forward: `x-capa-edit` removed, then set to `1`
103
+ * when the token verified. Hand them to `NextResponse.next({ request: { headers } })`.
104
+ */
105
+ headers: Headers;
106
+ /** `EDIT_CACHE_CONTROL` in edit mode, otherwise null. Set it on the response. */
107
+ cacheControl: string | null;
108
+ }
109
+ /**
110
+ * The middleware half of edit mode.
111
+ *
112
+ * export async function middleware(request: NextRequest) {
113
+ * const edit = await resolveEditRequest(request, publishedClient());
114
+ * const response = NextResponse.next({ request: { headers: edit.headers } });
115
+ * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
116
+ * return response;
117
+ * }
118
+ *
119
+ * The token is checked with Capa (`client.preview`), exactly as a preview link
120
+ * is: the site never holds the signing key. A bad, expired or unverifiable
121
+ * token means not in edit mode; it never throws, because a broken edit link
122
+ * must still render the public page.
123
+ */
124
+ export declare function resolveEditRequest(request: {
125
+ url: string | URL;
126
+ headers: Headers;
127
+ cookies?: {
128
+ has(name: string): boolean;
129
+ };
130
+ }, client: Pick<CapaNextClient, "preview">): Promise<EditRequest>;
131
+ /** Where the env-driven helpers read their settings (M6). */
132
+ export declare const CAPA_ENV: {
133
+ readonly baseUrl: "CAPA_API_URL";
134
+ readonly apiKey: "CAPA_KEY";
135
+ readonly draftKey: "CAPA_DRAFT_KEY";
136
+ readonly version: "CAPA_API_VERSION";
137
+ };
138
+ export declare const DEFAULT_API_VERSION = "2026-10-01";
139
+ type DraftModeFn = () => {
140
+ isEnabled: boolean;
141
+ enable?: () => void;
142
+ disable?: () => void;
143
+ } | Promise<{
144
+ isEnabled: boolean;
145
+ enable?: () => void;
146
+ disable?: () => void;
147
+ }>;
148
+ /** The published-key client from env: what verifying a token needs. */
149
+ export declare function getPublishedClient(overrides?: Partial<CapaNextConfig>): CapaNextClient;
150
+ /**
151
+ * The client for this request, from env: the draft key under draft mode,
152
+ * otherwise the published key, and `editMode` worked out for you.
153
+ *
154
+ * import { draftMode, headers } from "next/headers";
155
+ * const capa = await getCapaClient({ draftMode, headers });
156
+ */
157
+ export declare function getCapaClient(input: {
158
+ draftMode: DraftModeFn;
159
+ headers: () => HeaderReader | Promise<HeaderReader>;
160
+ config?: Partial<CapaNextConfig>;
161
+ }): Promise<CapaNextClient>;
162
+ /** Only a path on this site: never `//elsewhere.example` or a full URL. */
163
+ export declare function safeSitePath(value: string | null | undefined): string;
164
+ /**
165
+ * `app/api/capa/preview/route.ts`:
166
+ *
167
+ * import { draftMode } from "next/headers";
168
+ * import { redirect } from "next/navigation";
169
+ * export const GET = createPreviewRoute({ draftMode, redirect });
170
+ *
171
+ * Checks the token with Capa (the site never holds the signing key), turns
172
+ * draft mode on and lands on the entry's page. A bad or expired token lands on
173
+ * the page without draft mode and `?preview=expired`; Capa unreachable gives
174
+ * `?preview=unavailable`.
175
+ */
176
+ export declare function createPreviewRoute(input: {
177
+ draftMode: DraftModeFn;
178
+ redirect: (url: string) => never | void;
179
+ client?: () => Pick<CapaNextClient, "preview">;
180
+ /** Runs after draft mode is enabled, before the redirect (cookie tweaks). */
181
+ onEnable?: () => void | Promise<void>;
182
+ }): (request: Request) => Promise<Response | void>;
183
+ /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
184
+ export declare function exitPreviewRoute(input: {
185
+ draftMode: DraftModeFn;
186
+ redirect: (url: string) => never | void;
187
+ }): (request: Request) => Promise<Response | void>;
188
+ interface MiddlewareRequest {
189
+ url: string;
190
+ headers: Headers;
191
+ nextUrl: URL & {
192
+ clone(): URL;
193
+ };
194
+ cookies: {
195
+ getAll(): Array<{
196
+ name: string;
197
+ value: string;
198
+ }>;
199
+ };
200
+ }
201
+ interface NextResponseLike {
202
+ next(init?: {
203
+ request?: {
204
+ headers?: Headers;
205
+ };
206
+ }): {
207
+ headers: Headers;
208
+ };
209
+ rewrite(url: URL): unknown;
210
+ }
211
+ /**
212
+ * `middleware.ts` in one line:
213
+ *
214
+ * import { NextResponse } from "next/server";
215
+ * export const middleware = capaMiddleware({ NextResponse });
216
+ *
217
+ * - `?capa-preview=<token>` on any page goes to `previewRoute`, which turns
218
+ * draft mode on and comes back;
219
+ * - `?capa-view=published` renders without the draft cookie, for the editor's
220
+ * Published view;
221
+ * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
222
+ * - every edit-mode response is `private, no-store`.
223
+ */
224
+ export declare function capaMiddleware(input: {
225
+ NextResponse: NextResponseLike;
226
+ previewRoute?: string;
227
+ client?: () => Pick<CapaNextClient, "preview">;
228
+ }): (request: MiddlewareRequest) => Promise<unknown>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LAYOUT_PAGE = void 0;
3
+ exports.DEFAULT_API_VERSION = exports.CAPA_ENV = exports.EDIT_CACHE_CONTROL = exports.DRAFT_COOKIE = exports.EDIT_HEADER = exports.EDIT_PARAM = exports.LAYOUT_PAGE = void 0;
4
4
  exports.withCache = withCache;
5
5
  exports.tagsFor = tagsFor;
6
6
  exports.revalidateFromWebhook = revalidateFromWebhook;
@@ -8,6 +8,14 @@ exports.draftClient = draftClient;
8
8
  exports.routeOf = routeOf;
9
9
  exports.preview = preview;
10
10
  exports.pagesFor = pagesFor;
11
+ exports.editMode = editMode;
12
+ exports.resolveEditRequest = resolveEditRequest;
13
+ exports.getPublishedClient = getPublishedClient;
14
+ exports.getCapaClient = getCapaClient;
15
+ exports.safeSitePath = safeSitePath;
16
+ exports.createPreviewRoute = createPreviewRoute;
17
+ exports.exitPreviewRoute = exitPreviewRoute;
18
+ exports.capaMiddleware = capaMiddleware;
11
19
  const next_1 = require("../next");
12
20
  Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return next_1.LAYOUT_PAGE; } });
13
21
  /** Add Next.js fetch-cache options without importing `next/*`. */
@@ -192,3 +200,217 @@ async function preview(token, client) {
192
200
  function pagesFor(client) {
193
201
  return client.pages;
194
202
  }
203
+ // -------------------------------------------------------------- edit mode ---
204
+ /**
205
+ * The query parameter that turns edit mode on for one request without draft
206
+ * content: `?capa-edit=<token>`, where the token is a Capa preview token. The
207
+ * Capa editor's Published view sends it so the published page is still
208
+ * clickable. It never switches the site to draft data.
209
+ */
210
+ exports.EDIT_PARAM = "capa-edit";
211
+ /**
212
+ * The request header `resolveEditRequest` sets once a `capa-edit` token has
213
+ * been verified, for `editMode()` to read. Any copy a browser sent is removed
214
+ * first, so it cannot be forged from outside.
215
+ */
216
+ exports.EDIT_HEADER = "x-capa-edit";
217
+ /** The cookie Next's `draftMode().enable()` sets. */
218
+ exports.DRAFT_COOKIE = "__prerender_bypass";
219
+ /** What every edit-mode response must send: never cached, never shared. */
220
+ exports.EDIT_CACHE_CONTROL = "private, no-store";
221
+ /**
222
+ * Whether this request renders in edit mode: Next draft mode is on, or the
223
+ * request carried a verified `capa-edit` token (see `resolveEditRequest`).
224
+ *
225
+ * Pass Next's own functions; this package imports nothing from `next`:
226
+ *
227
+ * import { draftMode, headers } from "next/headers";
228
+ * const edit = await editMode({ draftMode, headers });
229
+ * const client = createClient({ ...config, editMode: edit });
230
+ *
231
+ * A client built with `editMode: edit` marks what it reads, and `capaAttrs`
232
+ * then tags those entries and only those.
233
+ */
234
+ async function editMode(input) {
235
+ const [draft, headers] = await Promise.all([input.draftMode(), input.headers()]);
236
+ return draft.isEnabled === true || headers.get(exports.EDIT_HEADER) === "1";
237
+ }
238
+ /**
239
+ * The middleware half of edit mode.
240
+ *
241
+ * export async function middleware(request: NextRequest) {
242
+ * const edit = await resolveEditRequest(request, publishedClient());
243
+ * const response = NextResponse.next({ request: { headers: edit.headers } });
244
+ * if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
245
+ * return response;
246
+ * }
247
+ *
248
+ * The token is checked with Capa (`client.preview`), exactly as a preview link
249
+ * is: the site never holds the signing key. A bad, expired or unverifiable
250
+ * token means not in edit mode; it never throws, because a broken edit link
251
+ * must still render the public page.
252
+ */
253
+ async function resolveEditRequest(request, client) {
254
+ const headers = new Headers(request.headers);
255
+ headers.delete(exports.EDIT_HEADER);
256
+ const token = new URL(String(request.url)).searchParams.get(exports.EDIT_PARAM);
257
+ let verified = false;
258
+ if (token) {
259
+ try {
260
+ verified = (await client.preview(token)) !== null;
261
+ }
262
+ catch {
263
+ verified = false;
264
+ }
265
+ }
266
+ if (verified)
267
+ headers.set(exports.EDIT_HEADER, "1");
268
+ const draft = request.cookies?.has(exports.DRAFT_COOKIE) ??
269
+ (headers.get("cookie") ?? "").split(/;\s*/).some((c) => c.startsWith(`${exports.DRAFT_COOKIE}=`));
270
+ const edit = verified || draft;
271
+ return { edit, verified, headers, cacheControl: edit ? exports.EDIT_CACHE_CONTROL : null };
272
+ }
273
+ // ------------------------------------------------- five-minute integration ---
274
+ /** Where the env-driven helpers read their settings (M6). */
275
+ exports.CAPA_ENV = {
276
+ baseUrl: "CAPA_API_URL",
277
+ apiKey: "CAPA_KEY",
278
+ draftKey: "CAPA_DRAFT_KEY",
279
+ version: "CAPA_API_VERSION",
280
+ };
281
+ exports.DEFAULT_API_VERSION = "2026-10-01";
282
+ function readEnv(name) {
283
+ const env = globalThis.process?.env;
284
+ const value = env?.[name];
285
+ return value === undefined || value === "" ? undefined : value;
286
+ }
287
+ function requireEnv(name) {
288
+ const value = readEnv(name);
289
+ if (!value)
290
+ throw new Error(`@capacms/sdk/nextjs: ${name} is not set.`);
291
+ return value;
292
+ }
293
+ /** The published-key client from env: what verifying a token needs. */
294
+ function getPublishedClient(overrides = {}) {
295
+ return (0, next_1.createClient)({
296
+ baseUrl: requireEnv(exports.CAPA_ENV.baseUrl),
297
+ apiKey: requireEnv(exports.CAPA_ENV.apiKey),
298
+ version: readEnv(exports.CAPA_ENV.version) ?? exports.DEFAULT_API_VERSION,
299
+ ...overrides,
300
+ });
301
+ }
302
+ /**
303
+ * The client for this request, from env: the draft key under draft mode,
304
+ * otherwise the published key, and `editMode` worked out for you.
305
+ *
306
+ * import { draftMode, headers } from "next/headers";
307
+ * const capa = await getCapaClient({ draftMode, headers });
308
+ */
309
+ async function getCapaClient(input) {
310
+ const draft = (await input.draftMode()).isEnabled === true;
311
+ const edit = await editMode({ draftMode: input.draftMode, headers: input.headers });
312
+ return (0, next_1.createClient)({
313
+ baseUrl: requireEnv(exports.CAPA_ENV.baseUrl),
314
+ apiKey: requireEnv(draft ? exports.CAPA_ENV.draftKey : exports.CAPA_ENV.apiKey),
315
+ version: readEnv(exports.CAPA_ENV.version) ?? exports.DEFAULT_API_VERSION,
316
+ editMode: edit,
317
+ ...input.config,
318
+ });
319
+ }
320
+ /** Only a path on this site: never `//elsewhere.example` or a full URL. */
321
+ function safeSitePath(value) {
322
+ if (!value || !value.startsWith("/") || value.startsWith("//"))
323
+ return "/";
324
+ return value;
325
+ }
326
+ /**
327
+ * `app/api/capa/preview/route.ts`:
328
+ *
329
+ * import { draftMode } from "next/headers";
330
+ * import { redirect } from "next/navigation";
331
+ * export const GET = createPreviewRoute({ draftMode, redirect });
332
+ *
333
+ * Checks the token with Capa (the site never holds the signing key), turns
334
+ * draft mode on and lands on the entry's page. A bad or expired token lands on
335
+ * the page without draft mode and `?preview=expired`; Capa unreachable gives
336
+ * `?preview=unavailable`.
337
+ */
338
+ function createPreviewRoute(input) {
339
+ return async (request) => {
340
+ const url = new URL(request.url);
341
+ const token = url.searchParams.get("token") ?? url.searchParams.get("capa-preview") ?? "";
342
+ // Reached two ways: directly, or through the middleware's rewrite of a page
343
+ // URL carrying `?capa-preview=`, where Next may hand over the ORIGINAL URL.
344
+ // Then the page itself is the path.
345
+ const path = safeSitePath(url.searchParams.get("path") ?? (url.searchParams.has("capa-preview") ? url.pathname : null));
346
+ let claim = null;
347
+ let failed = false;
348
+ try {
349
+ claim = await (input.client ?? getPublishedClient)().preview(token);
350
+ }
351
+ catch {
352
+ failed = true;
353
+ }
354
+ const draft = await input.draftMode();
355
+ if (!claim) {
356
+ draft.disable?.();
357
+ return input.redirect(`${path}?preview=${failed ? "unavailable" : "expired"}`);
358
+ }
359
+ draft.enable?.();
360
+ await input.onEnable?.();
361
+ return input.redirect(safeSitePath(claim.path ?? path));
362
+ };
363
+ }
364
+ /** `app/api/capa/exit/route.ts`: `export const GET = exitPreviewRoute({ draftMode, redirect });` */
365
+ function exitPreviewRoute(input) {
366
+ return async (request) => {
367
+ (await input.draftMode()).disable?.();
368
+ return input.redirect(safeSitePath(new URL(request.url).searchParams.get("path")));
369
+ };
370
+ }
371
+ /**
372
+ * `middleware.ts` in one line:
373
+ *
374
+ * import { NextResponse } from "next/server";
375
+ * export const middleware = capaMiddleware({ NextResponse });
376
+ *
377
+ * - `?capa-preview=<token>` on any page goes to `previewRoute`, which turns
378
+ * draft mode on and comes back;
379
+ * - `?capa-view=published` renders without the draft cookie, for the editor's
380
+ * Published view;
381
+ * - `?capa-edit=<token>` turns edit mode on (ids and overlay, published data);
382
+ * - every edit-mode response is `private, no-store`.
383
+ */
384
+ function capaMiddleware(input) {
385
+ const previewRoute = input.previewRoute ?? "/api/capa/preview";
386
+ return async (request) => {
387
+ const token = request.nextUrl.searchParams.get("capa-preview");
388
+ if (token) {
389
+ const target = request.nextUrl.clone();
390
+ target.pathname = previewRoute;
391
+ target.search = "";
392
+ target.searchParams.set("token", token);
393
+ target.searchParams.set("path", request.nextUrl.pathname);
394
+ return input.NextResponse.rewrite(target);
395
+ }
396
+ const headers = new Headers(request.headers);
397
+ if (request.nextUrl.searchParams.get("capa-view") === "published") {
398
+ const cookies = request.cookies
399
+ .getAll()
400
+ .filter((cookie) => cookie.name !== exports.DRAFT_COOKIE)
401
+ .map((cookie) => `${cookie.name}=${encodeURIComponent(cookie.value)}`)
402
+ .join("; ");
403
+ if (cookies)
404
+ headers.set("cookie", cookies);
405
+ else
406
+ headers.delete("cookie");
407
+ }
408
+ // The client is built only when a token needs checking, so a missing env
409
+ // value cannot break every page of the site.
410
+ const edit = await resolveEditRequest({ url: request.url, headers }, { preview: (t) => (input.client ?? getPublishedClient)().preview(t) });
411
+ const response = input.NextResponse.next({ request: { headers: edit.headers } });
412
+ if (edit.cacheControl)
413
+ response.headers.set("Cache-Control", edit.cacheControl);
414
+ return response;
415
+ };
416
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/sdk",
3
- "version": "1.0.0-next.3",
3
+ "version": "1.0.0-next.4",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,6 +71,6 @@
71
71
  "scripts": {
72
72
  "build": "tsc -p tsconfig.json",
73
73
  "typecheck": "tsc -p tsconfig.json --noEmit",
74
- "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
74
+ "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js test/inflate.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
75
75
  }
76
76
  }