@eldrajs/theme-vue 0.0.0-stage → 0.1.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/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +30 -2
- package/dist/index.d.ts +542 -0
- package/dist/index.js +1049 -0
- package/package.json +54 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import { Fragment, computed, defineAsyncComponent, defineComponent, h, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, provide, reactive, readonly, ref, shallowRef, toValue, watch, watchEffect } from "vue";
|
|
2
|
+
import { EMPTY_THEME_MESSAGES, sanitizeLocaleMessages } from "@eldrajs/theme-core/i18n";
|
|
3
|
+
import { DEFAULT_IMAGE_FRAMING, DEFAULT_LAYOUT_BREAKPOINTS, buildRichTextTree, buildRichTextTree as buildRichTextTree$1, buildTemplateBlockRenames, createLayoutRenderModel, createReusableLayoutRenderModel, createTemplateLayoutRenderModel, designTokenRevisionHash, generateDesignTokenCss, hiddenLayoutBreakpoints, imageFramingAttrs, imageFramingStyle, isBlockFieldLocalized, isBlockFieldLocalized as isBlockFieldLocalized$1, layoutIdentityOf, normalizeImageFraming, normalizeThemeDesignTokens, projectEntryDataLocale, registerBlockFields, restampRichTextPositions, safeHref } from "@eldrajs/theme-core";
|
|
4
|
+
import { linkTargetKeys, resolveLink, resolveLink as resolveLink$1, safeLinkHref } from "@eldrajs/theme-core/links";
|
|
5
|
+
import blockImports from "virtual:eldra/blocks";
|
|
6
|
+
import themeManifest from "virtual:eldra/manifest";
|
|
7
|
+
import themeBreakpoints from "virtual:eldra/breakpoints";
|
|
8
|
+
//#region src/context.ts
|
|
9
|
+
const ELDRA_KEY = Symbol.for("eldra.themeContext");
|
|
10
|
+
/**
|
|
11
|
+
* The preview slice of a fresh theme context, reactive and complete. Every
|
|
12
|
+
* adapter that assembles an `EldraContext` by hand — theme-nuxt's runtime
|
|
13
|
+
* plugin, or an equivalent entry for another framework — must build it from
|
|
14
|
+
* here: the hand-written literal had already drifted several fields behind
|
|
15
|
+
* this interface, and a missing field is a runtime error rather than a type
|
|
16
|
+
* error once the object is assembled elsewhere.
|
|
17
|
+
*/
|
|
18
|
+
function createEldraPreviewState() {
|
|
19
|
+
return reactive({
|
|
20
|
+
active: false,
|
|
21
|
+
mode: "preview",
|
|
22
|
+
locale: null,
|
|
23
|
+
sourceDrafts: {},
|
|
24
|
+
drafts: {},
|
|
25
|
+
draftSchemaApiIds: {},
|
|
26
|
+
refreshRevision: 0,
|
|
27
|
+
revision: 0,
|
|
28
|
+
designTokensRevision: 0,
|
|
29
|
+
messagesRevision: 0,
|
|
30
|
+
tokenRevision: 0,
|
|
31
|
+
editorSupportsSlots: false,
|
|
32
|
+
richTextRenderRevision: 0,
|
|
33
|
+
isRichTextRenderDeferred: void 0,
|
|
34
|
+
slotGeometryReporter: void 0
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The link slice of a fresh theme context, reactive and empty. Built here for
|
|
39
|
+
* the same reason `createEldraPreviewState` is: a missing field is a runtime
|
|
40
|
+
* error rather than a type error once the object is assembled elsewhere.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The locale slice of a context with no router behind it — a Storybook story, a unit-test mount, a
|
|
44
|
+
* site whose organisation configures no locales. Every answer is the one-unprefixed-site answer,
|
|
45
|
+
* so a block written against `useEldraLocale()` renders identically in all three.
|
|
46
|
+
*
|
|
47
|
+
* An adapter that *has* routing builds its own (see `@eldrajs/theme-nuxt`'s runtime plugin): the
|
|
48
|
+
* active locale and the paths are derived from the route, which this layer knows nothing about.
|
|
49
|
+
*/
|
|
50
|
+
function createEldraLocaleState() {
|
|
51
|
+
return reactive({
|
|
52
|
+
active: null,
|
|
53
|
+
defaultLocale: null,
|
|
54
|
+
supported: [],
|
|
55
|
+
name: (locale) => locale,
|
|
56
|
+
path: (href) => href,
|
|
57
|
+
switchPath: () => "/",
|
|
58
|
+
select: () => {}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function createEldraLinkState() {
|
|
62
|
+
return reactive({
|
|
63
|
+
pages: [],
|
|
64
|
+
templates: [],
|
|
65
|
+
targets: /* @__PURE__ */ new Map()
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function provideEldra(opts) {
|
|
69
|
+
const context = {
|
|
70
|
+
client: opts.client,
|
|
71
|
+
designTokens: reactive(normalizeThemeDesignTokens(opts.designTokens ?? { colors: {} })),
|
|
72
|
+
messages: reactive(opts.messages ?? EMPTY_THEME_MESSAGES),
|
|
73
|
+
links: createEldraLinkState(),
|
|
74
|
+
locales: createEldraLocaleState(),
|
|
75
|
+
preview: createEldraPreviewState()
|
|
76
|
+
};
|
|
77
|
+
provide(ELDRA_KEY, context);
|
|
78
|
+
return context;
|
|
79
|
+
}
|
|
80
|
+
function useEldra() {
|
|
81
|
+
const context = inject(ELDRA_KEY);
|
|
82
|
+
if (context === void 0) throw new Error("[eldra] useEldra() called without provideEldra() in an ancestor");
|
|
83
|
+
return context;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/useEldraLink.ts
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a `link` field's value against the site's own routes.
|
|
89
|
+
*
|
|
90
|
+
* Call it once per component and keep the returned function — it closes over
|
|
91
|
+
* the reactive link state, so every call reads whatever the adapter has filled
|
|
92
|
+
* in by then, and a row re-resolves when a target arrives or a preview draft
|
|
93
|
+
* changes the value.
|
|
94
|
+
*
|
|
95
|
+
* Outside a themed app (a block rendered in isolation, a story with no
|
|
96
|
+
* provider) it resolves nothing: every value comes back `null`, which a block
|
|
97
|
+
* already has to handle for a target that no longer exists.
|
|
98
|
+
*
|
|
99
|
+
* Every href it hands back is spelled in the **active content locale**
|
|
100
|
+
* (`useEldraLocale()`), so a navigation resolved on `/is-IS/about` points at
|
|
101
|
+
* `/is-IS/products/x` and a visitor cannot be dropped back into the default
|
|
102
|
+
* language by following a link. The rewrite is idempotent, which is what lets a
|
|
103
|
+
* theme's router-link component prefix as well without the two compounding.
|
|
104
|
+
*/
|
|
105
|
+
function useEldraLink() {
|
|
106
|
+
const context = inject(ELDRA_KEY, null);
|
|
107
|
+
if (context === null || context.links === void 0) return () => null;
|
|
108
|
+
return (value) => localized(resolveLink$1(value, context.links), context.locales);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The resolved link with every same-site href under the active locale, children included.
|
|
112
|
+
*
|
|
113
|
+
* Short-circuits on the two states that need no rewrite — no locale state at all, and a page in
|
|
114
|
+
* the locale served at `/` — because that is nearly every page of nearly every site, and a
|
|
115
|
+
* navigation block resolves one of these per row on every render.
|
|
116
|
+
*/
|
|
117
|
+
function localized(link, locales) {
|
|
118
|
+
if (link === null || locales === void 0) return link;
|
|
119
|
+
if (locales.active === null || locales.active === locales.defaultLocale) return link;
|
|
120
|
+
const prefix = locales.path;
|
|
121
|
+
const walk = (one) => ({
|
|
122
|
+
...one,
|
|
123
|
+
href: one.href === null ? null : prefix(one.href),
|
|
124
|
+
children: one.children.map(walk)
|
|
125
|
+
});
|
|
126
|
+
return walk(link);
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/useEldraLocale.ts
|
|
130
|
+
/**
|
|
131
|
+
* The locale this page is in, the locales the site serves, and how to spell a destination in one
|
|
132
|
+
* of them.
|
|
133
|
+
*
|
|
134
|
+
* Call it once per component and keep what it returns — the state is reactive, so a template that
|
|
135
|
+
* reads `active` or calls `path()` re-renders when the visitor changes language.
|
|
136
|
+
*
|
|
137
|
+
* Outside a themed app, and on a site whose organisation configures no locales, it answers the
|
|
138
|
+
* one-unprefixed-site answer: `active` and `defaultLocale` `null`, `supported` empty, `path()` the
|
|
139
|
+
* identity. A block can therefore read it unconditionally and render the same markup in a
|
|
140
|
+
* Storybook story as on a single-locale site — which is also why a language switcher's own rule is
|
|
141
|
+
* `supported.length > 1`, never "is there a locale at all".
|
|
142
|
+
*/
|
|
143
|
+
function useEldraLocale() {
|
|
144
|
+
return inject(ELDRA_KEY, null)?.locales ?? INERT;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* One shared inert state, not one per call: it is immutable in every way that matters (nothing
|
|
148
|
+
* writes to it) and a block calling this in `setup()` must not allocate a reactive object per
|
|
149
|
+
* instance.
|
|
150
|
+
*/
|
|
151
|
+
const INERT = createEldraLocaleState();
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/EldraBlockZone.ts
|
|
154
|
+
/** Resolve the schema API id across both supported gateway response shapes. */
|
|
155
|
+
function getBlockSchemaApiId(entry) {
|
|
156
|
+
const direct = entry.schemaApiId;
|
|
157
|
+
if (typeof direct === "string") return direct;
|
|
158
|
+
const schema = entry.schema;
|
|
159
|
+
if (typeof schema === "object" && schema !== null && typeof schema.apiId === "string") return schema.apiId;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const componentCache = /* @__PURE__ */ new Map();
|
|
163
|
+
/** Internal renderer lookup shared by the flat and responsive layout adapters. */
|
|
164
|
+
function getBlockComponent(apiId) {
|
|
165
|
+
const importer = blockImports[apiId];
|
|
166
|
+
if (importer === void 0) return null;
|
|
167
|
+
let component = componentCache.get(apiId);
|
|
168
|
+
if (component === void 0) {
|
|
169
|
+
component = defineAsyncComponent(async () => (await importer()).default);
|
|
170
|
+
componentCache.set(apiId, component);
|
|
171
|
+
}
|
|
172
|
+
return component;
|
|
173
|
+
}
|
|
174
|
+
const EldraBlockZone = defineComponent({
|
|
175
|
+
name: "EldraBlockZone",
|
|
176
|
+
props: { blocks: {
|
|
177
|
+
type: Array,
|
|
178
|
+
required: true
|
|
179
|
+
} },
|
|
180
|
+
setup(props) {
|
|
181
|
+
return () => props.blocks.map((entry) => {
|
|
182
|
+
const apiId = getBlockSchemaApiId(entry);
|
|
183
|
+
const component = apiId === null ? null : getBlockComponent(apiId);
|
|
184
|
+
if (apiId === null || component === null) {
|
|
185
|
+
console.warn(`[eldra] no theme block component for schemaApiId "${String(apiId)}" (entry ${entry.id})`);
|
|
186
|
+
return h("div", {
|
|
187
|
+
key: entry.id,
|
|
188
|
+
style: "display:none",
|
|
189
|
+
"data-eldra-missing-block": apiId ?? "unknown"
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return h("div", {
|
|
193
|
+
key: entry.id,
|
|
194
|
+
"data-eldra-block": entry.id,
|
|
195
|
+
"data-eldra-schema": apiId
|
|
196
|
+
}, [h(component, { entry })]);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/EldraLayout.ts
|
|
202
|
+
/**
|
|
203
|
+
* Marker-mode-only styling for editor slot markers. Injected as a style
|
|
204
|
+
* element only while an editor with the `block-slots` capability is active;
|
|
205
|
+
* static output never emits it, so public layout is unaffected. Markers
|
|
206
|
+
* themselves are inert (pointer-events:none) and aria-hidden.
|
|
207
|
+
*/
|
|
208
|
+
const SLOT_MARKER_CSS = [
|
|
209
|
+
".eldra-slot-marker{",
|
|
210
|
+
"display:inline-block;",
|
|
211
|
+
"max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;",
|
|
212
|
+
"font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;",
|
|
213
|
+
"color:#3f3f46;background:#fafafa;",
|
|
214
|
+
"border:1px dashed #b6dfff;border-radius:3px;",
|
|
215
|
+
"padding:0 6px;margin:4px 0;",
|
|
216
|
+
"}"
|
|
217
|
+
].join("");
|
|
218
|
+
/**
|
|
219
|
+
* Module-scope catalog of every block's declared slots, derived once from the
|
|
220
|
+
* theme manifest. Keys are the same apiIds `getBlockSchemaApiId` resolves, so
|
|
221
|
+
* a rendered block can look up the slot ids its author actually declared.
|
|
222
|
+
*/
|
|
223
|
+
function buildSlotCatalog() {
|
|
224
|
+
const blocks = themeManifest.blocks ?? [];
|
|
225
|
+
const out = {};
|
|
226
|
+
for (const block of blocks) if (block.apiId && Array.isArray(block.slots)) out[block.apiId] = block.slots;
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
const SLOT_CATALOG = buildSlotCatalog();
|
|
230
|
+
function buildTemplateBlockCatalog() {
|
|
231
|
+
const blocks = themeManifest.blocks ?? [];
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const block of blocks) {
|
|
234
|
+
if (typeof block.apiId !== "string" || getBlockComponent(block.apiId) === null) continue;
|
|
235
|
+
const renames = buildTemplateBlockRenames(block.migrations);
|
|
236
|
+
out[block.apiId] = {
|
|
237
|
+
apiId: block.apiId,
|
|
238
|
+
fields: (block.fields ?? []).filter((field) => typeof field.fieldId === "string").map((field) => ({
|
|
239
|
+
fieldId: field.fieldId,
|
|
240
|
+
...Object.hasOwn(field, "default") ? { default: field.default } : {}
|
|
241
|
+
})),
|
|
242
|
+
...Object.keys(renames).length === 0 ? {} : { renames }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
const TEMPLATE_BLOCK_CATALOG = buildTemplateBlockCatalog();
|
|
248
|
+
/**
|
|
249
|
+
* The theme's own tablet/normal layout breakpoints, already resolved and
|
|
250
|
+
* validated by Core (`resolveLayoutBreakpoints`) at build time — this
|
|
251
|
+
* binding only reads it through. Carried by the separate
|
|
252
|
+
* `virtual:eldra/breakpoints` module, not `virtual:eldra/manifest`: the
|
|
253
|
+
* manifest is exactly what is persisted/uploaded, and Core's ingest rejects
|
|
254
|
+
* an unrecognized top-level key on that file. Falls back to the defaults for
|
|
255
|
+
* a build whose plugin predates this virtual module (an unresolved import
|
|
256
|
+
* would throw, not silently omit the field the way an optional manifest key
|
|
257
|
+
* would have).
|
|
258
|
+
*/
|
|
259
|
+
const MANIFEST_BREAKPOINTS$1 = themeBreakpoints ?? DEFAULT_LAYOUT_BREAKPOINTS;
|
|
260
|
+
function indexEntries(entries) {
|
|
261
|
+
const indexed = /* @__PURE__ */ new Map();
|
|
262
|
+
for (const entry of entries) if (!indexed.has(entry.id)) indexed.set(entry.id, entry);
|
|
263
|
+
return indexed;
|
|
264
|
+
}
|
|
265
|
+
function renderIdentity(node) {
|
|
266
|
+
return node.renderId ?? node.id;
|
|
267
|
+
}
|
|
268
|
+
function reusablePlacement(node) {
|
|
269
|
+
if (!("placementId" in node)) return void 0;
|
|
270
|
+
return typeof node.placementId === "string" ? node.placementId : void 0;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* `data-eldra-hidden` — the breakpoints the layout hides this node at, space
|
|
274
|
+
* separated, or nothing when it is visible everywhere.
|
|
275
|
+
*
|
|
276
|
+
* Rendered unconditionally, in preview and on the published site alike: it
|
|
277
|
+
* carries no styling of its own (the generated layout CSS does the hiding), it
|
|
278
|
+
* is identical on the server and on the client, and it is how the overlay finds
|
|
279
|
+
* the nodes to mark when Studio switches the theme into edit mode. Deciding it
|
|
280
|
+
* per bridge mode instead would be a hydration mismatch.
|
|
281
|
+
*/
|
|
282
|
+
function hiddenBreakpoints(style) {
|
|
283
|
+
return hiddenLayoutBreakpoints(style).join(" ") || void 0;
|
|
284
|
+
}
|
|
285
|
+
function renderBlock(node, entries, markerMode) {
|
|
286
|
+
const entry = entries.get(node.entryId);
|
|
287
|
+
const apiId = entry === void 0 ? null : getBlockSchemaApiId(entry);
|
|
288
|
+
const component = apiId === null ? null : getBlockComponent(apiId);
|
|
289
|
+
if (entry === void 0 || apiId === null || component === null) return h("div", {
|
|
290
|
+
key: renderIdentity(node),
|
|
291
|
+
class: node.className,
|
|
292
|
+
hidden: true,
|
|
293
|
+
"data-eldra-layout-node": node.id,
|
|
294
|
+
"data-eldra-hidden": hiddenBreakpoints(node.style),
|
|
295
|
+
"data-eldra-reusable-placement": reusablePlacement(node),
|
|
296
|
+
"data-eldra-missing-block": apiId ?? "unknown"
|
|
297
|
+
});
|
|
298
|
+
const children = [blockWithSlots(node, apiId, entries, markerMode)];
|
|
299
|
+
if (markerMode) for (const def of SLOT_CATALOG[apiId] ?? []) children.push(h("div", {
|
|
300
|
+
key: `slot-marker-${renderIdentity(node)}-${def.id}`,
|
|
301
|
+
class: "eldra-slot-marker",
|
|
302
|
+
"data-eldra-slot-marker": "",
|
|
303
|
+
"data-eldra-slot-id": def.id,
|
|
304
|
+
"data-eldra-layout-node-id": node.id,
|
|
305
|
+
"aria-hidden": "true",
|
|
306
|
+
style: "pointer-events:none"
|
|
307
|
+
}, `${def.label} · ${node.slots?.[def.id]?.length ?? 0}/${def.maxItems}`));
|
|
308
|
+
return h("div", {
|
|
309
|
+
key: renderIdentity(node),
|
|
310
|
+
class: node.className,
|
|
311
|
+
"data-eldra-layout-node": node.id,
|
|
312
|
+
"data-eldra-hidden": hiddenBreakpoints(node.style),
|
|
313
|
+
"data-eldra-reusable-placement": reusablePlacement(node),
|
|
314
|
+
"data-eldra-block": entry.id,
|
|
315
|
+
"data-eldra-schema": apiId
|
|
316
|
+
}, children);
|
|
317
|
+
}
|
|
318
|
+
function renderSlotChildren(host, slotId, slotChildren, entries, markerMode) {
|
|
319
|
+
const hostIdentity = renderIdentity(host);
|
|
320
|
+
return slotChildren.map((child) => {
|
|
321
|
+
const identity = [
|
|
322
|
+
hostIdentity,
|
|
323
|
+
slotId,
|
|
324
|
+
renderIdentity(child)
|
|
325
|
+
].join("\0");
|
|
326
|
+
return h("div", {
|
|
327
|
+
key: identity,
|
|
328
|
+
class: child.className,
|
|
329
|
+
"data-eldra-layout-node": child.id,
|
|
330
|
+
"data-eldra-hidden": hiddenBreakpoints(child.style),
|
|
331
|
+
"data-eldra-slot-id": slotId,
|
|
332
|
+
"data-eldra-block": child.entryId
|
|
333
|
+
}, [renderBlock(child, entries, markerMode)]);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
function blockWithSlots(host, apiId, entries, markerMode) {
|
|
337
|
+
const declared = SLOT_CATALOG[apiId] ?? [];
|
|
338
|
+
const named = {};
|
|
339
|
+
for (const def of declared) {
|
|
340
|
+
const children = host.slots?.[def.id];
|
|
341
|
+
if (children === void 0 || children.length === 0) continue;
|
|
342
|
+
named[def.id] = () => renderSlotChildren(host, def.id, children, entries, markerMode);
|
|
343
|
+
}
|
|
344
|
+
const entry = entries.get(host.entryId);
|
|
345
|
+
const component = getBlockComponent(apiId);
|
|
346
|
+
return h(component, { entry }, named);
|
|
347
|
+
}
|
|
348
|
+
function renderNode(node, entries, markerMode) {
|
|
349
|
+
if (node.type === "template-block") {
|
|
350
|
+
const component = getBlockComponent(node.apiId);
|
|
351
|
+
if (component === null) return h("div", {
|
|
352
|
+
hidden: true,
|
|
353
|
+
"data-eldra-invalid-layout": ""
|
|
354
|
+
});
|
|
355
|
+
return h("div", {
|
|
356
|
+
key: `${node.id}\u0000${node.entry.id}`,
|
|
357
|
+
class: node.className,
|
|
358
|
+
"data-eldra-layout-node": node.id,
|
|
359
|
+
"data-eldra-template-block": node.id,
|
|
360
|
+
"data-eldra-block": node.entry.id,
|
|
361
|
+
"data-eldra-schema": node.apiId
|
|
362
|
+
}, [h(component, { entry: node.entry })]);
|
|
363
|
+
}
|
|
364
|
+
if (node.type === "block") return renderBlock(node, entries, markerMode);
|
|
365
|
+
return h("div", {
|
|
366
|
+
key: renderIdentity(node),
|
|
367
|
+
class: node.className,
|
|
368
|
+
"data-eldra-layout-node": node.id,
|
|
369
|
+
"data-eldra-hidden": hiddenBreakpoints(node.style),
|
|
370
|
+
"data-eldra-reusable-placement": reusablePlacement(node),
|
|
371
|
+
"data-eldra-layout-container": node.type
|
|
372
|
+
}, node.children.map((child) => renderNode(child, entries, markerMode)));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Render a validated responsive layout with the same model and stylesheet used
|
|
376
|
+
* by static generation. Invalid documents and unresolved entry references fail
|
|
377
|
+
* closed before any customer block component is mounted.
|
|
378
|
+
*/
|
|
379
|
+
const EldraLayout = defineComponent({
|
|
380
|
+
name: "EldraLayout",
|
|
381
|
+
props: {
|
|
382
|
+
layout: {
|
|
383
|
+
type: null,
|
|
384
|
+
required: true
|
|
385
|
+
},
|
|
386
|
+
blocks: {
|
|
387
|
+
type: Array,
|
|
388
|
+
required: true
|
|
389
|
+
},
|
|
390
|
+
nonce: {
|
|
391
|
+
type: String,
|
|
392
|
+
required: false,
|
|
393
|
+
default: void 0
|
|
394
|
+
},
|
|
395
|
+
designTokens: {
|
|
396
|
+
type: null,
|
|
397
|
+
required: false,
|
|
398
|
+
default: void 0
|
|
399
|
+
},
|
|
400
|
+
reusableComponentProjection: {
|
|
401
|
+
type: null,
|
|
402
|
+
required: false,
|
|
403
|
+
default: void 0
|
|
404
|
+
},
|
|
405
|
+
templateEntry: {
|
|
406
|
+
type: null,
|
|
407
|
+
required: false,
|
|
408
|
+
default: void 0
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
setup(props) {
|
|
412
|
+
const context = inject(ELDRA_KEY, void 0);
|
|
413
|
+
const markerMode = computed(() => context?.preview.active === true && context?.preview.editorSupportsSlots === true);
|
|
414
|
+
const resolvedTokens = () => props.designTokens !== void 0 ? normalizeThemeDesignTokens(props.designTokens) : context?.designTokens ?? normalizeThemeDesignTokens({ colors: {} });
|
|
415
|
+
const state = computed(() => {
|
|
416
|
+
context?.preview.revision;
|
|
417
|
+
const entries = indexEntries(props.blocks.map((entry) => {
|
|
418
|
+
const draft = context?.preview.drafts[entry.id];
|
|
419
|
+
return draft === void 0 ? entry : {
|
|
420
|
+
...entry,
|
|
421
|
+
data: draft
|
|
422
|
+
};
|
|
423
|
+
}));
|
|
424
|
+
try {
|
|
425
|
+
const tokens = resolvedTokens();
|
|
426
|
+
if (props.templateEntry !== void 0) {
|
|
427
|
+
const draft = context?.preview.drafts[props.templateEntry.id];
|
|
428
|
+
const entry = draft === void 0 ? props.templateEntry : {
|
|
429
|
+
...props.templateEntry,
|
|
430
|
+
data: draft
|
|
431
|
+
};
|
|
432
|
+
return {
|
|
433
|
+
model: createTemplateLayoutRenderModel(props.layout, {
|
|
434
|
+
entry,
|
|
435
|
+
blockEntries: [...entries.values()],
|
|
436
|
+
blockCatalog: TEMPLATE_BLOCK_CATALOG,
|
|
437
|
+
allowedContainerIds: new Set(Object.keys(tokens.containers)),
|
|
438
|
+
breakpoints: MANIFEST_BREAKPOINTS$1,
|
|
439
|
+
reusableComponentProjection: props.reusableComponentProjection ?? {
|
|
440
|
+
bindings: [],
|
|
441
|
+
revisions: []
|
|
442
|
+
}
|
|
443
|
+
}),
|
|
444
|
+
entries
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const allowedEntryIds = new Set(entries.keys());
|
|
448
|
+
const allowedContainerIds = new Set(Object.keys(tokens.containers));
|
|
449
|
+
const reusable = props.reusableComponentProjection;
|
|
450
|
+
const layoutVersion = typeof props.layout === "object" && props.layout !== null ? props.layout.version : void 0;
|
|
451
|
+
const slotContext = {
|
|
452
|
+
slotCatalog: SLOT_CATALOG,
|
|
453
|
+
entryApiId: (entryId) => {
|
|
454
|
+
const entry = entries.get(entryId);
|
|
455
|
+
return entry === void 0 ? void 0 : getBlockSchemaApiId(entry) ?? void 0;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
model: reusable === void 0 && layoutVersion !== 2 && layoutVersion !== 3 ? createLayoutRenderModel(props.layout, allowedEntryIds, allowedContainerIds, slotContext, MANIFEST_BREAKPOINTS$1) : createReusableLayoutRenderModel(props.layout, {
|
|
460
|
+
projection: reusable ?? {
|
|
461
|
+
bindings: [],
|
|
462
|
+
revisions: []
|
|
463
|
+
},
|
|
464
|
+
allowedEntryIds,
|
|
465
|
+
allowedContainerIds,
|
|
466
|
+
slotContext,
|
|
467
|
+
breakpoints: MANIFEST_BREAKPOINTS$1
|
|
468
|
+
}),
|
|
469
|
+
entries
|
|
470
|
+
};
|
|
471
|
+
} catch {
|
|
472
|
+
return {
|
|
473
|
+
model: null,
|
|
474
|
+
entries
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
const reportSlotMarkers = async () => {
|
|
479
|
+
if (!markerMode.value) return;
|
|
480
|
+
await nextTick();
|
|
481
|
+
const reporter = context?.preview.slotGeometryReporter;
|
|
482
|
+
if (reporter === void 0) return;
|
|
483
|
+
const elements = document.querySelectorAll("[data-eldra-slot-marker]");
|
|
484
|
+
if (elements.length === 0) return;
|
|
485
|
+
const slots = [];
|
|
486
|
+
for (const element of elements) {
|
|
487
|
+
const rect = element.getBoundingClientRect();
|
|
488
|
+
slots.push({
|
|
489
|
+
layoutNodeId: element.getAttribute("data-eldra-layout-node-id") ?? "",
|
|
490
|
+
slotId: element.getAttribute("data-eldra-slot-id") ?? "",
|
|
491
|
+
rect: {
|
|
492
|
+
x: rect.x,
|
|
493
|
+
y: rect.y,
|
|
494
|
+
width: rect.width,
|
|
495
|
+
height: rect.height
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
reporter(slots);
|
|
500
|
+
};
|
|
501
|
+
onMounted(() => {
|
|
502
|
+
reportSlotMarkers();
|
|
503
|
+
});
|
|
504
|
+
onUpdated(() => {
|
|
505
|
+
reportSlotMarkers();
|
|
506
|
+
});
|
|
507
|
+
return () => {
|
|
508
|
+
const { model, entries } = state.value;
|
|
509
|
+
if (model === null) return h("div", {
|
|
510
|
+
hidden: true,
|
|
511
|
+
"data-eldra-invalid-layout": ""
|
|
512
|
+
});
|
|
513
|
+
return h(Fragment, [
|
|
514
|
+
h("style", {
|
|
515
|
+
"data-eldra-design-token-styles": "",
|
|
516
|
+
"data-eldra-design-token-revision": designTokenRevisionHash(resolvedTokens()),
|
|
517
|
+
nonce: props.nonce
|
|
518
|
+
}, generateDesignTokenCss(resolvedTokens())),
|
|
519
|
+
h("style", {
|
|
520
|
+
"data-eldra-layout-styles": "",
|
|
521
|
+
nonce: props.nonce
|
|
522
|
+
}, model.css),
|
|
523
|
+
markerMode.value ? h("style", {
|
|
524
|
+
"data-eldra-slot-marker-styles": "",
|
|
525
|
+
nonce: props.nonce
|
|
526
|
+
}, SLOT_MARKER_CSS) : null,
|
|
527
|
+
renderNode(model.root, entries, markerMode.value)
|
|
528
|
+
]);
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/richTextRender.ts
|
|
534
|
+
/**
|
|
535
|
+
* Render a TipTap JSON document's content as Vue VNodes. Non-doc input renders
|
|
536
|
+
* as an empty root.
|
|
537
|
+
*
|
|
538
|
+
* `generation` changes the key prefix, which makes Vue discard the subtree and
|
|
539
|
+
* build it again instead of patching. §18 v3's deferred rendering needs that
|
|
540
|
+
* exactly once per deferral: while a field is deferred the browser edits the
|
|
541
|
+
* DOM behind Vue's back, so the vnode tree Vue is holding no longer describes
|
|
542
|
+
* what is on screen, and an ordinary patch can skip a change it believes is
|
|
543
|
+
* already applied. Generation 0 keeps the original keys, so nothing about the
|
|
544
|
+
* ordinary render path changes.
|
|
545
|
+
*/
|
|
546
|
+
function renderRichTextDoc(doc, options, generation = 0) {
|
|
547
|
+
return mapChildren(buildRichTextTree$1(doc, options), generation === 0 ? "rt" : `rt${generation}`);
|
|
548
|
+
}
|
|
549
|
+
function mapChildren(children, key) {
|
|
550
|
+
return children.map((child, index) => typeof child === "string" ? child : mapNode(child, `${key}-${index}`));
|
|
551
|
+
}
|
|
552
|
+
function mapNode(node, key) {
|
|
553
|
+
if (node.children.length === 0) return h(node.tag, {
|
|
554
|
+
key,
|
|
555
|
+
...node.attrs
|
|
556
|
+
});
|
|
557
|
+
return h(node.tag, {
|
|
558
|
+
key,
|
|
559
|
+
...node.attrs
|
|
560
|
+
}, mapChildren(node.children, key));
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/useEldraBlockField.ts
|
|
564
|
+
/**
|
|
565
|
+
* Vue-facing name for theme-core's block-field lookup. The registry and the
|
|
566
|
+
* `localized` resolution live in `@eldrajs/theme-core` so every framework
|
|
567
|
+
* binding shares one implementation; this wrapper exists only so themes keep
|
|
568
|
+
* calling a composable-shaped API.
|
|
569
|
+
*
|
|
570
|
+
* There is no `toolbar` here any more: §18 v3 keeps the rich-text toolbar in
|
|
571
|
+
* Studio, which reads `metadata.toolbar` from Core's field definitions. The
|
|
572
|
+
* vite plugin still validates the control ids at build time.
|
|
573
|
+
*/
|
|
574
|
+
function useEldraBlockField(apiId, fieldId) {
|
|
575
|
+
return { localized: isBlockFieldLocalized$1(apiId, fieldId) };
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/EldraRichText.ts
|
|
579
|
+
/**
|
|
580
|
+
* Renders a §18 rich-text field's TipTap JSON document. Read render only —
|
|
581
|
+
* the theme SDK is a bridge, not an editor: it hosts no editing UI and
|
|
582
|
+
* depends on neither TipTap nor a component library.
|
|
583
|
+
*
|
|
584
|
+
* §18 v3: the operator edits this render natively — theme-core's overlay
|
|
585
|
+
* makes the root `contenteditable` in edit mode and reports selection, text
|
|
586
|
+
* ops and commands in ProseMirror document positions; Studio owns the one
|
|
587
|
+
* headless editor and the docked toolbar and echoes the document back
|
|
588
|
+
* through `editor:content-update`. While Studio is editing, the root is
|
|
589
|
+
* marked `data-eldra-rich-text-editing`; nothing is hidden or overlaid.
|
|
590
|
+
*
|
|
591
|
+
* The root carries the marking attributes the overlay's
|
|
592
|
+
* `decorateStegaTextNodes`/`applyEditable`/`reconcileExternalDrafts`
|
|
593
|
+
* recognize (`[data-eldra-rich-text]`) so the overlay skips per-leaf stega
|
|
594
|
+
* decoration here and hands click-to-select to `theme:field-clicked`
|
|
595
|
+
* instead (see theme-core's `overlay.ts`).
|
|
596
|
+
*
|
|
597
|
+
* Per-block toolbar: the `metadata.toolbar` data path is unchanged
|
|
598
|
+
* (`block.json` → manifest → Core field metadata → Studio), but the theme no
|
|
599
|
+
* longer reads it — Studio's docked toolbar does. The vite plugin's scanner
|
|
600
|
+
* still validates the control ids at build time.
|
|
601
|
+
*
|
|
602
|
+
* Locale: an omitted `locale` prop defaults to the preview's active content
|
|
603
|
+
* locale when the manifest marks the field `localized`, and to `null`
|
|
604
|
+
* otherwise. That resolved locale is what `data-eldra-locale` carries, so
|
|
605
|
+
* Studio edits the locale the render actually used.
|
|
606
|
+
*
|
|
607
|
+
* Heading outline: `minHeadingLevel` floors every heading tag at render time,
|
|
608
|
+
* so one stored document fits under two different page outlines without being
|
|
609
|
+
* rewritten (see that prop's own comment).
|
|
610
|
+
*/
|
|
611
|
+
const EldraRichText = defineComponent({
|
|
612
|
+
name: "EldraRichText",
|
|
613
|
+
props: {
|
|
614
|
+
entryId: {
|
|
615
|
+
type: String,
|
|
616
|
+
required: true
|
|
617
|
+
},
|
|
618
|
+
field: {
|
|
619
|
+
type: String,
|
|
620
|
+
required: true
|
|
621
|
+
},
|
|
622
|
+
locale: {
|
|
623
|
+
type: String,
|
|
624
|
+
required: false,
|
|
625
|
+
default: null
|
|
626
|
+
},
|
|
627
|
+
doc: {
|
|
628
|
+
type: null,
|
|
629
|
+
required: true
|
|
630
|
+
},
|
|
631
|
+
/** The owning block's schema id — a block component passes `getBlockSchemaApiId(entry)`.
|
|
632
|
+
* Used to resolve the field's `localized` flag from the block manifest. */
|
|
633
|
+
apiId: {
|
|
634
|
+
type: String,
|
|
635
|
+
required: false,
|
|
636
|
+
default: void 0
|
|
637
|
+
},
|
|
638
|
+
/**
|
|
639
|
+
* A floor for every heading in the document: a `heading` node renders as
|
|
640
|
+
* `h{max(minHeadingLevel, level)}`, still capped at 6. Defaults to 1 — the
|
|
641
|
+
* generic clamp, i.e. whatever level the document carries.
|
|
642
|
+
*
|
|
643
|
+
* A page owns its heading outline; a rich-text field does not. The same body
|
|
644
|
+
* document is legitimately an h2-and-down section in one block and an
|
|
645
|
+
* h3-and-down one in another (the starter's `article` body starts at h2, its
|
|
646
|
+
* `rich-text` block at h3), and nothing in the document or in `block.json`
|
|
647
|
+
* can say so — `metadata.toolbar`'s `heading` control id is one
|
|
648
|
+
* level-agnostic entry, so an editor can insert any level anywhere. Without
|
|
649
|
+
* this prop the only place to enforce it was a per-block transform over the
|
|
650
|
+
* TipTap JSON before handing it in (the starter shipped one); doing it here
|
|
651
|
+
* instead means the **document is never rewritten** — no copy, no
|
|
652
|
+
* reactive-state surgery, and an editor's own level survives a round trip
|
|
653
|
+
* through Studio untouched, because only the rendered tag moves.
|
|
654
|
+
*
|
|
655
|
+
* A floor, never an offset: an author who already wrote h3 under
|
|
656
|
+
* `minHeadingLevel: 3` keeps h3, and h4 stays h4 — only levels above the
|
|
657
|
+
* floor move. Every guard is in `@eldrajs/theme-core`'s own
|
|
658
|
+
* `clampHeadingLevel`, so an out-of-range or non-integer value can never
|
|
659
|
+
* produce an `<h0>`/`<h7>`.
|
|
660
|
+
*
|
|
661
|
+
* Rendering-only, deliberately: the value takes no part in position
|
|
662
|
+
* stamping (`data-eldra-pos` is the document's own numbering, which a
|
|
663
|
+
* rendered tag name cannot change), so native editing, selection reporting
|
|
664
|
+
* and `restampRichTextPositions` all behave exactly as they do without it.
|
|
665
|
+
*/
|
|
666
|
+
minHeadingLevel: {
|
|
667
|
+
type: Number,
|
|
668
|
+
required: false,
|
|
669
|
+
default: 1
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
setup(props, { expose }) {
|
|
673
|
+
const context = inject(ELDRA_KEY, null);
|
|
674
|
+
const apiId = props.apiId ?? void 0;
|
|
675
|
+
const blockField = useEldraBlockField(apiId, props.field);
|
|
676
|
+
if (apiId === void 0 && props.locale === null && context !== null) {
|
|
677
|
+
let warned = false;
|
|
678
|
+
watchEffect(() => {
|
|
679
|
+
if (warned) return;
|
|
680
|
+
if (context.preview.mode !== "edit" || context.preview.locale === null) return;
|
|
681
|
+
warned = true;
|
|
682
|
+
console.warn(`[eldra] EldraRichText: field "${props.field}" has neither an \`apiId\` nor a \`locale\` prop, so locale resolution is skipped and it reports \`locale: null\` while the preview is on "${context.preview.locale}". Pass \`api-id\` (getBlockSchemaApiId(entry)) or an explicit \`locale\`.`);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
const locale = computed(() => props.locale ?? (blockField.localized ? context?.preview.locale ?? null : null));
|
|
686
|
+
expose({ locale });
|
|
687
|
+
/**
|
|
688
|
+
* §18 v3 deferred rendering. While the operator types natively in this
|
|
689
|
+
* root, the browser owns its DOM and theme-core says so
|
|
690
|
+
* (`isRichTextRenderDeferred`); replacing the tree would take the caret
|
|
691
|
+
* with it. So the component renders from `rendered` rather than straight
|
|
692
|
+
* from the prop, and while the field is deferred a new document only
|
|
693
|
+
* re-stamps the positions in place.
|
|
694
|
+
*
|
|
695
|
+
* Every decision here is core's: this is the predicate, the counter that
|
|
696
|
+
* makes it reactive, and the `renderKey` bump that guarantees Vue rebuilds
|
|
697
|
+
* the subtree once the field comes back — its vnode tree is stale by then,
|
|
698
|
+
* because the browser edited the DOM behind it, so an ordinary patch could
|
|
699
|
+
* skip a change it wrongly believes is already applied.
|
|
700
|
+
*/
|
|
701
|
+
const root = shallowRef(null);
|
|
702
|
+
const rendered = shallowRef(props.doc);
|
|
703
|
+
const renderKey = shallowRef(0);
|
|
704
|
+
let domOwnedByBrowser = false;
|
|
705
|
+
watch([() => props.doc, () => context?.preview.richTextRenderRevision ?? 0], () => {
|
|
706
|
+
if (context?.preview.isRichTextRenderDeferred?.({
|
|
707
|
+
entryId: props.entryId,
|
|
708
|
+
fieldPath: props.field,
|
|
709
|
+
locale: locale.value,
|
|
710
|
+
...layoutIdentityOf(root.value)
|
|
711
|
+
}) === true) {
|
|
712
|
+
domOwnedByBrowser = true;
|
|
713
|
+
if (root.value !== null && restampRichTextPositions(root.value, props.doc)) return;
|
|
714
|
+
}
|
|
715
|
+
if (domOwnedByBrowser) {
|
|
716
|
+
renderKey.value += 1;
|
|
717
|
+
domOwnedByBrowser = false;
|
|
718
|
+
}
|
|
719
|
+
rendered.value = props.doc;
|
|
720
|
+
}, { flush: "post" });
|
|
721
|
+
return () => h("div", {
|
|
722
|
+
ref: root,
|
|
723
|
+
"data-eldra-rich-text": "",
|
|
724
|
+
"data-eldra-field": props.field,
|
|
725
|
+
"data-eldra-entry": props.entryId,
|
|
726
|
+
...locale.value ? { "data-eldra-locale": locale.value } : {}
|
|
727
|
+
}, renderRichTextDoc(rendered.value, {
|
|
728
|
+
safeHref,
|
|
729
|
+
padEmptyBlocks: context?.preview.mode === "edit",
|
|
730
|
+
minHeadingLevel: props.minHeadingLevel
|
|
731
|
+
}, renderKey.value));
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region src/useEldraEntry.ts
|
|
736
|
+
function useEldraEntry(opts) {
|
|
737
|
+
const context = useEldra();
|
|
738
|
+
const entry = ref(null);
|
|
739
|
+
const pending = ref(false);
|
|
740
|
+
const error = ref(null);
|
|
741
|
+
let requestVersion = 0;
|
|
742
|
+
function overlayDraft() {
|
|
743
|
+
const current = entry.value;
|
|
744
|
+
if (current === null) return;
|
|
745
|
+
const draft = context.preview.drafts[current.id];
|
|
746
|
+
if (draft !== void 0) entry.value = {
|
|
747
|
+
...current,
|
|
748
|
+
data: draft
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
async function refresh() {
|
|
752
|
+
const version = ++requestVersion;
|
|
753
|
+
pending.value = true;
|
|
754
|
+
error.value = null;
|
|
755
|
+
try {
|
|
756
|
+
const id = toValue(opts.entryId);
|
|
757
|
+
let result;
|
|
758
|
+
if (id !== void 0) result = await context.client.getEntry(opts.schemaApiId, id, opts.query);
|
|
759
|
+
else if (opts.uniqueField !== void 0) result = await context.client.getEntryByUniqueField(opts.schemaApiId, opts.uniqueField.fieldId, toValue(opts.uniqueField.value), opts.query);
|
|
760
|
+
else result = null;
|
|
761
|
+
if (version !== requestVersion) return;
|
|
762
|
+
entry.value = result;
|
|
763
|
+
overlayDraft();
|
|
764
|
+
} catch (cause) {
|
|
765
|
+
if (version !== requestVersion) return;
|
|
766
|
+
error.value = cause instanceof Error ? cause.message : String(cause);
|
|
767
|
+
} finally {
|
|
768
|
+
if (version === requestVersion) pending.value = false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
watch(() => [toValue(opts.entryId), opts.uniqueField === void 0 ? void 0 : toValue(opts.uniqueField.value)], () => {
|
|
772
|
+
refresh();
|
|
773
|
+
}, { immediate: true });
|
|
774
|
+
watch(() => context.preview.revision, overlayDraft);
|
|
775
|
+
return {
|
|
776
|
+
entry,
|
|
777
|
+
pending,
|
|
778
|
+
error,
|
|
779
|
+
refresh
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/useEldraPreview.ts
|
|
784
|
+
/**
|
|
785
|
+
* The theme's own tablet/normal layout breakpoints, already resolved and
|
|
786
|
+
* validated by Core (`resolveLayoutBreakpoints`) at build time — included in
|
|
787
|
+
* every `theme:ready` (see below) so Studio's UI can react to the theme's
|
|
788
|
+
* actual ranges rather than a value it may not share. Carried by the
|
|
789
|
+
* separate `virtual:eldra/breakpoints` module, not `virtual:eldra/manifest`:
|
|
790
|
+
* the manifest is exactly what is persisted/uploaded, and Core's ingest
|
|
791
|
+
* rejects an unrecognized top-level key on that file.
|
|
792
|
+
*/
|
|
793
|
+
const MANIFEST_BREAKPOINTS = themeBreakpoints ?? DEFAULT_LAYOUT_BREAKPOINTS;
|
|
794
|
+
/**
|
|
795
|
+
* Bounds theme:slots-rendered reports, mirroring Studio's sanitizeRenderedBlocks
|
|
796
|
+
* (contracts C14): at most 500 entries, finite rect numbers, and width/height
|
|
797
|
+
* within 0..100_000. The editor-side sanitizer is the authority; stay in sync.
|
|
798
|
+
*/
|
|
799
|
+
function sanitizeSlotGeometry(slots) {
|
|
800
|
+
if (!Array.isArray(slots)) return [];
|
|
801
|
+
const out = [];
|
|
802
|
+
for (const candidate of slots.slice(0, 500)) {
|
|
803
|
+
if (candidate === null || typeof candidate !== "object") continue;
|
|
804
|
+
const entry = candidate;
|
|
805
|
+
if (typeof entry.layoutNodeId !== "string" || entry.layoutNodeId.length > 128) continue;
|
|
806
|
+
if (typeof entry.slotId !== "string" || entry.slotId.length > 128) continue;
|
|
807
|
+
const rect = entry.rect;
|
|
808
|
+
if (rect === null || typeof rect !== "object") continue;
|
|
809
|
+
const r = rect;
|
|
810
|
+
const x = r.x;
|
|
811
|
+
const y = r.y;
|
|
812
|
+
const width = r.width;
|
|
813
|
+
const height = r.height;
|
|
814
|
+
if (typeof x !== "number" || !Number.isFinite(x)) continue;
|
|
815
|
+
if (typeof y !== "number" || !Number.isFinite(y)) continue;
|
|
816
|
+
if (typeof width !== "number" || !Number.isFinite(width) || width < 0 || width > 1e5) continue;
|
|
817
|
+
if (typeof height !== "number" || !Number.isFinite(height) || height < 0 || height > 1e5) continue;
|
|
818
|
+
out.push({
|
|
819
|
+
layoutNodeId: entry.layoutNodeId,
|
|
820
|
+
slotId: entry.slotId,
|
|
821
|
+
rect: {
|
|
822
|
+
x,
|
|
823
|
+
y,
|
|
824
|
+
width,
|
|
825
|
+
height
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
return out;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Exported for `previewDrafts.spec.ts`: proving a draft's own top-level
|
|
833
|
+
* select field unwraps the same way a select nested in an embedded block
|
|
834
|
+
* does (SF-2) needs to call this directly, without standing up the whole
|
|
835
|
+
* `startEldraPreview` bridge/overlay runtime that owns every other call site.
|
|
836
|
+
*/
|
|
837
|
+
function projectDrafts(context) {
|
|
838
|
+
for (const [entryId, draftDoc] of Object.entries(context.preview.sourceDrafts)) context.preview.drafts[entryId] = context.client.encodeEntryDataStega(entryId, projectEntryDataLocale(draftDoc, context.preview.locale, context.preview.draftSchemaApiIds[entryId]), context.preview.locale, context.preview.draftSchemaApiIds[entryId]);
|
|
839
|
+
}
|
|
840
|
+
function startEldraPreview(context, opts) {
|
|
841
|
+
const active = ref(false);
|
|
842
|
+
if (typeof window === "undefined" || window.parent === window) return {
|
|
843
|
+
active: readonly(active),
|
|
844
|
+
destroy: () => void 0
|
|
845
|
+
};
|
|
846
|
+
let destroyRuntime = null;
|
|
847
|
+
let disposed = false;
|
|
848
|
+
(async () => {
|
|
849
|
+
const [{ createThemeBridge }, { createOverlayRuntime, createPreviewMessageRouter }] = await Promise.all([import("@eldrajs/theme-core/bridge"), import("@eldrajs/theme-core/overlay")]);
|
|
850
|
+
if (disposed) return;
|
|
851
|
+
let overlay;
|
|
852
|
+
/** The preview token the last `editor:init` carried — see `tokenRevision`. */
|
|
853
|
+
let lastPreviewToken = null;
|
|
854
|
+
const router = createPreviewMessageRouter(() => overlay);
|
|
855
|
+
const bridge = createThemeBridge({
|
|
856
|
+
allowedOrigins: opts.allowedOrigins,
|
|
857
|
+
breakpoints: MANIFEST_BREAKPOINTS,
|
|
858
|
+
onEditorHello(capabilities) {
|
|
859
|
+
context.preview.editorSupportsSlots = router.negotiate(capabilities).slots;
|
|
860
|
+
},
|
|
861
|
+
onMessage(type, payload) {
|
|
862
|
+
if (router.route(type, payload)) return;
|
|
863
|
+
if (type === "editor:init") {
|
|
864
|
+
const init = payload;
|
|
865
|
+
context.client.enablePreview(init.previewToken);
|
|
866
|
+
if (init.previewToken !== lastPreviewToken) {
|
|
867
|
+
lastPreviewToken = init.previewToken;
|
|
868
|
+
context.preview.tokenRevision += 1;
|
|
869
|
+
}
|
|
870
|
+
context.preview.active = true;
|
|
871
|
+
context.preview.mode = init.mode;
|
|
872
|
+
context.preview.locale = init.locale;
|
|
873
|
+
projectDrafts(context);
|
|
874
|
+
active.value = true;
|
|
875
|
+
overlay.setMode(init.mode);
|
|
876
|
+
overlay.start();
|
|
877
|
+
context.preview.refreshRevision += 1;
|
|
878
|
+
context.preview.revision += 1;
|
|
879
|
+
if (init.path !== window.location.pathname) opts.onNavigate?.(init.path);
|
|
880
|
+
} else if (type === "editor:content-update") {
|
|
881
|
+
const entries = payload.entries;
|
|
882
|
+
overlay.acceptExternalUpdate(entries.map(({ entryId }) => entryId));
|
|
883
|
+
for (const update of entries) {
|
|
884
|
+
context.preview.sourceDrafts[update.entryId] = update.draftDoc;
|
|
885
|
+
context.preview.draftSchemaApiIds[update.entryId] = update.schemaApiId;
|
|
886
|
+
}
|
|
887
|
+
projectDrafts(context);
|
|
888
|
+
context.preview.refreshRevision += 1;
|
|
889
|
+
context.preview.revision += 1;
|
|
890
|
+
queueMicrotask(() => {
|
|
891
|
+
overlay.rescan();
|
|
892
|
+
overlay.reconcileExternalDrafts(context.preview.drafts, entries.map(({ entryId }) => entryId));
|
|
893
|
+
});
|
|
894
|
+
} else if (type === "editor:design-tokens") applyResolvedDesignTokens(context, payload);
|
|
895
|
+
else if (type === "editor:theme-messages") applyThemeMessages(context, payload);
|
|
896
|
+
else if (type === "editor:set-mode") {
|
|
897
|
+
const mode = payload.mode;
|
|
898
|
+
context.preview.mode = mode;
|
|
899
|
+
overlay.setMode(mode);
|
|
900
|
+
} else if (type === "editor:navigate") opts.onNavigate?.(payload.path);
|
|
901
|
+
else if (type === "editor:refresh") context.preview.refreshRevision += 1;
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
overlay = createOverlayRuntime({
|
|
905
|
+
post: (type, payload) => bridge.post(type, payload),
|
|
906
|
+
breakpoints: MANIFEST_BREAKPOINTS
|
|
907
|
+
});
|
|
908
|
+
context.preview.slotGeometryReporter = (slots) => {
|
|
909
|
+
const safe = sanitizeSlotGeometry(slots);
|
|
910
|
+
if (safe.length === 0) return;
|
|
911
|
+
bridge.post("theme:slots-rendered", { slots: safe });
|
|
912
|
+
};
|
|
913
|
+
context.preview.isRichTextRenderDeferred = (identity) => overlay.isRichTextRenderDeferred(identity);
|
|
914
|
+
const stopRichTextRenderState = overlay.onRichTextRenderState(() => {
|
|
915
|
+
context.preview.richTextRenderRevision += 1;
|
|
916
|
+
});
|
|
917
|
+
const stopRequestErrors = context.client.onRequestError?.((error) => {
|
|
918
|
+
if (!context.client.previewEnabled || error.status === 404) return;
|
|
919
|
+
bridge.post("theme:request-failed", {
|
|
920
|
+
status: error.status,
|
|
921
|
+
path: error.path.slice(0, 256)
|
|
922
|
+
});
|
|
923
|
+
}) ?? (() => void 0);
|
|
924
|
+
destroyRuntime = () => {
|
|
925
|
+
context.client.disablePreview();
|
|
926
|
+
context.preview.active = false;
|
|
927
|
+
context.preview.editorSupportsSlots = false;
|
|
928
|
+
context.preview.slotGeometryReporter = void 0;
|
|
929
|
+
stopRichTextRenderState();
|
|
930
|
+
stopRequestErrors();
|
|
931
|
+
context.preview.isRichTextRenderDeferred = void 0;
|
|
932
|
+
active.value = false;
|
|
933
|
+
router.reset();
|
|
934
|
+
overlay.stop();
|
|
935
|
+
bridge.destroy();
|
|
936
|
+
document.querySelector("style[data-eldra-live-design-token-styles]")?.remove();
|
|
937
|
+
};
|
|
938
|
+
})();
|
|
939
|
+
return {
|
|
940
|
+
active: readonly(active),
|
|
941
|
+
destroy: () => {
|
|
942
|
+
disposed = true;
|
|
943
|
+
destroyRuntime?.();
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function boundedPayload(value) {
|
|
948
|
+
try {
|
|
949
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength <= 65536;
|
|
950
|
+
} catch {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function replaceRecord(target, source) {
|
|
955
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
956
|
+
for (const key of Object.keys(source).sort((a, b) => a.localeCompare(b))) target[key] = source[key];
|
|
957
|
+
}
|
|
958
|
+
const staticDesignTokenIds = /* @__PURE__ */ new WeakMap();
|
|
959
|
+
function getStaticDesignTokenIds(context) {
|
|
960
|
+
const existing = staticDesignTokenIds.get(context);
|
|
961
|
+
if (existing !== void 0) return existing;
|
|
962
|
+
const captured = {
|
|
963
|
+
colors: Object.keys(context.designTokens.colors),
|
|
964
|
+
containers: Object.keys(context.designTokens.containers)
|
|
965
|
+
};
|
|
966
|
+
staticDesignTokenIds.set(context, captured);
|
|
967
|
+
return captured;
|
|
968
|
+
}
|
|
969
|
+
function applyResolvedDesignTokens(context, update) {
|
|
970
|
+
if (!boundedPayload(update)) return false;
|
|
971
|
+
try {
|
|
972
|
+
const resolved = normalizeThemeDesignTokens(update.resolved);
|
|
973
|
+
if (!Number.isSafeInteger(update.revision) || update.revision <= context.preview.designTokensRevision) return false;
|
|
974
|
+
const staticIds = getStaticDesignTokenIds(context);
|
|
975
|
+
const removedColors = staticIds.colors.filter((id) => resolved.colors[id] === void 0);
|
|
976
|
+
const removedContainers = staticIds.containers.filter((id) => resolved.containers[id] === void 0);
|
|
977
|
+
replaceRecord(context.designTokens.colors, resolved.colors);
|
|
978
|
+
replaceRecord(context.designTokens.containers, resolved.containers);
|
|
979
|
+
if (resolved.allowCustomColors === true) context.designTokens.allowCustomColors = true;
|
|
980
|
+
else delete context.designTokens.allowCustomColors;
|
|
981
|
+
context.preview.designTokensRevision = update.revision;
|
|
982
|
+
if (typeof document !== "undefined") syncDesignTokenStyle(context, {
|
|
983
|
+
colors: removedColors,
|
|
984
|
+
containers: removedContainers
|
|
985
|
+
});
|
|
986
|
+
return true;
|
|
987
|
+
} catch {
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Applies an `editor:theme-messages` push from Studio's "Theme texts" page: for every locale tag
|
|
993
|
+
* the payload names, **replaces** `context.messages.locales[tag]` wholesale with the payload's own
|
|
994
|
+
* record (sanitized through `@eldrajs/theme-core/i18n`'s `sanitizeLocaleMessages` — non-string
|
|
995
|
+
* values dropped, and a forbidden tag, `__proto__`/`prototype`/`constructor`, skipped entirely
|
|
996
|
+
* rather than written through: `context.messages` is `reactive(...)`, so an assignment to that
|
|
997
|
+
* literal key on its `locales` object would reassign the live, shared object's own prototype
|
|
998
|
+
* instead of adding a property named for it) — never a per-key merge, since Studio always sends a
|
|
999
|
+
* locale's full resolved set, the same shape `virtual:eldra/messages` serves. A locale the payload
|
|
1000
|
+
* does not mention is left untouched.
|
|
1001
|
+
*
|
|
1002
|
+
* `revision` guards the same way `designTokensRevision` does: a push whose revision is not
|
|
1003
|
+
* strictly greater than the last one applied is ignored, so an out-of-order delivery (a Studio
|
|
1004
|
+
* reconnect replaying its last push, say) cannot roll a live edit backwards.
|
|
1005
|
+
*/
|
|
1006
|
+
function applyThemeMessages(context, update) {
|
|
1007
|
+
if (!boundedPayload(update)) return false;
|
|
1008
|
+
if (!Number.isSafeInteger(update.revision) || update.revision <= context.preview.messagesRevision) return false;
|
|
1009
|
+
if (typeof update.locales !== "object" || update.locales === null) return false;
|
|
1010
|
+
for (const tag of Object.keys(update.locales).sort((a, b) => a.localeCompare(b))) {
|
|
1011
|
+
const record = update.locales[tag];
|
|
1012
|
+
if (typeof record !== "object" || record === null) continue;
|
|
1013
|
+
const sanitized = sanitizeLocaleMessages(tag, record);
|
|
1014
|
+
if (sanitized === null) continue;
|
|
1015
|
+
context.messages.locales[tag] = sanitized;
|
|
1016
|
+
}
|
|
1017
|
+
context.preview.messagesRevision = update.revision;
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
function syncDesignTokenStyle(context, removed) {
|
|
1021
|
+
let style = document.querySelector("style[data-eldra-live-design-token-styles]");
|
|
1022
|
+
if (style === null) {
|
|
1023
|
+
style = document.createElement("style");
|
|
1024
|
+
style.dataset.eldraLiveDesignTokenStyles = "";
|
|
1025
|
+
const nonce = document.querySelector("style[data-eldra-layout-styles][nonce]")?.nonce;
|
|
1026
|
+
if (nonce) style.nonce = nonce;
|
|
1027
|
+
document.head.append(style);
|
|
1028
|
+
}
|
|
1029
|
+
const removedDeclarations = [...removed.colors.sort((a, b) => a.localeCompare(b)).flatMap((id) => [`--eldra-color-${id}:initial;`, `--color-${id}:initial;`]), ...removed.containers.sort((a, b) => a.localeCompare(b)).flatMap((id) => [
|
|
1030
|
+
`--eldra-container-${id}-max-width:initial;`,
|
|
1031
|
+
`--eldra-container-${id}-gutter-normal:initial;`,
|
|
1032
|
+
`--eldra-container-${id}-gutter-tablet:initial;`,
|
|
1033
|
+
`--eldra-container-${id}-gutter-mobile:initial;`
|
|
1034
|
+
])].join("");
|
|
1035
|
+
const tombstones = removedDeclarations === "" ? "" : `:root{${removedDeclarations}}`;
|
|
1036
|
+
style.textContent = `${tombstones}${generateDesignTokenCss(context.designTokens)}`;
|
|
1037
|
+
style.dataset.eldraDesignTokenRevision = String(context.preview.designTokensRevision);
|
|
1038
|
+
}
|
|
1039
|
+
function useEldraPreview(opts) {
|
|
1040
|
+
const active = ref(false);
|
|
1041
|
+
if (typeof window === "undefined" || window.parent === window) return { active: readonly(active) };
|
|
1042
|
+
const runtime = startEldraPreview(useEldra(), opts);
|
|
1043
|
+
onBeforeUnmount(() => {
|
|
1044
|
+
runtime.destroy();
|
|
1045
|
+
});
|
|
1046
|
+
return { active: runtime.active };
|
|
1047
|
+
}
|
|
1048
|
+
//#endregion
|
|
1049
|
+
export { DEFAULT_IMAGE_FRAMING, ELDRA_KEY, EldraBlockZone, EldraLayout, EldraRichText, buildRichTextTree, createEldraLinkState, createEldraLocaleState, createEldraPreviewState, getBlockSchemaApiId, imageFramingAttrs, imageFramingStyle, isBlockFieldLocalized, linkTargetKeys, normalizeImageFraming, provideEldra, registerBlockFields, renderRichTextDoc, resolveLink, safeLinkHref, startEldraPreview, useEldra, useEldraBlockField, useEldraEntry, useEldraLink, useEldraLocale, useEldraPreview };
|