@capacms/sdk 1.0.0-next.0 → 1.0.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -326,6 +326,107 @@ const capa = await draftClient({
326
326
  Set the preview base URL for your project in Capa (Settings), otherwise the
327
327
  editor sees the path without a link to open it.
328
328
 
329
+ ## Live preview
330
+
331
+ The Capa editor can show your site beside the form: focus a field and its spot
332
+ on the page is outlined, click the page and the editor jumps to the field, save
333
+ and the draft re-renders in place. It needs three things on your side. A
334
+ complete, runnable example is `examples/sdk-demo` in this repository.
335
+
336
+ ### 1. Tag what an editor can click
337
+
338
+ `capaAttrs(entry, field, enabled)` from `@capacms/sdk/next` returns the two
339
+ attributes the overlay looks for. `field` is the field's namespace, which is its
340
+ key in `entry.fields`, and it autocompletes when the entry is typed. Pass the
341
+ draft flag as `enabled` so a published page ships no entry ids.
342
+
343
+ ```tsx
344
+ import { capaAttrs } from "@capacms/sdk/next";
345
+
346
+ <h1 {...capaAttrs(article, "title", isDraft)}>{article.fields.title}</h1>
347
+ ```
348
+
349
+ ### 2. Start the overlay in draft mode
350
+
351
+ ```tsx
352
+ // app/capa-overlay.tsx
353
+ "use client";
354
+ import { useEffect } from "react";
355
+ import { useRouter } from "next/navigation";
356
+ import { startOverlay } from "@capacms/sdk/overlay";
357
+
358
+ export function CapaOverlay({ adminOrigins }: { adminOrigins: string[] }) {
359
+ const router = useRouter();
360
+ useEffect(() => startOverlay({ adminOrigins, onRefresh: () => router.refresh() }), [adminOrigins, router]);
361
+ return null;
362
+ }
363
+ ```
364
+
365
+ Render it from your root layout only when `(await draftMode()).isEnabled`.
366
+ `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
367
+ does nothing at all, and inside one it only listens to a parent window at one of
368
+ `adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position
369
+ is kept either way.
370
+
371
+ ### 3. Accept the preview link on any page
372
+
373
+ The editor loads `<preview base URL><path>?capa-preview=<token>`. Honour the
374
+ token on the request itself, not only on a dedicated route: rewrite any request
375
+ carrying it to your draft route, verify it with `preview`, enable draft mode and
376
+ redirect back.
377
+
378
+ ```ts
379
+ // middleware.ts
380
+ export function middleware(request: NextRequest) {
381
+ // The editor's Published view: render this one request without draft mode.
382
+ if (request.nextUrl.searchParams.get("capa-view") === "published") {
383
+ const headers = new Headers(request.headers);
384
+ const cookies = request.cookies.getAll().filter((c) => c.name !== "__prerender_bypass");
385
+ headers.set("cookie", cookies.map((c) => `${c.name}=${encodeURIComponent(c.value)}`).join("; "));
386
+ return NextResponse.next({ request: { headers } });
387
+ }
388
+ const token = request.nextUrl.searchParams.get("capa-preview");
389
+ if (!token) return NextResponse.next();
390
+ const target = request.nextUrl.clone();
391
+ target.pathname = "/api/draft";
392
+ target.search = `?token=${encodeURIComponent(token)}&path=${encodeURIComponent(request.nextUrl.pathname)}`;
393
+ return NextResponse.rewrite(target);
394
+ }
395
+ ```
396
+
397
+ `/api/draft` is the route handler shown under "Open a draft in your own site"
398
+ above, reading `token` and `path`.
399
+
400
+ **Cookies in a frame.** The editor frames your site from another site, and a
401
+ browser only sends a cookie into a cross-site frame when it is
402
+ `SameSite=None; Secure`. Next sets the draft-mode cookie that way in a
403
+ production build. `next dev` sets it `SameSite=Lax`, so re-set it as
404
+ `SameSite=None; Secure` in your draft route while developing (browsers accept
405
+ `Secure` on `http://localhost`). Safari's third-party cookie blocking can still
406
+ drop it, which is why every preview load carries a fresh token and step 3
407
+ honours it on any page.
408
+
409
+ **Let Capa frame the site.** Send
410
+ `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` (your
411
+ admin origin) so the editor can frame the site and nothing else can.
412
+
413
+ ### The protocol
414
+
415
+ Every message is `{ source, v: 1, type, ...fields }`. The admin sends
416
+ `hello`, `highlight { entryId, field }` (`entryId: ""` clears), `outline { on }`
417
+ and `refresh`. The site answers `ready { path, entries }` after every hello and
418
+ every navigation, `select { entryId, field }` on a click, and `hover`.
419
+ `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported
420
+ for your own tests.
421
+
422
+ Since 1.0.0-next.2 the site also sends `visible { entryId, field }` while the
423
+ page scrolls (at most every 150ms, and only when it changes): the tagged element
424
+ at the centre of the viewport, chosen by `pickCentred`. At the very top of a
425
+ page, where a heading can never reach the centre, it is the topmost visible
426
+ element instead, and at the very bottom the bottommost. The editor's "Follow the
427
+ page" scrolls the form to that field. It is additive and still `v: 1`: an older
428
+ overlay never sends it and the editor simply does not follow.
429
+
329
430
  ## Not yet
330
431
 
331
432
  `--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The attributes that make a rendered field clickable in Capa's live preview.
3
+ *
4
+ * <h1 {...capaAttrs(entry, "title", isDraft)}>{entry.fields.title}</h1>
5
+ *
6
+ * `field` is the field's namespace, which is the key it has in `entry.fields`:
7
+ * the API renders every field under its namespace, and the Capa editor tags
8
+ * each field row with that same namespace, so one string names the field on
9
+ * both sides of the preview frame.
10
+ *
11
+ * Pass `enabled = false` outside draft mode and the element carries nothing, so
12
+ * a published page never ships entry ids in its markup.
13
+ */
14
+ export type CapaAttrs = {
15
+ "data-capa-entry": string;
16
+ "data-capa-field": string;
17
+ } | {
18
+ "data-capa-entry"?: undefined;
19
+ "data-capa-field"?: undefined;
20
+ };
21
+ export declare function capaAttrs<T = Record<string, unknown>>(entry: {
22
+ id: string;
23
+ fields?: T;
24
+ }, field: Extract<keyof T, string>, enabled?: boolean): CapaAttrs;
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.capaAttrs = capaAttrs;
4
+ function capaAttrs(entry, field, enabled = true) {
5
+ if (!enabled)
6
+ return {};
7
+ return { "data-capa-entry": entry.id, "data-capa-field": field };
8
+ }
@@ -1,3 +1,5 @@
1
1
  export { CapaError, createClient, isCapaError, resolveNextConfig, serializeSelect, } from "./client";
2
+ export { capaAttrs } from "./attrs";
3
+ export type { CapaAttrs } from "./attrs";
2
4
  export type { CallOptions, CapaNextClient, CapaNextConfig, EntriesResource, Entry, Filter, FilterOperator, FilterScalar, FilterValue, GetOptions, ListOptions, Page, PageDetail, PageInfo, PageSummary, PagesListOptions, PagesResource, PreviewClaim, ResponseMeta, Single, } from "./client";
3
5
  export type { CapaRelation, CapaRelationList, RelationSelectOptions, Select, SelectItem, SelectSort, } from "./select-types";
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serializeSelect = exports.resolveNextConfig = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
3
+ exports.capaAttrs = exports.serializeSelect = exports.resolveNextConfig = exports.isCapaError = exports.createClient = exports.CapaError = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "CapaError", { enumerable: true, get: function () { return client_1.CapaError; } });
6
6
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
7
7
  Object.defineProperty(exports, "isCapaError", { enumerable: true, get: function () { return client_1.isCapaError; } });
8
8
  Object.defineProperty(exports, "resolveNextConfig", { enumerable: true, get: function () { return client_1.resolveNextConfig; } });
9
9
  Object.defineProperty(exports, "serializeSelect", { enumerable: true, get: function () { return client_1.serializeSelect; } });
10
+ var attrs_1 = require("./attrs");
11
+ Object.defineProperty(exports, "capaAttrs", { enumerable: true, get: function () { return attrs_1.capaAttrs; } });
@@ -94,9 +94,22 @@ function routeOf(file) {
94
94
  if (typeof file !== "string" || file === "") {
95
95
  throw new TypeError("@capacms/sdk/nextjs: routeOf needs a file path.");
96
96
  }
97
- // `import.meta.url` is a file: URL, and a Windows path uses backslashes.
98
- const withoutScheme = file.startsWith("file://") ? file.slice("file://".length) : file;
99
- const normalised = withoutScheme.replace(/\\/g, "/").split("?")[0];
97
+ // `import.meta.url` is a file: URL, and a Windows path uses backslashes. A
98
+ // file: URL is percent-encoded (`[slug]` arrives as `%5Bslug%5D`), so it is
99
+ // decoded; a plain path is taken as written.
100
+ const isUrl = file.startsWith("file://");
101
+ const withoutScheme = isUrl ? file.slice("file://".length) : file;
102
+ const unquoted = withoutScheme.split("?")[0].split("#")[0];
103
+ let decoded = unquoted;
104
+ if (isUrl) {
105
+ try {
106
+ decoded = decodeURIComponent(unquoted);
107
+ }
108
+ catch {
109
+ // A malformed escape: keep the raw text and let the checks below judge it.
110
+ }
111
+ }
112
+ const normalised = decoded.replace(/\\/g, "/");
100
113
  const parts = normalised.split("/").filter((part) => part !== "" && part !== ".");
101
114
  // The LAST router root wins, so a project with its own `app/` inside
102
115
  // `packages/site/app/...` resolves against the one nearest the route.
@@ -0,0 +1,20 @@
1
+ export { acceptMessage, hoverMessage, normaliseOrigins, pickCentred, readyMessage, scrollEdge, selectMessage, visibleMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
2
+ export type { AdminMessage, MessageLike, SiteMessage, TaggedBox } from "./protocol";
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
+ onRefresh?: () => void | Promise<void>;
14
+ }
15
+ /**
16
+ * Start the overlay. Returns a disposer that removes every listener and the
17
+ * drawing layer. Calling it again while it runs updates the options and returns
18
+ * the same disposer, so a React effect that runs twice does not listen twice.
19
+ */
20
+ export declare function startOverlay(options: OverlayOptions): () => void;
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SITE_SOURCE = exports.PROTOCOL_VERSION = exports.ADMIN_SOURCE = exports.visibleMessage = exports.selectMessage = exports.scrollEdge = exports.readyMessage = exports.pickCentred = exports.normaliseOrigins = exports.hoverMessage = exports.acceptMessage = void 0;
4
+ exports.startOverlay = startOverlay;
5
+ /**
6
+ * `@capacms/sdk/overlay`: the site half of Capa's live preview.
7
+ *
8
+ * "use client";
9
+ * useEffect(() => startOverlay({ adminOrigins: ["https://app.capacms.com"] }), []);
10
+ *
11
+ * Inside the Capa editor's preview frame this outlines the element an editor is
12
+ * working on, reports clicks on tagged elements back to the editor, and
13
+ * re-renders the draft after a save. Outside a frame it does nothing at all:
14
+ * no listeners, no DOM, no cost.
15
+ *
16
+ * Vanilla DOM and no imports beyond the pure protocol module, so it works in
17
+ * any framework and adds nothing to a bundle that does not call it.
18
+ */
19
+ const protocol_1 = require("./protocol");
20
+ var protocol_2 = require("./protocol");
21
+ Object.defineProperty(exports, "acceptMessage", { enumerable: true, get: function () { return protocol_2.acceptMessage; } });
22
+ Object.defineProperty(exports, "hoverMessage", { enumerable: true, get: function () { return protocol_2.hoverMessage; } });
23
+ Object.defineProperty(exports, "normaliseOrigins", { enumerable: true, get: function () { return protocol_2.normaliseOrigins; } });
24
+ Object.defineProperty(exports, "pickCentred", { enumerable: true, get: function () { return protocol_2.pickCentred; } });
25
+ Object.defineProperty(exports, "readyMessage", { enumerable: true, get: function () { return protocol_2.readyMessage; } });
26
+ Object.defineProperty(exports, "scrollEdge", { enumerable: true, get: function () { return protocol_2.scrollEdge; } });
27
+ Object.defineProperty(exports, "selectMessage", { enumerable: true, get: function () { return protocol_2.selectMessage; } });
28
+ Object.defineProperty(exports, "visibleMessage", { enumerable: true, get: function () { return protocol_2.visibleMessage; } });
29
+ Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return protocol_2.ADMIN_SOURCE; } });
30
+ Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_2.PROTOCOL_VERSION; } });
31
+ Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return protocol_2.SITE_SOURCE; } });
32
+ const BLUE = "#2563eb";
33
+ const TAGGED = "[data-capa-entry][data-capa-field]";
34
+ /** Survives the `location.reload()` fallback so the page comes back where it was. */
35
+ const SCROLL_KEY = "capa-overlay:scrollY";
36
+ /** How long a refresh keeps putting the scroll position back while the page re-renders. */
37
+ const RESTORE_WINDOW_MS = 1500;
38
+ /** `visible` is sent at most this often while the page scrolls. */
39
+ const VISIBLE_THROTTLE_MS = 150;
40
+ const noop = () => { };
41
+ let running = null;
42
+ /**
43
+ * Start the overlay. Returns a disposer that removes every listener and the
44
+ * drawing layer. Calling it again while it runs updates the options and returns
45
+ * the same disposer, so a React effect that runs twice does not listen twice.
46
+ */
47
+ function startOverlay(options) {
48
+ if (typeof window === "undefined" || typeof document === "undefined")
49
+ return noop;
50
+ // Not framed: this is an ordinary visit to the site.
51
+ if (window.parent === window)
52
+ return noop;
53
+ if (running) {
54
+ running.setOptions(options);
55
+ return running.dispose;
56
+ }
57
+ running = createOverlay(options);
58
+ return running.dispose;
59
+ }
60
+ function createOverlay(initial) {
61
+ let origins = (0, protocol_1.normaliseOrigins)(initial.adminOrigins ?? []);
62
+ let onRefresh = initial.onRefresh;
63
+ /** The origin that said hello. Every message this page sends goes there and nowhere else. */
64
+ let adminOrigin = null;
65
+ let highlight = null;
66
+ let outlineAll = false;
67
+ let hovered = null;
68
+ let lastReady = "";
69
+ let pendingScroll = null;
70
+ let layer = null;
71
+ let frame = 0;
72
+ let readyFrame = 0;
73
+ let hoverFrame = 0;
74
+ let lastPointer = null;
75
+ let visibleTimer = 0;
76
+ let lastVisible = "";
77
+ const post = (message) => {
78
+ if (!adminOrigin)
79
+ return;
80
+ window.parent.postMessage(message, adminOrigin);
81
+ };
82
+ // ------------------------------------------------------------- drawing ---
83
+ const ensureLayer = () => {
84
+ if (layer && layer.isConnected)
85
+ return layer;
86
+ layer = document.createElement("div");
87
+ layer.setAttribute("data-capa-overlay", "");
88
+ layer.setAttribute("aria-hidden", "true");
89
+ Object.assign(layer.style, {
90
+ position: "fixed",
91
+ inset: "0",
92
+ pointerEvents: "none",
93
+ zIndex: "2147483647",
94
+ overflow: "hidden",
95
+ });
96
+ document.body.appendChild(layer);
97
+ return layer;
98
+ };
99
+ const tagged = () => Array.from(document.querySelectorAll(TAGGED));
100
+ const matching = (entryId, field) => tagged().filter((el) => el.getAttribute("data-capa-entry") === entryId &&
101
+ (field === null || el.getAttribute("data-capa-field") === field));
102
+ const box = (el, style, label) => {
103
+ const rect = el.getBoundingClientRect();
104
+ if (rect.width === 0 && rect.height === 0)
105
+ return null;
106
+ const pad = style === "faint" ? 1 : 3;
107
+ const div = document.createElement("div");
108
+ Object.assign(div.style, {
109
+ position: "absolute",
110
+ left: `${rect.left - pad}px`,
111
+ top: `${rect.top - pad}px`,
112
+ width: `${rect.width + pad * 2}px`,
113
+ height: `${rect.height + pad * 2}px`,
114
+ boxSizing: "border-box",
115
+ borderRadius: "3px",
116
+ border: style === "faint"
117
+ ? "1px solid rgba(37, 99, 235, 0.35)"
118
+ : style === "hover"
119
+ ? `2px dashed ${BLUE}`
120
+ : `2px solid ${BLUE}`,
121
+ });
122
+ if (label) {
123
+ const chip = document.createElement("span");
124
+ chip.textContent = label;
125
+ // Above the box when there is room, tucked inside its top edge when the
126
+ // element sits at the very top of the viewport.
127
+ const above = rect.top - pad >= 20;
128
+ Object.assign(chip.style, {
129
+ position: "absolute",
130
+ left: "-2px",
131
+ top: above ? "-20px" : "0px",
132
+ background: BLUE,
133
+ color: "#fff",
134
+ font: "500 11px/18px system-ui, -apple-system, 'Segoe UI', sans-serif",
135
+ padding: "0 6px",
136
+ borderRadius: "3px",
137
+ whiteSpace: "nowrap",
138
+ letterSpacing: "0",
139
+ });
140
+ div.appendChild(chip);
141
+ }
142
+ return div;
143
+ };
144
+ const draw = () => {
145
+ frame = 0;
146
+ const nothing = !outlineAll && !highlight && !hovered;
147
+ if (nothing) {
148
+ if (layer)
149
+ layer.replaceChildren();
150
+ return;
151
+ }
152
+ const target = ensureLayer();
153
+ const boxes = [];
154
+ const active = highlight ? matching(highlight.entryId, highlight.field) : [];
155
+ if (outlineAll) {
156
+ for (const el of tagged()) {
157
+ if (active.includes(el))
158
+ continue;
159
+ const b = box(el, "faint", null);
160
+ if (b)
161
+ boxes.push(b);
162
+ }
163
+ }
164
+ if (hovered && hovered.isConnected && !active.includes(hovered)) {
165
+ const b = box(hovered, "hover", hovered.getAttribute("data-capa-field"));
166
+ if (b)
167
+ boxes.push(b);
168
+ }
169
+ active.forEach((el, i) => {
170
+ const label = i === 0 ? (highlight?.field ?? "entry") : null;
171
+ const b = box(el, "active", label);
172
+ if (b)
173
+ boxes.push(b);
174
+ });
175
+ target.replaceChildren(...boxes);
176
+ };
177
+ const render = () => {
178
+ if (frame)
179
+ return;
180
+ frame = window.requestAnimationFrame(draw);
181
+ };
182
+ // --------------------------------------------------------------- ready ---
183
+ const sendReady = (force) => {
184
+ readyFrame = 0;
185
+ if (!adminOrigin)
186
+ return;
187
+ const entries = [];
188
+ for (const el of tagged()) {
189
+ const id = el.getAttribute("data-capa-entry");
190
+ if (id && !entries.includes(id))
191
+ entries.push(id);
192
+ }
193
+ const path = window.location.pathname;
194
+ const key = `${path}\n${entries.join(",")}`;
195
+ if (!force && key === lastReady)
196
+ return;
197
+ lastReady = key;
198
+ post((0, protocol_1.readyMessage)(path, entries));
199
+ };
200
+ const scheduleReady = () => {
201
+ if (readyFrame)
202
+ return;
203
+ readyFrame = window.requestAnimationFrame(() => sendReady(false));
204
+ };
205
+ // ------------------------------------------------------------- visible ---
206
+ /**
207
+ * Which tagged element sits at the centre of the viewport, reported when it
208
+ * changes. The editor's "Follow the page" scrolls the form to match. Sent
209
+ * only to an admin that has said hello, and only on a change, so a still
210
+ * page sends nothing.
211
+ */
212
+ const reportVisible = () => {
213
+ visibleTimer = 0;
214
+ if (!adminOrigin)
215
+ return;
216
+ const boxes = tagged().map((el) => {
217
+ const rect = el.getBoundingClientRect();
218
+ return {
219
+ entryId: el.getAttribute("data-capa-entry") ?? "",
220
+ field: el.getAttribute("data-capa-field") ?? "",
221
+ top: rect.top,
222
+ bottom: rect.bottom,
223
+ };
224
+ });
225
+ const edge = (0, protocol_1.scrollEdge)(window.scrollY, window.innerHeight, document.documentElement.scrollHeight);
226
+ const pick = (0, protocol_1.pickCentred)(boxes, window.innerHeight, edge);
227
+ if (!pick)
228
+ return;
229
+ const key = `${pick.entryId}\n${pick.field}`;
230
+ if (key === lastVisible)
231
+ return;
232
+ lastVisible = key;
233
+ post((0, protocol_1.visibleMessage)(pick.entryId, pick.field));
234
+ };
235
+ const onScroll = () => {
236
+ render();
237
+ if (!visibleTimer)
238
+ visibleTimer = window.setTimeout(reportVisible, VISIBLE_THROTTLE_MS);
239
+ };
240
+ // -------------------------------------------------------------- scroll ---
241
+ const restoreScroll = () => {
242
+ if (!pendingScroll)
243
+ return;
244
+ if (Date.now() > pendingScroll.until) {
245
+ pendingScroll = null;
246
+ return;
247
+ }
248
+ if (Math.abs(window.scrollY - pendingScroll.y) > 1)
249
+ window.scrollTo(0, pendingScroll.y);
250
+ };
251
+ // A `location.reload()` fallback left the position behind before it went.
252
+ try {
253
+ const saved = window.sessionStorage.getItem(SCROLL_KEY);
254
+ if (saved !== null) {
255
+ window.sessionStorage.removeItem(SCROLL_KEY);
256
+ const y = Number(saved);
257
+ if (Number.isFinite(y)) {
258
+ pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
259
+ window.requestAnimationFrame(restoreScroll);
260
+ }
261
+ }
262
+ }
263
+ catch {
264
+ // Storage blocked (a sandboxed or third-party frame): start at the top.
265
+ }
266
+ const refresh = () => {
267
+ const y = window.scrollY;
268
+ if (!onRefresh) {
269
+ try {
270
+ window.sessionStorage.setItem(SCROLL_KEY, String(y));
271
+ }
272
+ catch {
273
+ // Nowhere to keep it; the reload lands at the top.
274
+ }
275
+ window.location.reload();
276
+ return;
277
+ }
278
+ pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
279
+ Promise.resolve()
280
+ .then(() => onRefresh?.())
281
+ .catch(() => window.location.reload())
282
+ .finally(() => window.requestAnimationFrame(restoreScroll));
283
+ };
284
+ // ------------------------------------------------------------ messages ---
285
+ const onMessage = (event) => {
286
+ const message = (0, protocol_1.acceptMessage)(event, origins, window.parent);
287
+ if (!message)
288
+ return;
289
+ switch (message.type) {
290
+ case "hello":
291
+ adminOrigin = event.origin;
292
+ lastVisible = "";
293
+ sendReady(true);
294
+ render();
295
+ return;
296
+ case "highlight": {
297
+ if (message.entryId === "") {
298
+ highlight = null;
299
+ render();
300
+ return;
301
+ }
302
+ highlight = { entryId: message.entryId, field: message.field };
303
+ const first = matching(message.entryId, message.field)[0];
304
+ if (first)
305
+ first.scrollIntoView({ block: "center", behavior: "smooth" });
306
+ render();
307
+ return;
308
+ }
309
+ case "outline":
310
+ outlineAll = message.on;
311
+ render();
312
+ return;
313
+ case "refresh":
314
+ refresh();
315
+ return;
316
+ }
317
+ };
318
+ // --------------------------------------------------------- interaction ---
319
+ const taggedFrom = (target) => {
320
+ const el = target;
321
+ return el && typeof el.closest === "function" ? el.closest(TAGGED) : null;
322
+ };
323
+ const onClick = (event) => {
324
+ // Only once the admin has said hello: a framed page that is not talking to
325
+ // Capa keeps its links working.
326
+ if (!adminOrigin)
327
+ return;
328
+ const el = taggedFrom(event.target);
329
+ if (!el)
330
+ return;
331
+ event.preventDefault();
332
+ event.stopPropagation();
333
+ post((0, protocol_1.selectMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
334
+ };
335
+ const updateHover = () => {
336
+ hoverFrame = 0;
337
+ const el = taggedFrom(lastPointer);
338
+ if (el === hovered)
339
+ return;
340
+ hovered = el;
341
+ post(el
342
+ ? (0, protocol_1.hoverMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
343
+ : (0, protocol_1.hoverMessage)("", null));
344
+ render();
345
+ };
346
+ const onPointerMove = (event) => {
347
+ if (!adminOrigin)
348
+ return;
349
+ lastPointer = event.target;
350
+ if (!hoverFrame)
351
+ hoverFrame = window.requestAnimationFrame(updateHover);
352
+ };
353
+ const onPointerLeave = () => {
354
+ lastPointer = null;
355
+ if (!hoverFrame)
356
+ hoverFrame = window.requestAnimationFrame(updateHover);
357
+ };
358
+ const observer = new MutationObserver((records) => {
359
+ // Our own layer redrawing is not the page changing.
360
+ if (records.every((r) => layer !== null && (r.target === layer || layer.contains(r.target))))
361
+ return;
362
+ scheduleReady();
363
+ render();
364
+ restoreScroll();
365
+ });
366
+ window.addEventListener("message", onMessage);
367
+ window.addEventListener("scroll", onScroll, { capture: true, passive: true });
368
+ window.addEventListener("resize", render, { passive: true });
369
+ window.addEventListener("popstate", scheduleReady);
370
+ document.addEventListener("click", onClick, true);
371
+ document.addEventListener("pointermove", onPointerMove, { capture: true, passive: true });
372
+ document.documentElement.addEventListener("pointerleave", onPointerLeave);
373
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
374
+ const dispose = () => {
375
+ window.removeEventListener("message", onMessage);
376
+ window.removeEventListener("scroll", onScroll, { capture: true });
377
+ window.removeEventListener("resize", render);
378
+ window.removeEventListener("popstate", scheduleReady);
379
+ document.removeEventListener("click", onClick, true);
380
+ document.removeEventListener("pointermove", onPointerMove, { capture: true });
381
+ document.documentElement.removeEventListener("pointerleave", onPointerLeave);
382
+ observer.disconnect();
383
+ for (const id of [frame, readyFrame, hoverFrame])
384
+ if (id)
385
+ window.cancelAnimationFrame(id);
386
+ if (visibleTimer)
387
+ window.clearTimeout(visibleTimer);
388
+ layer?.remove();
389
+ layer = null;
390
+ if (running?.dispose === dispose)
391
+ running = null;
392
+ };
393
+ return {
394
+ dispose,
395
+ setOptions: (next) => {
396
+ origins = (0, protocol_1.normaliseOrigins)(next.adminOrigins ?? []);
397
+ onRefresh = next.onRefresh;
398
+ if (adminOrigin && !origins.includes(adminOrigin))
399
+ adminOrigin = null;
400
+ },
401
+ };
402
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The live preview protocol, site side. Pure: no DOM, no globals, so it can be
3
+ * tested under plain `node --test`.
4
+ *
5
+ * The Capa admin frames the site and the two talk over `postMessage`. Every
6
+ * message is a plain object `{ source, v: 1, type, ...fields }`:
7
+ *
8
+ * admin -> site (source "capa-admin")
9
+ * hello {} sent on the frame's load
10
+ * highlight { entryId, field: string | null } entryId "" clears
11
+ * outline { on: boolean } outline every tagged element
12
+ * refresh {} re-render the draft
13
+ *
14
+ * site -> admin (source "capa")
15
+ * ready { path, entries } after hello, and after every navigation
16
+ * select { entryId, field } a tagged element was clicked
17
+ * hover { entryId, field } | { entryId: "", field: null }
18
+ * visible { entryId, field } the tagged element at the centre of the
19
+ * viewport changed (throttled, on scroll)
20
+ *
21
+ * `visible` arrived in 1.0.0-next.2 and is OPTIONAL: it is still `v: 1`,
22
+ * because an admin that does not know it drops an unknown type, and an older
23
+ * overlay simply never sends it. Additive messages keep the version; only a
24
+ * change to an existing shape would move it.
25
+ *
26
+ * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
27
+ * this package). If you change one side, change the other.
28
+ */
29
+ export declare const PROTOCOL_VERSION = 1;
30
+ export declare const ADMIN_SOURCE = "capa-admin";
31
+ export declare const SITE_SOURCE = "capa";
32
+ export type AdminMessage = {
33
+ source: typeof ADMIN_SOURCE;
34
+ v: 1;
35
+ type: "hello";
36
+ } | {
37
+ source: typeof ADMIN_SOURCE;
38
+ v: 1;
39
+ type: "highlight";
40
+ entryId: string;
41
+ field: string | null;
42
+ } | {
43
+ source: typeof ADMIN_SOURCE;
44
+ v: 1;
45
+ type: "outline";
46
+ on: boolean;
47
+ } | {
48
+ source: typeof ADMIN_SOURCE;
49
+ v: 1;
50
+ type: "refresh";
51
+ };
52
+ export type SiteMessage = {
53
+ source: typeof SITE_SOURCE;
54
+ v: 1;
55
+ type: "ready";
56
+ path: string;
57
+ entries: string[];
58
+ } | {
59
+ source: typeof SITE_SOURCE;
60
+ v: 1;
61
+ type: "select";
62
+ entryId: string;
63
+ field: string;
64
+ } | {
65
+ source: typeof SITE_SOURCE;
66
+ v: 1;
67
+ type: "hover";
68
+ entryId: string;
69
+ field: string | null;
70
+ } | {
71
+ source: typeof SITE_SOURCE;
72
+ v: 1;
73
+ type: "visible";
74
+ entryId: string;
75
+ field: string;
76
+ };
77
+ /** The parts of a `MessageEvent` the filter reads, so a test can hand in a literal. */
78
+ export interface MessageLike {
79
+ origin: string;
80
+ source: unknown;
81
+ data: unknown;
82
+ }
83
+ /**
84
+ * `https://admin.example.com/` and `https://admin.example.com` are one origin,
85
+ * and a configured value that is not a URL at all is dropped rather than
86
+ * compared as a string, because `"*"` must never mean "anyone".
87
+ */
88
+ export declare function normaliseOrigins(origins: readonly string[]): string[];
89
+ /**
90
+ * The one gate every incoming message goes through. A message is accepted only
91
+ * when all of these hold, and is `null` otherwise:
92
+ *
93
+ * - it came from the window that framed this page (`parent`), not from a
94
+ * popup, a sibling frame or this page itself
95
+ * - its origin is one the site configured as its admin
96
+ * - it says it is from the admin, speaks version 1, and has a known type
97
+ * whose fields have the right shapes
98
+ *
99
+ * `allowedOrigins` is expected already normalised (`normaliseOrigins`).
100
+ */
101
+ export declare function acceptMessage(event: MessageLike, allowedOrigins: readonly string[], parent: unknown): AdminMessage | null;
102
+ export declare function readyMessage(path: string, entries: string[]): SiteMessage;
103
+ export declare function selectMessage(entryId: string, field: string): SiteMessage;
104
+ export declare function hoverMessage(entryId: string, field: string | null): SiteMessage;
105
+ export declare function visibleMessage(entryId: string, field: string): SiteMessage;
106
+ /** The part of a tagged element `pickCentred` reads, so a test can hand in literals. */
107
+ export interface TaggedBox {
108
+ entryId: string;
109
+ field: string;
110
+ /** `getBoundingClientRect()`, viewport coordinates. */
111
+ top: number;
112
+ bottom: number;
113
+ }
114
+ /**
115
+ * The tagged element a reader is looking at: the one that contains the
116
+ * viewport's horizontal centre line, and when several do (a field inside a
117
+ * card that is also tagged) the SMALLEST, because the innermost element is
118
+ * the specific one. When none crosses the line, the nearest one that is at
119
+ * least partly on screen. Null when nothing tagged is on screen at all.
120
+ *
121
+ * `edge` is where the page is scrolled to. A heading near the top of a page
122
+ * can never reach the centre line, because the page cannot scroll above its
123
+ * top; at the top the topmost visible element is the one being read, and at
124
+ * the bottom the bottommost. The same rule a table of contents' scroll spy
125
+ * uses.
126
+ *
127
+ * Pure, so the admin's "follow the page" can be tested without a DOM.
128
+ */
129
+ export declare function pickCentred(boxes: readonly TaggedBox[], viewportHeight: number, edge?: "top" | "bottom" | null): TaggedBox | null;
130
+ /**
131
+ * Which edge a scroll position is at, for `pickCentred`. A page that does not
132
+ * scroll at all is at neither: nothing moves, and the centre rule stands.
133
+ */
134
+ export declare function scrollEdge(scrollY: number, viewportHeight: number, scrollHeight: number): "top" | "bottom" | null;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ /**
3
+ * The live preview protocol, site side. Pure: no DOM, no globals, so it can be
4
+ * tested under plain `node --test`.
5
+ *
6
+ * The Capa admin frames the site and the two talk over `postMessage`. Every
7
+ * message is a plain object `{ source, v: 1, type, ...fields }`:
8
+ *
9
+ * admin -> site (source "capa-admin")
10
+ * hello {} sent on the frame's load
11
+ * highlight { entryId, field: string | null } entryId "" clears
12
+ * outline { on: boolean } outline every tagged element
13
+ * refresh {} re-render the draft
14
+ *
15
+ * site -> admin (source "capa")
16
+ * ready { path, entries } after hello, and after every navigation
17
+ * select { entryId, field } a tagged element was clicked
18
+ * hover { entryId, field } | { entryId: "", field: null }
19
+ * visible { entryId, field } the tagged element at the centre of the
20
+ * viewport changed (throttled, on scroll)
21
+ *
22
+ * `visible` arrived in 1.0.0-next.2 and is OPTIONAL: it is still `v: 1`,
23
+ * because an admin that does not know it drops an unknown type, and an older
24
+ * overlay simply never sends it. Additive messages keep the version; only a
25
+ * change to an existing shape would move it.
26
+ *
27
+ * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
28
+ * this package). If you change one side, change the other.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.SITE_SOURCE = exports.ADMIN_SOURCE = exports.PROTOCOL_VERSION = void 0;
32
+ exports.normaliseOrigins = normaliseOrigins;
33
+ exports.acceptMessage = acceptMessage;
34
+ exports.readyMessage = readyMessage;
35
+ exports.selectMessage = selectMessage;
36
+ exports.hoverMessage = hoverMessage;
37
+ exports.visibleMessage = visibleMessage;
38
+ exports.pickCentred = pickCentred;
39
+ exports.scrollEdge = scrollEdge;
40
+ exports.PROTOCOL_VERSION = 1;
41
+ exports.ADMIN_SOURCE = "capa-admin";
42
+ exports.SITE_SOURCE = "capa";
43
+ /**
44
+ * `https://admin.example.com/` and `https://admin.example.com` are one origin,
45
+ * and a configured value that is not a URL at all is dropped rather than
46
+ * compared as a string, because `"*"` must never mean "anyone".
47
+ */
48
+ function normaliseOrigins(origins) {
49
+ const out = [];
50
+ for (const raw of origins) {
51
+ if (typeof raw !== "string" || raw.trim() === "")
52
+ continue;
53
+ try {
54
+ const origin = new URL(raw.trim()).origin;
55
+ if (origin !== "null" && !out.includes(origin))
56
+ out.push(origin);
57
+ }
58
+ catch {
59
+ // Not a URL: never an origin anybody can send from.
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * The one gate every incoming message goes through. A message is accepted only
66
+ * when all of these hold, and is `null` otherwise:
67
+ *
68
+ * - it came from the window that framed this page (`parent`), not from a
69
+ * popup, a sibling frame or this page itself
70
+ * - its origin is one the site configured as its admin
71
+ * - it says it is from the admin, speaks version 1, and has a known type
72
+ * whose fields have the right shapes
73
+ *
74
+ * `allowedOrigins` is expected already normalised (`normaliseOrigins`).
75
+ */
76
+ function acceptMessage(event, allowedOrigins, parent) {
77
+ if (!event || event.source !== parent || parent == null)
78
+ return null;
79
+ if (typeof event.origin !== "string" || !allowedOrigins.includes(event.origin))
80
+ return null;
81
+ const data = event.data;
82
+ if (typeof data !== "object" || data === null || Array.isArray(data))
83
+ return null;
84
+ const m = data;
85
+ if (m.source !== exports.ADMIN_SOURCE || m.v !== exports.PROTOCOL_VERSION)
86
+ return null;
87
+ switch (m.type) {
88
+ case "hello":
89
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "hello" };
90
+ case "refresh":
91
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "refresh" };
92
+ case "outline":
93
+ if (typeof m.on !== "boolean")
94
+ return null;
95
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "outline", on: m.on };
96
+ case "highlight":
97
+ if (typeof m.entryId !== "string")
98
+ return null;
99
+ if (m.field !== null && typeof m.field !== "string")
100
+ return null;
101
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "highlight", entryId: m.entryId, field: m.field };
102
+ default:
103
+ return null;
104
+ }
105
+ }
106
+ function readyMessage(path, entries) {
107
+ return { source: exports.SITE_SOURCE, v: 1, type: "ready", path, entries };
108
+ }
109
+ function selectMessage(entryId, field) {
110
+ return { source: exports.SITE_SOURCE, v: 1, type: "select", entryId, field };
111
+ }
112
+ function hoverMessage(entryId, field) {
113
+ return entryId === ""
114
+ ? { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId: "", field: null }
115
+ : { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId, field };
116
+ }
117
+ function visibleMessage(entryId, field) {
118
+ return { source: exports.SITE_SOURCE, v: 1, type: "visible", entryId, field };
119
+ }
120
+ /** Above any element height, so a box that misses the centre line never outranks one that crosses it. */
121
+ const MISS_FLOOR = 1e9;
122
+ /**
123
+ * The tagged element a reader is looking at: the one that contains the
124
+ * viewport's horizontal centre line, and when several do (a field inside a
125
+ * card that is also tagged) the SMALLEST, because the innermost element is
126
+ * the specific one. When none crosses the line, the nearest one that is at
127
+ * least partly on screen. Null when nothing tagged is on screen at all.
128
+ *
129
+ * `edge` is where the page is scrolled to. A heading near the top of a page
130
+ * can never reach the centre line, because the page cannot scroll above its
131
+ * top; at the top the topmost visible element is the one being read, and at
132
+ * the bottom the bottommost. The same rule a table of contents' scroll spy
133
+ * uses.
134
+ *
135
+ * Pure, so the admin's "follow the page" can be tested without a DOM.
136
+ */
137
+ function pickCentred(boxes, viewportHeight, edge = null) {
138
+ const centre = viewportHeight / 2;
139
+ let best = null;
140
+ let bestScore = Infinity;
141
+ for (const b of boxes) {
142
+ if (!b.entryId || !b.field)
143
+ continue;
144
+ if (b.bottom <= 0 || b.top >= viewportHeight || b.bottom <= b.top)
145
+ continue;
146
+ const height = b.bottom - b.top;
147
+ let score;
148
+ if (edge === "top") {
149
+ // Topmost first; of two that start together, the smaller (inner) one.
150
+ score = b.top * MISS_FLOOR + height;
151
+ }
152
+ else if (edge === "bottom") {
153
+ score = (viewportHeight - b.bottom) * MISS_FLOOR + height;
154
+ }
155
+ else {
156
+ // Crossing the line scores by height (smaller wins) and always beats a
157
+ // box that misses it, which scores by its distance from the line on top
158
+ // of a floor no height can reach.
159
+ score =
160
+ b.top <= centre && b.bottom >= centre
161
+ ? height
162
+ : MISS_FLOOR + Math.min(Math.abs(b.top - centre), Math.abs(b.bottom - centre));
163
+ }
164
+ if (score < bestScore) {
165
+ best = b;
166
+ bestScore = score;
167
+ }
168
+ }
169
+ return best;
170
+ }
171
+ /**
172
+ * Which edge a scroll position is at, for `pickCentred`. A page that does not
173
+ * scroll at all is at neither: nothing moves, and the centre rule stands.
174
+ */
175
+ function scrollEdge(scrollY, viewportHeight, scrollHeight) {
176
+ const max = scrollHeight - viewportHeight;
177
+ if (max <= 1)
178
+ return null;
179
+ if (scrollY <= 1)
180
+ return "top";
181
+ if (scrollY >= max - 1)
182
+ return "bottom";
183
+ return null;
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/sdk",
3
- "version": "1.0.0-next.0",
3
+ "version": "1.0.0-next.2",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,6 +39,10 @@
39
39
  "types": "./dist/nextjs/index.d.ts",
40
40
  "default": "./dist/nextjs/index.js"
41
41
  },
42
+ "./overlay": {
43
+ "types": "./dist/overlay/index.d.ts",
44
+ "default": "./dist/overlay/index.js"
45
+ },
42
46
  "./package.json": "./package.json"
43
47
  },
44
48
  "typesVersions": {
@@ -49,6 +53,9 @@
49
53
  "nextjs": [
50
54
  "dist/nextjs/index.d.ts"
51
55
  ],
56
+ "overlay": [
57
+ "dist/overlay/index.d.ts"
58
+ ],
52
59
  "*": [
53
60
  "dist/index.d.ts"
54
61
  ]
@@ -64,6 +71,6 @@
64
71
  "scripts": {
65
72
  "build": "tsc -p tsconfig.json",
66
73
  "typecheck": "tsc -p tsconfig.json --noEmit",
67
- "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
74
+ "test": "tsc -p tsconfig.json && node --test test/codegen.test.js test/client.test.js test/next-client.test.js test/nextjs.test.js test/webhooks.test.js test/attrs.test.js test/overlay.test.js && tsc -p test/types/tsconfig.consumer.json --noEmit"
68
75
  }
69
76
  }