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

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,99 @@ 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
+
329
422
  ## Not yet
330
423
 
331
424
  `--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, readyMessage, selectMessage, ADMIN_SOURCE, PROTOCOL_VERSION, SITE_SOURCE, } from "./protocol";
2
+ export type { AdminMessage, MessageLike, SiteMessage } 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,357 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SITE_SOURCE = exports.PROTOCOL_VERSION = exports.ADMIN_SOURCE = exports.selectMessage = exports.readyMessage = 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, "readyMessage", { enumerable: true, get: function () { return protocol_2.readyMessage; } });
25
+ Object.defineProperty(exports, "selectMessage", { enumerable: true, get: function () { return protocol_2.selectMessage; } });
26
+ Object.defineProperty(exports, "ADMIN_SOURCE", { enumerable: true, get: function () { return protocol_2.ADMIN_SOURCE; } });
27
+ Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_2.PROTOCOL_VERSION; } });
28
+ Object.defineProperty(exports, "SITE_SOURCE", { enumerable: true, get: function () { return protocol_2.SITE_SOURCE; } });
29
+ const BLUE = "#2563eb";
30
+ const TAGGED = "[data-capa-entry][data-capa-field]";
31
+ /** Survives the `location.reload()` fallback so the page comes back where it was. */
32
+ const SCROLL_KEY = "capa-overlay:scrollY";
33
+ /** How long a refresh keeps putting the scroll position back while the page re-renders. */
34
+ const RESTORE_WINDOW_MS = 1500;
35
+ const noop = () => { };
36
+ let running = null;
37
+ /**
38
+ * Start the overlay. Returns a disposer that removes every listener and the
39
+ * drawing layer. Calling it again while it runs updates the options and returns
40
+ * the same disposer, so a React effect that runs twice does not listen twice.
41
+ */
42
+ function startOverlay(options) {
43
+ if (typeof window === "undefined" || typeof document === "undefined")
44
+ return noop;
45
+ // Not framed: this is an ordinary visit to the site.
46
+ if (window.parent === window)
47
+ return noop;
48
+ if (running) {
49
+ running.setOptions(options);
50
+ return running.dispose;
51
+ }
52
+ running = createOverlay(options);
53
+ return running.dispose;
54
+ }
55
+ function createOverlay(initial) {
56
+ let origins = (0, protocol_1.normaliseOrigins)(initial.adminOrigins ?? []);
57
+ let onRefresh = initial.onRefresh;
58
+ /** The origin that said hello. Every message this page sends goes there and nowhere else. */
59
+ let adminOrigin = null;
60
+ let highlight = null;
61
+ let outlineAll = false;
62
+ let hovered = null;
63
+ let lastReady = "";
64
+ let pendingScroll = null;
65
+ let layer = null;
66
+ let frame = 0;
67
+ let readyFrame = 0;
68
+ let hoverFrame = 0;
69
+ let lastPointer = null;
70
+ const post = (message) => {
71
+ if (!adminOrigin)
72
+ return;
73
+ window.parent.postMessage(message, adminOrigin);
74
+ };
75
+ // ------------------------------------------------------------- drawing ---
76
+ const ensureLayer = () => {
77
+ if (layer && layer.isConnected)
78
+ return layer;
79
+ layer = document.createElement("div");
80
+ layer.setAttribute("data-capa-overlay", "");
81
+ layer.setAttribute("aria-hidden", "true");
82
+ Object.assign(layer.style, {
83
+ position: "fixed",
84
+ inset: "0",
85
+ pointerEvents: "none",
86
+ zIndex: "2147483647",
87
+ overflow: "hidden",
88
+ });
89
+ document.body.appendChild(layer);
90
+ return layer;
91
+ };
92
+ const tagged = () => Array.from(document.querySelectorAll(TAGGED));
93
+ const matching = (entryId, field) => tagged().filter((el) => el.getAttribute("data-capa-entry") === entryId &&
94
+ (field === null || el.getAttribute("data-capa-field") === field));
95
+ const box = (el, style, label) => {
96
+ const rect = el.getBoundingClientRect();
97
+ if (rect.width === 0 && rect.height === 0)
98
+ return null;
99
+ const pad = style === "faint" ? 1 : 3;
100
+ const div = document.createElement("div");
101
+ Object.assign(div.style, {
102
+ position: "absolute",
103
+ left: `${rect.left - pad}px`,
104
+ top: `${rect.top - pad}px`,
105
+ width: `${rect.width + pad * 2}px`,
106
+ height: `${rect.height + pad * 2}px`,
107
+ boxSizing: "border-box",
108
+ borderRadius: "3px",
109
+ border: style === "faint"
110
+ ? "1px solid rgba(37, 99, 235, 0.35)"
111
+ : style === "hover"
112
+ ? `2px dashed ${BLUE}`
113
+ : `2px solid ${BLUE}`,
114
+ });
115
+ if (label) {
116
+ const chip = document.createElement("span");
117
+ chip.textContent = label;
118
+ // Above the box when there is room, tucked inside its top edge when the
119
+ // element sits at the very top of the viewport.
120
+ const above = rect.top - pad >= 20;
121
+ Object.assign(chip.style, {
122
+ position: "absolute",
123
+ left: "-2px",
124
+ top: above ? "-20px" : "0px",
125
+ background: BLUE,
126
+ color: "#fff",
127
+ font: "500 11px/18px system-ui, -apple-system, 'Segoe UI', sans-serif",
128
+ padding: "0 6px",
129
+ borderRadius: "3px",
130
+ whiteSpace: "nowrap",
131
+ letterSpacing: "0",
132
+ });
133
+ div.appendChild(chip);
134
+ }
135
+ return div;
136
+ };
137
+ const draw = () => {
138
+ frame = 0;
139
+ const nothing = !outlineAll && !highlight && !hovered;
140
+ if (nothing) {
141
+ if (layer)
142
+ layer.replaceChildren();
143
+ return;
144
+ }
145
+ const target = ensureLayer();
146
+ const boxes = [];
147
+ const active = highlight ? matching(highlight.entryId, highlight.field) : [];
148
+ if (outlineAll) {
149
+ for (const el of tagged()) {
150
+ if (active.includes(el))
151
+ continue;
152
+ const b = box(el, "faint", null);
153
+ if (b)
154
+ boxes.push(b);
155
+ }
156
+ }
157
+ if (hovered && hovered.isConnected && !active.includes(hovered)) {
158
+ const b = box(hovered, "hover", hovered.getAttribute("data-capa-field"));
159
+ if (b)
160
+ boxes.push(b);
161
+ }
162
+ active.forEach((el, i) => {
163
+ const label = i === 0 ? (highlight?.field ?? "entry") : null;
164
+ const b = box(el, "active", label);
165
+ if (b)
166
+ boxes.push(b);
167
+ });
168
+ target.replaceChildren(...boxes);
169
+ };
170
+ const render = () => {
171
+ if (frame)
172
+ return;
173
+ frame = window.requestAnimationFrame(draw);
174
+ };
175
+ // --------------------------------------------------------------- ready ---
176
+ const sendReady = (force) => {
177
+ readyFrame = 0;
178
+ if (!adminOrigin)
179
+ return;
180
+ const entries = [];
181
+ for (const el of tagged()) {
182
+ const id = el.getAttribute("data-capa-entry");
183
+ if (id && !entries.includes(id))
184
+ entries.push(id);
185
+ }
186
+ const path = window.location.pathname;
187
+ const key = `${path}\n${entries.join(",")}`;
188
+ if (!force && key === lastReady)
189
+ return;
190
+ lastReady = key;
191
+ post((0, protocol_1.readyMessage)(path, entries));
192
+ };
193
+ const scheduleReady = () => {
194
+ if (readyFrame)
195
+ return;
196
+ readyFrame = window.requestAnimationFrame(() => sendReady(false));
197
+ };
198
+ // -------------------------------------------------------------- scroll ---
199
+ const restoreScroll = () => {
200
+ if (!pendingScroll)
201
+ return;
202
+ if (Date.now() > pendingScroll.until) {
203
+ pendingScroll = null;
204
+ return;
205
+ }
206
+ if (Math.abs(window.scrollY - pendingScroll.y) > 1)
207
+ window.scrollTo(0, pendingScroll.y);
208
+ };
209
+ // A `location.reload()` fallback left the position behind before it went.
210
+ try {
211
+ const saved = window.sessionStorage.getItem(SCROLL_KEY);
212
+ if (saved !== null) {
213
+ window.sessionStorage.removeItem(SCROLL_KEY);
214
+ const y = Number(saved);
215
+ if (Number.isFinite(y)) {
216
+ pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
217
+ window.requestAnimationFrame(restoreScroll);
218
+ }
219
+ }
220
+ }
221
+ catch {
222
+ // Storage blocked (a sandboxed or third-party frame): start at the top.
223
+ }
224
+ const refresh = () => {
225
+ const y = window.scrollY;
226
+ if (!onRefresh) {
227
+ try {
228
+ window.sessionStorage.setItem(SCROLL_KEY, String(y));
229
+ }
230
+ catch {
231
+ // Nowhere to keep it; the reload lands at the top.
232
+ }
233
+ window.location.reload();
234
+ return;
235
+ }
236
+ pendingScroll = { y, until: Date.now() + RESTORE_WINDOW_MS };
237
+ Promise.resolve()
238
+ .then(() => onRefresh?.())
239
+ .catch(() => window.location.reload())
240
+ .finally(() => window.requestAnimationFrame(restoreScroll));
241
+ };
242
+ // ------------------------------------------------------------ messages ---
243
+ const onMessage = (event) => {
244
+ const message = (0, protocol_1.acceptMessage)(event, origins, window.parent);
245
+ if (!message)
246
+ return;
247
+ switch (message.type) {
248
+ case "hello":
249
+ adminOrigin = event.origin;
250
+ sendReady(true);
251
+ render();
252
+ return;
253
+ case "highlight": {
254
+ if (message.entryId === "") {
255
+ highlight = null;
256
+ render();
257
+ return;
258
+ }
259
+ highlight = { entryId: message.entryId, field: message.field };
260
+ const first = matching(message.entryId, message.field)[0];
261
+ if (first)
262
+ first.scrollIntoView({ block: "center", behavior: "smooth" });
263
+ render();
264
+ return;
265
+ }
266
+ case "outline":
267
+ outlineAll = message.on;
268
+ render();
269
+ return;
270
+ case "refresh":
271
+ refresh();
272
+ return;
273
+ }
274
+ };
275
+ // --------------------------------------------------------- interaction ---
276
+ const taggedFrom = (target) => {
277
+ const el = target;
278
+ return el && typeof el.closest === "function" ? el.closest(TAGGED) : null;
279
+ };
280
+ const onClick = (event) => {
281
+ // Only once the admin has said hello: a framed page that is not talking to
282
+ // Capa keeps its links working.
283
+ if (!adminOrigin)
284
+ return;
285
+ const el = taggedFrom(event.target);
286
+ if (!el)
287
+ return;
288
+ event.preventDefault();
289
+ event.stopPropagation();
290
+ post((0, protocol_1.selectMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field") ?? ""));
291
+ };
292
+ const updateHover = () => {
293
+ hoverFrame = 0;
294
+ const el = taggedFrom(lastPointer);
295
+ if (el === hovered)
296
+ return;
297
+ hovered = el;
298
+ post(el
299
+ ? (0, protocol_1.hoverMessage)(el.getAttribute("data-capa-entry") ?? "", el.getAttribute("data-capa-field"))
300
+ : (0, protocol_1.hoverMessage)("", null));
301
+ render();
302
+ };
303
+ const onPointerMove = (event) => {
304
+ if (!adminOrigin)
305
+ return;
306
+ lastPointer = event.target;
307
+ if (!hoverFrame)
308
+ hoverFrame = window.requestAnimationFrame(updateHover);
309
+ };
310
+ const onPointerLeave = () => {
311
+ lastPointer = null;
312
+ if (!hoverFrame)
313
+ hoverFrame = window.requestAnimationFrame(updateHover);
314
+ };
315
+ const observer = new MutationObserver((records) => {
316
+ // Our own layer redrawing is not the page changing.
317
+ if (records.every((r) => layer !== null && (r.target === layer || layer.contains(r.target))))
318
+ return;
319
+ scheduleReady();
320
+ render();
321
+ restoreScroll();
322
+ });
323
+ window.addEventListener("message", onMessage);
324
+ window.addEventListener("scroll", render, { capture: true, passive: true });
325
+ window.addEventListener("resize", render, { passive: true });
326
+ window.addEventListener("popstate", scheduleReady);
327
+ document.addEventListener("click", onClick, true);
328
+ document.addEventListener("pointermove", onPointerMove, { capture: true, passive: true });
329
+ document.documentElement.addEventListener("pointerleave", onPointerLeave);
330
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
331
+ const dispose = () => {
332
+ window.removeEventListener("message", onMessage);
333
+ window.removeEventListener("scroll", render, { capture: true });
334
+ window.removeEventListener("resize", render);
335
+ window.removeEventListener("popstate", scheduleReady);
336
+ document.removeEventListener("click", onClick, true);
337
+ document.removeEventListener("pointermove", onPointerMove, { capture: true });
338
+ document.documentElement.removeEventListener("pointerleave", onPointerLeave);
339
+ observer.disconnect();
340
+ for (const id of [frame, readyFrame, hoverFrame])
341
+ if (id)
342
+ window.cancelAnimationFrame(id);
343
+ layer?.remove();
344
+ layer = null;
345
+ if (running?.dispose === dispose)
346
+ running = null;
347
+ };
348
+ return {
349
+ dispose,
350
+ setOptions: (next) => {
351
+ origins = (0, protocol_1.normaliseOrigins)(next.adminOrigins ?? []);
352
+ onRefresh = next.onRefresh;
353
+ if (adminOrigin && !origins.includes(adminOrigin))
354
+ adminOrigin = null;
355
+ },
356
+ };
357
+ }
@@ -0,0 +1,91 @@
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
+ *
19
+ * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
20
+ * this package). If you change one side, change the other.
21
+ */
22
+ export declare const PROTOCOL_VERSION = 1;
23
+ export declare const ADMIN_SOURCE = "capa-admin";
24
+ export declare const SITE_SOURCE = "capa";
25
+ export type AdminMessage = {
26
+ source: typeof ADMIN_SOURCE;
27
+ v: 1;
28
+ type: "hello";
29
+ } | {
30
+ source: typeof ADMIN_SOURCE;
31
+ v: 1;
32
+ type: "highlight";
33
+ entryId: string;
34
+ field: string | null;
35
+ } | {
36
+ source: typeof ADMIN_SOURCE;
37
+ v: 1;
38
+ type: "outline";
39
+ on: boolean;
40
+ } | {
41
+ source: typeof ADMIN_SOURCE;
42
+ v: 1;
43
+ type: "refresh";
44
+ };
45
+ export type SiteMessage = {
46
+ source: typeof SITE_SOURCE;
47
+ v: 1;
48
+ type: "ready";
49
+ path: string;
50
+ entries: string[];
51
+ } | {
52
+ source: typeof SITE_SOURCE;
53
+ v: 1;
54
+ type: "select";
55
+ entryId: string;
56
+ field: string;
57
+ } | {
58
+ source: typeof SITE_SOURCE;
59
+ v: 1;
60
+ type: "hover";
61
+ entryId: string;
62
+ field: string | null;
63
+ };
64
+ /** The parts of a `MessageEvent` the filter reads, so a test can hand in a literal. */
65
+ export interface MessageLike {
66
+ origin: string;
67
+ source: unknown;
68
+ data: unknown;
69
+ }
70
+ /**
71
+ * `https://admin.example.com/` and `https://admin.example.com` are one origin,
72
+ * and a configured value that is not a URL at all is dropped rather than
73
+ * compared as a string, because `"*"` must never mean "anyone".
74
+ */
75
+ export declare function normaliseOrigins(origins: readonly string[]): string[];
76
+ /**
77
+ * The one gate every incoming message goes through. A message is accepted only
78
+ * when all of these hold, and is `null` otherwise:
79
+ *
80
+ * - it came from the window that framed this page (`parent`), not from a
81
+ * popup, a sibling frame or this page itself
82
+ * - its origin is one the site configured as its admin
83
+ * - it says it is from the admin, speaks version 1, and has a known type
84
+ * whose fields have the right shapes
85
+ *
86
+ * `allowedOrigins` is expected already normalised (`normaliseOrigins`).
87
+ */
88
+ export declare function acceptMessage(event: MessageLike, allowedOrigins: readonly string[], parent: unknown): AdminMessage | null;
89
+ export declare function readyMessage(path: string, entries: string[]): SiteMessage;
90
+ export declare function selectMessage(entryId: string, field: string): SiteMessage;
91
+ export declare function hoverMessage(entryId: string, field: string | null): SiteMessage;
@@ -0,0 +1,106 @@
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
+ *
20
+ * The admin keeps its own copy of these shapes (`apps/admin`, no dependency on
21
+ * this package). If you change one side, change the other.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.SITE_SOURCE = exports.ADMIN_SOURCE = exports.PROTOCOL_VERSION = void 0;
25
+ exports.normaliseOrigins = normaliseOrigins;
26
+ exports.acceptMessage = acceptMessage;
27
+ exports.readyMessage = readyMessage;
28
+ exports.selectMessage = selectMessage;
29
+ exports.hoverMessage = hoverMessage;
30
+ exports.PROTOCOL_VERSION = 1;
31
+ exports.ADMIN_SOURCE = "capa-admin";
32
+ exports.SITE_SOURCE = "capa";
33
+ /**
34
+ * `https://admin.example.com/` and `https://admin.example.com` are one origin,
35
+ * and a configured value that is not a URL at all is dropped rather than
36
+ * compared as a string, because `"*"` must never mean "anyone".
37
+ */
38
+ function normaliseOrigins(origins) {
39
+ const out = [];
40
+ for (const raw of origins) {
41
+ if (typeof raw !== "string" || raw.trim() === "")
42
+ continue;
43
+ try {
44
+ const origin = new URL(raw.trim()).origin;
45
+ if (origin !== "null" && !out.includes(origin))
46
+ out.push(origin);
47
+ }
48
+ catch {
49
+ // Not a URL: never an origin anybody can send from.
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * The one gate every incoming message goes through. A message is accepted only
56
+ * when all of these hold, and is `null` otherwise:
57
+ *
58
+ * - it came from the window that framed this page (`parent`), not from a
59
+ * popup, a sibling frame or this page itself
60
+ * - its origin is one the site configured as its admin
61
+ * - it says it is from the admin, speaks version 1, and has a known type
62
+ * whose fields have the right shapes
63
+ *
64
+ * `allowedOrigins` is expected already normalised (`normaliseOrigins`).
65
+ */
66
+ function acceptMessage(event, allowedOrigins, parent) {
67
+ if (!event || event.source !== parent || parent == null)
68
+ return null;
69
+ if (typeof event.origin !== "string" || !allowedOrigins.includes(event.origin))
70
+ return null;
71
+ const data = event.data;
72
+ if (typeof data !== "object" || data === null || Array.isArray(data))
73
+ return null;
74
+ const m = data;
75
+ if (m.source !== exports.ADMIN_SOURCE || m.v !== exports.PROTOCOL_VERSION)
76
+ return null;
77
+ switch (m.type) {
78
+ case "hello":
79
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "hello" };
80
+ case "refresh":
81
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "refresh" };
82
+ case "outline":
83
+ if (typeof m.on !== "boolean")
84
+ return null;
85
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "outline", on: m.on };
86
+ case "highlight":
87
+ if (typeof m.entryId !== "string")
88
+ return null;
89
+ if (m.field !== null && typeof m.field !== "string")
90
+ return null;
91
+ return { source: exports.ADMIN_SOURCE, v: 1, type: "highlight", entryId: m.entryId, field: m.field };
92
+ default:
93
+ return null;
94
+ }
95
+ }
96
+ function readyMessage(path, entries) {
97
+ return { source: exports.SITE_SOURCE, v: 1, type: "ready", path, entries };
98
+ }
99
+ function selectMessage(entryId, field) {
100
+ return { source: exports.SITE_SOURCE, v: 1, type: "select", entryId, field };
101
+ }
102
+ function hoverMessage(entryId, field) {
103
+ return entryId === ""
104
+ ? { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId: "", field: null }
105
+ : { source: exports.SITE_SOURCE, v: 1, type: "hover", entryId, field };
106
+ }
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.1",
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
  }