@websline/cms-view-utils 1.10.2 → 1.15.0

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.
@@ -1 +1 @@
1
- (()=>{var r={BLOCK_ADD:"BLOCK_ADD",BLOCK_COMMENT:"BLOCK_COMMENT",BLOCK_DELETE:"BLOCK_DELETE",BLOCK_EDIT:"BLOCK_EDIT",BLOCK_POSITION:"BLOCK_POSITION",BLOCK_SHOW:"BLOCK_SHOW",BLOCK_HIDE:"BLOCK_HIDE",PATH_CHANGED:"PATH_CHANGED",PATH_ON_LOAD:"PATH_ON_LOAD",IFRAME_DOM_LOADED:"IFRAME_DOM_LOADED",COMMENT_COUNTS:"COMMENT_COUNTS",CURRENT_BLOCK_EDIT:"CURRENT_BLOCK_EDIT",SCROLL_TO_BLOCK:"SCROLL_TO_BLOCK"},_=(i,O={})=>{i&&window.parent!==window&&window.parent.postMessage({type:i,payload:O},"*")};(function(){if(window.parent===window||window.__PREVIEW_BRIDGE_INITIALIZED__)return;window.__PREVIEW_BRIDGE_INITIALIZED__=!0;let i=t=>t.metaKey||t.ctrlKey||t.shiftKey,O=t=>{let n=t.target.closest("a[href]");if(!n||n.target==="_blank"||i(t))return;let o=new URL(n.href,window.location.origin);o.origin===window.location.origin&&(t.preventDefault(),t.stopPropagation(),_(r.PATH_CHANGED,{path:o.pathname}))};document.addEventListener("click",O,!0),_(r.PATH_ON_LOAD,{path:window.location.pathname}),window.addEventListener("load",()=>{_(r.IFRAME_DOM_LOADED)});let L=t=>{let n=`[data-cms-block-id="${t}"]`,o=0,e=10,a=setInterval(()=>{let E=document.querySelector(n);E&&(E.scrollIntoView({behavior:"smooth",block:"start"}),clearInterval(a)),++o>=e&&clearInterval(a)},100)};window.addEventListener("message",t=>{let{type:n,payload:o}=t.data||{};if(n&&n===r.SCROLL_TO_BLOCK){let{draftBlockUuid:e}=o||{};if(!e)return;L(e)}})})();})();
1
+ (()=>{var _={BLOCK_ADD:"BLOCK_ADD",BLOCK_COMMENT:"BLOCK_COMMENT",BLOCK_DELETE:"BLOCK_DELETE",BLOCK_EDIT:"BLOCK_EDIT",BLOCK_POSITION:"BLOCK_POSITION",BLOCK_SHOW:"BLOCK_SHOW",BLOCK_HIDE:"BLOCK_HIDE",PATH_CHANGED:"PATH_CHANGED",PATH_ON_LOAD:"PATH_ON_LOAD",IFRAME_DOM_LOADED:"IFRAME_DOM_LOADED",COMMENT_COUNTS:"COMMENT_COUNTS",CONTENT_READY:"CONTENT_READY",CONTENT_CHANGED:"CONTENT_CHANGED",CONTENT_PREVIEW:"CONTENT_PREVIEW",CONTENT_APPLIED:"CONTENT_APPLIED",CONTENT_FAILED:"CONTENT_FAILED",CURRENT_BLOCK_EDIT:"CURRENT_BLOCK_EDIT",SCROLL_TO_BLOCK:"SCROLL_TO_BLOCK"},r=(o,i={})=>{o&&window.parent!==window&&window.parent.postMessage({type:o,payload:i},"*")};(function(){if(window.parent===window||window.__PREVIEW_BRIDGE_INITIALIZED__)return;window.__PREVIEW_BRIDGE_INITIALIZED__=!0;let o=t=>t.metaKey||t.ctrlKey||t.shiftKey,i=t=>{let n=t.target.closest("a[href]");if(!n||n.target==="_blank"||o(t))return;let E=new URL(n.href,window.location.origin);E.origin===window.location.origin&&(t.preventDefault(),t.stopPropagation(),r(_.PATH_CHANGED,{path:E.pathname}))};document.addEventListener("click",i,!0),r(_.PATH_ON_LOAD,{path:window.location.pathname}),window.addEventListener("load",()=>{r(_.IFRAME_DOM_LOADED)});let C=t=>{let n=`[data-cms-block-id="${t}"]`,E=0,O=10,e=setInterval(()=>{let T=document.querySelector(n);T&&(T.scrollIntoView({behavior:"smooth",block:"start"}),clearInterval(e)),++E>=O&&clearInterval(e)},100)};window.addEventListener("message",t=>{let{type:n,payload:E}=t.data||{};if(n&&n===_.SCROLL_TO_BLOCK){let{draftBlockUuid:O}=E||{};if(!O)return;C(O)}})})();})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@websline/cms-view-utils",
3
- "version": "1.10.2",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -18,21 +18,22 @@
18
18
  "./dist/*": "./dist/*"
