@capacms/sdk 1.0.0-next.7 → 1.0.0-next.9
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 +64 -1
- package/README.md +292 -29
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +510 -0
- package/dist/esm/overlay/protocol.d.ts +134 -0
- package/dist/esm/overlay/protocol.js +173 -0
- package/dist/esm/package.json +4 -0
- package/dist/next/client.d.ts +6 -6
- package/dist/next/client.js +2 -1
- 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 +149 -9
- package/dist/nextjs/index.js +207 -15
- package/dist/nextjs/overlay.d.ts +26 -1
- package/dist/nextjs/overlay.js +49 -6
- package/dist/overlay/index.d.ts +14 -2
- package/dist/overlay/index.js +168 -45
- package/package.json +12 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- 1.0.0-next.9. Live preview on sites in production.
|
|
6
|
+
`@capacms/sdk/nextjs/overlay` and `@capacms/sdk/overlay` reach a bundler as
|
|
7
|
+
ES modules (the `import` condition, built to `dist/esm`) and stay CommonJS
|
|
8
|
+
for `require`. The CommonJS overlay's `require("next/navigation")` made
|
|
9
|
+
webpack keep every `next/navigation` export in the module that re-exports
|
|
10
|
+
them, which sits in chunks a visitor loads, so loading the overlay, even
|
|
11
|
+
lazily, changed a site's visitor chunks (17 on one site). An ES
|
|
12
|
+
`import { useRouter }` leaves them as they were.
|
|
13
|
+
- `<CapaOverlay />`: a save's `router.refresh()` runs in a React transition,
|
|
14
|
+
and while it is pending the overlay makes a no-op state update every 300 ms.
|
|
15
|
+
Under React 19.2 and Next 15.5 a draft refresh could stay suspended after
|
|
16
|
+
its whole response had arrived, inside a Suspense boundary already on
|
|
17
|
+
screen (a root `loading.tsx` makes one), and show nothing until some other
|
|
18
|
+
state update. A refresh that has not landed within `refreshTimeoutMs`
|
|
19
|
+
(10 seconds) reloads the page instead, at the same scroll position.
|
|
20
|
+
`refresh="reload"` reloads on every save.
|
|
21
|
+
- `startOverlay`: `onRefresh` may return a promise. The overlay waits for it
|
|
22
|
+
up to `refreshTimeoutMs` and reloads if it rejects or is still pending; a
|
|
23
|
+
function that returns nothing counts as done at once, as before. A reload
|
|
24
|
+
after a failed refresh now keeps the scroll position too. The position is
|
|
25
|
+
held while a refresh is pending, and an editor who scrolls meanwhile is not
|
|
26
|
+
pulled back. `REFRESH_TIMEOUT_MS` is exported.
|
|
27
|
+
- Inside the editor's frame, a ⌘-click (Ctrl-click on Windows and Linux) on a
|
|
28
|
+
tagged element that is a link, or sits inside one, follows the link in the
|
|
29
|
+
frame instead of selecting the field, and the hover label says so. A plain
|
|
30
|
+
click still selects, and an untagged link navigates as before. No message
|
|
31
|
+
between the overlay and the editor changed.
|
|
32
|
+
- Docs: load the overlay lazily from a small client component, since a direct
|
|
33
|
+
import from the root layout puts its code in the layout's chunk, which every
|
|
34
|
+
visitor downloads; and skip a page transition's `FrozenRoute` in draft mode,
|
|
35
|
+
or a save fetches the draft and shows nothing.
|
|
36
|
+
- 1.0.0-next.8. Preview on a live site, in draft mode on its production
|
|
37
|
+
domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_`
|
|
38
|
+
or unprefixed), as the API does: Capa checks the token with any key that
|
|
39
|
+
reads the project, and refuses a token made for another project. It used to
|
|
40
|
+
throw a `TypeError` for a legacy key before any request. Draft reads through
|
|
41
|
+
the SDK still take a `cap_` key only, and the legacy-key warning says so.
|
|
42
|
+
- Next.js: `createPreviewRoute` and `exitPreviewRoute` take Next's `cookies`.
|
|
43
|
+
Given it, the draft cookie is re-set
|
|
44
|
+
`HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`
|
|
45
|
+
(`maxAge` changes the hour), so Safari 26.2 and later send it inside the
|
|
46
|
+
editor's frame and it no longer lasts until the browser closes. Exit and a
|
|
47
|
+
bad link delete it partitioned, which is the only deletion that reaches
|
|
48
|
+
it. `redirect` is optional now: left out, each route answers with its own
|
|
49
|
+
307, marked `X-Robots-Tag: noindex, nofollow`,
|
|
50
|
+
`Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and
|
|
51
|
+
the preview route's carries the editor's `frame-ancestors`. Given
|
|
52
|
+
`redirect`, a route calls it as before.
|
|
53
|
+
- Next.js: `capaHeaders({ adminOrigins })` returns rules for `next.config`'s
|
|
54
|
+
`headers()` that send `X-Robots-Tag: noindex, nofollow` and
|
|
55
|
+
`Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
|
|
56
|
+
on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or
|
|
57
|
+
`capa-view` query, and on nothing else, so a visitor's response is
|
|
58
|
+
unchanged. Also exported: `draftHeaders`, `frameAncestors` (it throws for a
|
|
59
|
+
value that is not a bare origin), `frameDraftCookie`, `clearDraftCookie`,
|
|
60
|
+
`CAPA_ADMIN_ORIGIN`, `DRAFT_ROBOTS_TAG`, `DRAFT_COOKIE_MAX_AGE`,
|
|
61
|
+
`PREVIEW_PARAM` and `VIEW_PARAM`.
|
|
62
|
+
- Next.js: `capaMiddleware` marks every edit-mode response, and every request
|
|
63
|
+
carrying `capa-edit` or `capa-view`, `X-Robots-Tag: noindex, nofollow`, and
|
|
64
|
+
`resolveEditRequest` returns that value as `robotsTag`.
|
|
65
|
+
- Docs: "Add preview to a live site without changing it", the steps for a
|
|
66
|
+
site that keeps its own Capa reads, and why `editMode()` and
|
|
67
|
+
`getCapaClient()` make a static page dynamic.
|
|
5
68
|
- 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a
|
|
6
69
|
method of their config, which a browser refuses ("Failed to execute 'fetch'
|
|
7
70
|
on 'Window': Illegal invocation"), so every read from a browser failed unless
|
|
@@ -165,7 +228,7 @@
|
|
|
165
228
|
as it takes a tool spec.
|
|
166
229
|
|
|
167
230
|
The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL`
|
|
168
|
-
move between the spec the Explorer and `@
|
|
231
|
+
move between the spec the Explorer and `@capacms/mcp` share, GraphQL text and
|
|
169
232
|
the equal REST request, written exactly as the API writes it in
|
|
170
233
|
`extensions.capa.rest`: the filter as `where` JSON, and a system key a
|
|
171
234
|
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
|
|
@@ -1273,8 +1275,16 @@ editor sees the path without a link to open it.
|
|
|
1273
1275
|
|
|
1274
1276
|
### Quick start (Next.js)
|
|
1275
1277
|
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
+
Preview needs `@capacms/sdk` 1.0.0-next.8 or later. The `latest` tag is
|
|
1279
|
+
older and has no `capaHeaders`, so install the `next` tag:
|
|
1280
|
+
|
|
1281
|
+
```sh
|
|
1282
|
+
pnpm add @capacms/sdk@next
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
|
|
1286
|
+
holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
|
|
1287
|
+
`cap_` key only (see Keys). Then:
|
|
1278
1288
|
|
|
1279
1289
|
```ts
|
|
1280
1290
|
// middleware.ts
|
|
@@ -1283,10 +1293,13 @@ import { capaMiddleware } from "@capacms/sdk/nextjs";
|
|
|
1283
1293
|
export const middleware = capaMiddleware({ NextResponse });
|
|
1284
1294
|
|
|
1285
1295
|
// app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
|
|
1286
|
-
import { draftMode } from "next/headers";
|
|
1287
|
-
import { redirect } from "next/navigation";
|
|
1296
|
+
import { cookies, draftMode } from "next/headers";
|
|
1288
1297
|
import { createPreviewRoute } from "@capacms/sdk/nextjs";
|
|
1289
|
-
export const GET = createPreviewRoute({ draftMode,
|
|
1298
|
+
export const GET = createPreviewRoute({ draftMode, cookies });
|
|
1299
|
+
|
|
1300
|
+
// next.config.mjs
|
|
1301
|
+
import { capaHeaders } from "@capacms/sdk/nextjs";
|
|
1302
|
+
export default { async headers() { return capaHeaders(); } };
|
|
1290
1303
|
|
|
1291
1304
|
// in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
|
|
1292
1305
|
// in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
|
|
@@ -1346,11 +1359,20 @@ payload, or your own JSON endpoint. The page then shows the draft with no
|
|
|
1346
1359
|
the draft or edit state alongside the entry and hand it to `capaAttrs`:
|
|
1347
1360
|
|
|
1348
1361
|
```tsx
|
|
1349
|
-
//
|
|
1350
|
-
|
|
1362
|
+
// pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or
|
|
1363
|
+
// useAsyncData on the server hands over the flag the same way.
|
|
1364
|
+
import type { Entry } from "@capacms/sdk/next";
|
|
1365
|
+
import type { Articles } from "./capa-types";
|
|
1366
|
+
|
|
1367
|
+
export async function getServerSideProps({ draftMode = false }) {
|
|
1368
|
+
const entry = (await capa.entries.get<Articles>("articles", "entry-id"))!.data;
|
|
1369
|
+
return { props: { entry, edit: draftMode } };
|
|
1370
|
+
}
|
|
1351
1371
|
|
|
1352
1372
|
// in the component
|
|
1353
|
-
|
|
1373
|
+
export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
|
|
1374
|
+
return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
|
|
1375
|
+
}
|
|
1354
1376
|
```
|
|
1355
1377
|
|
|
1356
1378
|
Or re-mark the entries where they arrive with `markEditEntries(data)` when the
|
|
@@ -1388,6 +1410,7 @@ export async function middleware(request: NextRequest) {
|
|
|
1388
1410
|
const edit = await resolveEditRequest(request, publishedClient());
|
|
1389
1411
|
const response = NextResponse.next({ request: { headers: edit.headers } });
|
|
1390
1412
|
if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
|
|
1413
|
+
if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
|
|
1391
1414
|
return response;
|
|
1392
1415
|
}
|
|
1393
1416
|
```
|
|
@@ -1406,11 +1429,45 @@ import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
|
|
|
1406
1429
|
`@capacms/sdk/overlay` in your own effect.
|
|
1407
1430
|
|
|
1408
1431
|
Render it from your root layout only in edit mode
|
|
1409
|
-
(`await editMode({ draftMode, headers })`), so
|
|
1432
|
+
(`await editMode({ draftMode, headers })`), so it never runs for a visitor.
|
|
1433
|
+
Imported this way, its code still sits in the layout's chunk, which every
|
|
1434
|
+
visitor downloads. To keep it out, load it lazily from a small client
|
|
1435
|
+
component, as step 5 of "Add preview to a live site without changing it"
|
|
1436
|
+
shows. Both overlay entry points reach a bundler as ES modules, so loading
|
|
1437
|
+
the overlay leaves the rest of a visitor's chunks as they were.
|
|
1438
|
+
|
|
1410
1439
|
`startOverlay` returns a disposer and is safe to call twice. Outside a frame it
|
|
1411
1440
|
does nothing at all, and inside one it only listens to a parent window at one of
|
|
1412
|
-
`adminOrigins`.
|
|
1413
|
-
|
|
1441
|
+
`adminOrigins`.
|
|
1442
|
+
|
|
1443
|
+
**Clicks inside the editor's frame.** A click on a tagged element selects its
|
|
1444
|
+
field in the editor. A ⌘-click (Ctrl-click on Windows and Linux) on a tagged
|
|
1445
|
+
element that is a link, or sits inside one, follows the link inside the frame
|
|
1446
|
+
instead, so an editor can move between pages. The hover label says so over a
|
|
1447
|
+
link. A link with no tag on or around it navigates as usual.
|
|
1448
|
+
|
|
1449
|
+
**After a save.** `<CapaOverlay />` re-renders the draft in place with
|
|
1450
|
+
`router.refresh()` and keeps the scroll position.
|
|
1451
|
+
|
|
1452
|
+
- The refresh runs in a React transition. While it is pending, the overlay
|
|
1453
|
+
makes a no-op state update every 300 ms. Under React 19.2 and Next 15.5, a
|
|
1454
|
+
draft refresh can otherwise stay suspended after its whole response has
|
|
1455
|
+
arrived, when the page sits inside a Suspense boundary that is already on
|
|
1456
|
+
screen (a root `loading.tsx` makes one), and show nothing until some other
|
|
1457
|
+
state update.
|
|
1458
|
+
- A refresh that has not landed after `refreshTimeoutMs` (10 seconds) reloads
|
|
1459
|
+
the page instead, which comes back to the same scroll position.
|
|
1460
|
+
- `refresh="reload"` reloads the page on every save, for a site that prefers
|
|
1461
|
+
it:
|
|
1462
|
+
|
|
1463
|
+
```tsx
|
|
1464
|
+
<CapaOverlay adminOrigins={["https://app.capacms.com"]} refresh="reload" />
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
With `startOverlay`, `onRefresh` may return a promise that settles once the new
|
|
1468
|
+
draft is on screen. The overlay waits for it up to `refreshTimeoutMs`, and
|
|
1469
|
+
reloads if it rejects or is still pending. Without `onRefresh` every save
|
|
1470
|
+
reloads. The scroll position is kept every way.
|
|
1414
1471
|
|
|
1415
1472
|
### 3. Accept the preview link on any page
|
|
1416
1473
|
|
|
@@ -1441,22 +1498,224 @@ export function middleware(request: NextRequest) {
|
|
|
1441
1498
|
`/api/draft` is the route handler shown under "Open a draft in your own site"
|
|
1442
1499
|
above, reading `token` and `path`.
|
|
1443
1500
|
|
|
1444
|
-
**Cookies in a frame.** The editor frames your site from another site
|
|
1445
|
-
browser
|
|
1446
|
-
`SameSite=None; Secure
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1501
|
+
**Cookies in a frame.** The editor frames your site from another site. A
|
|
1502
|
+
browser sends a cookie into a cross-site frame only when it is
|
|
1503
|
+
`SameSite=None; Secure`, and Safari 26.2 and later only when it is
|
|
1504
|
+
`Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned`
|
|
1505
|
+
nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens
|
|
1506
|
+
every draft on the site until the browser closes. Pass Next's `cookies` to
|
|
1507
|
+
`createPreviewRoute` and `exitPreviewRoute` and they re-set it as
|
|
1508
|
+
`HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and
|
|
1509
|
+
delete it the same way. `maxAge` changes the hour. In your own route, call
|
|
1510
|
+
`frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)`
|
|
1511
|
+
after `disable()`. A browser that drops the cookie still gets a fresh token on
|
|
1512
|
+
every preview load, which step 3 honours on any page.
|
|
1513
|
+
|
|
1514
|
+
Leave `redirect` out of both routes. Each then answers with its own 307,
|
|
1515
|
+
marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the
|
|
1516
|
+
token is in the URL) and `Cache-Control: private, no-store`. Next's
|
|
1517
|
+
`redirect()` cannot carry headers, so passing it keeps the old redirect.
|
|
1518
|
+
|
|
1519
|
+
**Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for
|
|
1520
|
+
`next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and
|
|
1521
|
+
`Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a
|
|
1522
|
+
request carrying the draft cookie or a `capa-preview`, `capa-edit` or
|
|
1523
|
+
`capa-view` query, and on nothing else. A visitor's response, cached or not,
|
|
1524
|
+
is unchanged. Pass `adminOrigins` to name another admin, such as one running
|
|
1525
|
+
locally, and pass the same list to `createPreviewRoute`. A browser applies
|
|
1526
|
+
every CSP it is sent, so if your site sends its own `frame-ancestors` or
|
|
1527
|
+
`X-Frame-Options`, leave them off draft responses with
|
|
1528
|
+
`missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule.
|
|
1529
|
+
`capaMiddleware` marks its edit-mode responses noindex too, and
|
|
1530
|
+
`resolveEditRequest` returns the value as `robotsTag`.
|
|
1531
|
+
|
|
1532
|
+
### Add preview to a live site without changing it
|
|
1533
|
+
|
|
1534
|
+
A live Next.js site that reads Capa with its own code keeps that code. Preview
|
|
1535
|
+
adds a branch that only draft mode switches on, and draft mode is off for
|
|
1536
|
+
every visitor, at build time and during ISR. Reading
|
|
1537
|
+
`(await draftMode()).isEnabled` leaves a static page static, so a visitor gets
|
|
1538
|
+
the same pages, headers and cache as before.
|
|
1539
|
+
|
|
1540
|
+
1. **Read drafts in draft mode.** In the helper that fetches from Capa, before
|
|
1541
|
+
the existing fetch, read the same URL with a draft key and no cache. The
|
|
1542
|
+
data has the same shape, so every page renders unchanged.
|
|
1543
|
+
|
|
1544
|
+
```ts
|
|
1545
|
+
// lib/capa-draft.ts
|
|
1546
|
+
import "server-only";
|
|
1547
|
+
import { draftMode } from "next/headers";
|
|
1548
|
+
|
|
1549
|
+
export async function isDraft(): Promise<boolean> {
|
|
1550
|
+
// draftMode() throws outside a request (generateStaticParams, some build steps).
|
|
1551
|
+
try { return (await draftMode()).isEnabled; } catch { return false; }
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// in the fetch helper, before the existing fetch, which stays as it is
|
|
1555
|
+
if (await isDraft()) {
|
|
1556
|
+
return fetch(`https://api.capacms.com/v2/api/${endpoint}${query}`, {
|
|
1557
|
+
headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! },
|
|
1558
|
+
cache: "no-store",
|
|
1559
|
+
}).then(parse); // the helper's existing parse
|
|
1560
|
+
}
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
The draft key is any key of yours whose environment is not `production`
|
|
1564
|
+
(see Preview is a key, not a flag). Keep it server-only, never in a
|
|
1565
|
+
`NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a
|
|
1566
|
+
helper a client component also imports would bundle it.
|
|
1567
|
+
|
|
1568
|
+
2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode,
|
|
1569
|
+
cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick
|
|
1570
|
+
start. Set `CAPA_API_URL` to `https://api.capacms.com` and `CAPA_KEY` to
|
|
1571
|
+
the production key the site already reads with, server-only: the routes
|
|
1572
|
+
check the editor's token with it.
|
|
1573
|
+
|
|
1574
|
+
3. **Add the middleware behind a matcher**, so a visitor's request never runs
|
|
1575
|
+
it. Next reads `config` from the file itself, so write the matcher out:
|
|
1576
|
+
|
|
1577
|
+
```ts
|
|
1578
|
+
// middleware.ts (proxy.ts on Next 16)
|
|
1579
|
+
import { NextResponse } from "next/server";
|
|
1580
|
+
import { capaMiddleware } from "@capacms/sdk/nextjs";
|
|
1581
|
+
|
|
1582
|
+
export const middleware = capaMiddleware({ NextResponse });
|
|
1583
|
+
export const config = {
|
|
1584
|
+
matcher: [
|
|
1585
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
|
|
1586
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
|
|
1587
|
+
{ source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
|
|
1588
|
+
{ source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
|
|
1589
|
+
],
|
|
1590
|
+
};
|
|
1591
|
+
```
|
|
1592
|
+
|
|
1593
|
+
4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
|
|
1594
|
+
start: drafts are marked noindex and only the Capa admin can frame them.
|
|
1595
|
+
|
|
1596
|
+
5. **Start the overlay and tag fields in draft mode.** Load the overlay
|
|
1597
|
+
lazily from a small client component, so its code is not in a chunk a
|
|
1598
|
+
visitor downloads, and render that component from the root layout in
|
|
1599
|
+
draft mode only:
|
|
1600
|
+
|
|
1601
|
+
```tsx
|
|
1602
|
+
// components/capa-preview.tsx
|
|
1603
|
+
"use client";
|
|
1604
|
+
import { lazy, Suspense } from "react";
|
|
1605
|
+
|
|
1606
|
+
const CapaOverlay = lazy(() =>
|
|
1607
|
+
import("@capacms/sdk/nextjs/overlay").then((m) => ({ default: m.CapaOverlay })),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
export default function CapaPreview({ adminOrigins }: { adminOrigins: string[] }) {
|
|
1611
|
+
return (
|
|
1612
|
+
<Suspense fallback={null}>
|
|
1613
|
+
<CapaOverlay adminOrigins={adminOrigins} />
|
|
1614
|
+
</Suspense>
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// app/layout.tsx
|
|
1619
|
+
const draft = await isDraft();
|
|
1620
|
+
{draft ? <CapaPreview adminOrigins={["https://app.capacms.com"]} /> : null}
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
|
|
1624
|
+
With `draft` false it returns `{}`, so a visitor's HTML gains no
|
|
1625
|
+
attribute. The field is its key in the entry, which is its namespace.
|
|
1626
|
+
|
|
1627
|
+
6. **Route handlers that set their own `Cache-Control`** must send
|
|
1628
|
+
`private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by
|
|
1629
|
+
the editor's browser and serves it to everyone.
|
|
1630
|
+
|
|
1631
|
+
Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a
|
|
1632
|
+
static or ISR page or layout. Each reads `headers()`, which makes every route
|
|
1633
|
+
that calls it dynamic: the HTML stays the same, but the site loses ISR and
|
|
1634
|
+
renders every view. `isDraft()` above is the static-safe check. The editor's
|
|
1635
|
+
Published view then shows the static page without the overlay.
|
|
1636
|
+
|
|
1637
|
+
In Capa, set the project's preview URL to the site's production origin, and
|
|
1638
|
+
give each model with a page a route pattern, or Preview has no link to open.
|
|
1639
|
+
|
|
1640
|
+
### A save that shows nothing: a page transition that freezes the route
|
|
1641
|
+
|
|
1642
|
+
A site that animates page changes with framer-motion often wraps each page in
|
|
1643
|
+
a `FrozenRoute`, which pins Next's `LayoutRouterContext` so the page that is
|
|
1644
|
+
leaving keeps its content while it animates out:
|
|
1452
1645
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1646
|
+
```tsx
|
|
1647
|
+
"use client";
|
|
1648
|
+
|
|
1649
|
+
import { useContext, useRef } from "react";
|
|
1650
|
+
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
|
1651
|
+
|
|
1652
|
+
const FrozenRoute = ({ children }: { children: React.ReactNode }) => {
|
|
1653
|
+
const context = useContext(LayoutRouterContext);
|
|
1654
|
+
const frozen = useRef(context).current;
|
|
1655
|
+
|
|
1656
|
+
return (
|
|
1657
|
+
<LayoutRouterContext.Provider value={frozen}>
|
|
1658
|
+
{children}
|
|
1659
|
+
</LayoutRouterContext.Provider>
|
|
1660
|
+
);
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
export default FrozenRoute;
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
The pinned context keeps the page as the frame first drew it. After a save,
|
|
1667
|
+
`router.refresh()` fetches the draft and nothing on screen changes, and no
|
|
1668
|
+
overlay setting can reach inside the freeze. Skip it in draft mode only. Give
|
|
1669
|
+
the page-transition wrapper a `live` prop:
|
|
1670
|
+
|
|
1671
|
+
```tsx
|
|
1672
|
+
"use client";
|
|
1673
|
+
|
|
1674
|
+
import { usePathname } from "next/navigation";
|
|
1675
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
1676
|
+
import FrozenRoute from "./frozen-route";
|
|
1677
|
+
|
|
1678
|
+
const PageAnimatePresence = ({
|
|
1679
|
+
children,
|
|
1680
|
+
live,
|
|
1681
|
+
}: {
|
|
1682
|
+
children: React.ReactNode;
|
|
1683
|
+
/** Capa preview (draft mode only): no freeze, so an editor's save shows in place. */
|
|
1684
|
+
live?: boolean;
|
|
1685
|
+
}) => {
|
|
1686
|
+
const pathname = usePathname();
|
|
1687
|
+
|
|
1688
|
+
return (
|
|
1689
|
+
<AnimatePresence mode="wait">
|
|
1690
|
+
<motion.div className="flex flex-col flex-grow" key={pathname}>
|
|
1691
|
+
{live ? children : <FrozenRoute>{children}</FrozenRoute>}
|
|
1692
|
+
</motion.div>
|
|
1693
|
+
</AnimatePresence>
|
|
1694
|
+
);
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
export default PageAnimatePresence;
|
|
1698
|
+
```
|
|
1699
|
+
|
|
1700
|
+
and pass it from the root layout as a conditional spread, so a visitor's
|
|
1701
|
+
props, and the RSC payload that carries them, are exactly what they were:
|
|
1702
|
+
|
|
1703
|
+
```tsx
|
|
1704
|
+
// app/layout.tsx
|
|
1705
|
+
const draft = await isDraft();
|
|
1706
|
+
|
|
1707
|
+
<PageAnimatePresence {...(draft ? { live: true } : {})}>
|
|
1708
|
+
{children}
|
|
1709
|
+
</PageAnimatePresence>
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
Writing `live={draft}` instead would send `live: false` to every visitor.
|
|
1713
|
+
To find the freeze, search the site for `LayoutRouterContext`.
|
|
1456
1714
|
|
|
1457
1715
|
### The protocol
|
|
1458
1716
|
|
|
1459
|
-
Every message is `{ source, v: 1, type, ...fields }`.
|
|
1717
|
+
Every message is `{ source, v: 1, type, ...fields }`. `source` is
|
|
1718
|
+
`capa-admin` on the admin's messages and `capa` on the site's. The admin sends
|
|
1460
1719
|
`hello`, `highlight { entryId, field }` (`entryId: ""` clears), `outline { on }`
|
|
1461
1720
|
and `refresh`. The site answers `ready { path, entries }` after every hello and
|
|
1462
1721
|
every navigation, `select { entryId, field }` on a click, and `hover`.
|
|
@@ -1471,6 +1730,10 @@ and at the very bottom the bottommost. The editor's "Follow the page" scrolls
|
|
|
1471
1730
|
the form to that field. An overlay older than 1.0.0-next.2 never sends it, and
|
|
1472
1731
|
the editor then does not follow.
|
|
1473
1732
|
|
|
1733
|
+
Following a link with ⌘-click sends no message of its own: the page the link
|
|
1734
|
+
leads to answers with `ready`, as after any navigation, so an editor of any
|
|
1735
|
+
version follows along.
|
|
1736
|
+
|
|
1474
1737
|
## Not yet
|
|
1475
1738
|
|
|
1476
1739
|
`--select-from-depth`, `capa convert-url`, GraphQL mutations, and `/api/`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface CapaOverlayProps {
|
|
2
|
+
/** The Capa admin origins allowed to drive the overlay. */
|
|
3
|
+
adminOrigins: string[];
|
|
4
|
+
/**
|
|
5
|
+
* How a save shows. `"in-place"` (the default) re-renders the draft with
|
|
6
|
+
* `router.refresh()`, and reloads the page only if that has not landed
|
|
7
|
+
* within `refreshTimeoutMs`. `"reload"` reloads the page on every save.
|
|
8
|
+
* The scroll position is kept either way.
|
|
9
|
+
*/
|
|
10
|
+
refresh?: "in-place" | "reload";
|
|
11
|
+
/** How long an in-place refresh may take before the page reloads instead. 10 seconds. */
|
|
12
|
+
refreshTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The refresh runs in a transition, so `isPending` says when the new draft
|
|
16
|
+
* has been committed to the screen, and the overlay settles the refresh
|
|
17
|
+
* then: it puts the scroll position back, and a refresh that has not
|
|
18
|
+
* committed within `refreshTimeoutMs` reloads.
|
|
19
|
+
*
|
|
20
|
+
* While the transition is pending the overlay makes a no-op state update
|
|
21
|
+
* every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
|
|
22
|
+
* refresh suspended after every part of its streamed response has arrived:
|
|
23
|
+
* the page suspends inside an already visible Suspense boundary (a root
|
|
24
|
+
* `loading.tsx` makes one around every page), and the signal that its data
|
|
25
|
+
* is ready is lost, so nothing commits until some other state update. Any
|
|
26
|
+
* state update makes React retry the suspended render, which then completes.
|
|
27
|
+
* A refresh that commits on its own stops the nudges at once. Draft mode
|
|
28
|
+
* only: a visitor never runs this component.
|
|
29
|
+
*/
|
|
30
|
+
export declare function CapaOverlay({ adminOrigins, refresh, refreshTimeoutMs }: CapaOverlayProps): null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* `<CapaOverlay />`: Capa's live preview overlay as one Next.js client
|
|
4
|
+
* component (M6).
|
|
5
|
+
*
|
|
6
|
+
* // app/layout.tsx (a server component)
|
|
7
|
+
* import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
|
|
8
|
+
* {edit ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
|
|
9
|
+
*
|
|
10
|
+
* Render it only in edit mode (`editMode()` from `@capacms/sdk/nextjs`), so a
|
|
11
|
+
* visitor never downloads it. Inside the Capa editor it outlines the focused
|
|
12
|
+
* field, reports clicks back, and on a save re-renders the page with
|
|
13
|
+
* `router.refresh()`, keeping the scroll position. Outside a Capa frame it does
|
|
14
|
+
* nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* Its own entry point, apart from `@capacms/sdk/nextjs`, because it imports
|
|
17
|
+
* `react` and `next/navigation` and is a client module: the server helpers must
|
|
18
|
+
* stay free of both. A bundler that imports it gets an ES module, so it sees
|
|
19
|
+
* that only `useRouter` is used from `next/navigation` and leaves the chunks a
|
|
20
|
+
* visitor loads as they were.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useRef, useState, useTransition } from "react";
|
|
23
|
+
import { useRouter } from "next/navigation";
|
|
24
|
+
import { startOverlay } from "../overlay/index.js";
|
|
25
|
+
/**
|
|
26
|
+
* While a refresh is pending, the overlay updates its own unused state this
|
|
27
|
+
* often. See `CapaOverlay` for why.
|
|
28
|
+
*/
|
|
29
|
+
const REFRESH_NUDGE_MS = 300;
|
|
30
|
+
/**
|
|
31
|
+
* The refresh runs in a transition, so `isPending` says when the new draft
|
|
32
|
+
* has been committed to the screen, and the overlay settles the refresh
|
|
33
|
+
* then: it puts the scroll position back, and a refresh that has not
|
|
34
|
+
* committed within `refreshTimeoutMs` reloads.
|
|
35
|
+
*
|
|
36
|
+
* While the transition is pending the overlay makes a no-op state update
|
|
37
|
+
* every `REFRESH_NUDGE_MS`. React 19.2 under Next 15.5 can leave a draft
|
|
38
|
+
* refresh suspended after every part of its streamed response has arrived:
|
|
39
|
+
* the page suspends inside an already visible Suspense boundary (a root
|
|
40
|
+
* `loading.tsx` makes one around every page), and the signal that its data
|
|
41
|
+
* is ready is lost, so nothing commits until some other state update. Any
|
|
42
|
+
* state update makes React retry the suspended render, which then completes.
|
|
43
|
+
* A refresh that commits on its own stops the nudges at once. Draft mode
|
|
44
|
+
* only: a visitor never runs this component.
|
|
45
|
+
*/
|
|
46
|
+
export function CapaOverlay({ adminOrigins, refresh = "in-place", refreshTimeoutMs }) {
|
|
47
|
+
const router = useRouter();
|
|
48
|
+
const [refreshing, startRefresh] = useTransition();
|
|
49
|
+
const [, nudge] = useState(0);
|
|
50
|
+
/** One resolver per refresh waiting for its transition to commit. */
|
|
51
|
+
const waiting = useRef([]);
|
|
52
|
+
// A string, so a new array with the same origins does not restart it.
|
|
53
|
+
const origins = adminOrigins.join(",");
|
|
54
|
+
useEffect(() => startOverlay({
|
|
55
|
+
adminOrigins: origins.split(",").filter(Boolean),
|
|
56
|
+
onRefresh: refresh === "reload"
|
|
57
|
+
? undefined
|
|
58
|
+
: () => new Promise((resolve) => {
|
|
59
|
+
waiting.current.push(resolve);
|
|
60
|
+
startRefresh(() => router.refresh());
|
|
61
|
+
}),
|
|
62
|
+
refreshTimeoutMs,
|
|
63
|
+
}), [origins, router, refresh, refreshTimeoutMs]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!refreshing) {
|
|
66
|
+
// Committed: every refresh started before now is on screen.
|
|
67
|
+
for (const settle of waiting.current.splice(0))
|
|
68
|
+
settle();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const timer = window.setInterval(() => nudge((n) => n + 1), REFRESH_NUDGE_MS);
|
|
72
|
+
return () => window.clearInterval(timer);
|
|
73
|
+
}, [refreshing]);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol.js";
|
|
2
|
+
export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol.js";
|
|
3
|
+
export interface OverlayOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The Capa admin origins allowed to drive this page, for example
|
|
6
|
+
* `["https://app.capacms.com"]`. A message from anywhere else is ignored.
|
|
7
|
+
*/
|
|
8
|
+
adminOrigins: string[];
|
|
9
|
+
/**
|
|
10
|
+
* How to re-render the draft after the editor saves. `router.refresh()` in a
|
|
11
|
+
* Next app. Without it the page reloads. Scroll position is kept either way.
|
|
12
|
+
*
|
|
13
|
+
* Return a promise that settles once the new draft is on screen, and the
|
|
14
|
+
* overlay waits for it: a promise that rejects, or is still pending after
|
|
15
|
+
* `refreshTimeoutMs`, falls back to a reload, which keeps the scroll
|
|
16
|
+
* position too. A function that returns nothing counts as done at once.
|
|
17
|
+
*/
|
|
18
|
+
onRefresh?: () => void | Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* How long `onRefresh`'s promise may stay pending before the page reloads
|
|
21
|
+
* instead. 10 seconds.
|
|
22
|
+
*/
|
|
23
|
+
refreshTimeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/** How long an in-place refresh may take before the page reloads instead. */
|
|
26
|
+
export declare const REFRESH_TIMEOUT_MS = 10000;
|
|
27
|
+
/**
|
|
28
|
+
* Start the overlay. Returns a disposer that removes every listener and the
|
|
29
|
+
* drawing layer. Calling it again while it runs updates the options and returns
|
|
30
|
+
* the same disposer, so a React effect that runs twice does not listen twice.
|
|
31
|
+
*/
|
|
32
|
+
export declare function startOverlay(options: OverlayOptions): () => void;
|