@capacms/sdk 1.0.0-next.2 → 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
@@ -199,6 +254,19 @@ so that moving a folder cannot silently split one page's telemetry in two. Set
199
254
  `page` on the config instead when a client serves exactly one page; a value on
200
255
  the call wins over one on the config.
201
256
 
257
+ A layout is not a page. `app/layout.tsx` (and any nested `layout.*` or
258
+ `template.*`) renders around every page below it, and Next does not tell it
259
+ which one, so its reads cannot be charged to the page being rendered. `routeOf`
260
+ returns `"(layout)"` for these files, exported as `LAYOUT_PAGE`, and a read that
261
+ names it sends no `Capa-Page` header at all, even when the client was created
262
+ with a `page`. So a Site singleton or a nav read in your root layout is simply
263
+ not attributed, instead of making `/` look as if it read everything:
264
+
265
+ ```ts
266
+ // In app/layout.tsx: same call as in a page, and no page is recorded.
267
+ const site = await capa.entries.list("site", { page: routeOf(import.meta.url) });
268
+ ```
269
+
202
270
  A malformed value throws a `TypeError`. Capa itself ignores a header it cannot
203
271
  store, because a mangled page identity must never take a blog down, so the SDK
204
272
  is the place a typo surfaces.
@@ -328,22 +396,86 @@ editor sees the path without a link to open it.
328
396
 
329
397
  ## Live preview
330
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
+
331
421
  The Capa editor can show your site beside the form: focus a field and its spot
332
422
  on the page is outlined, click the page and the editor jumps to the field, save
333
423
  and the draft re-renders in place. It needs three things on your side. A
334
424
  complete, runnable example is `examples/sdk-demo` in this repository.
335
425
 
336
- ### 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
+ ```
337
447
 
338
- `capaAttrs(entry, field, enabled)` from `@capacms/sdk/next` returns the two
339
- attributes the overlay looks for. `field` is the field's namespace, which is its
340
- key in `entry.fields`, and it autocompletes when the entry is typed. Pass the
341
- 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.
342
453
 
343
454
  ```tsx
344
455
  import { capaAttrs } from "@capacms/sdk/next";
345
456
 
346
- <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
+ }
347
479
  ```
348
480
 
349
481
  ### 2. Start the overlay in draft mode
@@ -362,7 +494,8 @@ export function CapaOverlay({ adminOrigins }: { adminOrigins: string[] }) {
362
494
  }
363
495
  ```
364
496
 
