@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 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 `@capa/mcp` share, GraphQL text 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 `versions`. The first client built with
51
- one prints a warning once per process, naming the `cap_` key to mint. Draft
52
- previews keep their rule and take a `cap_` key only: `preview()`,
53
- `draftClient`'s `draft` config, and `CAPA_DRAFT_KEY` for `getCapaClient` and
54
- `graphql()` throw a `TypeError` for a legacy key before any request. An
55
- `apiKey` that is not a string is refused where the client is built.
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
- Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`) and `CAPA_DRAFT_KEY` (`cap_test_`).
1277
- Draft previews take `cap_` keys only (see Keys). Then:
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, redirect });
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
- // getServerSideProps, a +page.server.ts load, useAsyncData on the server...
1350
- return { props: { entry, edit: draft } };
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
- <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>
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 a visitor never downloads it.
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`. Without `onRefresh` a save reloads the page; the scroll position
1413
- is kept either way.
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, and a
1445
- browser only sends a cookie into a cross-site frame when it is
1446
- `SameSite=None; Secure`. Next sets the draft-mode cookie that way in a
1447
- production build. `next dev` sets it `SameSite=Lax`, so re-set it as
1448
- `SameSite=None; Secure` in your draft route while developing (browsers accept
1449
- `Secure` on `http://localhost`). Safari's third-party cookie blocking can still
1450
- drop it, which is why every preview load carries a fresh token and step 3
1451
- honours it on any page.
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
- **Let Capa frame the site.** Send
1454
- `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` (your
1455
- admin origin) so the editor can frame the site and nothing else can.
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 }`. The admin sends
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;