meno-core 1.0.52 → 1.0.53

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 (135) hide show
  1. package/build-astro.ts +183 -13
  2. package/build-next.ts +1361 -0
  3. package/build-static.ts +7 -5
  4. package/dist/bin/cli.js +2 -2
  5. package/dist/build-static.js +6 -6
  6. package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
  7. package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
  8. package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
  9. package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
  10. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
  11. package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
  12. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
  13. package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
  14. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
  15. package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
  16. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
  17. package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
  18. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
  19. package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
  20. package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
  21. package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
  22. package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
  23. package/dist/chunks/chunk-X754AHS5.js.map +7 -0
  24. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
  25. package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
  26. package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
  27. package/dist/entries/server-router.js +7 -7
  28. package/dist/lib/client/index.js +354 -59
  29. package/dist/lib/client/index.js.map +4 -4
  30. package/dist/lib/server/index.js +1458 -190
  31. package/dist/lib/server/index.js.map +4 -4
  32. package/dist/lib/shared/index.js +202 -34
  33. package/dist/lib/shared/index.js.map +4 -4
  34. package/dist/lib/test-utils/index.js +1 -1
  35. package/entries/client-router.tsx +5 -165
  36. package/lib/client/ErrorBoundary.test.tsx +27 -25
  37. package/lib/client/ErrorBoundary.tsx +34 -19
  38. package/lib/client/core/ComponentBuilder.ts +19 -2
  39. package/lib/client/core/builders/embedBuilder.ts +8 -4
  40. package/lib/client/core/builders/listBuilder.ts +23 -4
  41. package/lib/client/fontFamiliesService.test.ts +76 -0
  42. package/lib/client/fontFamiliesService.ts +69 -0
  43. package/lib/client/hmrCssReload.ts +160 -0
  44. package/lib/client/hooks/useColorVariables.ts +2 -0
  45. package/lib/client/index.ts +4 -0
  46. package/lib/client/meno-filter/ui.ts +2 -0
  47. package/lib/client/routing/RouteLoader.test.ts +2 -2
  48. package/lib/client/routing/RouteLoader.ts +8 -2
  49. package/lib/client/routing/Router.tsx +81 -15
  50. package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
  51. package/lib/client/scripts/ScriptExecutor.ts +56 -2
  52. package/lib/client/styles/StyleInjector.ts +20 -5
  53. package/lib/client/styles/UtilityClassCollector.ts +7 -1
  54. package/lib/client/styles/cspNonce.test.ts +67 -0
  55. package/lib/client/styles/cspNonce.ts +63 -0
  56. package/lib/client/templateEngine.test.ts +80 -0
  57. package/lib/client/templateEngine.ts +5 -0
  58. package/lib/server/astro/cmsPageEmitter.ts +35 -5
  59. package/lib/server/astro/componentEmitter.ts +61 -5
  60. package/lib/server/astro/nodeToAstro.ts +149 -11
  61. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
  62. package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
  63. package/lib/server/createServer.ts +11 -0
  64. package/lib/server/draftPageStore.ts +49 -0
  65. package/lib/server/fileWatcher.ts +62 -2
  66. package/lib/server/index.ts +13 -1
  67. package/lib/server/providers/fileSystemPageProvider.ts +8 -0
  68. package/lib/server/routes/api/components.ts +9 -4
  69. package/lib/server/routes/api/core-routes.ts +2 -2
  70. package/lib/server/routes/api/pages.ts +14 -22
  71. package/lib/server/routes/api/shared.ts +56 -0
  72. package/lib/server/routes/index.ts +90 -0
  73. package/lib/server/routes/pages.ts +13 -6
  74. package/lib/server/services/componentService.test.ts +199 -2
  75. package/lib/server/services/componentService.ts +354 -49
  76. package/lib/server/services/fileWatcherService.ts +4 -24
  77. package/lib/server/services/pageService.test.ts +23 -0
  78. package/lib/server/services/pageService.ts +124 -6
  79. package/lib/server/ssr/attributeBuilder.ts +8 -2
  80. package/lib/server/ssr/buildErrorOverlay.ts +1 -1
  81. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  82. package/lib/server/ssr/errorOverlay.ts +38 -11
  83. package/lib/server/ssr/htmlGenerator.test.ts +53 -13
  84. package/lib/server/ssr/htmlGenerator.ts +71 -27
  85. package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
  86. package/lib/server/ssr/ssrRenderer.test.ts +67 -0
  87. package/lib/server/ssr/ssrRenderer.ts +94 -9
  88. package/lib/server/websocketManager.ts +0 -1
  89. package/lib/shared/componentRefs.ts +45 -0
  90. package/lib/shared/constants.ts +8 -0
  91. package/lib/shared/cssGeneration.ts +2 -0
  92. package/lib/shared/cssProperties.ts +184 -0
  93. package/lib/shared/expressionEvaluator.ts +54 -0
  94. package/lib/shared/fontCss.ts +101 -0
  95. package/lib/shared/fontLoader.ts +8 -86
  96. package/lib/shared/friendlyError.test.ts +87 -0
  97. package/lib/shared/friendlyError.ts +121 -0
  98. package/lib/shared/hrefRefs.test.ts +130 -0
  99. package/lib/shared/hrefRefs.ts +100 -0
  100. package/lib/shared/index.ts +52 -0
  101. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  102. package/lib/shared/inlineSvgStyleRules.ts +134 -0
  103. package/lib/shared/interfaces/contentProvider.ts +13 -0
  104. package/lib/shared/itemTemplateUtils.test.ts +14 -0
  105. package/lib/shared/itemTemplateUtils.ts +4 -1
  106. package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
  107. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
  108. package/lib/shared/slugTranslator.test.ts +24 -0
  109. package/lib/shared/slugTranslator.ts +24 -0
  110. package/lib/shared/styleNodeUtils.ts +4 -1
  111. package/lib/shared/tree/PathBuilder.test.ts +128 -1
  112. package/lib/shared/tree/PathBuilder.ts +83 -31
  113. package/lib/shared/types/comment.ts +99 -0
  114. package/lib/shared/types/index.ts +12 -0
  115. package/lib/shared/types/rendering.ts +8 -0
  116. package/lib/shared/utilityClassConfig.ts +4 -2
  117. package/lib/shared/utilityClassMapper.test.ts +24 -0
  118. package/lib/shared/validation/commentValidators.ts +69 -0
  119. package/lib/shared/validation/index.ts +1 -0
  120. package/lib/shared/viewportUnits.integration.test.ts +42 -0
  121. package/lib/shared/viewportUnits.test.ts +103 -0
  122. package/lib/shared/viewportUnits.ts +63 -0
  123. package/lib/test-utils/dom-setup.ts +6 -0
  124. package/package.json +1 -1
  125. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  126. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  127. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  128. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  129. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  130. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  131. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  132. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  133. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  134. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  135. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
