@charlescms/astro 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/SECURITY.md +77 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/connector/worker.js +505 -0
  6. package/connector/wrangler.toml +15 -0
  7. package/package.json +92 -0
  8. package/scripts/check-licenses.js +45 -0
  9. package/scripts/check-package.js +62 -0
  10. package/scripts/setup.js +719 -0
  11. package/scripts/update-vendored-site.js +71 -0
  12. package/src/admin.astro +314 -0
  13. package/src/analyzer.js +639 -0
  14. package/src/asset-images.js +130 -0
  15. package/src/astro-frontmatter.js +17 -0
  16. package/src/boot.js +35 -0
  17. package/src/client.js +347 -0
  18. package/src/connector-client.js +185 -0
  19. package/src/content-bridge.js +162 -0
  20. package/src/content-panel.js +440 -0
  21. package/src/data-analyzer.js +304 -0
  22. package/src/edit-affordance.js +463 -0
  23. package/src/editor-styles.js +243 -0
  24. package/src/element-editor.js +355 -0
  25. package/src/fields.js +6 -0
  26. package/src/frontmatter.js +153 -0
  27. package/src/ids.js +20 -0
  28. package/src/index.js +681 -0
  29. package/src/js-ast.js +140 -0
  30. package/src/markdown-analyzer.js +95 -0
  31. package/src/media-preview.js +58 -0
  32. package/src/panel-manager.js +133 -0
  33. package/src/publishing.js +457 -0
  34. package/src/rich-text-editor.js +209 -0
  35. package/src/routes.js +21 -0
  36. package/src/runtime-controller.js +206 -0
  37. package/src/sanitize.js +150 -0
  38. package/src/section-editor.js +437 -0
  39. package/src/source-edit.js +310 -0
  40. package/src/source-map-runtime.js +184 -0
  41. package/src/staged-panel.js +145 -0
  42. package/src/toolbar.js +128 -0
  43. package/src/versions-panel.js +112 -0
