@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 +140 -7
- package/dist/next/attrs.d.ts +38 -3
- package/dist/next/attrs.js +64 -1
- package/dist/next/client.d.ts +71 -1
- package/dist/next/client.js +81 -8
- package/dist/next/index.d.ts +6 -4
- package/dist/next/index.js +8 -1
- package/dist/next/inflate.d.ts +33 -0
- package/dist/next/inflate.js +229 -0
- package/dist/next/select-types.d.ts +17 -0
- package/dist/nextjs/index.d.ts +176 -1
- package/dist/nextjs/index.js +241 -3
- package/package.json +2 -2
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.
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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"
|
|
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
|
|
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
|
package/dist/next/attrs.d.ts
CHANGED
|
@@ -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"
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
+
};
|
package/dist/next/attrs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/next/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/next/client.js
CHANGED
|
@@ -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
|
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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; } });
|