@@ -1,180 +1,21 @@
1
1
  import { createElement as h } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { Router, type RouterProps } from "../lib/client/routing/Router";
4
+ import { setupCssHmrListeners } from "../lib/client/hmrCssReload";
4
5
  import type { PrefetchConfig } from "../lib/shared/types/prefetch";
5
6
 
6
- // Extend Window interface for HMR state and config
7
+ // Extend Window interface for config
7
8
  declare global {
8
9
  interface Window {
9
- __hmrColorsInitialized?: boolean;
10
- __hmrVariablesInitialized?: boolean;
11
10
  __MENO_CONFIG__?: {
12
11
  prefetch?: Partial<PrefetchConfig>;
13
12
  };
14
13
  }
15
14
  }
16
15
 
17
- // Setup HMR colors update listener immediately on app load
18
- function setupColorsHMR() {
19
- if (typeof window === 'undefined') return;
20
-
21
- // Mark as initialized
22
- if (window.__hmrColorsInitialized) return;
23
- window.__hmrColorsInitialized = true;
24
-
25
- // Listen for color updates from HMR
26
- document.addEventListener('hmr-colors-update', async () => {
27
- await injectUpdatedThemeCSS();
28
- });
29
- }
30
-
31
- // Fetch and inject updated theme CSS
32
- async function injectUpdatedThemeCSS() {
33
- try {
34
- // Fetch the theme config
35
- const themesResponse = await fetch('/api/themes');
36
- if (!themesResponse.ok) return;
37
-
38
- const themesData = await themesResponse.json() as {
39
- themes: Array<{ name: string; label: string }>;
40
- default: string;
41
- };
42
-
43
- // Fetch colors for each theme
44
- const themeColors: Record<string, any> = {};
45
- for (const theme of themesData.themes) {
46
- const colorResponse = await fetch(
47
- `/api/colors?theme=${encodeURIComponent(theme.name)}`
48
- );
49
- if (colorResponse.ok) {
50
- themeColors[theme.name] = (await colorResponse.json()).colors;
51
- }
52
- }
53
-
54
- // Also get default theme colors
55
- const defaultResponse = await fetch('/api/colors');
56
- if (defaultResponse.ok) {
57
- themeColors['default'] = (await defaultResponse.json()).colors;
58
- }
59
-
60
- // Generate CSS
61
- let css = '';
62
-
63
- // Default theme in :root
64
- if (themeColors['default']) {
65
- const vars = Object.entries(themeColors['default'])
66
- .map(([name, value]) => ` --${name}: ${value};`)
67
- .join('\n');
68
- css += `:root {\n${vars}\n}\n\n`;
69
- }
70
-
71
- // Each theme in [theme="..."]
72
- for (const theme of themesData.themes) {
73
- if (themeColors[theme.name]) {
74
- const vars = Object.entries(themeColors[theme.name])
75
- .map(([name, value]) => ` --${name}: ${value};`)
76
- .join('\n');
77
- css += `[theme="${theme.name}"] {\n${vars}\n}\n\n`;
78
- }
79
- }
80
-
81
- // Find or create style tag
82
- let styleTag = document.getElementById('hmr-theme-variables');
83
- if (!styleTag) {
84
- styleTag = document.createElement('style');
85
- styleTag.id = 'hmr-theme-variables';
86
- document.head.appendChild(styleTag);
87
- }
88
-
89
- styleTag.textContent = css;
90
- } catch (error) {
91
- // Silently fail - not critical if CSS injection doesn't work
92
- }
93
- }
94
-
95
- // Setup HMR variables update listener immediately on app load
96
- function setupVariablesHMR() {
97
- if (typeof window === 'undefined') return;
98
-
99
- if (window.__hmrVariablesInitialized) return;
100
- window.__hmrVariablesInitialized = true;
101
-
102
- document.addEventListener('hmr-variables-update', async () => {
103
- await injectUpdatedVariablesCSS();
104
- });
105
- }
106
-
107
- // Fetch and inject updated variables CSS
108
- async function injectUpdatedVariablesCSS() {
109
- try {
110
- const response = await fetch('/api/variables-css');
111
- if (!response.ok) return;
112
-
113
- const css = await response.text();
114
-
115
- let styleTag = document.getElementById('hmr-css-variables');
116
- if (!styleTag) {
117
- styleTag = document.createElement('style');
118
- styleTag.id = 'hmr-css-variables';
119
- document.head.appendChild(styleTag);
120
- }
121
-
122
- styleTag.textContent = css;
123
- } catch (error) {
124
- // Silently fail - not critical if CSS injection doesn't work
125
- }
126
- }
127
-
128
- // Setup HMR fonts update listener immediately on app load
129
- function setupFontsHMR() {
130
- if (typeof window === 'undefined') return;
131
-
132
- if ((window as any).__hmrFontsCSSInitialized) return;
133
- (window as any).__hmrFontsCSSInitialized = true;
134
-
135
- document.addEventListener('hmr-fonts-update', async () => {
136
- await injectUpdatedFontsCSS();
137
- });
138
- }
139
-
140
- // Fetch and inject updated fonts CSS
141
- async function injectUpdatedFontsCSS() {
142
- try {
143
- const response = await fetch('/api/fonts-css');
144
- if (!response.ok) return;
145
-
146
- const css = await response.text();
147
-
148
- let styleTag = document.getElementById('hmr-fonts-css');
149
- if (!styleTag) {
150
- styleTag = document.createElement('style');
151
- styleTag.id = 'hmr-fonts-css';
152
- document.head.appendChild(styleTag);
153
- }
154
-
155
- styleTag.textContent = css;
156
- } catch (error) {
157
- // Silently fail
158
- }
159
- }
160
-
161
- // Setup HMR libraries update listener - triggers full page reload
162
- function setupLibrariesHMR() {
163
- if (typeof window === 'undefined') return;
164
-
165
- if ((window as any).__hmrLibrariesInitialized) return;
166
- (window as any).__hmrLibrariesInitialized = true;
167
-
168
- document.addEventListener('hmr-libraries-update', () => {
169
- location.reload();
170
- });
171
- }
172
-
173
- // Initialize HMR listeners
174
- setupColorsHMR();
175
- setupVariablesHMR();
176
- setupFontsHMR();
177
- setupLibrariesHMR();
16
+ // Wire CSS/asset hot-reload listeners (colors, variables, fonts, libraries).
17
+ // Shared with the editor preview router — see lib/client/hmrCssReload.ts.
18
+ setupCssHmrListeners();
178
19
 
