@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 +127 -7
- package/dist/next/attrs.d.ts +38 -3
- package/dist/next/attrs.js +64 -1
- package/dist/next/client.d.ts +57 -1
- package/dist/next/client.js +62 -7
- package/dist/next/index.d.ts +5 -3
- package/dist/next/index.js +7 -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 +174 -0
- package/dist/nextjs/index.js +223 -1
- 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
|
|
@@ -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.
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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"
|
|
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
|
|
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
|
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
|
/**
|
package/dist/next/client.js
CHANGED
|
@@ -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
|
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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 {};
|
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/nextjs/index.js
CHANGED
|
@@ -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
|
+
"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
|
}
|