19
19
  },
20
20
  "dependencies": {
21
+ "altcha-lib": "2.6.0",
21
22
  "jsonwebtoken": "^9.0.3",
22
23
  "tailwind-merge": "^3.7.0"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@eslint/compat": "^2.1.1",
26
27
  "@eslint/js": "^10.0.1",
27
- "eslint": "^10.11.0",
28
+ "eslint": "^10.12.0",
28
29
  "eslint-config-prettier": "^10.1.8",
29
30
  "eslint-plugin-svelte": "^3.23.0",
30
- "globals": "^17.12.0",
31
- "prettier": "^3.9.8",
31
+ "globals": "^17.13.0",
32
+ "prettier": "^3.9.9",
32
33
  "prettier-plugin-svelte": "^4.1.1",
33
34
  "prettier-plugin-tailwindcss": "^0.8.1",
34
35
  "tsup": "^8.5.1",
35
- "vitest": "^5.0.1"
36
+ "vitest": "^5.0.3"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "astro": ">=5",
@@ -108,6 +108,7 @@ const createAdRunner = ({
108
108
  const occupied = new Map();
109
109
  const teardowns = [];
110
110
  const counted = new Set();
111
+ const sent = new Set();
111
112
  const shown = [];
112
113
 
113
114
  /**
@@ -218,11 +219,24 @@ const createAdRunner = ({
218
219
  if (!ad.persistent) release(ad);
219
220
  },
220
221
 
222
+ /**
223
+ * The ad's form was sent: its click, suppressed like one, but it stays on
224
+ * screen with the thanks until the visitor closes it.
225
+ */
226
+ sent(ad) {
227
+ if (!ad || sent.has(ad.uuid)) return;
228
+
229
+ sent.add(ad.uuid);
230
+ track(ad.uuid, "click");
231
+ dismissals.record(ad.uuid, Date.now());
232
+ },
233
+
221
234
  /** An ad that is not closable has no close button, so nothing to dismiss. */
222
235
  dismiss(ad) {
223
236
  if (!ad || ad.closable === false) return;
224
237
 
225
- track(ad.uuid, "close");
238
+ // Closing the thanks of a sent form turns nothing down.
239
+ if (!sent.has(ad.uuid)) track(ad.uuid, "close");
226
240
  release(ad);
227
241
  },
228
242
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Props for one block, or null for an unknown block type. Blocks without data are
3
+ * normalized here so every mapper in a site's registry can read `data`
4
+ * unconditionally.
5
+ */
6
+ const resolveBlockProps = (registry, block, context = {}) => {
7
+ const entry = registry?.[block?.identifier];
8
+
9
+ if (!entry) return null;
10
+
11
+ return entry.props({ ...block, data: block?.data ?? {} }, context);
12
+ };
13
+
14
+ /**
15
+ * A registry entry declares `hydrate` to say the block has to run client-side on the
16
+ * live site. Astro resolves a client directive at build time and needs its component
17
+ * by name, so the directive itself can only live in the site's live renderer — a
18
+ * component read from a registry cannot carry one.
19
+ *
20
+ * Call this where the by-name lines end: reaching it means the block declared
21
+ * hydration that nobody wired, which would render server-only and lose its client
22
+ * behaviour without a single error. Loud while developing, silent in production.
23
+ */
24
+ const assertHydrationWired = (block, entry) => {
25
+ if (!import.meta.env.DEV) return;
26
+ if (!entry?.hydrate) return;
27
+
28
+ throw new Error(
29
+ `Block "${block?.identifier}" declares hydrate: "${entry.hydrate}" but has no line ` +
30
+ "in the site's live renderer, so it would render server-only.",
31
+ );
32
+ };
33
+
34
+ export { assertHydrationWired, resolveBlockProps };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Fetch a fresh ALTCHA spam challenge for one form; needed while the form's `spam.captcha` is `true`.
3
+ *
4
+ * @param {object} options
5
+ * @param {string} options.uuid - UUID of the form.
6
+ * @returns {Promise<object>} The challenge, to solve and send as `altcha` with the submit. It expires after ten minutes, names its form and stores one entry only.
7
+ */
8
+ const fetchFormChallenge = async ({ uuid }) => {
9
+ const response = await fetch(`/api/cms/api/public/forms/${uuid}/challenge`, {
10
+ method: "GET",
11
+ cache: "no-store",
12
+ });
13
+
14
+ if (!response.ok) {
15
+ throw new Error(`Challenge failed with ${response.status}`);
16
+ }
17
+
18
+ return response.json();
19
+ };
20
+
21
+ export { fetchFormChallenge };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Submit a form's values; the CMS checks them against the live form and stores the entry.
3
+ *
4
+ * @param {object} options
5
+ * @param {string} options.uuid - UUID of the form.
6
+ * @param {string} options.locale - Language the visitor filled the form in; decides the texts of the answer and of the confirmation mail.
7
+ * @param {Record<string, unknown>} options.values - Keyed by field `name`: text, textarea, email, phone, select → string; checkbox → array of option values, or boolean without options; date → `YYYY-MM-DD`; daterange → `{ from, to }`; persons → `{ adults, children: [age] }`; room, package → the option `value`.
8
+ * @param {string} [options.honeypot] - Content of the hidden honeypot input; empty for a real visitor.
9
+ * @param {boolean} [options.consent] - `true` once the guest agreed to the privacy policy; required while the form has `consent`.
10
+ * @param {{ challenge: object, solution: object }} [options.altcha] - A solved challenge from `fetchFormChallenge`; required while the form's `spam.captcha` is `true`. Missing, wrong, expired or already used, the answer is `422` with the error field `altcha`.
11
+ * @param {{ path?: string, adUuid?: string }} [options.source] - Where the form was sent from: the page path without leading slash and language segment, and the ad that opened the form.
12
+ * @returns {Promise<{ status: number, data: object }>} `201` with `{ mode: "redirect", url }` or `{ mode: "message", message }`; `422` with `errors: [{ field, code }]`; `429` once the visitor sent too many; `404` for a form that is gone or inactive.
13
+ */
14
+ const submitForm = async ({
15
+ uuid,
16
+ locale,
17
+ values,
18
+ honeypot,
19
+ consent,
20
+ altcha,
21
+ source,
22
+ }) => {
23
+ const response = await fetch(`/api/cms/api/public/forms/${uuid}/submissions`, {
24
+ method: "POST",
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({ locale, values, honeypot, consent, altcha, source }),
27
+ });
28
+
29
+ return { status: response.status, data: await response.json().catch(() => ({})) };
30
+ };
31
+
32
+ export { submitForm };
@@ -19,7 +19,11 @@ const fetchPage = async (context) => {
19
19
  const draftUuid = resolveDraftUuidFromToken(editorToken);
20
20
  const isCMSEditRoute = editorToken && draftUuid;
21
21
 
22
- const cmsUrl = buildCmsPageUrl({ draftUuid, path });
22
+ const cmsUrl = buildCmsPageUrl({
23
+ draftUuid,
24
+ path,
25
+ searchParams: url.searchParams,
26
+ });
23
27
 
24
28
  const response = await fetch(cmsUrl, {
25
29
  method: "GET",
@@ -56,6 +60,8 @@ const fetchPage = async (context) => {
56
60
  }
57
61
  if (isCMSEditRoute) {
58
62
  context.locals._edit = true;
63
+ // The editor's client-side renderer reloads the draft through the CMS proxy.
64
+ context.locals._draftUuid = draftUuid;
59
65
  }
60
66
  };
61
67
 
@@ -4,6 +4,30 @@ import { HttpError } from "../shared/errors.js";
4
4
  /** The events endpoint answers this, and a Response with a body on it throws. */
5
5
  const NO_CONTENT = 204;
6
6
 
7
+ /**
8
+ * The visitor's IP, behind nginx or behind CloudFront. nginx always overwrites
9
+ * `X-Real-IP`, so it wins; CloudFront names the viewer as `ip:port` and must not
10
+ * pass a viewer-sent `X-Real-IP` through.
11
+ */
12
+ const visitorIpOf = (headers) => {
13
+ const realIp = headers.get("x-real-ip");
14
+ if (realIp) return realIp;
15
+
16
+ const viewer = headers.get("cloudfront-viewer-address") ?? "";
17
+ const portAt = viewer.lastIndexOf(":");
18
+
19
+ return portAt > 0 ? viewer.slice(0, portAt) : viewer;
20
+ };
21
+
22
+ /**
23
+ * A path the allowlist can judge as it will be requested. `fetch` turns `\` into
24
+ * `/` and resolves dot segments, so `forms/..\..\x/submissions` would match the
25
+ * list and still reach another route.
26
+ */
27
+ const isPlainPath = (path) =>
28
+ !/[\\%]/.test(path) &&
29
+ !path.split("/").some((segment) => segment === "." || segment === "..");
30
+
7
31
  const proxyToCms = async (context, { allow } = {}) => {
8
32
  const cmsBase = import.meta.env.CMS_URL;
9
33
 
@@ -17,7 +41,7 @@ const proxyToCms = async (context, { allow } = {}) => {
17
41
  // A write forwards the site's CMS token, and that token may carry scopes far
18
42
  // beyond page delivery. Writes therefore reach only the paths named by the
19
43
  // route that opened them, and everything else looks like it is not there.
20
- if (allow && !allow(path)) {
44
+ if (allow && (!isPlainPath(path) || !allow(path))) {
21
45
  throw new HttpError(404, "Not Found");
22
46
  }
23
47
  const url = new URL(request.url);
@@ -33,12 +57,17 @@ const proxyToCms = async (context, { allow } = {}) => {
33
57
  ? {
34
58
  ...buildCmsHeaders(request),
35
59
  "content-type": request.headers.get("content-type") ?? "application/json",
60
+ "cms-visitor-ip": visitorIpOf(request.headers),
36
61
  }
37
62
  : buildCmsHeaders(request),
38
63
  ...(hasBody ? { body: await request.text() } : {}),
39
64
  });
40
65
 
41
- if (!response.ok) {
66
+ // A rejected write answers the visitor directly — a form shows the field errors
67
+ // of a 422 — so its body has to reach the browser.
68
+ const isRejectedWrite = hasBody && response.status >= 400 && response.status < 500;
69
+
70
+ if (!response.ok && !isRejectedWrite) {
42
71
  throw new HttpError(response.status, "CMS Error");
43
72
  }
44
73
 
@@ -47,11 +76,15 @@ const proxyToCms = async (context, { allow } = {}) => {
47
76
  }
48
77
 
49
78
  const data = await response.text();
79
+ // nginx caches every GET for minutes unless told not to; a spam challenge,
80
+ // for one, must reach each visitor fresh.
81
+ const noStore = /no-store/i.test(response.headers.get("cache-control") ?? "");
50
82
 
51
83
  return new Response(data, {
52
84
  status: response.status,
53
85
  headers: {
54
86
  "content-type": response.headers.get("content-type") ?? "application/json",
87
+ ...(noStore ? { "cache-control": "no-store" } : {}),
55
88
  },
56
89
  });
57
90
  };
@@ -1,13 +1,26 @@
1
1
  const CMS_BASE_URL = import.meta.env.CMS_URL;
2
2
 
3
- const buildCmsPageUrl = ({ draftUuid, path }) => {
3
+ // The editor's own parameters say nothing about the page.
4
+ const EDITOR_PARAMS = ["editorToken", "cmsLocale"];
5
+
6
+ /**
7
+ * The page's own query string goes along, so forms on the page come pre-filled
8
+ * from the link, e.g. the quick bar's `?arrival=…&departure=…`.
9
+ *
10
+ * @param {{ draftUuid?: string, path: string, searchParams?: URLSearchParams }} options
11
+ */
12
+ const buildCmsPageUrl = ({ draftUuid, path, searchParams }) => {
13
+ const query = new URLSearchParams(searchParams);
14
+ EDITOR_PARAMS.forEach((name) => query.delete(name));
15
+ const search = query.size ? `?${query}` : "";
16
+
4
17
  if (draftUuid) {
5
- return `${CMS_BASE_URL}/api/public/pages/preview/${draftUuid}`;
18
+ return `${CMS_BASE_URL}/api/public/pages/preview/${draftUuid}${search}`;
6
19
  }
7
20
 
8
21
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
9
22
 
10
- return `${CMS_BASE_URL}/api/public/pages${normalizedPath}`;
23
+ return `${CMS_BASE_URL}/api/public/pages${normalizedPath}${search}`;
11
24
  };
12
25
 
13
26
  const buildCmsAdsUrl = ({ locale, path }) => {
@@ -0,0 +1,50 @@
1
+ <script>
2
+ import AddBlockPlaceholder from "./AddBlockPlaceholder.svelte";
3
+ import BlockEditSlot from "./BlockEditSlot.svelte";
4
+ import { createEditorContent } from "../editor/editorContent.svelte.js";
5
+ import { resolveBlockProps } from "../blocks/registry.js";
6
+
7
+ let {
8
+ autoTranslate = false,
9
+ cmsLocale = "de",
10
+ content = [],
11
+ draftUuid,
12
+ locale = "de",
13
+ placeholderClass = "",
14
+ registry = {},
15
+ spacingValues,
16
+ } = $props();
17
+
18
+ // Server-rendered on first paint, then this island owns the list: the CMS signals
19
+ // a change, the store reloads the blocks, and only what actually differs is
20
+ // re-rendered. No page reload, no block swapped as HTML.
21
+ //
22
+ // Seeding from the props once is the point — Astro hands them over at hydration and
23
+ // never changes them again.
24
+ // svelte-ignore state_referenced_locally
25
+ const editorContent = createEditorContent(content, { draftUuid });
26
+
27
+ let blocks = $derived(editorContent.blocks);
28
+ </script>
29
+
30
+ {#each blocks as block, position (block.uuid)}
31
+ {@const entry = registry[block.identifier]}
32
+
33
+ <BlockEditSlot
34
+ {autoTranslate}
35
+ {block}
36
+ component={entry?.component}
37
+ locale={cmsLocale}
38
+ {position}
39
+ props={resolveBlockProps(registry, block, { locale })}
40
+ schema={entry?.schema}
41
+ {spacingValues} />
42
+ {/each}
43
+
44
+ <div class={`mt-10 flex w-full items-center justify-center px-4 ${placeholderClass}`}>
45
+ <AddBlockPlaceholder
46
+ disabled={autoTranslate}
47
+ class="w-full"
48
+ countBlockItems={blocks.length}
49
+ locale={cmsLocale} />
50
+ </div>
@@ -0,0 +1,47 @@
1
+ <script>
2
+ import BlockWrapper from "./BlockWrapper.svelte";
3
+ import EditableBlock from "./EditableBlock.svelte";
4
+ import { isValid } from "../editor/blockValidation.js";
5
+
6
+ let {
7
+ autoTranslate = false,
8
+ block,
9
+ component: Component,
10
+ locale = "de",
11
+ position,
12
+ props,
13
+ schema,
14
+ spacingValues,
15
+ } = $props();
16
+
17
+ // A block without a schema cannot be checked — child-block types have none. A type the
18
+ // site does not know is marked invalid, so the author sees it without its data.
19
+ let isValidBlock = $derived(
20
+ Component ? (schema ? isValid(schema, block) : true) : false,
21
+ );
22
+
23
+ const labels = {
24
+ de: { failed: "Block konnte nicht dargestellt werden" },
25
+ en: { failed: "Block could not be rendered" },
26
+ };
27
+
28
+ let t = $derived(labels[locale] ?? labels.de);
29
+ </script>
30
+
31
+ <BlockWrapper {block} {spacingValues}>
32
+ <EditableBlock {autoTranslate} {block} {isValidBlock} {locale} {position}>
33
+ {#if Component}
34
+ <!-- The editor re-renders on every change, so one throwing block must not take
35
+ the rest of the list with it. -->
36
+ <svelte:boundary onerror={(error) => console.error(block?.identifier, error)}>
37
+ <Component {...props ?? {}} />
38
+
39
+ {#snippet failed(error)}
40
+ <div class="mx-auto flex max-w-300 justify-center text-[#ff8b8b]">
41
+ {t.failed}: {error?.message ?? error}
42
+ </div>
43
+ {/snippet}
44
+ </svelte:boundary>
45
+ {/if}
46
+ </EditableBlock>
47
+ </BlockWrapper>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import BlockWrapper from "./BlockWrapper.svelte";
3
+ import { isValid } from "../editor/blockValidation.js";
4
+
5
+ const { block, schema, spacingValues } = Astro.props;
6
+
7
+ // A block without a schema cannot be checked — child-block types have none.
8
+ const isValidBlock = schema ? isValid(schema, block) : true;
9
+ ---
10
+
11
+ {
12
+ isValidBlock && (
13
+ <BlockWrapper {block} {spacingValues}>
14
+ <slot />
15
+ </BlockWrapper>
16
+ )
17
+ }
@@ -16,7 +16,9 @@
16
16
  const buildSrcset = (source) =>
17
17
  source.url2x ? `${source.url} 1x, ${source.url2x} 2x` : source.url;
18
18
 
19
- const data = (() => {
19
+ // Derived, not computed once: in the editor this component keeps its instance
20
+ // while the block's image changes underneath it.
21
+ const data = $derived.by(() => {
20
22
  if (!image || !image.formats) {
21
23
  return {
22
24
  resolved: [],
@@ -75,7 +77,7 @@
75
77
  const alt = image.alt ?? image.title ?? "";
76
78
 
77
79
  return { resolved, fallbackFormat, fallbackSrc, alt };
78
- })();
80
+ });
79
81
  </script>
80
82
 
81
83
  {#if data.resolved.length}
@@ -0,0 +1,168 @@
1
+ import { EVENTS, notifyCMS } from "./notify.js";
2
+
3
+ const fetchDraftContent = async (draftUuid) => {
4
+ const response = await fetch(`/api/cms/api/public/pages/preview/${draftUuid}`);
5
+
6
+ if (!response.ok) {
7
+ throw new Error(`Draft content request failed: ${response.status}`);
8
+ }
9
+
10
+ const data = await response.json();
11
+
12
+ return Array.isArray(data?.content) ? data.content : [];
13
+ };
14
+
15
+ const patchKey = ({ draftBlockUuid, itemUuid, identifier }) =>
16
+ `${draftBlockUuid}|${itemUuid ?? ""}|${identifier}`;
17
+
18
+ const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
19
+
20
+ /**
21
+ * Only primitive values are patched live. Structured fields — media, filters,
22
+ * dynamic content — reach the block fully resolved by the CMS; patching them from
23
+ * authoring data would render the block wrong or fail its schema, so they wait for
24
+ * the authoritative reload.
25
+ */
26
+ const isLivePatchable = (current, next) =>
27
+ typeof next !== "object" && (current == null || typeof current !== "object");
28
+
29
+ const withData = (entity, identifier, value) => ({
30
+ ...entity,
31
+ data: { ...(entity.data ?? {}), [identifier]: value },
32
+ });
33
+
34
+ /** Repeater items nest arbitrarily deep, so the item is searched by uuid. */
35
+ const patchItem = (items, itemUuid, identifier, value) =>
36
+ (items ?? []).map((item) => {
37
+ if (item?.uuid === itemUuid) return withData(item, identifier, value);
38
+ if (!item?.blocks?.length) return item;
39
+
40
+ return { ...item, blocks: patchItem(item.blocks, itemUuid, identifier, value) };
41
+ });
42
+
43
+ const applyPatch = (blocks, patch) =>
44
+ blocks.map((block) => {
45
+ if (block?.uuid !== patch.draftBlockUuid) return block;
46
+
47
+ if (!patch.itemUuid) return withData(block, patch.identifier, patch.value);
48
+
49
+ return {
50
+ ...block,
51
+ blocks: patchItem(block.blocks, patch.itemUuid, patch.identifier, patch.value),
52
+ };
53
+ });
54
+
55
+ const readValue = (blocks, patch) => {
56
+ const block = blocks.find((entry) => entry?.uuid === patch.draftBlockUuid);
57
+
58
+ if (!block) return undefined;
59
+ if (!patch.itemUuid) return block.data?.[patch.identifier];
60
+
61
+ const find = (items) => {
62
+ for (const item of items ?? []) {
63
+ if (item?.uuid === patch.itemUuid) return item;
64
+
65
+ const nested = find(item?.blocks);
66
+ if (nested) return nested;
67
+ }
68
+ };
69
+
70
+ return find(block.blocks)?.data?.[patch.identifier];
71
+ };
72
+
73
+ /**
74
+ * Owns the block list while the editor is open.
75
+ *
76
+ * Two channels feed it. Every keystroke arrives as a preview patch and is applied
77
+ * without touching the network, so typing shows up immediately. The CMS writes to the
78
+ * database on its own delay and only then signals a change; the blocks are reloaded
79
+ * from the CMS, which keeps dynamic content server-resolved.
80
+ *
81
+ * A reload can answer with text older than what the user has typed since, so pending
82
+ * patches are re-applied on top of it. A patch is released once the CMS reports that it
83
+ * has persisted exactly that value: from then on the server's answer wins, including
84
+ * the rewrites it does on the way — internal links, sanitizing, headline levels.
85
+ *
86
+ * Call this during component setup; it installs its own message listener.
87
+ */
88
+ const createEditorContent = (initialBlocks, { draftUuid } = {}) => {
89
+ let blocks = $state([...(initialBlocks ?? [])]);
90
+ let latestRequest = 0;
91
+ const pendingPatches = new Map();
92
+
93
+ /**
94
+ * The write that just landed carries what was typed last, so that patch has done
95
+ * its job. A newer keystroke keeps its own patch — its write is still pending.
96
+ */
97
+ const release = (persisted) => {
98
+ if (!persisted?.draftBlockUuid || !persisted?.identifier) return;
99
+
100
+ const key = patchKey(persisted);
101
+
102
+ if (sameValue(pendingPatches.get(key)?.value, persisted.value)) {
103
+ pendingPatches.delete(key);
104
+ }
105
+ };
106
+
107
+ const settle = (next) => {
108
+ for (const patch of pendingPatches.values()) {
109
+ next = applyPatch(next, patch);
110
+ }
111
+
112
+ return next;
113
+ };
114
+
115
+ const reload = async (persisted) => {
116
+ if (!draftUuid) return;
117
+
118
+ release(persisted);
119
+
120
+ const request = ++latestRequest;
121
+
122
+ try {
123
+ const next = await fetchDraftContent(draftUuid);
124
+
125
+ // A newer signal already sent its own request — that answer wins.
126
+ if (request !== latestRequest) return;
127
+
128
+ blocks = settle(next);
129
+ notifyCMS(EVENTS.CONTENT_APPLIED);
130
+ } catch {
131
+ notifyCMS(EVENTS.CONTENT_FAILED);
132
+ }
133
+ };
134
+
135
+ const preview = (patch) => {
136
+ if (!patch?.draftBlockUuid || !patch?.identifier) return;
137
+ if (!isLivePatchable(readValue(blocks, patch), patch.value)) return;
138
+
139
+ pendingPatches.set(patchKey(patch), patch);
140
+ blocks = applyPatch(blocks, patch);
141
+ };
142
+
143
+ const handleMessage = (event) => {
144
+ const { type, payload } = event.data || {};
145
+
146
+ if (type === EVENTS.CONTENT_CHANGED) reload(payload?.persisted);
147
+ if (type === EVENTS.CONTENT_PREVIEW) preview(payload);
148
+ };
149
+
150
+ $effect(() => {
151
+ window.addEventListener("message", handleMessage);
152
+
153
+ // Tells the CMS that this preview renders the block list itself, so it can send
154
+ // changes as a signal. A website that has not been rebuilt yet never says this
155
+ // and keeps getting a full reload.
156
+ notifyCMS(EVENTS.CONTENT_READY);
157
+
158
+ return () => window.removeEventListener("message", handleMessage);
159
+ });
160
+
161
+ return {
162
+ get blocks() {
163
+ return blocks;
164
+ },
165
+ };
166
+ };
167
+
168
+ export { createEditorContent };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The props the editor island needs, read from the CMS-owned locals. Which local
3
+ * carries what stays here: a site that reads the wrong one gets a preview that
4
+ * silently stops updating.
5
+ */
6
+ const editorProps = (locals = {}) => ({
7
+ autoTranslate: locals.page?.autoTranslate,
8
+ cmsLocale: locals._cmsLocale,
9
+ content: locals.content,
10
+ draftUuid: locals._draftUuid,
11
+ locale: locals.page?.locale,
12
+ });
13
+
14
+ export { editorProps };
@@ -10,6 +10,11 @@ const EVENTS = {
10
10
  PATH_ON_LOAD: "PATH_ON_LOAD",
11
11
  IFRAME_DOM_LOADED: "IFRAME_DOM_LOADED",
12
12
  COMMENT_COUNTS: "COMMENT_COUNTS",
13
+ CONTENT_READY: "CONTENT_READY",
14
+ CONTENT_CHANGED: "CONTENT_CHANGED",
15
+ CONTENT_PREVIEW: "CONTENT_PREVIEW",
16
+ CONTENT_APPLIED: "CONTENT_APPLIED",
17
+ CONTENT_FAILED: "CONTENT_FAILED",
13
18
  CURRENT_BLOCK_EDIT: "CURRENT_BLOCK_EDIT",
14
19
  SCROLL_TO_BLOCK: "SCROLL_TO_BLOCK",
15
20
  };