@websline/cms-view-utils 1.14.0 → 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 +1 -1
- package/src/blocks/registry.js +34 -0
- package/src/cms/fetchPage.js +2 -0
- 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/index.js +6 -0
|
@@ -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
|
@@ -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 };
|
package/src/cms/fetchPage.js
CHANGED
|
@@ -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
|
};
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { isValid } from "./editor/blockValidation.js";
|
|
2
|
+
export { assertHydrationWired, resolveBlockProps } from "./blocks/registry.js";
|
|
3
|
+
export { editorProps } from "./editor/editorProps.js";
|
|
4
|
+
export { createEditorContent } from "./editor/editorContent.svelte.js";
|
|
2
5
|
export { fetchSimilarRooms } from "./client/fetchSimilarRooms.js";
|
|
3
6
|
export { fetchSimilarPackages } from "./client/fetchSimilarPackages.js";
|
|
4
7
|
export { fetchFaqs } from "./client/fetchFaqs.js";
|
|
@@ -22,6 +25,9 @@ export { createFormTexts } from "./forms/formTexts.js";
|
|
|
22
25
|
export { FORM_CONTROLS } from "./forms/formControls.js";
|
|
23
26
|
|
|
24
27
|
export { default as AddBlockPlaceholder } from "./components/AddBlockPlaceholder.svelte";
|
|
28
|
+
export { default as BlockEditList } from "./components/BlockEditList.svelte";
|
|
29
|
+
export { default as BlockEditSlot } from "./components/BlockEditSlot.svelte";
|
|
30
|
+
export { default as BlockSlot } from "./components/BlockSlot.astro";
|
|
25
31
|
export { default as BlockWrapper } from "./components/BlockWrapper.svelte";
|
|
26
32
|
export { default as EditableBlock } from "./components/EditableBlock.svelte";
|
|
27
33
|
export { default as PageContent } from "./components/PageContent.svelte";
|