179
20
  // Render app with HMR support and prefetching enabled
180
21
  const rootElement = document.getElementById('root');
@@ -203,4 +44,3 @@ if (rootElement) {
203
44
  if (import.meta.hot) {
204
45
  import.meta.hot.accept();
205
46
  }
206
-
@@ -64,10 +64,9 @@ describe("ErrorBoundary - Error catching", () => {
64
64
  root.render(errorBoundary);
65
65
  await wait(10); // Wait for React to process error
66
66
 
67
- // Verify fallback UI is displayed with error message
68
- expect(container.textContent).toContain("Component Error");
67
+ // Verify friendly fallback UI is displayed with the component name
68
+ expect(container.textContent).toContain("This section ran into a problem");
69
69
  expect(container.textContent).toContain("TestComponent");
70
- expect(container.textContent).toContain("Test render error");
71
70
  } finally {
72
71
  root.unmount();
73
72
  cleanupContainer(container);
@@ -91,7 +90,7 @@ describe("ErrorBoundary - Error catching", () => {
91
90
  // Should render the safe content
92
91
  expect(container.textContent).toContain("Safe content");
93
92
  // Should NOT show error UI
94
- expect(container.textContent).not.toContain("Component Error");
93
+ expect(container.textContent).not.toContain("This section ran into a problem");
95
94
  } finally {
96
95
  root.unmount();
97
96
  cleanupContainer(container);
@@ -148,8 +147,9 @@ describe("ErrorBoundary - Error catching", () => {
148
147
  root.render(errorBoundary);
149
148
  await wait(10);
150
149
 
151
- // Should show page-level error UI
152
- expect(container.textContent).toContain("Page Rendering Error");
150
+ // Should show friendly page-level error UI
151
+ expect(container.textContent).toContain("This section ran into a problem");
152
+ // Raw message is preserved in the collapsible technical details.
153
153
  expect(container.textContent).toContain("Page rendering failed");
154
154
  } finally {
155
155
  root.unmount();
@@ -171,7 +171,7 @@ describe("ErrorBoundary - Error catching", () => {
171
171
  await wait(10);
172
172
 
173
173
  expect(container.textContent).toContain("Page content");
174
- expect(container.textContent).not.toContain("Page Rendering Error");
174
+ expect(container.textContent).not.toContain("This section ran into a problem");
175
175
  } finally {
176
176
  root.unmount();
177
177
  cleanupContainer(container);
@@ -200,7 +200,7 @@ describe("ErrorBoundary - Error catching", () => {
200
200
  // Should show custom fallback
201
201
  expect(container.textContent).toContain("Custom fallback: Custom error");
202
202
  // Should NOT show default error UI
203
- expect(container.textContent).not.toContain("Component Error");
203
+ expect(container.textContent).not.toContain("This section ran into a problem");
204
204
  } finally {
205
205
  root.unmount();
206
206
  cleanupContainer(container);
@@ -250,7 +250,8 @@ describe("ErrorBoundary - Error display", () => {
250
250
  root.render(errorBoundary);
251
251
  await wait(10);
252
252
 
253
- expect(container.textContent).toContain("Specific error message");
253
+ // Component-level shows friendly copy; the raw message is dev-only.
254
+ expect(container.textContent).toContain("This section ran into a problem");
254
255
  } finally {
255
256
  root.unmount();
256
257
  cleanupContainer(container);
@@ -271,7 +272,7 @@ describe("ErrorBoundary - Error display", () => {
271
272
  await wait(10);
272
273
 
273
274
  // Should still show error UI even without message
274
- expect(container.textContent).toContain("Component Error");
275
+ expect(container.textContent).toContain("This section ran into a problem");
275
276
  } finally {
276
277
  root.unmount();
277
278
  cleanupContainer(container);
@@ -295,6 +296,7 @@ describe("ErrorBoundary - Error display", () => {
295
296
  await wait(10);
296
297
 
297
298
  expect(container.textContent).toContain("MyCustomComponent");
299
+ expect(container.textContent).toContain("This section ran into a problem");
298
300
  } finally {
299
301
  root.unmount();
300
302
  cleanupContainer(container);
@@ -314,7 +316,7 @@ describe("ErrorBoundary - Error display", () => {
314
316
  root.render(errorBoundary);
315
317
  await wait(10);
316
318
 
317
- expect(container.textContent).toContain("Component Error");
319
+ expect(container.textContent).toContain("This section ran into a problem");
318
320
  } finally {
319
321
  root.unmount();
320
322
  cleanupContainer(container);
@@ -336,10 +338,10 @@ describe("ErrorBoundary - Error display", () => {
336
338
  root.render(errorBoundary);
337
339
  await wait(10);
338
340
 
339
- // Component-level shows "Component Error"
340
- expect(container.textContent).toContain("Component Error");
341
- // Should NOT show page-level error
342
- expect(container.textContent).not.toContain("Page Rendering Error");
341
+ // Component-level shows the friendly title...
342
+ expect(container.textContent).toContain("This section ran into a problem");
343
+ // ...but NOT the page-level "Reload Page" affordance.
344
+ expect(container.textContent).not.toContain("Reload Page");
343
345
  } finally {
344
346
  root.unmount();
345
347
  cleanupContainer(container);
@@ -483,8 +485,7 @@ describe("ErrorBoundary - Error types", () => {
483
485
  root.render(errorBoundary);
484
486
  await wait(10);
485
487
 
486
- expect(container.textContent).toContain("Type mismatch");
487
- expect(container.textContent).toContain("Component Error");
488
+ expect(container.textContent).toContain("This section ran into a problem");
488
489
  } finally {
489
490
  root.unmount();
490
491
  cleanupContainer(container);
@@ -504,7 +505,8 @@ describe("ErrorBoundary - Error types", () => {
504
505
  root.render(errorBoundary);
505
506
  await wait(10);
506
507
 
507
- expect(container.textContent).toContain("undefined variable");
508
+ // Raw message is dev-only for component-level; friendly title always shows.
509
+ expect(container.textContent).toContain("This section ran into a problem");
508
510
  } finally {
509
511
  root.unmount();
510
512
  cleanupContainer(container);
@@ -524,7 +526,8 @@ describe("ErrorBoundary - Error types", () => {
524
526
  root.render(errorBoundary);
525
527
  await wait(10);
526
528
 
527
- expect(container.textContent).toContain("Invalid component definition: Missing structure");
529
+ // Friendly, plain-language copy replaces the raw JS message for end users.
530
+ expect(container.textContent).toContain("This section ran into a problem");
528
531
  } finally {
529
532
  root.unmount();
530
533
  cleanupContainer(container);
@@ -556,7 +559,7 @@ describe("ErrorBoundary - Multiple instances", () => {
556
559
  await wait(10);
557
560
 
558
561
  // Both should be in the DOM
559
- expect(container.textContent).toContain("Component Error");
562
+ expect(container.textContent).toContain("This section ran into a problem");
560
563
  expect(container.textContent).toContain("Safe content");
561
564
  } finally {
562
565
  root.unmount();
@@ -582,11 +585,10 @@ describe("ErrorBoundary - Multiple instances", () => {
582
585
  root.render(app);
583
586
  await wait(10);
584
587
 
585
- // Inner boundary should catch the error
586
- expect(container.textContent).toContain("Component Error");
587
- expect(container.textContent).toContain("Inner error");
588
- // Should NOT show page-level error since inner boundary caught it
589
- expect(container.textContent).not.toContain("Page Rendering Error");
588
+ // Inner (component-level) boundary catches it: friendly title shows,
589
+ // and the page-level "Reload Page" affordance does not.
590
+ expect(container.textContent).toContain("This section ran into a problem");
591
+ expect(container.textContent).not.toContain("Reload Page");
590
592
  } finally {
591
593
  root.unmount();
592
594
  cleanupContainer(container);
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Component, createElement as h, ReactNode } from 'react';
7
+ import { toFriendlyError } from '../shared/friendlyError';
7
8
 
8
9
  interface ErrorBoundaryProps {
9
10
  children?: ReactNode;
@@ -67,6 +68,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
67
68
  return fallback(error, errorInfo);
68
69
  }
69
70
 
71
+ // Plain-language version of the raw error. Backticks (used for code spans
72
+ // in the overlay) are stripped for plain-text rendering here.
73
+ const friendly = toFriendlyError(error);
74
+ const friendlyMessage = friendly.friendlyMessage.replace(/`/g, '');
75
+ const friendlyHint = friendly.hint?.replace(/`/g, '');
76
+
70
77
  // Default fallback UI
71
78
  if (level === 'page') {
72
79
  return h('div', {
@@ -92,7 +99,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
92
99
  fontSize: '24px',
93
100
  fontWeight: '600',
94
101
  }
95
- }, '⚠️ Page Rendering Error'),
102
+ }, `⚠️ ${friendly.title}`),
96
103
  h('p', {
97
104
  style: {
98
105
  margin: '0 0 16px 0',
@@ -100,21 +107,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
100
107
  fontSize: '16px',
101
108
  lineHeight: '1.5',
102
109
  }
103
- }, 'An error occurred while rendering this page. Please check your JSON configuration.'),
104
- h('div', {
105
- style: {
106
- background: '#fff',
107
- border: '1px solid #ddd',
108
- borderRadius: '4px',
109
- padding: '16px',
110
- marginBottom: '16px',
111
- fontFamily: 'monospace',
112
- fontSize: '14px',
113
- color: '#c00',
114
- overflowX: 'auto',
115
- }
116
- }, error.message),
117
- process.env.NODE_ENV === 'development' && errorInfo?.componentStack && h('details', {
110
+ }, friendlyHint ? `${friendlyMessage} ${friendlyHint}` : friendlyMessage),
111
+ h('details', {
118
112
  style: {
119
113
  marginTop: '16px',
120
114
  fontSize: '14px',
@@ -127,8 +121,21 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
127
121
  fontWeight: '600',
128
122
  marginBottom: '8px',
129
123
  }
130
- }, 'Stack Trace'),
131
- h('pre', {
124
+ }, 'Technical details'),
125
+ h('div', {
126
+ style: {
127
+ background: '#fff',
128
+ border: '1px solid #ddd',
129
+ borderRadius: '4px',
130
+ padding: '16px',
131
+ margin: '8px 0',
132
+ fontFamily: 'monospace',
133
+ fontSize: '14px',
134
+ color: '#c00',
135
+ overflowX: 'auto',
136
+ }
137
+ }, error.message),
138
+ process.env.NODE_ENV === 'development' && errorInfo?.componentStack && h('pre', {
132
139
  style: {
133
140
  background: '#f5f5f5',
134
141
  border: '1px solid #ddd',
@@ -187,10 +194,18 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
187
194
  color: '#c00',
188
195
  fontSize: '14px',
189
196
  }
190
- }, `Component Error${componentName ? `: ${componentName}` : ''}`)
197
+ }, `${friendly.title}${componentName ? ` (${componentName})` : ''}`)
191
198
  ),
192
199
  h('div', {
193
200
  style: {
201
+ fontSize: '13px',
202
+ color: '#666',
203
+ lineHeight: '1.5',
204
+ }
205
+ }, friendlyHint ? `${friendlyMessage} ${friendlyHint}` : friendlyMessage),
206
+ process.env.NODE_ENV === 'development' && h('div', {
207
+ style: {
208
+ marginTop: '8px',
194
209
  fontSize: '12px',
195
210
  color: '#666',
196
211
  fontFamily: 'monospace',
@@ -318,6 +318,14 @@ export class ComponentBuilder {
318
318
 
319
319
  if (!node) return null;
320
320
 
321
+ // Verbatim-code markers ({ _code, expr }) come from the meno-astro dialect for
322
+ // arbitrary JS the model can't represent as a binding. They render natively in the
323
+ // Astro build; the editor preview has nothing to evaluate, so render nothing (the
324
+ // marker must never reach the element dispatch below, which would treat it as a tag).
325
+ if (typeof node === 'object' && (node as { _code?: unknown })._code === true) {
326
+ return null;
327
+ }
328
+
321
329
  // Resolve `_i18n` value objects to a single string before node-shape
322
330
  // dispatch. Authors can write a localized string anywhere `children` is
323
331
  // accepted — on raw `type: "node"` elements as well as component props.
@@ -824,7 +832,12 @@ export class ComponentBuilder {
824
832
  key = 0, elementPath = [0], parentComponentName = null, viewportWidth = 1920,
825
833
  componentContext = null, locale, i18nConfig, cmsContext = null, cmsLocale = null,
826
834
  collectionItemsMap = {}, itemContext = null, cmsItemIndexPath = null,
827
- cmsListPaths = null, templateContext = null
835
+ cmsListPaths = null, templateContext = null,
836
+ // The host (outer) component's resolved props. Forwarded to
837
+ // `processStructure` as `parentProps` so a `{{x}}` in this
838
+ // component's structure falls back to the host's `x` when this
839
+ // component's interface doesn't declare it.
840
+ componentResolvedProps: parentResolvedProps = null
828
841
  } = options;
829
842
 
830
843
  const componentDef = this.componentRegistry.get(componentName);
@@ -870,7 +883,11 @@ export class ComponentBuilder {
870
883
  const markedChildren = typedChildren ? markAsSlotContent(typedChildren) : undefined;
871
884
  const processedStructure = processStructure(
872
885
  structuredComponentDef.structure,
873
- { props: resolvedProps, componentDef: structuredComponentDef },
886
+ {
887
+ props: resolvedProps,
888
+ componentDef: structuredComponentDef,
889
+ parentProps: parentResolvedProps ?? undefined,
890
+ },
874
891
  viewportWidth,
875
892
  markedChildren
876
893
  );
@@ -15,6 +15,7 @@ import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
15
15
  import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
16
16
  import { UtilityClassCollector } from "../../styles/UtilityClassCollector";
17
17
  import DOMPurify from "isomorphic-dompurify";
18
+ import { inlineSvgStyleRules } from "../../../shared/inlineSvgStyleRules";
18
19
  import type { ElementRegistry } from "../../elementRegistry";
19
20
  import type { BuilderContext } from "./types";
20
21
  import { hasItemTemplates, processItemTemplate, processItemPropsTemplate, type ValueResolver } from "../../../shared/itemTemplateUtils";
@@ -39,8 +40,8 @@ export interface EmbedBuilderDeps {
39
40
  * Script tags and event handlers are still removed for security
40
41
  */
41
42
  const SANITIZE_CONFIG = {
42
- ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'style', 'animate', 'animateTransform', 'animateMotion', 'set'],
43
- ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title', 'attributeName', 'values', 'dur', 'begin', 'end', 'repeatCount', 'repeatDur', 'keyTimes', 'keySplines', 'calcMode', 'from', 'to', 'by', 'additive', 'accumulate', 'type', 'rotate', 'keyPoints', 'path'],
43
+ ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'style', 'animate', 'animateTransform', 'animateMotion', 'set', 'filter', 'feGaussianBlur', 'feOffset', 'feMerge', 'feMergeNode', 'feColorMatrix', 'feComposite', 'feFlood', 'feMorphology', 'feBlend', 'feDropShadow', 'feTurbulence', 'feDisplacementMap', 'foreignObject'],
44
+ ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'xmlns:xlink', 'xlink:href', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'fill-rule', 'clip-rule', 'clip-path', 'clipPathUnits', 'mask', 'mask-type', 'maskUnits', 'maskContentUnits', 'patternUnits', 'patternContentUnits', 'patternTransform', 'gradientUnits', 'gradientTransform', 'spreadMethod', 'preserveAspectRatio', 'marker-start', 'marker-mid', 'marker-end', 'markerUnits', 'markerWidth', 'markerHeight', 'refX', 'refY', 'orient', 'paint-order', 'vector-effect', 'filter', 'filterUnits', 'primitiveUnits', 'in', 'in2', 'result', 'stdDeviation', 'flood-color', 'flood-opacity', 'stroke-opacity', 'font-size', 'font-family', 'font-weight', 'font-style', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title', 'attributeName', 'values', 'dur', 'begin', 'end', 'repeatCount', 'repeatDur', 'keyTimes', 'keySplines', 'calcMode', 'from', 'to', 'by', 'additive', 'accumulate', 'type', 'rotate', 'keyPoints', 'path'],
44
45
  KEEP_CONTENT: true
45
46
  };
46
47
 
@@ -75,8 +76,11 @@ export function buildEmbed(
75
76
  htmlContent = processCMSTemplate(htmlContent, ctx.cmsContext, effectiveLocale);
76
77
  }
77
78
 
78
- // Sanitize HTML with allowlist
79
- const sanitizedHtml = DOMPurify.sanitize(htmlContent, SANITIZE_CONFIG);
79
+ // Sanitize HTML with allowlist, then inline simple SVG <style> rules so
80
+ // class-scoped declarations survive `innerHTML` reparses (editor preview's
81
+ // HMR `smartUpdate` fallback in `ssr/htmlGenerator.ts:574` strips CSSOM
82
+ // registration for SVG <style> elements re-inserted that way).
83
+ const sanitizedHtml = inlineSvgStyleRules(DOMPurify.sanitize(htmlContent, SANITIZE_CONFIG));
80
84
  const effectiveParentComponentName = deps.getEffectiveParentComponentName(componentContext, parentComponentName);
81
85
 
82
86
  // Extract attributes from node
@@ -80,6 +80,20 @@ export function buildList(
80
80
  containerProps['data-source'] = source || (sourceIsResolved ? 'resolved' : '');
81
81
  }
82
82
 
83
+ // When this list is itself rendered inside an outer list iteration, stamp
84
+ // the outer iteration context onto its container so the click handler and
85
+ // selection overlay can disambiguate the N sibling duplicates (one per
86
+ // outer item). Mirrors ComponentBuilder's write so both stay consistent.
87
+ if (cmsItemIndexPath && cmsItemIndexPath.length > 0) {
88
+ containerProps['data-cms-item-index'] = cmsItemIndexPath.join('.');
89
+ if (cmsListPaths && cmsListPaths.length === cmsItemIndexPath.length) {
90
+ containerProps['data-cms-context'] = JSON.stringify({
91
+ itemIndexPath: cmsItemIndexPath,
92
+ listPaths: cmsListPaths,
93
+ });
94
+ }
95
+ }
96
+
83
97
  // Add extracted attributes
84
98
  if (Object.keys(extractedAttributes).length > 0) {
85
99
  Object.assign(containerProps, extractedAttributes);
@@ -209,20 +223,25 @@ export function buildList(
209
223
  componentResolvedProps
210
224
  });
211
225
 
212
- // Add children directly (no wrapper div)
226
+ // Add children directly (no wrapper div).
227
+ // Use a stable identifier (CMS `_id` or `_filename`) as the React key
228
+ // instead of the loop index, so reordering / filtering moves DOM nodes
229
+ // rather than unmounting & re-mounting every row. Falls back to index
230
+ // for prop-mode lists whose items lack those fields.
231
+ const cmsItem = item as Partial<CMSItem> | undefined;
232
+ const itemKey = cmsItem?._id ?? cmsItem?._filename ?? index;
213
233
  if (itemChildren === null) continue;
214
234
  if (Array.isArray(itemChildren)) {
215
- // Add unique keys by combining original key with item index
216
235
  for (const child of itemChildren) {
217
236
  if (typeof child === 'object' && child !== null && 'key' in child) {
218
- renderedItems.push({ ...child, key: `${child.key}-item-${index}` } as ReactElement);
237
+ renderedItems.push({ ...child, key: `${child.key}-item-${itemKey}` } as ReactElement);
219
238
  } else {
220
239
  renderedItems.push(child);
221
240
  }
222
241
  }
223
242
  } else if (typeof itemChildren === 'object' && itemChildren !== null && 'key' in itemChildren) {
224
243
  // Single child - ensure unique key
225
- renderedItems.push({ ...itemChildren, key: `${(itemChildren as ReactElement).key}-item-${index}` } as ReactElement);
244
+ renderedItems.push({ ...itemChildren, key: `${(itemChildren as ReactElement).key}-item-${itemKey}` } as ReactElement);
226
245
  } else {
227
246
  renderedItems.push(itemChildren);
228
247
  }
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import {
3
+ fetchFontFamilies,
4
+ getCachedFontFamilies,
5
+ invalidateFontFamilies,
6
+ } from './fontFamiliesService';
7
+
8
+ const realFetch = globalThis.fetch;
9
+
10
+ function mockFetchOnce(response: any) {
11
+ globalThis.fetch = mock(async () => ({
12
+ ok: true,
13
+ json: async () => response,
14
+ })) as any;
15
+ }
16
+
17
+ describe('fontFamiliesService', () => {
18
+ beforeEach(() => {
19
+ invalidateFontFamilies();
20
+ });
21
+
22
+ afterEach(() => {
23
+ globalThis.fetch = realFetch;
24
+ });
25
+
26
+ test('getCachedFontFamilies returns empty array before fetch', () => {
27
+ expect(getCachedFontFamilies()).toEqual([]);
28
+ });
29
+
30
+ test('fetchFontFamilies extracts explicit family names', async () => {
31
+ mockFetchOnce({ fonts: [{ family: 'Inter' }, { family: 'Geomanist' }] });
32
+ const families = await fetchFontFamilies();
33
+ expect(families).toEqual(['Inter', 'Geomanist']);
34
+ expect(getCachedFontFamilies()).toEqual(['Inter', 'Geomanist']);
35
+ });
36
+
37
+ test('fetchFontFamilies dedupes families that repeat across weights/unicode-ranges', async () => {
38
+ mockFetchOnce({
39
+ fonts: [
40
+ { family: 'Fraunces', weight: 400 },
41
+ { family: 'Fraunces', weight: 700 },
42
+ { family: 'Inter' },
43
+ ],
44
+ });
45
+ const families = await fetchFontFamilies();
46
+ expect(families).toEqual(['Fraunces', 'Inter']);
47
+ });
48
+
49
+ test('fetchFontFamilies derives a family name from path when family is missing', async () => {
50
+ mockFetchOnce({
51
+ fonts: [{ path: '/fonts/roboto-mono-regular.ttf' }],
52
+ });
53
+ const families = await fetchFontFamilies();
54
+ expect(families).toEqual(['Roboto Mono Regular']);
55
+ });
56
+
57
+ test('fetchFontFamilies handles empty fonts array', async () => {
58
+ mockFetchOnce({ fonts: [] });
59
+ const families = await fetchFontFamilies();
60
+ expect(families).toEqual([]);
61
+ });
62
+
63
+ test('fetchFontFamilies handles config without fonts key', async () => {
64
+ mockFetchOnce({});
65
+ const families = await fetchFontFamilies();
66
+ expect(families).toEqual([]);
67
+ });
68
+
69
+ test('invalidateFontFamilies clears the cache', async () => {
70
+ mockFetchOnce({ fonts: [{ family: 'Inter' }] });
71
+ await fetchFontFamilies();
72
+ expect(getCachedFontFamilies()).toEqual(['Inter']);
73
+ invalidateFontFamilies();
74
+ expect(getCachedFontFamilies()).toEqual([]);
75
+ });
76
+ });