@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 +93 -0
- package/dist/next/attrs.d.ts +24 -0
- package/dist/next/attrs.js +8 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +3 -1
- package/dist/nextjs/index.js +16 -3
- package/dist/overlay/index.d.ts +20 -0
- package/dist/overlay/index.js +357 -0
- package/dist/overlay/protocol.d.ts +91 -0
- package/dist/overlay/protocol.js +106 -0
- package/package.json +9 -2
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;
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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; } });
|
package/dist/nextjs/index.js
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
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.
|
|
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
|
}
|