@capacms/sdk 1.0.0-next.6 → 1.0.0-next.8
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/CHANGELOG.md +55 -1
- package/README.md +192 -44
- package/dist/config.d.ts +0 -35
- package/dist/config.js +47 -1
- package/dist/next/client.d.ts +9 -8
- package/dist/next/client.js +19 -4
- package/dist/next/field-names.js +1 -1
- package/dist/next/graphql/introspection.d.ts +1 -1
- package/dist/next/graphql/introspection.js +1 -1
- package/dist/next/graphql/plan.js +2 -2
- package/dist/next/key-family.d.ts +9 -12
- package/dist/next/key-family.js +11 -19
- package/dist/nextjs/index.d.ts +182 -33
- package/dist/nextjs/index.js +244 -29
- package/package.json +2 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- 1.0.0-next.8. Preview on a live site, in draft mode on its production
|
|
6
|
+
domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_`
|
|
7
|
+
or unprefixed), as the API does: Capa checks the token with any key that
|
|
8
|
+
reads the project, and refuses a token made for another project. It used to
|
|
9
|
+
throw a `TypeError` for a legacy key before any request. Draft reads through
|
|
10
|
+
the SDK still take a `cap_` key only, and the legacy-key warning says so.
|
|
11
|
+
- Next.js: `createPreviewRoute` and `exitPreviewRoute` take Next's `cookies`.
|
|
12
|
+
Given it, the draft cookie is re-set
|
|
13
|
+
`HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`
|
|
14
|
+
(`maxAge` changes the hour), so Safari 26.2 and later send it inside the
|
|
15
|
+
editor's frame and it no longer lasts until the browser closes. Exit and a
|
|
16
|
+
bad link delete it partitioned, which is the only deletion that reaches
|
|
17
|
+
it. `redirect` is optional now: left out, each route answers with its own
|
|
18
|
+
307, marked `X-Robots-Tag: noindex, nofollow`,
|
|
19
|
+
`Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and
|
|
20
|
+
the preview route's carries the editor's `frame-ancestors`. Given
|
|
21
|
+
`redirect`, a route calls it as before.
|
|
22
|
+
- Next.js: `capaHeaders({ adminOrigins })` returns rules for `next.config`'s
|
|
23
|
+
`headers()` that send `X-Robots-Tag: noindex, nofollow` and
|
|
24
|
+
`Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
|
|
25
|
+
on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or
|
|
26
|
+
`capa-view` query, and on nothing else, so a visitor's response is
|
|
27
|
+
unchanged. Also exported: `draftHeaders`, `frameAncestors` (it throws for a
|
|
28
|
+
value that is not a bare origin), `frameDraftCookie`, `clearDraftCookie`,
|
|
29
|
+
`CAPA_ADMIN_ORIGIN`, `DRAFT_ROBOTS_TAG`, `DRAFT_COOKIE_MAX_AGE`,
|
|
30
|
+
`PREVIEW_PARAM` and `VIEW_PARAM`.
|
|
31
|
+
- Next.js: `capaMiddleware` marks every edit-mode response, and every request
|
|
32
|
+
carrying `capa-edit` or `capa-view`, `X-Robots-Tag: noindex, nofollow`, and
|
|
33
|
+
`resolveEditRequest` returns that value as `robotsTag`.
|
|
34
|
+
- Docs: "Add preview to a live site without changing it", the steps for a
|
|
35
|
+
site that keeps its own Capa reads, and why `editMode()` and
|
|
36
|
+
`getCapaClient()` make a static page dynamic.
|
|
37
|
+
- 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a
|
|
38
|
+
method of their config, which a browser refuses ("Failed to execute 'fetch'
|
|
39
|
+
on 'Window': Illegal invocation"), so every read from a browser failed unless
|
|
40
|
+
`fetch` was passed in. The default fetch is now called through `globalThis`
|
|
41
|
+
on each request, which also picks up a fetch a framework patches in later.
|
|
42
|
+
- `next` is no longer a peer dependency. No range matches every Next canary,
|
|
43
|
+
so a site on `next@16.3.0-canary.39` could not `npm install` the SDK without
|
|
44
|
+
`--legacy-peer-deps`. `@capacms/sdk/nextjs/overlay` still imports `next`,
|
|
45
|
+
which only a Next site loads.
|
|
46
|
+
- Docs: the edit mark is dropped wherever an entry is serialized to the
|
|
47
|
+
browser (Pages Router props, SvelteKit and Remix loaders, Nuxt payload);
|
|
48
|
+
pass the edit flag to `capaAttrs` there. Found testing preview on nine stacks.
|
|
49
|
+
- Next.js: a GraphQL read that gives neither `tags` nor `revalidate`, through
|
|
50
|
+
`getCapaClient().graphql()`, its builder or `graphql()`, is no longer kept
|
|
51
|
+
in Next's data cache. It is sent as `entries.list` and `entries.get` send
|
|
52
|
+
theirs, with no `cache` and no `next`, so a publish shows on the next render
|
|
53
|
+
whether the page reads by REST or by GraphQL. It used to be kept under
|
|
54
|
+
`capa:graphql` until a webhook revalidated it, so a site with no webhook
|
|
55
|
+
route kept serving the old answer by GraphQL while REST showed the new one.
|
|
56
|
+
A read that gives `tags` or a `revalidate` is kept as before. To keep a read
|
|
57
|
+
with no tags as it was kept, pass `revalidate: false`, which keeps it under
|
|
58
|
+
`capa:graphql` until the next publish. `tags: []` now counts as no tags.
|
|
5
59
|
- 1.0.0-next.6. GraphQL, on `@capacms/sdk/next` and `@capacms/sdk/nextjs`,
|
|
6
60
|
and two commands, `capa-codegen --graphql` and `capa persist`. Additive:
|
|
7
61
|
every call that existed behaves as before. `graphql` is an optional peer
|
|
@@ -143,7 +197,7 @@
|
|
|
143
197
|
as it takes a tool spec.
|
|
144
198
|
|
|
145
199
|
The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL`
|
|
146
|
-
move between the spec the Explorer and `@
|
|
200
|
+
move between the spec the Explorer and `@capacms/mcp` share, GraphQL text and
|
|
147
201
|
the equal REST request, written exactly as the API writes it in
|
|
148
202
|
`extensions.capa.rest`: the filter as `where` JSON, and a system key a
|
|
149
203
|
field shadows as `$tags`, `$createdAt` or `author.$id`. They nest at most 4
|
package/README.md
CHANGED
|
@@ -47,12 +47,14 @@ under Developers > Keys.
|
|
|
47
47
|
The legacy key your site already holds works too, for every read: a `pk_`
|
|
48
48
|
or `sk_` key, or an older key with no prefix, since every key but `cap_` is
|
|
49
49
|
a legacy key to the API. That covers `entries`, `graphql`,
|
|
50
|
-
`graphqlSchema`, `pages`, `me` and `
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`
|
|
50
|
+
`graphqlSchema`, `pages`, `me`, `versions` and `preview`: a site checks an
|
|
51
|
+
editor's preview link with the key it already holds, and Capa refuses a link
|
|
52
|
+
made for another project. The first client built with one prints a warning
|
|
53
|
+
once per process, naming the `cap_` key to mint. Draft reads through the SDK
|
|
54
|
+
keep their rule and take a `cap_` key only: `draftClient`'s `draft` config,
|
|
55
|
+
and `CAPA_DRAFT_KEY` for `getCapaClient` and `graphql()`, throw a `TypeError`
|
|
56
|
+
for a legacy key before any request. An `apiKey` that is not a string is
|
|
57
|
+
refused where the client is built.
|
|
56
58
|
|
|
57
59
|
`CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads: the
|
|
58
60
|
`/nextjs` helpers, `capa-codegen` and Capa's MCP server, so one `.env` serves
|
|
@@ -287,9 +289,10 @@ const capa = await draftClient<CapaQuery>({
|
|
|
287
289
|
`CapaQuery` types the GraphQL builder on the client it returns, as it does
|
|
288
290
|
for `createClient<CapaQuery>`; `getCapaClient<CapaQuery>` and
|
|
289
291
|
`getPublishedClient<CapaQuery>` take it the same way. Without it each helper
|
|
290
|
-
returns an untyped client. `getCapaClient`
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
returns an untyped client. `getCapaClient` sends its GraphQL reads as it
|
|
293
|
+
sends its REST reads, which Next does not keep, unless a call gives `tags` or
|
|
294
|
+
`revalidate`; then it keeps them in Next's data cache as `graphql()` does
|
|
295
|
+
(see The typed builder).
|
|
293
296
|
|
|
294
297
|
## GraphQL
|
|
295
298
|
|
|
@@ -335,8 +338,10 @@ once `capa-codegen --graphql` has read it (see Typed documents). Until then
|
|
|
335
338
|
the call does not compile, and the error says to run it, so a document edited
|
|
336
339
|
since the last run is never silently untyped.
|
|
337
340
|
|
|
338
|
-
`graphql()` keeps a published read in Next's
|
|
339
|
-
`unstable_cache`, under the tags you give.
|
|
341
|
+
Given `tags` or `revalidate`, `graphql()` keeps a published read in Next's
|
|
342
|
+
data cache, through Next's own `unstable_cache`, under the tags you give.
|
|
343
|
+
Given neither, it keeps nothing: the read is sent as a REST read is, and the
|
|
344
|
+
page shows a publish on its next render. `BlogIndexModels` lists the models
|
|
340
345
|
the query reads, which codegen writes beside its types: `articles`, and
|
|
341
346
|
`authors` for `author { name }`. It changes when the query does, so a model
|
|
342
347
|
the query starts reading is never left out. `tagsFor({ namespace })` makes
|
|
@@ -348,16 +353,19 @@ library, its alt text say, changes no entry, so it revalidates `capa:media`
|
|
|
348
353
|
instead, and every read tagged by its models shows the new text. How long a
|
|
349
354
|
read is kept:
|
|
350
355
|
|
|
351
|
-
- With
|
|
352
|
-
|
|
356
|
+
- With neither `tags` nor `revalidate`, not at all: every render reads the
|
|
357
|
+
API, as `entries.list` does, so a site with no webhook route still shows a
|
|
358
|
+
publish on the next request.
|
|
359
|
+
- With `tags` and no `revalidate`, until one of its tags is revalidated.
|
|
360
|
+
With the webhook route, that is until the next publish of a model it reads.
|
|
353
361
|
- With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs
|
|
354
|
-
a minute of stale content at most.
|
|
355
|
-
- With `revalidate: 0`, not at all
|
|
362
|
+
a minute of stale content at most.
|
|
363
|
+
- With `revalidate: 0`, not at all.
|
|
356
364
|
|
|
357
|
-
A read with no `tags` is tagged `capa:graphql`
|
|
358
|
-
`revalidateFromWebhook` revalidates on every content
|
|
359
|
-
stale after a publish; tag it with its models to
|
|
360
|
-
one of them changes.
|
|
365
|
+
A read with a `revalidate` and no `tags` is tagged `capa:graphql`
|
|
366
|
+
(`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content
|
|
367
|
+
change, so it is never stale after a publish; tag it with its models to
|
|
368
|
+
refresh the page only when one of them changes.
|
|
361
369
|
|
|
362
370
|
A read that answered with `errors` is never kept: a root field that timed
|
|
363
371
|
out is shown once and read again on the next request. Next's fetch cache is
|
|
@@ -513,10 +521,10 @@ it is never cached. The admin host serves GraphQL by POST only and answers a
|
|
|
513
521
|
GET as a path it does not serve; a read that did not ask for GET is then
|
|
514
522
|
repeated as a POST, and that host is read by POST for five minutes. A
|
|
515
523
|
`method: "GET"` read there throws "This Capa host does not serve GraphQL by
|
|
516
|
-
GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
never for a draft.
|
|
524
|
+
GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top
|
|
525
|
+
when a read gives `tags` or `revalidate`, for a published read with no
|
|
526
|
+
errors, a POST included: kept under its `tags` until one is revalidated, or
|
|
527
|
+
for `revalidate` seconds when you give it, and never for a draft.
|
|
520
528
|
|
|
521
529
|
### The typed builder
|
|
522
530
|
|
|
@@ -557,8 +565,8 @@ document is printed when it runs, so `capa persist` never stored it, and it
|
|
|
557
565
|
is already a GET that the API and the CDN cache. To persist a read, write it
|
|
558
566
|
as a `#graphql` literal (see Persisted queries).
|
|
559
567
|
|
|
560
|
-
In a Next.js server component, read through `getCapaClient`.
|
|
561
|
-
kept in Next's data cache
|
|
568
|
+
In a Next.js server component, read through `getCapaClient`. A read that
|
|
569
|
+
names tags is kept in Next's data cache under them:
|
|
562
570
|
|
|
563
571
|
```tsx
|
|
564
572
|
// app/blog/page.tsx
|
|
@@ -579,7 +587,8 @@ export default async function Blog() {
|
|
|
579
587
|
`tags` and `revalidate` work as they do for `graphql()` (see In a Next.js
|
|
580
588
|
server component). A read is kept until a publish of a model its tags name,
|
|
581
589
|
a read with errors is never kept, and a draft or an edit-mode page is read
|
|
582
|
-
uncached.
|
|
590
|
+
uncached. A read with neither is not kept, as the client's REST reads are
|
|
591
|
+
not. `capa.graphql(document)` on the same client takes them too.
|
|
583
592
|
|
|
584
593
|
Read one field twice in one request with an alias: any other key, with
|
|
585
594
|
`__aliasFor` naming the field it reads. A home page's featured and latest
|
|
@@ -1266,8 +1275,9 @@ editor sees the path without a link to open it.
|
|
|
1266
1275
|
|
|
1267
1276
|
### Quick start (Next.js)
|
|
1268
1277
|
|
|
1269
|
-
Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_
|
|
1270
|
-
|
|
1278
|
+
Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
|
|
1279
|
+
holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
|
|
1280
|
+
`cap_` key only (see Keys). Then:
|
|
1271
1281
|
|
|
1272
1282
|
```ts
|
|
1273
1283
|
// middleware.ts
|
|
@@ -1276,13 +1286,17 @@ import { capaMiddleware } from "@capacms/sdk/nextjs";
|
|
|
1276
1286
|
export const middleware = capaMiddleware({ NextResponse });
|
|
1277
1287
|
|
|
1278
1288
|
// app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
|
|
1279
|
-
import { draftMode } from "next/headers";
|
|
1280
|
-
import { redirect } from "next/navigation";
|
|
1289
|
+
import { cookies, draftMode } from "next/headers";
|
|
1281
1290
|
import { createPreviewRoute } from "@capacms/sdk/nextjs";
|
|
1282
|
-
export const GET = createPreviewRoute({ draftMode,
|
|
1291
|
+
export const GET = createPreviewRoute({ draftMode, cookies });
|
|
1292
|
+
|
|
1293
|
+
// next.config.mjs
|
|
1294
|
+
import { capaHeaders } from "@capacms/sdk/nextjs";
|
|
1295
|
+
export default { async headers() { return capaHeaders(); } };
|
|
1283
1296
|
|
|
1284
1297
|
// in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
|
|
1285
|
-
// in the root layout
|
|
1298
|
+
// in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
|
|
1299
|
+
// then {edit ? <CapaOverlay adminOrigins={[...]} /> : null} from @capacms/sdk/nextjs/overlay
|
|
1286
1300
|
```
|
|
1287
1301
|
|
|
1288
1302
|
In Capa, set the project's preview URL to your site, and editors can click
|
|
@@ -1330,6 +1344,33 @@ The mark does not survive a spread copy or being passed to a client component,
|
|
|
1330
1344
|
so tag in the server component that read the entry. `capaAttrs(entry, field,
|
|
1331
1345
|
true)` still forces the tags on and `false` forces them off.
|
|
1332
1346
|
|
|
1347
|
+
**When the entry crosses to the browser as data, pass the flag.** The mark is
|
|
1348
|
+
a hidden symbol, so anything that serializes the entry drops it silently: the
|
|
1349
|
+
Next Pages Router's `getServerSideProps`, SvelteKit and Remix loaders, Nuxt's
|
|
1350
|
+
payload, or your own JSON endpoint. The page then shows the draft with no
|
|
1351
|
+
`data-capa-` tags, and the editor has nothing to point at. Nothing errors. Send
|
|
1352
|
+
the draft or edit state alongside the entry and hand it to `capaAttrs`:
|
|
1353
|
+
|
|
1354
|
+
```tsx
|
|
1355
|
+
// pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or
|
|
1356
|
+
// useAsyncData on the server hands over the flag the same way.
|
|
1357
|
+
import type { Entry } from "@capacms/sdk/next";
|
|
1358
|
+
import type { Articles } from "./capa-types";
|
|
1359
|
+
|
|
1360
|
+
export async function getServerSideProps({ draftMode = false }) {
|
|
1361
|
+
const entry = (await capa.entries.get<Articles>("articles", "entry-id"))!.data;
|
|
1362
|
+
return { props: { entry, edit: draftMode } };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// in the component
|
|
1366
|
+
export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
|
|
1367
|
+
return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
|
|
1368
|
+
}
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
Or re-mark the entries where they arrive with `markEditEntries(data)` when the
|
|
1372
|
+
page is in edit mode. Tested on the Pages Router, SvelteKit and Nuxt.
|
|
1373
|
+
|
|
1333
1374
|
GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode
|
|
1334
1375
|
`createClient` or `graphql()` given `{ draftMode, headers }`. A node is an
|
|
1335
1376
|
entry when it selected `id` and `model`, and `field` is the field as you
|
|
@@ -1362,6 +1403,7 @@ export async function middleware(request: NextRequest) {
|
|
|
1362
1403
|
const edit = await resolveEditRequest(request, publishedClient());
|
|
1363
1404
|
const response = NextResponse.next({ request: { headers: edit.headers } });
|
|
1364
1405
|
if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
|
|
1406
|
+
if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
|
|
1365
1407
|
return response;
|
|
1366
1408
|
}
|
|
1367
1409
|
```
|
|
@@ -1415,18 +1457,124 @@ export function middleware(request: NextRequest) {
|
|
|
1415
1457
|
`/api/draft` is the route handler shown under "Open a draft in your own site"
|
|
1416
1458
|
above, reading `token` and `path`.
|
|
1417
1459
|
|
|
1418
|
-
**Cookies in a frame.** The editor frames your site from another site
|
|
1419
|
-
browser
|
|
1420
|
-
`SameSite=None; Secure
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
`
|
|
1429
|
-
|
|
1460
|
+
**Cookies in a frame.** The editor frames your site from another site. A
|
|
1461
|
+
browser sends a cookie into a cross-site frame only when it is
|
|
1462
|
+
`SameSite=None; Secure`, and Safari 26.2 and later only when it is
|
|
1463
|
+
`Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned`
|
|
1464
|
+
nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens
|
|
1465
|
+
every draft on the site until the browser closes. Pass Next's `cookies` to
|
|
1466
|
+
`createPreviewRoute` and `exitPreviewRoute` and they re-set it as
|
|
1467
|
+
`HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and
|
|
1468
|
+
delete it the same way. `maxAge` changes the hour. In your own route, call
|
|
1469
|
+
`frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)`
|
|
1470
|
+
after `disable()`. A browser that drops the cookie still gets a fresh token on
|
|
1471
|
+
every preview load, which step 3 honours on any page.
|
|
1472
|
+
|
|
1473
|
+
Leave `redirect` out of both routes. Each then answers with its own 307,
|
|
1474
|
+
marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the
|
|
1475
|
+
token is in the URL) and `Cache-Control: private, no-store`. Next's
|
|
1476
|
+
`redirect()` cannot carry headers, so passing it keeps the old redirect.
|
|
1477
|
+
|
|
1478
|
+
**Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for
|
|
1479
|
+
`next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and
|
|
1480
|
+
`Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a
|
|
1481
|
+
request carrying the draft cookie or a `capa-preview`, `capa-edit` or
|
|
1482
|
+
`capa-view` query, and on nothing else. A visitor's response, cached or not,
|
|
1483
|
+
is unchanged. Pass `adminOrigins` to name another admin, such as one running
|
|
1484
|
+
locally, and pass the same list to `createPreviewRoute`. A browser applies
|
|
1485
|
+
every CSP it is sent, so if your site sends its own `frame-ancestors` or
|
|
1486
|
+
`X-Frame-Options`, leave them off draft responses with
|
|
1487
|
+
`missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule.
|
|
1488
|
+
`capaMiddleware` marks its edit-mode responses noindex too, and
|
|
1489
|
+
`resolveEditRequest` returns the value as `robotsTag`.
|
|
1490
|
+
|
|
1491
|
+
### Add preview to a live site without changing it
|
|
1492
|
+
|
|
1493
|
+
A live Next.js site that reads Capa with its own code keeps that code. Preview
|
|
1494
|
+
adds a branch that only draft mode switches on, and draft mode is off for
|
|
1495
|
+
every visitor, at build time and during ISR. Reading
|
|
1496
|
+
`(await draftMode()).isEnabled` leaves a static page static, so a visitor gets
|
|
1497
|
+
the same pages, headers and cache as before.
|
|
1498
|
+
|
|
1499
|
+
1. **Read drafts in draft mode.** In the helper that fetches from Capa, before
|
|
1500
|
+
the existing fetch, read the same URL with a draft key and no cache. The
|
|
1501
|
+
data has the same shape, so every page renders unchanged.
|
|
1502
|
+
|
|
1503
|
+
```ts
|
|
1504
|
+
// lib/capa-draft.ts
|
|
1505
|
+
import "server-only";
|
|
1506
|
+
import { draftMode } from "next/headers";
|
|
1507
|
+
|
|
1508
|
+
export async function isDraft(): Promise<boolean> {
|
|
1509
|
+
// draftMode() throws outside a request (generateStaticParams, some build steps).
|
|
1510
|
+
try { return (await draftMode()).isEnabled; } catch { return false; }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// in the fetch helper, before the existing fetch, which stays as it is
|
|
1514
|
+
if (await isDraft()) {
|
|
1515
|
+
return fetch(`https://api.capacms.com/v2/api/${endpoint}${query}`, {
|
|
1516
|
+
headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! },
|
|
1517
|
+
cache: "no-store",
|
|
1518
|
+
}).then(parse); // the helper's existing parse
|
|
1519
|
+
}
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
The draft key is any key of yours whose environment is not `production`
|
|
1523
|
+
(see Preview is a key, not a flag). Keep it server-only, never in a
|
|
1524
|
+
`NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a
|
|
1525
|
+
helper a client component also imports would bundle it.
|
|
1526
|
+
|
|
1527
|
+
2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode,
|
|
1528
|
+
cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick
|
|
1529
|
+
start. Set `CAPA_API_URL` to `https://api.capacms.com` and `CAPA_KEY` to
|
|
1530
|
+
the production key the site already reads with, server-only: the routes
|
|
1531
|
+
check the editor's token with it.
|
|
1532
|
+
|
|
1533
|
+
3. **Add the middleware behind a matcher**, so a visitor's request never runs
|
|
1534
|
+
it. Next reads `config` from the file itself, so write the matcher out:
|
|
1535
|
+
|
|
1536
|
+
```ts
|
|
1537
|
+
// middleware.ts (proxy.ts on Next 16)
|
|
1538
|
+
import { NextResponse } from "next/server";
|
|
1539
|
+
import { capaMiddleware } from "@capacms/sdk/nextjs";
|
|
1540
|
+
|
|
1541
|
+
export const middleware = capaMiddleware({ NextResponse });
|
|
1542
|
+
export const config = {
|
|
1543
|
+
matcher: [
|
|
1544
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
|
|
1545
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
|
|
1546
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
|
|
1547
|
+
{ source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
|
|
1548
|
+
],
|
|
1549
|
+
};
|
|
1550
|
+
```
|
|
1551
|
+
|
|
1552
|
+
4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
|
|
1553
|
+
start: drafts are marked noindex and only the Capa admin can frame them.
|
|
1554
|
+
|
|
1555
|
+
5. **Start the overlay and tag fields in draft mode.** In the root layout:
|
|
1556
|
+
|
|
1557
|
+
```tsx
|
|
1558
|
+
const draft = await isDraft();
|
|
1559
|
+
{draft ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
|
|
1563
|
+
With `draft` false it returns `{}`, so a visitor's HTML gains no
|
|
1564
|
+
attribute. The field is its key in the entry, which is its namespace.
|
|
1565
|
+
|
|
1566
|
+
6. **Route handlers that set their own `Cache-Control`** must send
|
|
1567
|
+
`private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by
|
|
1568
|
+
the editor's browser and serves it to everyone.
|
|
1569
|
+
|
|
1570
|
+
Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a
|
|
1571
|
+
static or ISR page or layout. Each reads `headers()`, which makes every route
|
|
1572
|
+
that calls it dynamic: the HTML stays the same, but the site loses ISR and
|
|
1573
|
+
renders every view. `isDraft()` above is the static-safe check. The editor's
|
|
1574
|
+
Published view then shows the static page without the overlay.
|
|
1575
|
+
|
|
1576
|
+
In Capa, set the project's preview URL to the site's production origin, and
|
|
1577
|
+
give each model with a page a route pattern, or Preview has no link to open.
|
|
1430
1578
|
|
|
1431
1579
|
### The protocol
|
|
1432
1580
|
|
package/dist/config.d.ts
CHANGED
|
@@ -1,38 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client configuration, and the one thing about it that is not obvious.
|
|
3
|
-
*
|
|
4
|
-
* PREVIEW IS A KEY, NOT A FLAG
|
|
5
|
-
* Capa gates unpublished content on the API key's `environment` column, not on
|
|
6
|
-
* anything in the request:
|
|
7
|
-
*
|
|
8
|
-
* // apps/api/src/routes/v2/api.ts:203
|
|
9
|
-
* const environment = req.apiKeyEnvironment || "production";
|
|
10
|
-
* const includeDrafted = environment === "production" ? false : true;
|
|
11
|
-
*
|
|
12
|
-
* So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
|
|
13
|
-
* CANNOT be implemented against a published key — the server would ignore it.
|
|
14
|
-
* The honest surface is one client per key:
|
|
15
|
-
*
|
|
16
|
-
* const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
|
|
17
|
-
* const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
|
|
18
|
-
*
|
|
19
|
-
* Note also that the comparison above is exact and case-sensitive against a
|
|
20
|
-
* free-text column, so ANY environment that is not literally "production"
|
|
21
|
-
* returns drafts — `staging`, `development`, `draft`, and equally a typo like
|
|
22
|
-
* "Production".
|
|
23
|
-
*
|
|
24
|
-
* AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
|
|
25
|
-
* There is deliberately no `includesDrafts()` here, because it cannot be
|
|
26
|
-
* implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
|
|
27
|
-
* internally to compute `includeDrafted`, and returned to the caller by NO
|
|
28
|
-
* endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
|
|
29
|
-
*
|
|
30
|
-
* So a site handed a `draft`, `staging` or `development` key serves unpublished
|
|
31
|
-
* content to the public, and has no way to detect it — not at startup, not at
|
|
32
|
-
* runtime, not from any response. In production-shaped data 29 of 74 keys are
|
|
33
|
-
* non-production. Whatever this SDK offers, it cannot make that safe; the fix
|
|
34
|
-
* is for Capa to report the key's environment on a read a client already makes.
|
|
35
|
-
*/
|
|
36
1
|
export interface CapaConfig {
|
|
37
2
|
/** Base URL of the Capa API, e.g. https://api.example.com. No trailing slash required. */
|
|
38
3
|
baseUrl: string;
|
package/dist/config.js
CHANGED
|
@@ -1,13 +1,59 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resolveConfig = resolveConfig;
|
|
4
|
+
/**
|
|
5
|
+
* Client configuration, and the one thing about it that is not obvious.
|
|
6
|
+
*
|
|
7
|
+
* PREVIEW IS A KEY, NOT A FLAG
|
|
8
|
+
* Capa gates unpublished content on the API key's `environment` column, not on
|
|
9
|
+
* anything in the request:
|
|
10
|
+
*
|
|
11
|
+
* // apps/api/src/routes/v2/api.ts:203
|
|
12
|
+
* const environment = req.apiKeyEnvironment || "production";
|
|
13
|
+
* const includeDrafted = environment === "production" ? false : true;
|
|
14
|
+
*
|
|
15
|
+
* So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
|
|
16
|
+
* CANNOT be implemented against a published key — the server would ignore it.
|
|
17
|
+
* The honest surface is one client per key:
|
|
18
|
+
*
|
|
19
|
+
* const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
|
|
20
|
+
* const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
|
|
21
|
+
*
|
|
22
|
+
* Note also that the comparison above is exact and case-sensitive against a
|
|
23
|
+
* free-text column, so ANY environment that is not literally "production"
|
|
24
|
+
* returns drafts — `staging`, `development`, `draft`, and equally a typo like
|
|
25
|
+
* "Production".
|
|
26
|
+
*
|
|
27
|
+
* AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
|
|
28
|
+
* There is deliberately no `includesDrafts()` here, because it cannot be
|
|
29
|
+
* implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
|
|
30
|
+
* internally to compute `includeDrafted`, and returned to the caller by NO
|
|
31
|
+
* endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
|
|
32
|
+
*
|
|
33
|
+
* So a site handed a `draft`, `staging` or `development` key serves unpublished
|
|
34
|
+
* content to the public, and has no way to detect it — not at startup, not at
|
|
35
|
+
* runtime, not from any response. In production-shaped data 29 of 74 keys are
|
|
36
|
+
* non-production. Whatever this SDK offers, it cannot make that safe; the fix
|
|
37
|
+
* is for Capa to report the key's environment on a read a client already makes.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The platform fetch, called through `globalThis` on every request. Storing
|
|
41
|
+
* `globalThis.fetch` and calling it as a method of the config object gives it
|
|
42
|
+
* the wrong `this`, and a browser refuses that ("Illegal invocation"). Looking
|
|
43
|
+
* it up per call also picks up a fetch a framework patches in later.
|
|
44
|
+
*/
|
|
45
|
+
function defaultFetch() {
|
|
46
|
+
if (typeof globalThis.fetch !== "function")
|
|
47
|
+
return undefined;
|
|
48
|
+
return ((input, init) => globalThis.fetch(input, init));
|
|
49
|
+
}
|
|
4
50
|
function resolveConfig(config) {
|
|
5
51
|
const missing = ["baseUrl", "apiKey", "tenantId"].filter((k) => !config[k]);
|
|
6
52
|
if (missing.length) {
|
|
7
53
|
throw new Error(`@capacms/sdk: missing ${missing.join(", ")}. ` +
|
|
8
54
|
`createClient needs baseUrl, apiKey and tenantId.`);
|
|
9
55
|
}
|
|
10
|
-
const fetchImpl = config.fetch ??
|
|
56
|
+
const fetchImpl = config.fetch ?? defaultFetch();
|
|
11
57
|
if (typeof fetchImpl !== "function") {
|
|
12
58
|
throw new Error("@capacms/sdk: no fetch available. Pass one via config.fetch on older runtimes.");
|
|
13
59
|
}
|
package/dist/next/client.d.ts
CHANGED
|
@@ -9,9 +9,9 @@ export interface CapaNextConfig {
|
|
|
9
9
|
baseUrl: string;
|
|
10
10
|
/**
|
|
11
11
|
* A `cap_` key, or the legacy key a site already holds: `pk_`, `sk_`, or
|
|
12
|
-
* an older key with no prefix. A legacy key reads through every read call
|
|
13
|
-
* and warns once per process;
|
|
14
|
-
* only.
|
|
12
|
+
* an older key with no prefix. A legacy key reads through every read call,
|
|
13
|
+
* `preview()` included, and warns once per process; the draft clients of
|
|
14
|
+
* `@capacms/sdk/nextjs` take a `cap_` key only.
|
|
15
15
|
*/
|
|
16
16
|
apiKey: string;
|
|
17
17
|
version: string;
|
|
@@ -210,7 +210,7 @@ export interface EntriesResource {
|
|
|
210
210
|
* One suggestion drawn from a page's own reads.
|
|
211
211
|
*
|
|
212
212
|
* Computed on the API, never here. Three clients want this answer (this SDK,
|
|
213
|
-
* the Capa admin and `@
|
|
213
|
+
* the Capa admin and `@capacms/mcp`), and a second implementation of "this page
|
|
214
214
|
* over-fetches" would drift from the first the moment a threshold moved.
|
|
215
215
|
*/
|
|
216
216
|
export interface PageInsight {
|
|
@@ -387,8 +387,9 @@ export type BuilderCallOptions<O extends GraphQLCallOptions = GraphQLCallOptions
|
|
|
387
387
|
/**
|
|
388
388
|
* `client.graphql` over one way of reading a document: called with a
|
|
389
389
|
* document, and `query()` printing a selection's document for it. `createClient`
|
|
390
|
-
* reads straight from the API, and `getCapaClient` from `/nextjs`
|
|
391
|
-
* Next's data cache,
|
|
390
|
+
* reads straight from the API, and `getCapaClient` from `/nextjs` the same way
|
|
391
|
+
* unless a call gives `tags` or `revalidate`, then through Next's data cache,
|
|
392
|
+
* with the same refusals.
|
|
392
393
|
*/
|
|
393
394
|
export declare function graphqlClient<Q, O extends GraphQLCallOptions>(read: (document: string, variables: Record<string, unknown> | undefined, options: O | undefined) => Promise<GraphQLResult<unknown>>): GraphQLClient<Q, O>;
|
|
394
395
|
export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions = GraphQLCallOptions> {
|
|
@@ -400,8 +401,8 @@ export interface CapaNextClient<Q = UntypedQuery, O extends GraphQLCallOptions =
|
|
|
400
401
|
* Returns the claim, or NULL when the token is invalid or expired, because
|
|
401
402
|
* both mean the same thing to a preview route: do not enable draft mode.
|
|
402
403
|
* Every other failure throws, so a Capa outage does not look like a bad link.
|
|
403
|
-
*
|
|
404
|
-
*
|
|
404
|
+
* Takes any key the site holds, a legacy `pk_`, `sk_` or unprefixed key
|
|
405
|
+
* included: Capa answers a token minted for another tenant as invalid.
|
|
405
406
|
*/
|
|
406
407
|
preview(token: string, options?: CallOptions): Promise<PreviewClaim | null>;
|
|
407
408
|
me<T = Record<string, unknown>>(options?: CallOptions): Promise<T>;
|
package/dist/next/client.js
CHANGED
|
@@ -18,6 +18,19 @@ const typed_1 = require("./graphql/typed");
|
|
|
18
18
|
var errors_2 = require("./errors");
|
|
19
19
|
Object.defineProperty(exports, "CapaError", { enumerable: true, get: function () { return errors_2.CapaError; } });
|
|
20
20
|
Object.defineProperty(exports, "isCapaError", { enumerable: true, get: function () { return errors_2.isCapaError; } });
|
|
21
|
+
/**
|
|
22
|
+
* The platform fetch, called through `globalThis` on every request. Storing
|
|
23
|
+
* `globalThis.fetch` and calling it as a method of the config object gives it
|
|
24
|
+
* the wrong `this`, and a browser refuses that ("Failed to execute 'fetch' on
|
|
25
|
+
* 'Window': Illegal invocation"); Node does not care, which is why only
|
|
26
|
+
* browser reads broke. Looking it up per call also picks up a fetch a
|
|
27
|
+
* framework patches in after the client is built (Next does).
|
|
28
|
+
*/
|
|
29
|
+
function defaultFetch() {
|
|
30
|
+
if (typeof globalThis.fetch !== "function")
|
|
31
|
+
return undefined;
|
|
32
|
+
return ((input, init) => globalThis.fetch(input, init));
|
|
33
|
+
}
|
|
21
34
|
/**
|
|
22
35
|
* What a `Capa-Schema` value may look like: hex, 8 to 64 characters.
|
|
23
36
|
*
|
|
@@ -35,8 +48,9 @@ const BUILDER_NOT_PERSISTED = "@capacms/sdk/next: graphql.query() cannot send pe
|
|
|
35
48
|
/**
|
|
36
49
|
* `client.graphql` over one way of reading a document: called with a
|
|
37
50
|
* document, and `query()` printing a selection's document for it. `createClient`
|
|
38
|
-
* reads straight from the API, and `getCapaClient` from `/nextjs`
|
|
39
|
-
* Next's data cache,
|
|
51
|
+
* reads straight from the API, and `getCapaClient` from `/nextjs` the same way
|
|
52
|
+
* unless a call gives `tags` or `revalidate`, then through Next's data cache,
|
|
53
|
+
* with the same refusals.
|
|
40
54
|
*/
|
|
41
55
|
function graphqlClient(read) {
|
|
42
56
|
const query = async (selection, options) => {
|
|
@@ -61,7 +75,7 @@ function resolveNextConfig(config) {
|
|
|
61
75
|
if (value.contract !== undefined && value.contract !== 1) {
|
|
62
76
|
throw new Error("@capacms/sdk/next: contract must be 1 when provided.");
|
|
63
77
|
}
|
|
64
|
-
const fetchImpl = value.fetch ??
|
|
78
|
+
const fetchImpl = value.fetch ?? defaultFetch();
|
|
65
79
|
if (typeof fetchImpl !== "function") {
|
|
66
80
|
throw new Error("@capacms/sdk/next: no fetch available. Pass one via config.fetch.");
|
|
67
81
|
}
|
|
@@ -487,7 +501,8 @@ function createClient(config) {
|
|
|
487
501
|
return readSchema(options.signal);
|
|
488
502
|
},
|
|
489
503
|
async preview(token, options = {}) {
|
|
490
|
-
|
|
504
|
+
// Any key the site holds, legacy included: the API checks the token
|
|
505
|
+
// belongs to the key's own tenant (see key-family.ts).
|
|
491
506
|
const query = new URLSearchParams();
|
|
492
507
|
query.set("token", token);
|
|
493
508
|
try {
|
package/dist/next/field-names.js
CHANGED
|
@@ -28,7 +28,7 @@ exports.readPath = readPath;
|
|
|
28
28
|
*
|
|
29
29
|
* The API's own writer is `writeName` in `@capa/shared`. This package ships
|
|
30
30
|
* with no runtime dependencies, so the rule is copied here and in
|
|
31
|
-
* `@
|
|
31
|
+
* `@capacms/mcp`, and test/fixtures/field-names.json pins all three to the same
|
|
32
32
|
* vectors.
|
|
33
33
|
*/
|
|
34
34
|
const system_keys_1 = require("./system-keys");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* One request carries both the standard introspection selection and the
|
|
6
6
|
* `version` root field, so a schema summary always says which platform version
|
|
7
|
-
* it describes. Kept byte-stable: `@
|
|
7
|
+
* it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
|
|
8
8
|
* persisted copy of it is one hash for every client.
|
|
9
9
|
*
|
|
10
10
|
* Deprecated arguments and input fields are asked for too, with their
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* One request carries both the standard introspection selection and the
|
|
7
7
|
* `version` root field, so a schema summary always says which platform version
|
|
8
|
-
* it describes. Kept byte-stable: `@
|
|
8
|
+
* it describes. Kept byte-stable: `@capacms/mcp` sends the same text, and a
|
|
9
9
|
* persisted copy of it is one hash for every client.
|
|
10
10
|
*
|
|
11
11
|
* Deprecated arguments and input fields are asked for too, with their
|
|
@@ -80,7 +80,7 @@ function didYouMean(wanted, candidates) {
|
|
|
80
80
|
/**
|
|
81
81
|
* The refusal for a model the key reads that GraphQL leaves out (N1): REST
|
|
82
82
|
* serves it, so the message names that read and why, and suggests no other
|
|
83
|
-
* model. `@
|
|
83
|
+
* model. `@capacms/mcp` says the same, pinned by a test.
|
|
84
84
|
*/
|
|
85
85
|
function restOnlyModel(namespace, restOnly) {
|
|
86
86
|
// N1 leaves models out when their type names would collide, as these
|
|
@@ -176,7 +176,7 @@ function checkSort(value, model, where) {
|
|
|
176
176
|
// A spec written as REST writes a read (`author.name` or `author(name)` for a
|
|
177
177
|
// relation's fields, `*` for every field, `-publishedAt` for a sort) is
|
|
178
178
|
// refused with what to write instead, spelled out, since the same read is one
|
|
179
|
-
// edit away. @
|
|
179
|
+
// edit away. @capacms/mcp refuses them in the same words, pinned by
|
|
180
180
|
// test/fixtures/graphql-rest-forms.json.
|
|
181
181
|
/** A REST sort key as a sort value: `-publishedAt` is `publishedAt_DESC`, `author.name` is `author__name_ASC`. */
|
|
182
182
|
function restSortValue(value) {
|