cms-renderer 0.5.0 → 0.5.1

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.
@@ -14,6 +14,7 @@ type TextInfo = {
14
14
  path: Array<string | number>;
15
15
  parentType?: React__default.ElementType;
16
16
  key?: React__default.Key | null;
17
+ inSvg?: boolean;
17
18
  };
18
19
  type ElementInfo = {
19
20
  element: React__default.ReactElement;
@@ -35,15 +36,21 @@ type WalkVisitors = {
35
36
  */
36
37
  onElement?: (info: ElementInfo) => React__default.ReactElement;
37
38
  };
38
- /**
39
- * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.
40
- * SSR-safe: does not touch DOM APIs.
41
- */
42
39
  declare function walkReactNode(node: React__default.ReactNode, visitors: WalkVisitors, ctx?: {
43
40
  path?: Array<string | number>;
44
41
  parentType?: React__default.ElementType;
45
42
  key?: React__default.Key | null;
43
+ inSvg?: boolean;
46
44
  }): React__default.ReactNode;
45
+ /**
46
+ * Renders the shared CSS and event-wiring script needed for the CMS edit
47
+ * overlay. Render this **once** at page level when edit mode is active —
48
+ * e.g. just before the block list in your route component.
49
+ *
50
+ * If you use `BlockRenderer` standalone (outside of `ParametricRoutePage`)
51
+ * you must render `<CmsEditableInit />` yourself somewhere on the page.
52
+ */
53
+ declare function CmsEditableInit(): React__default.JSX.Element;
47
54
  interface BlockRendererProps {
48
55
  /**
49
56
  * The block data to render.
@@ -81,4 +88,4 @@ interface BlockRendererProps {
81
88
  */
82
89
  declare function BlockRenderer({ block, registry, disableEditable, routeParams, path, }: BlockRendererProps): React__default.JSX.Element | null;
83
90
 
84
- export { BlockRenderer, walkReactNode };
91
+ export { BlockRenderer, CmsEditableInit, walkReactNode };
@@ -2,13 +2,27 @@
2
2
  import React from "react";
3
3
  import { BlockToolbar } from "./block-toolbar.js";
4
4
  import { ClientEditableBlock } from "./client-editable-block.js";
5
- import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ var SKIP_SPAN_PARENTS = /* @__PURE__ */ new Set([
7
+ // SVG text-bearing elements — spans are not valid SVG children
8
+ "text",
9
+ "tspan",
10
+ "textPath",
11
+ "desc",
12
+ // Form elements whose text content must not be interrupted
13
+ "option",
14
+ "optgroup",
15
+ // Non-rendered elements
16
+ "script",
17
+ "style",
18
+ "noscript"
19
+ ]);
6
20
  function walkReactNode(node, visitors, ctx = {}) {
7
21
  const path = ctx.path ?? [];
8
22
  if (node == null || typeof node === "boolean") return node;
9
23
  if (typeof node === "string" || typeof node === "number") {
10
24
  const value = String(node);
11
- return visitors.onText ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key }) : node;
25
+ return visitors.onText ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key, inSvg: ctx.inSvg }) : node;
12
26
  }