@@ -0,0 +1,463 @@
1
+ // Provides one consistent hover/tap ring and click behavior for every editable
2
+ // element. It does not know how editing works; elements expose a `charlescmsEdit`
3
+ // action and this module chooses the right action under the pointer — preferring
4
+ // the most specific element, staying stable as the pointer moves, and, when several
5
+ // editables overlap (a logo's monogram + wordmark, text over a background image),
6
+ // popping a picker so the user chooses exactly instead of fighting a hover target.
7
+ export function createEditAffordance({ state, isVisible }) {
8
+ // px of slack kept around a target so a steady hand never flickers between two
9
+ // elements that sit close together — and so a slightly-off click still gathers
10
+ // the tightly-packed neighbours into the picker.
11
+ const HIT_PAD = 10;
12
+ // ms the ring lingers after the pointer leaves — long enough to travel from the
13
+ // element up to its label without the ring vanishing on the way.
14
+ const HIDE_DELAY = 260;
15
+
16
+ const area = (el) => {
17
+ const r = el.getBoundingClientRect();
18
+ return Math.max(1, r.width) * Math.max(1, r.height);
19
+ };
20
+
21
+ // The single element the user is pointing at is the SMALLEST editable under the
22
+ // point — a small price label on a big card resolves to the price, not the card.
23
+ function resolveEditableAt(x, y) {
24
+ let best = null;
25
+ let bestArea = Infinity;
26
+ for (const node of document.elementsFromPoint(x, y)) {
27
+ if (node.closest?.("[data-charlescms-ui]")) continue;
28
+ const element = node.closest?.('[data-charlescms-editable="true"]');
29
+ if (!element || !element.charlescmsEdit || !isVisible(element)) continue;
30
+ const a = area(element);
31
+ if (a < bestArea) { best = element; bestArea = a; }
32
+ }
33
+ return best;
34
+ }
35
+
36
+ // Every editable at OR within a few px of the point — the candidates for the
37
+ // picker. Stacked elements (under the point) and tightly-packed neighbours
38
+ // (beside it) both qualify, so a logo's three crammed pieces all show up even on
39
+ // an imprecise click. Returned top-to-bottom, left-to-right (reading order).
40
+ function gatherCandidatesAt(x, y) {
41
+ const found = new Set();
42
+ for (const node of document.elementsFromPoint(x, y)) {
43
+ if (node.closest?.("[data-charlescms-ui]")) continue;
44
+ const element = node.closest?.('[data-charlescms-editable="true"]');
45
+ if (element?.charlescmsEdit && isVisible(element)) found.add(element);
46
+ }
47
+ for (const element of document.querySelectorAll('[data-charlescms-editable="true"]')) {
48
+ if (found.has(element) || !element.charlescmsEdit || element.closest("[data-charlescms-ui]") || !isVisible(element)) continue;
49
+ const r = element.getBoundingClientRect();
50
+ if (x >= r.left - HIT_PAD && x <= r.right + HIT_PAD && y >= r.top - HIT_PAD && y <= r.bottom + HIT_PAD) found.add(element);
51
+ }
52
+ const sortReading = (list) => list.sort((a, b) => {
53
+ const ra = a.getBoundingClientRect();
54
+ const rb = b.getBoundingClientRect();
55
+ return Math.abs(ra.top - rb.top) > 6 ? ra.top - rb.top : ra.left - rb.left;
56
+ }).slice(0, 7);
57
+ const list = [...found];
58
+ if (list.length > 1) {
59
+ // Keep only genuinely COMPARABLE targets. A far-larger container (a full-bleed
60
+ // hero behind small text or a fixed header) shouldn't turn every click into a
61
+ // picker — the picker is for things that actually compete for the same spot.
62
+ // You still edit a background by clicking its own bare area, where it's the
63
+ // sole candidate.
64
+ const minArea = Math.min(...list.map(area));
65
+ const comparable = list.filter((el) => area(el) <= minArea * 12);
66
+ if (comparable.length) return sortReading(comparable);
67
+ }
68
+ return sortReading(list);
69
+ }
70
+
71
+ function withinPadded(element, x, y, pad = HIT_PAD) {
72
+ const r = element.getBoundingClientRect();
73
+ return x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
74
+ }
75
+
76
+ function installEditAffordance() {
77
+ if (state.affordanceInstalled) return;
78
+ state.affordanceInstalled = true;
79
+
80
+ // A quiet hover ring only — no floating "Edit" label. The hand cursor already
81
+ // signals "clickable", and a click edits directly (or opens the picker when
82
+ // several editables overlap), so a separate edit button is just noise.
83
+ const spot = document.createElement("div");
84
+ spot.dataset.charlescmsUi = "true";
85
+ spot.className = "charlescms-spot";
86
+ spot.style.display = "none";
87
+ document.body.append(spot);
88
+
89
+ let current = null;
90
+ let hideTimer;
91
+ const hideNow = () => {
92
+ clearTimeout(hideTimer);
93
+ spot.style.display = "none";
94
+ current = null;
95
+ };
96
+ const hideSoon = () => {
97
+ clearTimeout(hideTimer);
98
+ hideTimer = setTimeout(hideNow, HIDE_DELAY);
99
+ };
100
+ state.hideEditSpot = hideNow;
101
+
102
+ const place = (element) => {
103
+ const rect = element.getBoundingClientRect();
104
+ spot.style.display = "block";
105
+ spot.style.left = `${Math.round(rect.left)}px`;
106
+ spot.style.top = `${Math.round(rect.top)}px`;
107
+ spot.style.width = `${Math.round(rect.width)}px`;
108
+ spot.style.height = `${Math.round(rect.height)}px`;
109
+ };
110
+
111
+ const show = (element) => { current = element; place(element); };
112
+
113
+ // ---- Disambiguation picker (shown when several editables overlap) ----
114
+ let picker = null;
115
+ let backdrop = null;
116
+ const removePicker = () => {
117
+ picker?.remove();
118
+ backdrop?.remove();
119
+ picker = null;
120
+ backdrop = null;
121
+ document.removeEventListener("keydown", onKey, true);
122
+ };
123
+ const onKey = (event) => { if (event.key === "Escape") { removePicker(); hideNow(); } };
124
+
125
+ const showPicker = (candidates, x, y, link) => {
126
+ removePicker();
127
+ // A transparent full-screen backdrop sits behind the picker and ABSORBS the
128
+ // next outside click — so dismissing the picker is a single action that never
129
+ // also edits or selects whatever was underneath.
130
+ backdrop = document.createElement("div");
131
+ backdrop.dataset.charlescmsUi = "true";
132
+ backdrop.className = "charlescms-picker-backdrop";
133
+ backdrop.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); removePicker(); hideNow(); });
134
+ document.body.append(backdrop);
135
+ picker = document.createElement("div");
136
+ picker.dataset.charlescmsUi = "true";
137
+ picker.className = "charlescms-picker";
138
+ picker.innerHTML = `<div class="charlescms-picker-head">Pick what to edit</div>`;
139
+ const addRow = (label, preview, onPick, onHover) => {
140
+ const row = document.createElement("button");
141
+ row.type = "button";
142
+ row.dataset.charlescmsUi = "true";
143
+ row.className = "charlescms-picker-item";
144
+ row.innerHTML = `<span class="charlescms-picker-label">${escapeHtml(label)}</span><span class="charlescms-picker-prev">${escapeHtml(preview)}</span>`;
145
+ row.addEventListener("pointerenter", () => onHover?.());
146
+ row.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onPick(); });
147
+ picker.append(row);
148
+ };
149
+ for (const element of candidates) {
150
+ addRow(affordanceLabel(element), pickerPreview(element),
151
+ () => { removePicker(); hideNow(); element.charlescmsEdit.open(); },
152
+ () => place(element));
153
+ }
154
+ if (link) {
155
+ addRow("↗ Open link", link.getAttribute("href"),
156
+ // Open in a NEW tab so the current edit session and any unpublished
157
+ // changes are never lost by navigating the page away.
158
+ () => { removePicker(); hideNow(); window.open(link.href, "_blank"); },
159
+ () => hideNow());
160
+ }
161
+ document.body.append(picker);
162
+ positionPicker(picker, x, y);
163
+ document.addEventListener("keydown", onKey, true);
164
+ };
165
+ state.removeEditPicker = removePicker;
166
+
167
+ const onMove = (event) => {
168
+ if (event.pointerType === "touch") return;
169
+ if (event.target?.closest?.("[data-charlescms-ui]")) { clearTimeout(hideTimer); return; }
170
+ if (picker || document.querySelector(".charlescms-panel, .charlescms-rt-toolbar")) { hideNow(); clearIslandHighlight(); return; }
171
+ const next = resolveEditableAt(event.clientX, event.clientY);
172
+ if (!next || next.isContentEditable || next.classList.contains("charlescms-editing")) {
173
+ if (current) hideSoon();
174
+ // Editable wins; otherwise reveal the boundary of a hydrated island under
175
+ // the pointer so locked, in-code regions read as "interactive", not broken.
176
+ if (!next) highlightIslandAt(event.clientX, event.clientY);
177
+ return;
178
+ }
179
+ clearIslandHighlight();
180
+ clearTimeout(hideTimer);
181
+ if (current && current.isConnected && isVisible(current) && next !== current
182
+ && next.contains(current) && withinPadded(current, event.clientX, event.clientY)) {
183
+ place(current);
184
+ return;
185
+ }
186
+ show(next);
187
+ };
188
+
189
+ const onScroll = () => {
190
+ if (picker) { removePicker(); hideNow(); return; }
191
+ if (current && isVisible(current)) place(current);
192
+ else if (current) hideSoon();
193
+ repositionIslandHighlight();
194
+ };
195
+
196
+ const onClick = (event) => {
197
+ if (!document.documentElement.classList.contains("charlescms-active")) return;
198
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
199
+ if (event.target.closest("[data-charlescms-ui]")) return;
200
+
201
+ const candidates = gatherCandidatesAt(event.clientX, event.clientY);
202
+ if (!candidates.length) {
203
+ const direct = event.target.closest('[data-charlescms-editable="true"]');
204
+ if (direct?.charlescmsEdit) candidates.push(direct);
205
+ else { showBoundaryHint(event.target, event.clientX, event.clientY); return; }
206
+ }
207
+ const link = event.target.closest("a[href]");
208
+ const navLink = link && !/^(?:tel:|mailto:|sms:)/i.test(link.getAttribute("href") || "") ? link : null;
209
+
210
+ // Several editables overlap here → let the user choose, never guess. Works
211
+ // the same for mouse and touch; the picker rows are big, finger-sized targets
212
+ // and suppress link navigation so a wrapped logo stays fully editable.
213
+ if (candidates.length > 1) {
214
+ event.preventDefault();
215
+ event.stopPropagation();
216
+ hideNow();
217
+ showPicker(candidates, event.clientX, event.clientY, navLink);
218
+ return;
219
+ }
220
+
221
+ const element = candidates[0];
222
+ if (element.isContentEditable || element.classList.contains("charlescms-editing")) return;
223
+
224
+ // A single editable wrapped in a navigation link (the logo, a menu item)
225
+ // must never silently navigate on click — that's the "logo can't be edited"
226
+ // trap. Offer an explicit Edit vs Open choice instead, for mouse and touch.
227
+ if (navLink && navLink.contains(element)) {
228
+ event.preventDefault();
229
+ event.stopPropagation();
230
+ hideNow();
231
+ showPicker([element], event.clientX, event.clientY, navLink);
232
+ return;
233
+ }
234
+
235
+ // A single editable: a tap or click edits it directly. (Several overlapping
236
+ // editables took the picker branch above; the picker's big rows cover the
237
+ // fat-finger case, so no separate touch confirm step is needed.)
238
+ event.preventDefault();
239
+ event.stopPropagation();
240
+ hideNow();
241
+ element.charlescmsEdit.open();
242
+ };
243
+
244
+ document.addEventListener("pointermove", onMove, { passive: true });
245
+ document.addEventListener("scroll", onScroll, { passive: true, capture: true });
246
+ document.addEventListener("click", onClick, true);
247
+
248
+ state.affordanceCleanup = () => {
249
+ document.removeEventListener("pointermove", onMove);
250
+ document.removeEventListener("scroll", onScroll, { capture: true });
251
+ document.removeEventListener("click", onClick, true);
252
+ removePicker();
253
+ clearIslandHighlight();
254
+ clearBoundaryHint();
255
+ clearTimeout(hideTimer);
256
+ state.hideEditSpot = null;
257
+ state.removeEditPicker = null;
258
+ spot.remove();
259
+ };
260
+ }
261
+
262
+ return { installEditAffordance };
263
+ }
264
+
265
+ // A short, human preview of an editable for a picker row, so the choice is obvious
266
+ // without reading the page ("Edit text · Zum Charles", "Replace image · hero.jpg").
267
+ function pickerPreview(element) {
268
+ if (element.tagName === "IMG" || element.querySelector?.("img")) {
269
+ const img = element.tagName === "IMG" ? element : element.querySelector("img");
270
+ return img.getAttribute("alt") || (img.getAttribute("src") || "").split(/[?#]/)[0].split("/").pop() || "image";
271
+ }
272
+ // A link row describes WHERE it points, so "Edit link" never reads as a duplicate
273
+ // of the "Edit text" row for the very same words ("Edit link · /about" vs "Edit
274
+ // text · About us").
275
+ if (element.tagName === "A") {
276
+ const href = (element.getAttribute("href") || "").trim();
277
+ if (href) return href;
278
+ }
279
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
280
+ return text.length > 44 ? `${text.slice(0, 44)}…` : (text || element.tagName.toLowerCase());
281
+ }
282
+
283
+ // Place the picker next to the click, clamped to stay fully on-screen.
284
+ function positionPicker(menu, x, y) {
285
+ const r = menu.getBoundingClientRect();
286
+ let left = x + 10;
287
+ let top = y + 10;
288
+ if (left + r.width > window.innerWidth - 8) left = Math.max(8, x - r.width - 10);
289
+ if (top + r.height > window.innerHeight - 8) top = Math.max(8, y - r.height - 10);
290
+ menu.style.left = `${Math.max(8, left)}px`;
291
+ menu.style.top = `${Math.max(8, top)}px`;
292
+ }
293
+
294
+ // Explains the editing boundary when a click lands on content the page generates
295
+ // (so there is no source to edit). Fires only on text/media-like elements the user
296
+ // could reasonably expect to edit — never on chrome, links, or already-editable
297
+ // content — so it informs without nagging.
298
+ let boundaryHintTimer;
299
+ function showBoundaryHint(clickTarget, pointerX, pointerY) {
300
+ const element = boundaryHintTarget(clickTarget);
301
+ if (!element) return;
302
+ clearBoundaryHint();
303
+ const isMedia = element.tagName === "IMG" || element.tagName === "PICTURE" || element.tagName === "SVG"
304
+ || Boolean(element.querySelector?.("img,video,iframe,svg"));
305
+ // An Astro framework island (a hydrated React/Vue/Svelte/… component) renders to
306
+ // a <astro-island> custom element. Its text lives in code, not in any editable
307
+ // source the CMS owns — so say so plainly, and outline the WHOLE island so the
308
+ // user sees exactly which block is off-limits, instead of guessing per word.
309
+ const island = element.closest("astro-island");
310
+ const content = boundaryHintContent({ isMedia, isInteractive: Boolean(island) });
311
+
312
+ // The outline + badge are owned by the hover layer (so they persist while the
313
+ // pointer is over the island); a click just adds the plain-language "why".
314
+ if (island) highlightIsland(island);
315
+
316
+ const rect = element.getBoundingClientRect();
317
+ const hint = document.createElement("div");
318
+ hint.dataset.charlescmsUi = "true";
319
+ hint.className = `charlescms-boundary-hint charlescms-boundary-hint--${content.variant}`;
320
+ // For an island the badge already carries the ⚡ title, so the callout is just
321
+ // the plain-language "why"; media/data keep their icon + line.
322
+ hint.innerHTML = island
323
+ ? `<span>${escapeHtml(content.text)}</span>`
324
+ : `<span class="charlescms-boundary-hint-icon" aria-hidden="true">${content.icon}</span>`
325
+ + `<span class="charlescms-boundary-hint-body">`
326
+ + (content.title ? `<span class="charlescms-boundary-hint-title">${escapeHtml(content.title)}</span>` : "")
327
+ + `<span>${escapeHtml(content.text)}</span></span>`;
328
+ // Anchor the callout to the click point (offset so it never sits under the
329
+ // cursor or hides the very content it describes), clamped on-screen.
330
+ const anchorX = Number.isFinite(pointerX) ? pointerX : rect.left;
331
+ const anchorY = Number.isFinite(pointerY) ? pointerY : rect.bottom;
332
+ document.body.append(hint);
333
+ const hr = hint.getBoundingClientRect();
334
+ let left = anchorX + 14;
335
+ let top = anchorY + 14;
336
+ if (left + hr.width > window.innerWidth - 10) left = Math.max(10, anchorX - hr.width - 14);
337
+ if (top + hr.height > window.innerHeight - 10) top = Math.max(10, anchorY - hr.height - 14);
338
+ hint.style.left = `${Math.round(left)}px`;
339
+ hint.style.top = `${Math.round(top)}px`;
340
+ requestAnimationFrame(() => hint.classList.add("charlescms-boundary-hint-in"));
341
+ clearTimeout(boundaryHintTimer);
342
+ boundaryHintTimer = setTimeout(clearBoundaryHint, 3200);
343
+ }
344
+
345
+ function clearBoundaryHint() {
346
+ clearTimeout(boundaryHintTimer);
347
+ for (const node of document.querySelectorAll(".charlescms-boundary-hint")) node.remove();
348
+ }
349
+
350
+ // ---- Hydrated-island highlight (hover + click) ------------------------------
351
+ // One amber outline + "Interactive component" badge marks the boundary of a
352
+ // hydrated <astro-island>. It is purely visual (never writes), so it stays safe
353
+ // on every page; it simply makes the edit boundary legible the way a design tool
354
+ // marks a locked symbol. Module-level state: one editor instance per page.
355
+ let islandHighlight = { island: null, veil: null, badge: null };
356
+
357
+ function highlightIslandAt(x, y) {
358
+ const top = document.elementFromPoint(x, y);
359
+ if (!top || top.closest("[data-charlescms-ui]") || top.closest('[data-charlescms-editable="true"]')) {
360
+ clearIslandHighlight();
361
+ return;
362
+ }
363
+ const island = top.closest("astro-island");
364
+ if (island) highlightIsland(island);
365
+ else clearIslandHighlight();
366
+ }
367
+
368
+ function highlightIsland(island) {
369
+ if (islandHighlight.island === island && islandHighlight.veil?.isConnected) {
370
+ repositionIslandHighlight();
371
+ return;
372
+ }
373
+ clearIslandHighlight();
374
+ const veil = document.createElement("div");
375
+ veil.dataset.charlescmsUi = "true";
376
+ veil.className = "charlescms-island-veil";
377
+ const badge = document.createElement("div");
378
+ badge.dataset.charlescmsUi = "true";
379
+ badge.className = "charlescms-island-badge";
380
+ badge.textContent = "⚡ Interactive component";
381
+ document.body.append(veil, badge);
382
+ islandHighlight = { island, veil, badge };
383
+ repositionIslandHighlight();
384
+ requestAnimationFrame(() => {
385
+ veil.classList.add("charlescms-island-veil-in");
386
+ badge.classList.add("charlescms-island-veil-in");
387
+ });
388
+ }
389
+
390
+ function repositionIslandHighlight() {
391
+ const { island, veil, badge } = islandHighlight;
392
+ if (!island?.isConnected || !veil) return;
393
+ const r = island.getBoundingClientRect();
394
+ veil.style.left = `${Math.round(r.left)}px`;
395
+ veil.style.top = `${Math.round(r.top)}px`;
396
+ veil.style.width = `${Math.round(r.width)}px`;
397
+ veil.style.height = `${Math.round(r.height)}px`;
398
+ badge.style.left = `${Math.max(8, Math.round(r.left))}px`;
399
+ badge.style.top = `${Math.max(8, Math.round(r.top - 10))}px`;
400
+ }
401
+
402
+ function clearIslandHighlight() {
403
+ islandHighlight.veil?.remove();
404
+ islandHighlight.badge?.remove();
405
+ islandHighlight = { island: null, veil: null, badge: null };
406
+ }
407
+
408
+ // The boundary message, kept pure so the copy and classification are unit-tested
409
+ // (the DOM detection above stays thin glue). Interactive islands get the most
410
+ // specific, actionable line; media and generated values fall back in that order.
411
+ export function boundaryHintContent({ isMedia, isInteractive }) {
412
+ if (isInteractive) {
413
+ return {
414
+ variant: "interactive",
415
+ icon: "⚡",
416
+ title: "Interactive component",
417
+ text: "This area is built in code, so a developer edits it — it can’t be changed here."
418
+ };
419
+ }
420
+ if (isMedia) {
421
+ return {
422
+ variant: "media",
423
+ icon: "🔒",
424
+ title: "Managed in code",
425
+ text: "This image comes from the page’s code or data, so it can’t be replaced here."
426
+ };
427
+ }
428
+ return {
429
+ variant: "data",
430
+ icon: "🔒",
431
+ title: "Auto-filled value",
432
+ text: "This is generated automatically (like a date or a data field), so it can’t be edited here."
433
+ };
434
+ }
435
+
436
+ function boundaryHintTarget(node) {
437
+ if (!node?.closest || node.closest("[data-charlescms-ui]")) return null;
438
+ if (node.closest("a[href]")) return null; // links navigate, so don't second-guess them
439
+ // Cover the content elements a person could plausibly try to edit — including
440
+ // generated values (a <time> date, a <span> from data) — so clicking ANY of
441
+ // them explains itself instead of being a silent dead-end on any site.
442
+ const element = node.closest(
443
+ "p,h1,h2,h3,h4,h5,h6,li,blockquote,figcaption,td,th,dd,dt,time,span,em,strong,small,b,i,mark,cite,abbr,label,address,button,img,picture,svg,figure"
444
+ );
445
+ if (!element || element.closest('[data-charlescms-editable="true"]')) return null;
446
+ const isMedia = element.tagName === "IMG" || element.tagName === "PICTURE" || element.tagName === "SVG"
447
+ || Boolean(element.querySelector?.("img,video,iframe,svg"));
448
+ if (!isMedia && (element.textContent || "").trim().length < 2) return null;
449
+ return element;
450
+ }
451
+
452
+ function affordanceLabel(element) {
453
+ // A staged (not yet published) section edits as one block in the builder.
454
+ if (element.dataset?.charlescmsPreviewBlock) return "✎ Edit new section";
455
+ if (element.tagName === "IMG" || element.querySelector?.("img")) return "⤢ Replace image";
456
+ if (element.tagName === "A") return "✎ Edit link";
457
+ if (element.matches?.("ul,ol") || element.querySelector?.("li")) return "✎ Edit list";
458
+ return "✎ Edit text";
459
+ }
460
+
461
+ function escapeHtml(value) {
462
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" })[char]);
463
+ }