@capacms/sdk 1.0.0-next.8 → 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,37 @@
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.
5
36
  - 1.0.0-next.8. Preview on a live site, in draft mode on its production
6
37
  domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_`
7
38
  or unprefixed), as the API does: Capa checks the token with any key that
package/README.md CHANGED
@@ -1275,6 +1275,13 @@ editor sees the path without a link to open it.
1275
1275
 
1276
1276
  ### Quick start (Next.js)
1277
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
+
1278
1285
  Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
1279
1286
  holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
1280
1287
  `cap_` key only (see Keys). Then:
@@ -1422,11 +1429,45 @@ import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
1422
1429
  `@capacms/sdk/overlay` in your own effect.
1423
1430
 
1424
1431
  Render it from your root layout only in edit mode
1425
- (`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
+
1426
1439
  `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
1427
1440
  does nothing at all, and inside one it only listens to a parent window at one of
1428
- `adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position
1429
- 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.
1430
1471
 
1431
1472
  ### 3. Accept the preview link on any page
1432
1473
 
@@ -1552,11 +1593,31 @@ the same pages, headers and cache as before.
1552
1593
  4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
1553
1594
  start: drafts are marked noindex and only the Capa admin can frame them.
1554
1595
 
1555
- 5. **Start the overlay and tag fields in draft mode.** In the root layout:
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:
1556
1600
 
1557
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
1558
1619
  const draft = await isDraft();
1559
- {draft ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
1620
+ {draft ? <CapaPreview adminOrigins={["https://app.capacms.com"]} /> : null}
1560
1621
  ```
1561
1622
 
1562
1623
  Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
@@ -1576,9 +1637,85 @@ Published view then shows the static page without the overlay.
1576
1637
  In Capa, set the project's preview URL to the site's production origin, and
1577
1638
  give each model with a page a route pattern, or Preview has no link to open.
1578
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:
1645
+
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`.
1714
+
1579
1715
  ### The protocol
1580
1716
 
1581
- 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
1582
1719
  `hello`, `highlight { entryId, field }` (`entryId: ""` clears), `outline { on }`
1583
1720
  and `refresh`. The site answers `ready { path, entries }` after every hello and
1584
1721
  every navigation, `select { entryId, field }` on a click, and `hover`.
@@ -1593,6 +1730,10 @@ and at the very bottom the bottommost. The editor's "Follow the page" scrolls
1593
1730
  the form to that field. An overlay older than 1.0.0-next.2 never sends it, and
1594
1731
  the editor then does not follow.
1595
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
+
1596
1737
  ## Not yet
1597
1738
 
1598
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;