13
27
  if (Array.isArray(node)) {
14
28
  return node.map((child, i) => {
@@ -16,7 +30,8 @@ function walkReactNode(node, visitors, ctx = {}) {
16
30
  const result = walkReactNode(child, visitors, {
17
31
  path: [...path, i],
18
32
  parentType: ctx.parentType,
19
- key: childKey
33
+ key: childKey,
34
+ inSvg: ctx.inSvg
20
35
  });
21
36
  if (React.isValidElement(result) && result.key == null) {
22
37
  return React.cloneElement(result, { key: childKey ?? `arr-${path.join("-")}-${i}` });
@@ -27,13 +42,15 @@ function walkReactNode(node, visitors, ctx = {}) {
27
42
  if (React.isValidElement(node)) {
28
43
  const el = node;
29
44
  const elProps = el.props;
45
+ const nextInSvg = ctx.inSvg || el.type === "svg";
30
46
  const hasChildren = elProps && "children" in elProps;
31
47
  const nextChildren = hasChildren ? React.Children.map(elProps.children, (child, i) => {
32
48
  const childKey = child?.key ?? null;
33
49
  const result = walkReactNode(child, visitors, {
34
50
  path: [...path, "children", i],
35
51
  parentType: el.type,
36
- key: childKey
52
+ key: childKey,
53
+ inSvg: nextInSvg
37
54
  });
38
55
  if (React.isValidElement(result) && result.key == null) {
39
56
  return React.cloneElement(result, { key: childKey ?? `child-${path.join("-")}-${i}` });
@@ -66,6 +83,140 @@ function extractContentValues(content, basePath = []) {
66
83
  walk(content, basePath);
67
84
  return map;
68
85
  }
86
+ var CMS_EDITABLE_STYLES = `
87
+ .cms-block-toolbar {
88
+ position: fixed;
89
+ transform: translateX(-50%);
90
+ display: flex;
91
+ gap: 4px;
92
+ background: #1f2937;
93
+ border-radius: 6px;
94
+ padding: 4px;
95
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
96
+ opacity: 0;
97
+ pointer-events: none;
98
+ transition: opacity 0.15s ease;
99
+ z-index: 9999;
100
+ }
101
+ .cms-block-toolbar button {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ width: 28px;
106
+ height: 28px;
107
+ border: none;
108
+ background: transparent;
109
+ color: #9ca3af;
110
+ border-radius: 4px;
111
+ cursor: pointer;
112
+ transition: background 0.15s ease, color 0.15s ease;
113
+ }
114
+ .cms-block-toolbar button:hover {
115
+ background: #374151;
116
+ color: #fff;
117
+ }
118
+ .cms-block-toolbar button.delete:hover {
119
+ background: #dc2626;
120
+ color: #fff;
121
+ }
122
+ .cms-block-toolbar button:disabled {
123
+ opacity: 0.4;
124
+ cursor: not-allowed;
125
+ }
126
+ .cms-block-toolbar button:disabled:hover {
127
+ background: transparent;
128
+ color: #9ca3af;
129
+ }
130
+ .cms-block-toolbar svg {
131
+ width: 16px;
132
+ height: 16px;
133
+ }
134
+ `;
135
+ var CMS_EDITABLE_SCRIPT = `
136
+ (function() {
137
+ if (!window.__cmsEditableInitialized) {
138
+ window.__cmsEditableInitialized = true;
139
+ var _ab = null;
140
+
141
+ function _tb(bid) {
142
+ return document.querySelector('.cms-block-toolbar[data-block-id="' + bid + '"]');
143
+ }
144
+
145
+ function _show(bid, target) {
146
+ var tb = _tb(bid);
147
+ if (tb && target) {
148
+ var r = target.getBoundingClientRect();
149
+ tb.style.left = Math.round(r.left + r.width / 2) + 'px';
150
+ tb.style.top = Math.round(r.bottom + 8) + 'px';
151
+ tb.style.opacity = '1';
152
+ tb.style.pointerEvents = 'auto';
153
+ }
154
+ }
155
+
156
+ function _hide(bid) {
157
+ var tb = _tb(bid);
158
+ if (tb) { tb.style.opacity = '0'; tb.style.pointerEvents = 'none'; }
159
+ }
160
+
161
+ document.addEventListener('mouseover', function(e) {
162
+ if (e.target.closest('.cms-block-toolbar')) return;
163
+ var bel = e.target.closest('[data-cms-block]');
164
+ var bid = bel ? bel.getAttribute('data-block-id') : null;
165
+ if (bid === _ab) return;
166
+ if (_ab) _hide(_ab);
167
+ _ab = bid;
168
+ if (bid) _show(bid, e.target);
169
+ });
170
+
171
+ document.addEventListener('click', function(e) {
172
+ if (e.target.closest('.cms-block-toolbar')) return;
173
+
174
+ var path = e.composedPath ? e.composedPath() : [e.target];
175
+ var et = null;
176
+ for (var i = 0; i < path.length; i++) {
177
+ var n = path[i];
178
+ if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute('data-cms-editable')) { et = n; break; }
179
+ }
180
+ if (!et) et = e.target.closest && e.target.closest('[data-cms-editable]');
181
+
182
+ if (et) {
183
+ if (window.parent && window.parent !== window) {
184
+ window.parent.postMessage({
185
+ type: 'cms-editable-click',
186
+ blockId: et.getAttribute('data-block-id'),
187
+ blockType: et.getAttribute('data-block-type'),
188
+ contentPath: et.getAttribute('data-content-path')
189
+ }, '*');
190
+ }
191
+ return;
192
+ }
193
+
194
+ var bt = e.target.closest && e.target.closest('[data-cms-block]');
195
+ if (bt) {
196
+ if (window.parent && window.parent !== window) {
197
+ window.parent.postMessage({
198
+ type: 'cms-editable-click',
199
+ blockId: bt.getAttribute('data-block-id'),
200
+ blockType: bt.getAttribute('data-block-type'),
201
+ contentPath: null
202
+ }, '*');
203
+ }
204
+ }
205
+ });
206
+ }
207
+ })();
208
+ `;
209
+ function CmsEditableInit() {
210
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
211
+ /* @__PURE__ */ jsx("style", { children: CMS_EDITABLE_STYLES }),
212
+ /* @__PURE__ */ jsx(
213
+ "script",
214
+ {
215
+ dangerouslySetInnerHTML: { __html: CMS_EDITABLE_SCRIPT }
216
+ }
217
+ )
218
+ ] });
219
+ }
69
220
  function pathMatchesPattern(path, pattern) {
70
221
  const pathSegs = path.split("/").filter(Boolean);
71
222
  const patternSegs = pattern.split("/").filter(Boolean);
@@ -155,7 +306,11 @@ function BlockRenderer({
155
306
  if (isWalkable) {
156
307
  const usedPaths = /* @__PURE__ */ new Set();
157
308
  renderedComponent = walkReactNode(renderedTree, {
158
- onText: ({ value, key, path: path2 }) => {
309
+ onText: ({ value, key, path: path2, inSvg, parentType }) => {
310
+ if (inSvg) return value;
311
+ if (parentType && typeof parentType === "string" && SKIP_SPAN_PARENTS.has(parentType)) {
312
+ return value;
313
+ }
159
314
  const matches = contentValueMap.get(value);
160
315
  if (!matches || matches.length === 0) return value;
161
316
  const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];
@@ -169,6 +324,7 @@ function BlockRenderer({
169
324
  "data-block-id": block.id,
170
325
  "data-block-type": block.type,
171
326
  "data-content-path": match.contentPath,
327
+ style: { display: "contents" },
172
328
  children: value
173
329
  },
174
330
  spanKey
@@ -188,143 +344,6 @@ function BlockRenderer({
188
344
  "data-block-type": block.type,
189
345
  style: { display: "contents" },
190
346
  children: [
191
- /* @__PURE__ */ jsx("style", { children: `
192
- [data-cms-block] {
193
- display: contents;
194
- }
195
- [data-cms-editable] {
196
- display: contents;
197
- cursor: pointer;
198
- }
199
- .cms-block-toolbar {
200
- position: fixed;
201
- transform: translateX(-50%);
202
- display: flex;
203
- gap: 4px;
204
- background: #1f2937;
205
- border-radius: 6px;
206
- padding: 4px;
207
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
208
- opacity: 0;
209
- pointer-events: none;
210
- transition: opacity 0.15s ease;
211
- z-index: 9999;
212
- }
213
- .cms-block-toolbar button {
214
- display: flex;
215
- align-items: center;
216
- justify-content: center;
217
- width: 28px;
218
- height: 28px;
219
- border: none;
220
- background: transparent;
221
- color: #9ca3af;
222
- border-radius: 4px;
223
- cursor: pointer;
224
- transition: background 0.15s ease, color 0.15s ease;
225
- }
226
- .cms-block-toolbar button:hover {
227
- background: #374151;
228
- color: #fff;
229
- }
230
- .cms-block-toolbar button.delete:hover {
231
- background: #dc2626;
232
- color: #fff;
233
- }
234
- .cms-block-toolbar button:disabled {
235
- opacity: 0.4;
236
- cursor: not-allowed;
237
- }
238
- .cms-block-toolbar button:disabled:hover {
239
- background: transparent;
240
- color: #9ca3af;
241
- }
242
- .cms-block-toolbar svg {
243
- width: 16px;
244
- height: 16px;
245
- }
246
- ` }),
247
- /* @__PURE__ */ jsx(
248
- "script",
249
- {
250
- dangerouslySetInnerHTML: {
251
- __html: `
252
- (function() {
253
- if (!window.__cmsEditableInitialized) {
254
- window.__cmsEditableInitialized = true;
255
- var _ab = null;
256
-
257
- function _tb(bid) {
258
- return document.querySelector('.cms-block-toolbar[data-block-id="' + bid + '"]');
259
- }
260
-
261
- function _show(bid, target) {
262
- var tb = _tb(bid);
263
- if (tb && target) {
264
- var r = target.getBoundingClientRect();
265
- tb.style.left = Math.round(r.left + r.width / 2) + 'px';
266
- tb.style.top = Math.round(r.bottom + 8) + 'px';
267
- tb.style.opacity = '1';
268
- tb.style.pointerEvents = 'auto';
269
- }
270
- }
271
-
272
- function _hide(bid) {
273
- var tb = _tb(bid);
274
- if (tb) { tb.style.opacity = '0'; tb.style.pointerEvents = 'none'; }
275
- }
276
-
277
- document.addEventListener('mouseover', function(e) {
278
- if (e.target.closest('.cms-block-toolbar')) return;
279
- var bel = e.target.closest('[data-cms-block]');
280
- var bid = bel ? bel.getAttribute('data-block-id') : null;
281
- if (bid === _ab) return;
282
- if (_ab) _hide(_ab);
283
- _ab = bid;
284
- if (bid) _show(bid, e.target);
285
- });
286
-
287
- document.addEventListener('click', function(e) {
288
- if (e.target.closest('.cms-block-toolbar')) return;
289
-
290
- var path = e.composedPath ? e.composedPath() : [e.target];
291
- var et = null;
292
- for (var i = 0; i < path.length; i++) {
293
- var n = path[i];
294
- if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute('data-cms-editable')) { et = n; break; }
295
- }
296
- if (!et) et = e.target.closest && e.target.closest('[data-cms-editable]');
297
-
298
- if (et) {
299
- if (window.parent && window.parent !== window) {
300
- window.parent.postMessage({
301
- type: 'cms-editable-click',
302
- blockId: et.getAttribute('data-block-id'),
303
- blockType: et.getAttribute('data-block-type'),
304
- contentPath: et.getAttribute('data-content-path')
305
- }, '*');
306
- }
307
- return;
308
- }
309
-
310
- var bt = e.target.closest && e.target.closest('[data-cms-block]');
311
- if (bt) {
312
- if (window.parent && window.parent !== window) {
313
- window.parent.postMessage({
314
- type: 'cms-editable-click',
315
- blockId: bt.getAttribute('data-block-id'),
316
- blockType: bt.getAttribute('data-block-type'),
317
- contentPath: null
318
- }, '*');
319
- }
320
- }
321
- });
322
- }
323
- })();
324
- `
325
- }
326
- }
327
- ),
328
347
  needsClientSideSpans && contentEntries.length > 0 ? /* @__PURE__ */ jsx(
329
348
  ClientEditableBlock,
330
349
  {
@@ -342,6 +361,7 @@ function BlockRenderer({
342
361
  }
343
362
  export {
344
363
  BlockRenderer,
364
+ CmsEditableInit,
345
365
  walkReactNode
346
366
  };
347
367
  //# sourceMappingURL=block-renderer.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { BlockToolbar } from './block-toolbar';\nimport { ClientEditableBlock } from './client-editable-block';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nfunction extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n /**\n * Resolved route parameters from parametric routes.\n * Each key is a param name (e.g., \"country\") with its value, schema name, and full document.\n */\n routeParams?: ResolvedRouteParams;\n /**\n * The current URL path (e.g. \"/en/test\").\n * When provided, enables path-namespaced component lookup.\n * Registry keys like \"/{lang}/test Article\" will be matched against this path,\n * where `{x}` and `(x)` are treated as single-segment wildcards.\n */\n path?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Path-namespaced component resolution\n// -----------------------------------------------------------------------------\n\n/**\n * Returns true if each segment of `path` matches the corresponding segment in\n * `pattern`, where a pattern segment wrapped in `{…}` or `(…)` is a wildcard.\n */\nfunction pathMatchesPattern(path: string, pattern: string): boolean {\n const pathSegs = path.split('/').filter(Boolean);\n const patternSegs = pattern.split('/').filter(Boolean);\n if (pathSegs.length !== patternSegs.length) return false;\n for (let i = 0; i < patternSegs.length; i++) {\n const seg = patternSegs[i];\n if (!seg) return false;\n if ((seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('(') && seg.endsWith(')'))) {\n continue;\n }\n if (seg !== pathSegs[i]) return false;\n }\n return true;\n}\n\n/**\n * Resolves the component for `blockType` from the registry.\n *\n * When `path` is provided, keys of the form `\"/{pattern} BlockType\"` are\n * checked first. The first key whose path pattern matches the current path\n * and whose block-type suffix matches `blockType` wins. Falls back to a\n * direct `registry[blockType]` lookup.\n */\nfunction resolveComponent(\n registry: Partial<BlockComponentRegistry>,\n blockType: string,\n path?: string\n): BlockComponentRegistry[string] | undefined {\n if (path) {\n for (const key of Object.keys(registry)) {\n if (!key.startsWith('/')) continue;\n const spaceIdx = key.indexOf(' ');\n if (spaceIdx === -1) continue;\n const pathPattern = key.slice(0, spaceIdx);\n const registeredType = key.slice(spaceIdx + 1);\n if (registeredType !== blockType) continue;\n if (pathMatchesPattern(path, pathPattern)) {\n return registry[key];\n }\n }\n }\n return registry[blockType];\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Recursively renders a React node, invoking function components to get their output.\n * This allows us to walk the full rendered tree, not just the element wrappers.\n */\nfunction renderToWalkableTree(node: React.ReactNode, keyPrefix = ''): React.ReactNode {\n if (node == null || typeof node === 'boolean') return node;\n if (typeof node === 'string' || typeof node === 'number') return node;\n\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n const result = renderToWalkableTree(child, `${keyPrefix}${i}-`);\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n // biome-ignore lint/suspicious/noExplicitAny: Adding key to element\n const existingKey = (child as any)?.key;\n return React.cloneElement(result, { key: existingKey ?? `${keyPrefix}${i}` });\n }\n return result;\n });\n }\n\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // If it's a function component, invoke it to get the rendered output\n if (typeof el.type === 'function') {\n try {\n // biome-ignore lint/complexity/noBannedTypes: Need to invoke React function component\n const rendered = (el.type as Function)(el.props);\n return renderToWalkableTree(rendered, keyPrefix);\n } catch {\n // If component throws (e.g., uses hooks), return as-is\n return node;\n }\n }\n\n // For host elements (div, span, etc.), recurse into children\n if (elProps && 'children' in elProps) {\n const newChildren = renderToWalkableTree(elProps.children as React.ReactNode, keyPrefix);\n return React.cloneElement(el, undefined, newChildren);\n }\n\n return node;\n }\n\n return node;\n}\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * Internally, it:\n * 1. Renders the component tree by invoking function components\n * 2. Extracts all string values from block.content\n * 3. Walks the rendered tree and wraps matching text nodes with spans\n */\nexport function BlockRenderer({\n block,\n registry,\n disableEditable,\n routeParams,\n path,\n}: BlockRendererProps) {\n const Component = resolveComponent(registry, block.type, path);\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union\n const component = <Component content={block.content as any} routeParams={routeParams} />;\n\n if (disableEditable) {\n return component;\n }\n\n // Extract all string values from content with their paths\n const contentValueMap = extractContentValues(block.content as Record<string, unknown>);\n\n // Try server-side tree walking for text span injection (best-effort).\n // This works for sync server components but fails for client components,\n // async components, and components using hooks.\n let renderedComponent: React.ReactNode = component;\n let needsClientSideSpans = true;\n\n try {\n const renderedTree = renderToWalkableTree(component);\n\n // Check if tree walking produced host elements (div, section, etc.)\n // vs the original component reference (function type = couldn't be invoked)\n const isWalkable =\n React.isValidElement(renderedTree) &&\n typeof (renderedTree as React.ReactElement<Record<string, unknown>>).type === 'string';\n\n if (isWalkable) {\n const usedPaths = new Set<string>();\n\n renderedComponent = walkReactNode(renderedTree, {\n onText: ({ value, key, path }) => {\n const matches = contentValueMap.get(value);\n if (!matches || matches.length === 0) return value;\n\n const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];\n if (!match) return value;\n\n usedPaths.add(match.contentPath);\n const spanKey = key ?? `${block.id}-${match.contentPath}-${path.join('-')}`;\n\n return (\n <span\n key={spanKey}\n data-cms-editable\n data-block-id={block.id}\n data-block-type={block.type}\n data-content-path={match.contentPath}\n >\n {value}\n </span>\n );\n },\n });\n\n needsClientSideSpans = false;\n }\n } catch {\n // Tree walking failed entirely, fall back to client-side injection\n }\n\n // Build content entries for client-side text matching when server-side fails\n const contentEntries = needsClientSideSpans\n ? Array.from(contentValueMap.entries())\n .map(([value, matches]) => ({ v: value, p: matches[0]?.contentPath }))\n .filter((e): e is { v: string; p: string } => !!e.p)\n : [];\n\n return (\n <div\n key={block.id}\n data-cms-block\n data-block-id={block.id}\n data-block-type={block.type}\n style={{ display: 'contents' }}\n >\n <style>{`\n [data-cms-block] {\n display: contents;\n }\n [data-cms-editable] {\n display: contents;\n cursor: pointer;\n }\n .cms-block-toolbar {\n position: fixed;\n transform: translateX(-50%);\n display: flex;\n gap: 4px;\n background: #1f2937;\n border-radius: 6px;\n padding: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.15s ease;\n z-index: 9999;\n }\n .cms-block-toolbar button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: #9ca3af;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .cms-block-toolbar button:hover {\n background: #374151;\n color: #fff;\n }\n .cms-block-toolbar button.delete:hover {\n background: #dc2626;\n color: #fff;\n }\n .cms-block-toolbar button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n .cms-block-toolbar button:disabled:hover {\n background: transparent;\n color: #9ca3af;\n }\n .cms-block-toolbar svg {\n width: 16px;\n height: 16px;\n }\n `}</style>\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for iframe postMessage click routing\n dangerouslySetInnerHTML={{\n __html: `\n (function() {\n if (!window.__cmsEditableInitialized) {\n window.__cmsEditableInitialized = true;\n var _ab = null;\n\n function _tb(bid) {\n return document.querySelector('.cms-block-toolbar[data-block-id=\"' + bid + '\"]');\n }\n\n function _show(bid, target) {\n var tb = _tb(bid);\n if (tb && target) {\n var r = target.getBoundingClientRect();\n tb.style.left = Math.round(r.left + r.width / 2) + 'px';\n tb.style.top = Math.round(r.bottom + 8) + 'px';\n tb.style.opacity = '1';\n tb.style.pointerEvents = 'auto';\n }\n }\n\n function _hide(bid) {\n var tb = _tb(bid);\n if (tb) { tb.style.opacity = '0'; tb.style.pointerEvents = 'none'; }\n }\n\n document.addEventListener('mouseover', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n var bel = e.target.closest('[data-cms-block]');\n var bid = bel ? bel.getAttribute('data-block-id') : null;\n if (bid === _ab) return;\n if (_ab) _hide(_ab);\n _ab = bid;\n if (bid) _show(bid, e.target);\n });\n\n document.addEventListener('click', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n\n var path = e.composedPath ? e.composedPath() : [e.target];\n var et = null;\n for (var i = 0; i < path.length; i++) {\n var n = path[i];\n if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute('data-cms-editable')) { et = n; break; }\n }\n if (!et) et = e.target.closest && e.target.closest('[data-cms-editable]');\n\n if (et) {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({\n type: 'cms-editable-click',\n blockId: et.getAttribute('data-block-id'),\n blockType: et.getAttribute('data-block-type'),\n contentPath: et.getAttribute('data-content-path')\n }, '*');\n }\n return;\n }\n\n var bt = e.target.closest && e.target.closest('[data-cms-block]');\n if (bt) {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({\n type: 'cms-editable-click',\n blockId: bt.getAttribute('data-block-id'),\n blockType: bt.getAttribute('data-block-type'),\n contentPath: null\n }, '*');\n }\n }\n });\n }\n })();\n `,\n }}\n />\n {needsClientSideSpans && contentEntries.length > 0 ? (\n <ClientEditableBlock\n blockId={block.id}\n blockType={block.type}\n contentEntries={contentEntries}\n >\n {renderedComponent}\n </ClientEditableBlock>\n ) : (\n renderedComponent\n )}\n <BlockToolbar key=\"cms-toolbar\" blockId={block.id} />\n </div>\n );\n}\n"],"mappings":"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","names":["path"]}
1
+ {"version":3,"sources":["../../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { BlockToolbar } from './block-toolbar';\nimport { ClientEditableBlock } from './client-editable-block';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\n// Elements where injecting a <span> would break layout or produce invalid HTML\nconst SKIP_SPAN_PARENTS = new Set([\n // SVG text-bearing elements — spans are not valid SVG children\n 'text',\n 'tspan',\n 'textPath',\n 'desc',\n // Form elements whose text content must not be interrupted\n 'option',\n 'optgroup',\n // Non-rendered elements\n 'script',\n 'style',\n 'noscript',\n]);\n\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key, inSvg: ctx.inSvg })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n inSvg: ctx.inSvg,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Track SVG context so we never inject <span> inside SVG subtrees\n const nextInSvg = ctx.inSvg || el.type === 'svg';\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n inSvg: nextInSvg,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nfunction extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// CmsEditableInit — global styles + event wiring, rendered ONCE per page\n// -----------------------------------------------------------------------------\n\nconst CMS_EDITABLE_STYLES = `\n .cms-block-toolbar {\n position: fixed;\n transform: translateX(-50%);\n display: flex;\n gap: 4px;\n background: #1f2937;\n border-radius: 6px;\n padding: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.15s ease;\n z-index: 9999;\n }\n .cms-block-toolbar button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: #9ca3af;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .cms-block-toolbar button:hover {\n background: #374151;\n color: #fff;\n }\n .cms-block-toolbar button.delete:hover {\n background: #dc2626;\n color: #fff;\n }\n .cms-block-toolbar button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n .cms-block-toolbar button:disabled:hover {\n background: transparent;\n color: #9ca3af;\n }\n .cms-block-toolbar svg {\n width: 16px;\n height: 16px;\n }\n`;\n\nconst CMS_EDITABLE_SCRIPT = `\n(function() {\n if (!window.__cmsEditableInitialized) {\n window.__cmsEditableInitialized = true;\n var _ab = null;\n\n function _tb(bid) {\n return document.querySelector('.cms-block-toolbar[data-block-id=\"' + bid + '\"]');\n }\n\n function _show(bid, target) {\n var tb = _tb(bid);\n if (tb && target) {\n var r = target.getBoundingClientRect();\n tb.style.left = Math.round(r.left + r.width / 2) + 'px';\n tb.style.top = Math.round(r.bottom + 8) + 'px';\n tb.style.opacity = '1';\n tb.style.pointerEvents = 'auto';\n }\n }\n\n function _hide(bid) {\n var tb = _tb(bid);\n if (tb) { tb.style.opacity = '0'; tb.style.pointerEvents = 'none'; }\n }\n\n document.addEventListener('mouseover', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n var bel = e.target.closest('[data-cms-block]');\n var bid = bel ? bel.getAttribute('data-block-id') : null;\n if (bid === _ab) return;\n if (_ab) _hide(_ab);\n _ab = bid;\n if (bid) _show(bid, e.target);\n });\n\n document.addEventListener('click', function(e) {\n if (e.target.closest('.cms-block-toolbar')) return;\n\n var path = e.composedPath ? e.composedPath() : [e.target];\n var et = null;\n for (var i = 0; i < path.length; i++) {\n var n = path[i];\n if (n.nodeType === 1 && n.hasAttribute && n.hasAttribute('data-cms-editable')) { et = n; break; }\n }\n if (!et) et = e.target.closest && e.target.closest('[data-cms-editable]');\n\n if (et) {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({\n type: 'cms-editable-click',\n blockId: et.getAttribute('data-block-id'),\n blockType: et.getAttribute('data-block-type'),\n contentPath: et.getAttribute('data-content-path')\n }, '*');\n }\n return;\n }\n\n var bt = e.target.closest && e.target.closest('[data-cms-block]');\n if (bt) {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({\n type: 'cms-editable-click',\n blockId: bt.getAttribute('data-block-id'),\n blockType: bt.getAttribute('data-block-type'),\n contentPath: null\n }, '*');\n }\n }\n });\n }\n})();\n`;\n\n/**\n * Renders the shared CSS and event-wiring script needed for the CMS edit\n * overlay. Render this **once** at page level when edit mode is active —\n * e.g. just before the block list in your route component.\n *\n * If you use `BlockRenderer` standalone (outside of `ParametricRoutePage`)\n * you must render `<CmsEditableInit />` yourself somewhere on the page.\n */\nexport function CmsEditableInit() {\n return (\n <>\n <style>{CMS_EDITABLE_STYLES}</style>\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for iframe postMessage click routing\n dangerouslySetInnerHTML={{ __html: CMS_EDITABLE_SCRIPT }}\n />\n </>\n );\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n /**\n * Resolved route parameters from parametric routes.\n * Each key is a param name (e.g., \"country\") with its value, schema name, and full document.\n */\n routeParams?: ResolvedRouteParams;\n /**\n * The current URL path (e.g. \"/en/test\").\n * When provided, enables path-namespaced component lookup.\n * Registry keys like \"/{lang}/test Article\" will be matched against this path,\n * where `{x}` and `(x)` are treated as single-segment wildcards.\n */\n path?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Path-namespaced component resolution\n// -----------------------------------------------------------------------------\n\n/**\n * Returns true if each segment of `path` matches the corresponding segment in\n * `pattern`, where a pattern segment wrapped in `{…}` or `(…)` is a wildcard.\n */\nfunction pathMatchesPattern(path: string, pattern: string): boolean {\n const pathSegs = path.split('/').filter(Boolean);\n const patternSegs = pattern.split('/').filter(Boolean);\n if (pathSegs.length !== patternSegs.length) return false;\n for (let i = 0; i < patternSegs.length; i++) {\n const seg = patternSegs[i];\n if (!seg) return false;\n if ((seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('(') && seg.endsWith(')'))) {\n continue;\n }\n if (seg !== pathSegs[i]) return false;\n }\n return true;\n}\n\n/**\n * Resolves the component for `blockType` from the registry.\n *\n * When `path` is provided, keys of the form `\"/{pattern} BlockType\"` are\n * checked first. The first key whose path pattern matches the current path\n * and whose block-type suffix matches `blockType` wins. Falls back to a\n * direct `registry[blockType]` lookup.\n */\nfunction resolveComponent(\n registry: Partial<BlockComponentRegistry>,\n blockType: string,\n path?: string\n): BlockComponentRegistry[string] | undefined {\n if (path) {\n for (const key of Object.keys(registry)) {\n if (!key.startsWith('/')) continue;\n const spaceIdx = key.indexOf(' ');\n if (spaceIdx === -1) continue;\n const pathPattern = key.slice(0, spaceIdx);\n const registeredType = key.slice(spaceIdx + 1);\n if (registeredType !== blockType) continue;\n if (pathMatchesPattern(path, pathPattern)) {\n return registry[key];\n }\n }\n }\n return registry[blockType];\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Recursively renders a React node, invoking function components to get their output.\n * This allows us to walk the full rendered tree, not just the element wrappers.\n */\nfunction renderToWalkableTree(node: React.ReactNode, keyPrefix = ''): React.ReactNode {\n if (node == null || typeof node === 'boolean') return node;\n if (typeof node === 'string' || typeof node === 'number') return node;\n\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n const result = renderToWalkableTree(child, `${keyPrefix}${i}-`);\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n // biome-ignore lint/suspicious/noExplicitAny: Adding key to element\n const existingKey = (child as any)?.key;\n return React.cloneElement(result, { key: existingKey ?? `${keyPrefix}${i}` });\n }\n return result;\n });\n }\n\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // If it's a function component, invoke it to get the rendered output\n if (typeof el.type === 'function') {\n try {\n // biome-ignore lint/complexity/noBannedTypes: Need to invoke React function component\n const rendered = (el.type as Function)(el.props);\n return renderToWalkableTree(rendered, keyPrefix);\n } catch {\n // If component throws (e.g., uses hooks), return as-is\n return node;\n }\n }\n\n // For host elements (div, span, etc.), recurse into children\n if (elProps && 'children' in elProps) {\n const newChildren = renderToWalkableTree(elProps.children as React.ReactNode, keyPrefix);\n return React.cloneElement(el, undefined, newChildren);\n }\n\n return node;\n }\n\n return node;\n}\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * Internally, it:\n * 1. Renders the component tree by invoking function components\n * 2. Extracts all string values from block.content\n * 3. Walks the rendered tree and wraps matching text nodes with spans\n */\nexport function BlockRenderer({\n block,\n registry,\n disableEditable,\n routeParams,\n path,\n}: BlockRendererProps) {\n const Component = resolveComponent(registry, block.type, path);\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union\n const component = <Component content={block.content as any} routeParams={routeParams} />;\n\n if (disableEditable) {\n return component;\n }\n\n // Extract all string values from content with their paths\n const contentValueMap = extractContentValues(block.content as Record<string, unknown>);\n\n // Try server-side tree walking for text span injection (best-effort).\n // This works for sync server components but fails for client components,\n // async components, and components using hooks.\n let renderedComponent: React.ReactNode = component;\n let needsClientSideSpans = true;\n\n try {\n const renderedTree = renderToWalkableTree(component);\n\n // Check if tree walking produced host elements (div, section, etc.)\n // vs the original component reference (function type = couldn't be invoked)\n const isWalkable =\n React.isValidElement(renderedTree) &&\n typeof (renderedTree as React.ReactElement<Record<string, unknown>>).type === 'string';\n\n if (isWalkable) {\n const usedPaths = new Set<string>();\n\n renderedComponent = walkReactNode(renderedTree, {\n onText: ({ value, key, path, inSvg, parentType }) => {\n // Never inject spans inside SVG subtrees or elements that don't accept span children\n if (inSvg) return value;\n if (parentType && typeof parentType === 'string' && SKIP_SPAN_PARENTS.has(parentType)) {\n return value;\n }\n\n const matches = contentValueMap.get(value);\n if (!matches || matches.length === 0) return value;\n\n const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];\n if (!match) return value;\n\n usedPaths.add(match.contentPath);\n const spanKey = key ?? `${block.id}-${match.contentPath}-${path.join('-')}`;\n\n return (\n <span\n key={spanKey}\n data-cms-editable\n data-block-id={block.id}\n data-block-type={block.type}\n data-content-path={match.contentPath}\n style={{ display: 'contents' }}\n >\n {value}\n </span>\n );\n },\n });\n\n needsClientSideSpans = false;\n }\n } catch {\n // Tree walking failed entirely, fall back to client-side injection\n }\n\n // Build content entries for client-side text matching when server-side fails\n const contentEntries = needsClientSideSpans\n ? Array.from(contentValueMap.entries())\n .map(([value, matches]) => ({ v: value, p: matches[0]?.contentPath }))\n .filter((e): e is { v: string; p: string } => !!e.p)\n : [];\n\n return (\n <div\n key={block.id}\n data-cms-block\n data-block-id={block.id}\n data-block-type={block.type}\n style={{ display: 'contents' }}\n >\n {needsClientSideSpans && contentEntries.length > 0 ? (\n <ClientEditableBlock\n blockId={block.id}\n blockType={block.type}\n contentEntries={contentEntries}\n >\n {renderedComponent}\n </ClientEditableBlock>\n ) : (\n renderedComponent\n )}\n <BlockToolbar key=\"cms-toolbar\" blockId={block.id} />\n </div>\n );\n}\n"],"mappings":"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","names":["path"]}
@@ -41,6 +41,7 @@ function ClientEditableBlock({
41
41
  n = walker.nextNode();
42
42
  }
43
43
  for (const textNode of textNodes) {
44
+ if (textNode.parentElement?.closest("svg") != null) continue;
44
45
  const text = textNode.nodeValue;
45
46
  if (!text) continue;
46
47
  for (const entry of contentEntries) {
@@ -48,6 +49,7 @@ function ClientEditableBlock({
48
49
  if (text.indexOf(entry.v) !== -1 && text.trim() === entry.v.trim()) {
49
50
  used.add(entry.p);
50
51
  const span = document.createElement("span");
52
+ span.style.display = "contents";
51
53
  span.setAttribute("data-cms-editable", "");
52
54
  span.setAttribute("data-block-id", blockId);
53
55
  span.setAttribute("data-block-type", blockType);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../lib/client-editable-block.tsx"],"sourcesContent":["'use client';\n\nimport { useCallback, useLayoutEffect, useRef } from 'react';\n\ninterface ContentEntry {\n v: string;\n p: string;\n}\n\ninterface ClientEditableBlockProps {\n blockId: string;\n blockType: string;\n contentEntries: ContentEntry[];\n children: React.ReactNode;\n}\n\n/**\n * Client-side span injector for blocks whose components use React hooks\n * and therefore can't be walked server-side.\n *\n * Uses a MutationObserver to re-inject [data-cms-editable] spans after every\n * React commit (including state-driven re-renders like animations), so the\n * overlays survive React's reconciliation.\n */\nexport function ClientEditableBlock({\n blockId,\n blockType,\n contentEntries,\n children,\n}: ClientEditableBlockProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const observerRef = useRef<MutationObserver | null>(null);\n const isInjectingRef = useRef(false);\n\n const injectSpans = useCallback(() => {\n if (isInjectingRef.current) return;\n const container = containerRef.current;\n if (!container) return;\n\n isInjectingRef.current = true;\n // Disconnect observer while we mutate the DOM to avoid re-entrant calls.\n observerRef.current?.disconnect();\n\n try {\n // Remove previously injected text-level spans, restoring the original text nodes.\n const existing = Array.from(\n container.querySelectorAll<HTMLElement>('span[data-cms-editable][data-content-path]')\n );\n for (const span of existing) {\n const parent = span.parentNode;\n if (!parent) continue;\n while (span.firstChild) parent.insertBefore(span.firstChild, span);\n parent.removeChild(span);\n }\n\n // Walk all visible text nodes and wrap the ones that match content values.\n const used = new Set<string>();\n const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);\n const textNodes: Text[] = [];\n let n = walker.nextNode();\n while (n !== null) {\n const textNode = n as Text;\n if (\n textNode.parentElement?.closest('.cms-block-toolbar') == null &&\n textNode.nodeValue?.trim()\n ) {\n textNodes.push(textNode);\n }\n n = walker.nextNode();\n }\n\n for (const textNode of textNodes) {\n const text = textNode.nodeValue;\n if (!text) continue;\n for (const entry of contentEntries) {\n if (used.has(entry.p)) continue;\n if (text.indexOf(entry.v) !== -1 && text.trim() === entry.v.trim()) {\n used.add(entry.p);\n const span = document.createElement('span');\n span.setAttribute('data-cms-editable', '');\n span.setAttribute('data-block-id', blockId);\n span.setAttribute('data-block-type', blockType);\n span.setAttribute('data-content-path', entry.p);\n textNode.parentNode?.insertBefore(span, textNode);\n span.appendChild(textNode);\n break;\n }\n }\n }\n } finally {\n isInjectingRef.current = false;\n // Reconnect after our mutations are done.\n const current = containerRef.current;\n if (current && observerRef.current) {\n observerRef.current.observe(current, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n }\n }, [blockId, blockType, contentEntries]);\n\n useLayoutEffect(() => {\n // Initial injection after React's first commit.\n injectSpans();\n\n // Re-inject whenever the child component mutates the DOM\n // (e.g. animated state changes that swap visible elements).\n const observer = new MutationObserver(injectSpans);\n observerRef.current = observer;\n\n if (containerRef.current) {\n observer.observe(containerRef.current, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n return () => {\n observer.disconnect();\n observerRef.current = null;\n };\n }, [injectSpans]);\n\n return (\n <div ref={containerRef} style={{ display: 'contents' }}>\n {children}\n </div>\n );\n}\n"],"mappings":";;;;AAEA,SAAS,aAAa,iBAAiB,cAAc;AA6HjD;AAvGG,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6B;AAC3B,QAAM,eAAe,OAAuB,IAAI;AAChD,QAAM,cAAc,OAAgC,IAAI;AACxD,QAAM,iBAAiB,OAAO,KAAK;AAEnC,QAAM,cAAc,YAAY,MAAM;AACpC,QAAI,eAAe,QAAS;AAC5B,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAEhB,mBAAe,UAAU;AAEzB,gBAAY,SAAS,WAAW;AAEhC,QAAI;AAEF,YAAM,WAAW,MAAM;AAAA,QACrB,UAAU,iBAA8B,4CAA4C;AAAA,MACtF;AACA,iBAAW,QAAQ,UAAU;AAC3B,cAAM,SAAS,KAAK;AACpB,YAAI,CAAC,OAAQ;AACb,eAAO,KAAK,WAAY,QAAO,aAAa,KAAK,YAAY,IAAI;AACjE,eAAO,YAAY,IAAI;AAAA,MACzB;AAGA,YAAM,OAAO,oBAAI,IAAY;AAC7B,YAAM,SAAS,SAAS,iBAAiB,WAAW,WAAW,WAAW,IAAI;AAC9E,YAAM,YAAoB,CAAC;AAC3B,UAAI,IAAI,OAAO,SAAS;AACxB,aAAO,MAAM,MAAM;AACjB,cAAM,WAAW;AACjB,YACE,SAAS,eAAe,QAAQ,oBAAoB,KAAK,QACzD,SAAS,WAAW,KAAK,GACzB;AACA,oBAAU,KAAK,QAAQ;AAAA,QACzB;AACA,YAAI,OAAO,SAAS;AAAA,MACtB;AAEA,iBAAW,YAAY,WAAW;AAChC,cAAM,OAAO,SAAS;AACtB,YAAI,CAAC,KAAM;AACX,mBAAW,SAAS,gBAAgB;AAClC,cAAI,KAAK,IAAI,MAAM,CAAC,EAAG;AACvB,cAAI,KAAK,QAAQ,MAAM,CAAC,MAAM,MAAM,KAAK,KAAK,MAAM,MAAM,EAAE,KAAK,GAAG;AAClE,iBAAK,IAAI,MAAM,CAAC;AAChB,kBAAM,OAAO,SAAS,cAAc,MAAM;AAC1C,iBAAK,aAAa,qBAAqB,EAAE;AACzC,iBAAK,aAAa,iBAAiB,OAAO;AAC1C,iBAAK,aAAa,mBAAmB,SAAS;AAC9C,iBAAK,aAAa,qBAAqB,MAAM,CAAC;AAC9C,qBAAS,YAAY,aAAa,MAAM,QAAQ;AAChD,iBAAK,YAAY,QAAQ;AACzB;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF,UAAE;AACA,qBAAe,UAAU;AAEzB,YAAM,UAAU,aAAa;AAC7B,UAAI,WAAW,YAAY,SAAS;AAClC,oBAAY,QAAQ,QAAQ,SAAS;AAAA,UACnC,WAAW;AAAA,UACX,SAAS;AAAA,UACT,eAAe;AAAA,QACjB,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,GAAG,CAAC,SAAS,WAAW,cAAc,CAAC;AAEvC,kBAAgB,MAAM;AAEpB,gBAAY;AAIZ,UAAM,WAAW,IAAI,iBAAiB,WAAW;AACjD,gBAAY,UAAU;AAEtB,QAAI,aAAa,SAAS;AACxB,eAAS,QAAQ,aAAa,SAAS;AAAA,QACrC,WAAW;AAAA,QACX,SAAS;AAAA,QACT,eAAe;AAAA,MACjB,CAAC;AAAA,IACH;AAEA,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,WAAW,CAAC;AAEhB,SACE,oBAAC,SAAI,KAAK,cAAc,OAAO,EAAE,SAAS,WAAW,GAClD,UACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../lib/client-editable-block.tsx"],"sourcesContent":["'use client';\n\nimport { useCallback, useLayoutEffect, useRef } from 'react';\n\ninterface ContentEntry {\n v: string;\n p: string;\n}\n\ninterface ClientEditableBlockProps {\n blockId: string;\n blockType: string;\n contentEntries: ContentEntry[];\n children: React.ReactNode;\n}\n\n/**\n * Client-side span injector for blocks whose components use React hooks\n * and therefore can't be walked server-side.\n *\n * Uses a MutationObserver to re-inject [data-cms-editable] spans after every\n * React commit (including state-driven re-renders like animations), so the\n * overlays survive React's reconciliation.\n */\nexport function ClientEditableBlock({\n blockId,\n blockType,\n contentEntries,\n children,\n}: ClientEditableBlockProps) {\n const containerRef = useRef<HTMLDivElement>(null);\n const observerRef = useRef<MutationObserver | null>(null);\n const isInjectingRef = useRef(false);\n\n const injectSpans = useCallback(() => {\n if (isInjectingRef.current) return;\n const container = containerRef.current;\n if (!container) return;\n\n isInjectingRef.current = true;\n // Disconnect observer while we mutate the DOM to avoid re-entrant calls.\n observerRef.current?.disconnect();\n\n try {\n // Remove previously injected text-level spans, restoring the original text nodes.\n const existing = Array.from(\n container.querySelectorAll<HTMLElement>('span[data-cms-editable][data-content-path]')\n );\n for (const span of existing) {\n const parent = span.parentNode;\n if (!parent) continue;\n while (span.firstChild) parent.insertBefore(span.firstChild, span);\n parent.removeChild(span);\n }\n\n // Walk all visible text nodes and wrap the ones that match content values.\n const used = new Set<string>();\n const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);\n const textNodes: Text[] = [];\n let n = walker.nextNode();\n while (n !== null) {\n const textNode = n as Text;\n if (\n textNode.parentElement?.closest('.cms-block-toolbar') == null &&\n textNode.nodeValue?.trim()\n ) {\n textNodes.push(textNode);\n }\n n = walker.nextNode();\n }\n\n for (const textNode of textNodes) {\n // Never inject spans inside SVG — <span> is not valid SVG content\n if (textNode.parentElement?.closest('svg') != null) continue;\n\n const text = textNode.nodeValue;\n if (!text) continue;\n for (const entry of contentEntries) {\n if (used.has(entry.p)) continue;\n if (text.indexOf(entry.v) !== -1 && text.trim() === entry.v.trim()) {\n used.add(entry.p);\n const span = document.createElement('span');\n // Inline style ensures layout-transparency before the stylesheet cascade applies\n span.style.display = 'contents';\n span.setAttribute('data-cms-editable', '');\n span.setAttribute('data-block-id', blockId);\n span.setAttribute('data-block-type', blockType);\n span.setAttribute('data-content-path', entry.p);\n textNode.parentNode?.insertBefore(span, textNode);\n span.appendChild(textNode);\n break;\n }\n }\n }\n } finally {\n isInjectingRef.current = false;\n // Reconnect after our mutations are done.\n const current = containerRef.current;\n if (current && observerRef.current) {\n observerRef.current.observe(current, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n }\n }, [blockId, blockType, contentEntries]);\n\n useLayoutEffect(() => {\n // Initial injection after React's first commit.\n injectSpans();\n\n // Re-inject whenever the child component mutates the DOM\n // (e.g. animated state changes that swap visible elements).\n const observer = new MutationObserver(injectSpans);\n observerRef.current = observer;\n\n if (containerRef.current) {\n observer.observe(containerRef.current, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n\n return () => {\n observer.disconnect();\n observerRef.current = null;\n };\n }, [injectSpans]);\n\n return (\n <div ref={containerRef} style={{ display: 'contents' }}>\n {children}\n </div>\n );\n}\n"],"mappings":";;;;AAEA,SAAS,aAAa,iBAAiB,cAAc;AAkIjD;AA5GG,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6B;AAC3B,QAAM,eAAe,OAAuB,IAAI;AAChD,QAAM,cAAc,OAAgC,IAAI;AACxD,QAAM,iBAAiB,OAAO,KAAK;AAEnC,QAAM,cAAc,YAAY,MAAM;AACpC,QAAI,eAAe,QAAS;AAC5B,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAEhB,mBAAe,UAAU;AAEzB,gBAAY,SAAS,WAAW;AAEhC,QAAI;AAEF,YAAM,WAAW,MAAM;AAAA,QACrB,UAAU,iBAA8B,4CAA4C;AAAA,MACtF;AACA,iBAAW,QAAQ,UAAU;AAC3B,cAAM,SAAS,KAAK;AACpB,YAAI,CAAC,OAAQ;AACb,eAAO,KAAK,WAAY,QAAO,aAAa,KAAK,YAAY,IAAI;AACjE,eAAO,YAAY,IAAI;AAAA,MACzB;AAGA,YAAM,OAAO,oBAAI,IAAY;AAC7B,YAAM,SAAS,SAAS,iBAAiB,WAAW,WAAW,WAAW,IAAI;AAC9E,YAAM,YAAoB,CAAC;AAC3B,UAAI,IAAI,OAAO,SAAS;AACxB,aAAO,MAAM,MAAM;AACjB,cAAM,WAAW;AACjB,YACE,SAAS,eAAe,QAAQ,oBAAoB,KAAK,QACzD,SAAS,WAAW,KAAK,GACzB;AACA,oBAAU,KAAK,QAAQ;AAAA,QACzB;AACA,YAAI,OAAO,SAAS;AAAA,MACtB;AAEA,iBAAW,YAAY,WAAW;AAEhC,YAAI,SAAS,eAAe,QAAQ,KAAK,KAAK,KAAM;AAEpD,cAAM,OAAO,SAAS;AACtB,YAAI,CAAC,KAAM;AACX,mBAAW,SAAS,gBAAgB;AAClC,cAAI,KAAK,IAAI,MAAM,CAAC,EAAG;AACvB,cAAI,KAAK,QAAQ,MAAM,CAAC,MAAM,MAAM,KAAK,KAAK,MAAM,MAAM,EAAE,KAAK,GAAG;AAClE,iBAAK,IAAI,MAAM,CAAC;AAChB,kBAAM,OAAO,SAAS,cAAc,MAAM;AAE1C,iBAAK,MAAM,UAAU;AACrB,iBAAK,aAAa,qBAAqB,EAAE;AACzC,iBAAK,aAAa,iBAAiB,OAAO;AAC1C,iBAAK,aAAa,mBAAmB,SAAS;AAC9C,iBAAK,aAAa,qBAAqB,MAAM,CAAC;AAC9C,qBAAS,YAAY,aAAa,MAAM,QAAQ;AAChD,iBAAK,YAAY,QAAQ;AACzB;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF,UAAE;AACA,qBAAe,UAAU;AAEzB,YAAM,UAAU,aAAa;AAC7B,UAAI,WAAW,YAAY,SAAS;AAClC,oBAAY,QAAQ,QAAQ,SAAS;AAAA,UACnC,WAAW;AAAA,UACX,SAAS;AAAA,UACT,eAAe;AAAA,QACjB,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,GAAG,CAAC,SAAS,WAAW,cAAc,CAAC;AAEvC,kBAAgB,MAAM;AAEpB,gBAAY;AAIZ,UAAM,WAAW,IAAI,iBAAiB,WAAW;AACjD,gBAAY,UAAU;AAEtB,QAAI,aAAa,SAAS;AACxB,eAAS,QAAQ,aAAa,SAAS;AAAA,QACrC,WAAW;AAAA,QACX,SAAS;AAAA,QACT,eAAe;AAAA,MACjB,CAAC;AAAA,IACH;AAEA,WAAO,MAAM;AACX,eAAS,WAAW;AACpB,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,WAAW,CAAC;AAEhB,SACE,oBAAC,SAAI,KAAK,cAAc,OAAO,EAAE,SAAS,WAAW,GAClD,UACH;AAEJ;","names":[]}