365
- 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.
366
499
  `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
367
500
  does nothing at all, and inside one it only listens to a parent window at one of
368
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
  /**
@@ -287,6 +343,20 @@ export declare class CapaError extends Error {
287
343
  }
288
344
  export declare function isCapaError(error: unknown): error is CapaError;
289
345
  export declare function resolveNextConfig(config: CapaNextConfig): ResolvedNextConfig;
346
+ /**
347
+ * The page a layout (or template) reads for: none.
348
+ *
349
+ * A root layout renders around every page on the site, so charging its reads
350
+ * to `/`, the route its file sits at, made the home page look as if it read
351
+ * every Site singleton and nav on the site. `routeOf` returns this for a
352
+ * `layout.*` or `template.*` file, and a read that names it sends NO
353
+ * `Capa-Page` at all, even when the client was built with a `page`.
354
+ *
355
+ * Not sent as a value, because the API would drop it anyway: it is not a
356
+ * `PAGE_ID`, and the API ignores what it cannot store. Staying absent keeps a
357
+ * layout read byte-identical to one from a client that never named a page.
358
+ */
359
+ export declare const LAYOUT_PAGE = "(layout)";
290
360
  type SelectInput = string | ReadonlyArray<unknown>;
291
361
  /** Serialize the SDK object form into the canonical `/api/entries` grammar. */
292
362
  export declare function serializeSelect(select: SelectInput): string;
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CapaError = void 0;
3
+ exports.LAYOUT_PAGE = exports.CapaError = void 0;
4
4
  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
@@ -85,6 +87,20 @@ function resolveNextConfig(config) {
85
87
  * quietly stops arriving rather than as an error.
86
88
  */
87
89
  const PAGE_ID = /^\/[A-Za-z0-9._\-[\]/]{0,199}$/;
90
+ /**
91
+ * The page a layout (or template) reads for: none.
92
+ *
93
+ * A root layout renders around every page on the site, so charging its reads
94
+ * to `/`, the route its file sits at, made the home page look as if it read
95
+ * every Site singleton and nav on the site. `routeOf` returns this for a
96
+ * `layout.*` or `template.*` file, and a read that names it sends NO
97
+ * `Capa-Page` at all, even when the client was built with a `page`.
98
+ *
99
+ * Not sent as a value, because the API would drop it anyway: it is not a
100
+ * `PAGE_ID`, and the API ignores what it cannot store. Staying absent keeps a
101
+ * layout read byte-identical to one from a client that never named a page.
102
+ */
103
+ exports.LAYOUT_PAGE = "(layout)";
88
104
  /**
89
105
  * The page for one call: the call's own value, else the client's, else none.
90
106
  *
@@ -97,11 +113,33 @@ function resolvePage(configPage, callPage) {
97
113
  const value = callPage !== undefined ? callPage : configPage;
98
114
  if (value === undefined || value === null)
99
115
  return undefined;
116
+ // A layout's read, named on purpose: no header, and the config's page does
117
+ // not stand in for it either.
118
+ if (value === exports.LAYOUT_PAGE)
119
+ return undefined;
100
120
  if (typeof value !== "string" || !PAGE_ID.test(value)) {
101
121
  throw new TypeError(`@capacms/sdk/next: page must be a path such as "/blog/[slug]" or "/blog/hello". Got ${JSON.stringify(value)}.`);
102
122
  }
103
123
  return value;
104
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
+ }
105
143
  const NAME = /^[A-Za-z0-9_-]+$/;
106
144
  function selectName(value, context) {
107
145
  if (typeof value !== "string" || !NAME.test(value)) {
@@ -207,10 +245,21 @@ function filterValue(operator, value) {
207
245
  }
208
246
  return String(value);
209
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
+ }
210
257
  function listQuery(options) {
211
258
  const query = new URLSearchParams();
212
259
  if (options.select !== undefined)
213
260
  query.set("select", serializeSelect(options.select));
261
+ if (shapeOf(options) === "flat")
262
+ query.set("shape", "flat");
214
263
  if (options.filter !== undefined) {
215
264
  for (const [path, operations] of Object.entries(options.filter)) {
216
265
  for (const [rawOperator, value] of Object.entries(operations)) {
@@ -276,7 +325,7 @@ function cacheTags(response) {
276
325
  return value.split(/\s+/).filter(Boolean);
277
326
  }
278
327
  function createRequester(config) {
279
- return async function request(path, query = new URLSearchParams(), signal, page) {
328
+ return async function request(path, query = new URLSearchParams(), signal, page, pagePath) {
280
329
  const url = new URL(config.baseUrl + path);
281
330
  query.forEach((value, key) => url.searchParams.append(key, value));
282
331
  const headers = {
@@ -290,6 +339,8 @@ function createRequester(config) {
290
339
  // byte-identical requests to the ones it sent before this option existed.
291
340
  if (page !== undefined)
292
341
  headers["Capa-Page"] = page;
342
+ if (page !== undefined && pagePath !== undefined)
343
+ headers["Capa-Path"] = pagePath;
293
344
  // Same rule, and on EVERY call rather than only the entries reads: the
294
345
  // stamp describes the build, so a page whose only Capa call is `me()`
295
346
  // still reports which schema it was generated from.
@@ -309,24 +360,43 @@ function createRequester(config) {
309
360
  throw errorFromEnvelope(response.status, body, requestId);
310
361
  if (!body || typeof body !== "object")
311
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
+ }
312
371
  return { body: body, cacheTags: cacheTags(response) };
313
372
  };
314
373
  }
315
374
  function createClient(config) {
316
375
  const resolved = resolveNextConfig(config);
317
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
+ };
318
386
  const entries = {
319
387
  async list(namespace, options = {}) {
320
- const result = await request(`/api/entries/${encodeURIComponent(namespace)}`, listQuery(options), options.signal, resolvePage(resolved.page, options.page));
321
- 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);
322
390
  },
323
391
  async get(namespace, id, options = {}) {
324
392
  const query = new URLSearchParams();
325
393
  if (options.select !== undefined)
326
394
  query.set("select", serializeSelect(options.select));
395
+ if (shapeOf(options) === "flat")
396
+ query.set("shape", "flat");
327
397
  try {
328
- const result = await request(`/api/entries/${encodeURIComponent(namespace)}/${encodeURIComponent(id)}`, query, options.signal, resolvePage(resolved.page, options.page));
329
- 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);
330
400
  }
331
401
  catch (error) {
332
402
  if (error instanceof CapaError &&
@@ -339,6 +409,9 @@ function createClient(config) {
339
409
  }
340
410
  },
341
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
+ }
342
415
  let after = options.after;
343
416
  for (;;) {
344
417
  const page = await entries.list(namespace, { ...options, after });
@@ -399,10 +472,10 @@ function createClient(config) {
399
472
  }
400
473
  },
401
474
  async me(options = {}) {
402
- 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;
403
476
  },
404
477
  async versions(options = {}) {
405
- 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;
406
479
  },
407
480
  };
408
481
  }
@@ -1,5 +1,7 @@
1
- export { CapaError, createClient, isCapaError, resolveNextConfig, serializeSelect, } from "./client";
2
- export { capaAttrs } from "./attrs";
1
+ export { CapaError, createClient, isCapaError, LAYOUT_PAGE, resolveNextConfig, serializeSelect, } from "./client";
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,11 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.capaAttrs = exports.serializeSelect = exports.resolveNextConfig = 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; } });
7
7
  Object.defineProperty(exports, "isCapaError", { enumerable: true, get: function () { return client_1.isCapaError; } });
8
+ Object.defineProperty(exports, "LAYOUT_PAGE", { enumerable: true, get: function () { return client_1.LAYOUT_PAGE; } });
8
9
  Object.defineProperty(exports, "resolveNextConfig", { enumerable: true, get: function () { return client_1.resolveNextConfig; } });
9
10
  Object.defineProperty(exports, "serializeSelect", { enumerable: true, get: function () { return client_1.serializeSelect; } });
10
11
  var attrs_1 = require("./attrs");
12
+ Object.defineProperty(exports, "CAPA_EDIT", { enumerable: true, get: function () { return attrs_1.CAPA_EDIT; } });
11
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; } });