@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.
- package/dist/previewBridge.global.js +1 -1
- package/package.json +6 -5
- package/src/ads/createAdRunner.js +15 -1
- package/src/blocks/registry.js +34 -0
- package/src/client/fetchFormChallenge.js +21 -0
- package/src/client/submitForm.js +32 -0
- package/src/cms/fetchPage.js +7 -1
- package/src/cms/proxyToCms.js +35 -2
- package/src/cms/urlResolver.js +16 -3
- package/src/components/BlockEditList.svelte +50 -0
- package/src/components/BlockEditSlot.svelte +47 -0
- package/src/components/BlockSlot.astro +17 -0
- package/src/components/Picture.svelte +4 -2
- package/src/editor/editorContent.svelte.js +168 -0
- package/src/editor/editorProps.js +14 -0
- package/src/editor/notify.js +5 -0
- package/src/forms/createFormController.js +326 -0
- package/src/forms/fieldStates.js +175 -0
- package/src/forms/formControls.js +58 -0
- package/src/forms/formTexts.js +173 -0
- package/src/forms/formValues.js +101 -0
- package/src/forms/spamChallenge.js +61 -0
- package/src/index.js +12 -0
- package/src/routes/cmsProxy.js +9 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{var
|
|
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.
|
|
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.
|
|
28
|
+
"eslint": "^10.12.0",
|
|
28
29
|
"eslint-config-prettier": "^10.1.8",
|
|
29
30
|
"eslint-plugin-svelte": "^3.23.0",
|
|
30
|
-
"globals": "^17.
|
|
31
|
-
"prettier": "^3.9.
|
|
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.
|
|
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
|
-
|
|
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 };
|
package/src/cms/fetchPage.js
CHANGED
|
@@ -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({
|
|
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
|
|
package/src/cms/proxyToCms.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/cms/urlResolver.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
const CMS_BASE_URL = import.meta.env.CMS_URL;
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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 };
|
package/src/editor/notify.js
CHANGED
|
@@ -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
|
};
|