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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Client-side Font Families Service
3
+ * Provides cached, deduplicated access to the project's font families
4
+ * (read from meno.json's `fonts` array via /api/config).
5
+ */
6
+
7
+ interface ConfigFont {
8
+ path?: string;
9
+ src?: string;
10
+ family?: string;
11
+ }
12
+
13
+ function extractFamilyFromPath(path: string): string {
14
+ const filename = path.split('/').pop() || 'Font';
15
+ const name = filename.replace(/\.(ttf|woff2?|otf)$/i, '');
16
+ return name
17
+ .split('-')
18
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
19
+ .join(' ');
20
+ }
21
+
22
+ function extractFamilies(fonts: ConfigFont[] | undefined): string[] {
23
+ if (!Array.isArray(fonts)) return [];
24
+ const set = new Set<string>();
25
+ for (const font of fonts) {
26
+ const path = font.path || font.src;
27
+ const family = font.family || (path ? extractFamilyFromPath(path) : null);
28
+ if (family) set.add(family);
29
+ }
30
+ return [...set];
31
+ }
32
+
33
+ let cachedFamilies: string[] | null = null;
34
+ let inFlight: Promise<string[]> | null = null;
35
+
36
+ export async function fetchFontFamilies(): Promise<string[]> {
37
+ if (cachedFamilies) return cachedFamilies;
38
+ if (inFlight) return inFlight;
39
+
40
+ inFlight = fetch('/api/config')
41
+ .then(res => (res.ok ? res.json() : { fonts: [] }))
42
+ .then(config => {
43
+ cachedFamilies = extractFamilies(config.fonts);
44
+ return cachedFamilies;
45
+ })
46
+ .catch(() => {
47
+ cachedFamilies = [];
48
+ return cachedFamilies;
49
+ })
50
+ .finally(() => {
51
+ inFlight = null;
52
+ });
53
+
54
+ return inFlight;
55
+ }
56
+
57
+ export function getCachedFontFamilies(): string[] {
58
+ return cachedFamilies || [];
59
+ }
60
+
61
+ export function invalidateFontFamilies(): void {
62
+ cachedFamilies = null;
63
+ inFlight = null;
64
+ }
65
+
66
+ export async function initializeFontFamilies(forceRefresh = false): Promise<void> {
67
+ if (forceRefresh) invalidateFontFamilies();
68
+ await fetchFontFamilies();
69
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Shared CSS/asset hot-reload wiring for the preview iframes.
3
+ *
4
+ * Both the runtime client router (packages/core/entries/client-router.tsx) and
5
+ * the editor preview router (packages/studio/entries/client-editor-router.tsx)
6
+ * need the same set of HMR listeners that re-fetch and re-inject colors,
7
+ * variables and fonts CSS (and full-reload on library changes). This used to be
8
+ * duplicated in both entries — and a fix applied to only one copy is exactly how
9
+ * the per-breakpoint variable "flash then revert" bug survived in select mode.
10
+ * Keep this the single source of truth; consume it via `setupCssHmrListeners()`.
11
+ *
12
+ * Each injected `<style>` is stamped with the per-page CSP nonce (`applyNonce`):
13
+ * under the Electron preview's `style-src 'nonce-…'` policy Chromium silently
14
+ * drops any unstamped `<style>`, which breaks CSS hot-reload (incl. per-breakpoint
15
+ * variable edits). See styles/cspNonce.ts.
16
+ */
17
+
18
+ import { applyNonce } from './styles/cspNonce';
19
+
20
+ declare global {
21
+ interface Window {
22
+ __hmrColorsInitialized?: boolean;
23
+ __hmrVariablesCSSInitialized?: boolean;
24
+ __hmrFontsCSSInitialized?: boolean;
25
+ __hmrLibrariesInitialized?: boolean;
26
+ }
27
+ }
28
+
29
+ // Fetch and inject updated theme (color) CSS into <style id="hmr-theme-variables">
30
+ async function injectUpdatedThemeCSS(): Promise<void> {
31
+ try {
32
+ const themesResponse = await fetch('/api/themes');
33
+ if (!themesResponse.ok) return;
34
+
35
+ const themesData = await themesResponse.json() as {
36
+ themes: Array<{ name: string; label: string }>;
37
+ default: string;
38
+ };
39
+
40
+ // Fetch colors for each theme
41
+ const themeColors: Record<string, any> = {};
42
+ for (const theme of themesData.themes) {
43
+ const colorResponse = await fetch(
44
+ `/api/colors?theme=${encodeURIComponent(theme.name)}`
45
+ );
46
+ if (colorResponse.ok) {
47
+ themeColors[theme.name] = (await colorResponse.json()).colors;
48
+ }
49
+ }
50
+
51
+ // Also get default theme colors
52
+ const defaultResponse = await fetch('/api/colors');
53
+ if (defaultResponse.ok) {
54
+ themeColors['default'] = (await defaultResponse.json()).colors;
55
+ }
56
+
57
+ // Generate CSS — default theme in :root, each named theme in [theme="..."]
58
+ let css = '';
59
+ if (themeColors['default']) {
60
+ const vars = Object.entries(themeColors['default'])
61
+ .map(([name, value]) => ` --${name}: ${value};`)
62
+ .join('\n');
63
+ css += `:root {\n${vars}\n}\n\n`;
64
+ }
65
+ for (const theme of themesData.themes) {
66
+ if (themeColors[theme.name]) {
67
+ const vars = Object.entries(themeColors[theme.name])
68
+ .map(([name, value]) => ` --${name}: ${value};`)
69
+ .join('\n');
70
+ css += `[theme="${theme.name}"] {\n${vars}\n}\n\n`;
71
+ }
72
+ }
73
+
74
+ let styleTag = document.getElementById('hmr-theme-variables');
75
+ if (!styleTag) {
76
+ styleTag = document.createElement('style');
77
+ styleTag.id = 'hmr-theme-variables';
78
+ applyNonce(styleTag);
79
+ document.head.appendChild(styleTag);
80
+ }
81
+ styleTag.textContent = css;
82
+ } catch {
83
+ // Silently fail - not critical if CSS injection doesn't work
84
+ }
85
+ }
86
+
87
+ // Fetch and inject updated variables CSS into <style id="hmr-css-variables">
88
+ async function injectUpdatedVariablesCSS(): Promise<void> {
89
+ try {
90
+ const response = await fetch('/api/variables-css');
91
+ if (!response.ok) return;
92
+ const css = await response.text();
93
+
94
+ let styleTag = document.getElementById('hmr-css-variables');
95
+ if (!styleTag) {
96
+ styleTag = document.createElement('style');
97
+ styleTag.id = 'hmr-css-variables';
98
+ applyNonce(styleTag);
99
+ document.head.appendChild(styleTag);
100
+ }
101
+ styleTag.textContent = css;
102
+ } catch {
103
+ // Silently fail
104
+ }
105
+ }
106
+
107
+ // Fetch and inject updated fonts CSS into <style id="hmr-fonts-css">
108
+ async function injectUpdatedFontsCSS(): Promise<void> {
109
+ try {
110
+ const response = await fetch('/api/fonts-css');
111
+ if (!response.ok) return;
112
+ const css = await response.text();
113
+
114
+ let styleTag = document.getElementById('hmr-fonts-css');
115
+ if (!styleTag) {
116
+ styleTag = document.createElement('style');
117
+ styleTag.id = 'hmr-fonts-css';
118
+ applyNonce(styleTag);
119
+ document.head.appendChild(styleTag);
120
+ }
121
+ styleTag.textContent = css;
122
+ } catch {
123
+ // Silently fail
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Wire all CSS/asset hot-reload listeners (colors, variables, fonts, libraries).
129
+ * Idempotent — each channel is guarded by a `window.__hmr*Initialized` flag, so
130
+ * calling this more than once (e.g. across a Bun HMR re-eval) is safe.
131
+ *
132
+ * NOTE: the variables flag is `__hmrVariablesCSSInitialized`, deliberately
133
+ * distinct from the `__hmrVariablesInitialized` that the `useVariables` hook sets
134
+ * on module load. Sharing one flag let the hook win the race, skipping this
135
+ * listener so the per-breakpoint variable CSS never refreshed and edits appeared
136
+ * to revert.
137
+ */
138
+ export function setupCssHmrListeners(): void {
139
+ if (typeof window === 'undefined') return;
140
+
141
+ if (!window.__hmrColorsInitialized) {
142
+ window.__hmrColorsInitialized = true;
143
+ document.addEventListener('hmr-colors-update', () => { void injectUpdatedThemeCSS(); });
144
+ }
145
+
146
+ if (!window.__hmrVariablesCSSInitialized) {
147
+ window.__hmrVariablesCSSInitialized = true;
148
+ document.addEventListener('hmr-variables-update', () => { void injectUpdatedVariablesCSS(); });
149
+ }
150
+
151
+ if (!window.__hmrFontsCSSInitialized) {
152
+ window.__hmrFontsCSSInitialized = true;
153
+ document.addEventListener('hmr-fonts-update', () => { void injectUpdatedFontsCSS(); });
154
+ }
155
+
156
+ if (!window.__hmrLibrariesInitialized) {
157
+ window.__hmrLibrariesInitialized = true;
158
+ document.addEventListener('hmr-libraries-update', () => { location.reload(); });
159
+ }
160
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useState, useEffect, useRef } from 'react';
6
6
  import type { ColorVariables, ThemeEntry, HMRMessage } from '../../shared/types';
7
+ import { applyNonce } from '../styles/cspNonce';
7
8
 
8
9
  let cachedColors: Record<string, ColorVariables> = {};
9
10
  let cachedThemes: ThemeEntry[] | null = null;
@@ -90,6 +91,7 @@ async function injectUpdatedThemeCSS() {
90
91
  if (!themeStyleTag) {
91
92
  themeStyleTag = document.createElement('style');
92
93
  themeStyleTag.id = 'hmr-theme-variables';
94
+ applyNonce(themeStyleTag);
93
95
  // Insert after the main style tag
94
96
  const mainStyle = document.querySelector('style');
95
97
  if (mainStyle && mainStyle.nextSibling) {
@@ -31,6 +31,7 @@ export * from './responsiveStyleResolver';
31
31
  // Scripts and styles injection
32
32
  export { StyleInjector } from './styles/StyleInjector';
33
33
  export { ScriptExecutor } from './scripts/ScriptExecutor';
34
+ export { setupCssHmrListeners } from './hmrCssReload';
34
35
 
35
36
  // Prefetch
36
37
  export { PrefetchService } from './services/PrefetchService';
@@ -38,6 +39,9 @@ export { PrefetchService } from './services/PrefetchService';
38
39
  // i18n
39
40
  export * from './i18nConfigService';
40
41
 
42
+ // Font families (project config)
43
+ export * from './fontFamiliesService';
44
+
41
45
  // Registries
42
46
  export { globalComponentRegistry, ComponentRegistry } from './componentRegistry';
43
47
  export { ElementRegistry, elementRegistry, setElementRegistry } from './elementRegistry';
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { MenoFilter, type CMSItem } from './MenoFilter';
11
+ import { applyNonce } from '../styles/cspNonce';
11
12
 
12
13
  // ============================================================================
13
14
  // Types
@@ -304,6 +305,7 @@ function injectCSS(options: Required<UIOptions>): void {
304
305
  }
305
306
  `;
306
307
 
308
+ applyNonce(style);
307
309
  document.head.appendChild(style);
308
310
  cssInjected = true;
309
311
  }
@@ -55,7 +55,7 @@ describe('RouteLoader', () => {
55
55
 
56
56
  expect(componentRegistry.get('Button')).toBeDefined();
57
57
  expect(mockFetch).toHaveBeenCalledWith(API_ROUTES.COMPONENTS, {
58
- cache: 'no-store',
58
+ cache: 'default',
59
59
  });
60
60
  });
61
61
 
@@ -87,7 +87,7 @@ describe('RouteLoader', () => {
87
87
  await routeLoader.loadGlobalComponents();
88
88
 
89
89
  expect(mockFetch).toHaveBeenCalledWith(API_ROUTES.COMPONENTS, {
90
- cache: 'no-store',
90
+ cache: 'default',
91
91
  });
92
92
  });
93
93
 
@@ -49,7 +49,11 @@ export class RouteLoader {
49
49
  */
50
50
  async loadGlobalComponents(signal?: AbortSignal): Promise<void> {
51
51
  try {
52
- const fetchOptions: RequestInit = { cache: 'no-store' };
52
+ // `cache: 'default'` lets the browser keep the body and revalidate
53
+ // cheaply via `If-None-Match` — the server responds 304 when nothing
54
+ // changed (see cachedJsonResponse on `/api/components`). Repeat
55
+ // navigations skip the JSON download entirely.
56
+ const fetchOptions: RequestInit = { cache: 'default' };
53
57
  if (signal) {
54
58
  fetchOptions.signal = signal;
55
59
  }
@@ -169,8 +173,10 @@ export class RouteLoader {
169
173
  return tree;
170
174
  }
171
175
 
176
+ // `cache: 'default'` lets the browser revalidate via ETag — server
177
+ // responds 304 when the page JSON hasn't changed since last fetch.
172
178
  const response = await fetch(`${API_ROUTES.PAGE_CONTENT}?page=${encodeURIComponent(pathWithoutLocale)}`, {
173
- cache: 'no-store',
179
+ cache: 'default',
174
180
  signal: abortController.signal,
175
181
  });
176
182
 
@@ -24,11 +24,13 @@ import { initializeBreakpoints } from "../responsiveStyleResolver";
24
24
  import { elementRegistry } from "../elementRegistry";
25
25
  import { InteractiveStylesRegistry } from "../InteractiveStylesRegistry";
26
26
  import { UtilityClassCollector } from "../styles/UtilityClassCollector";
27
+ import { applyNonce } from "../styles/cspNonce";
27
28
  import { initializeClient, setupEventHandlers } from "../ClientInitializer";
28
29
  import type { ComponentNode, I18nConfig, PrefetchConfig, CMSItem } from "../../shared/types";
29
30
  import { parseLocaleFromPath, setStoredLocale, DEFAULT_I18N_CONFIG } from "../../shared/i18n";
30
31
  import { fetchI18nConfig, setI18nConfig as setCachedI18nConfig } from "../i18nConfigService";
31
32
  import { IFRAME_MESSAGE_TYPES } from "../../shared/constants";
33
+ import { rewriteViewportUnits } from "../../shared/viewportUnits";
32
34
 
33
35
  /** SSR-serialized CMS context for client-side hydration */
34
36
  interface SSRCMSContext {
@@ -146,6 +148,13 @@ export function Router(props: RouterProps = {}): ReactElement {
146
148
  const lastCommitTimestampRef = useRef(0);
147
149
  const COMMIT_GRACE_MS = 1000;
148
150
 
151
+ // Tracks whether the most recent commit changed tree structure (nodes
152
+ // added/removed/reparented) vs only edited props/styles. Leaf-only commits
153
+ // don't need the 100ms post-render scriptExecutor pass because no new
154
+ // elements appeared to bind scripts to. Default true (safe); the editor
155
+ // sends `structureChanged: false` on prop/style/CSS/JS edits.
156
+ const lastCommitStructureChangedRef = useRef(true);
157
+
149
158
  // Track if initial mount used SSR CMS context (to skip redundant path-based load)
150
159
  const ssrCmsHandledRef = useRef(false);
151
160
  // Track if initial load is done (to prevent currentPath effect from firing on mount)
@@ -255,9 +264,41 @@ export function Router(props: RouterProps = {}): ReactElement {
255
264
  if (typeof window === 'undefined') return;
256
265
 
257
266
  const handleMessage = (event: MessageEvent) => {
258
- if (event.data?.type === IFRAME_MESSAGE_TYPES.CSS_VARIABLE_UPDATE) {
259
- const { name, value } = event.data as { name: string; value: string };
260
- document.documentElement.style.setProperty(name, value);
267
+ // Live CSS variable preview. Writes the override into a managed
268
+ // <style id="meno-scrub-preview"> tag rather than as an inline style on
269
+ // <html>: inline styles outrank @media rules from `meno-styles`, which
270
+ // would break the responsive cascade for the rest of the session.
271
+ //
272
+ // `media` scopes the rule so it only applies at the breakpoint being
273
+ // edited (base → min-width above the largest non-base breakpoint;
274
+ // non-base → max-width of that breakpoint). When `media` is omitted (no
275
+ // breakpoints configured) the rule applies unconditionally. The legacy
276
+ // {maxWidth} payload from CSS_VARIABLE_SCOPED_UPDATE is still accepted.
277
+ if (event.data?.type === IFRAME_MESSAGE_TYPES.CSS_VARIABLE_UPDATE
278
+ || event.data?.type === IFRAME_MESSAGE_TYPES.CSS_VARIABLE_SCOPED_UPDATE) {
279
+ const { name, value, media, maxWidth } = event.data as {
280
+ name: string;
281
+ value: string;
282
+ media?: string;
283
+ maxWidth?: number;
284
+ };
285
+ const styleId = 'meno-scrub-preview';
286
+ let styleTag = document.getElementById(styleId) as HTMLStyleElement | null;
287
+ if (!styleTag) {
288
+ styleTag = document.createElement('style');
289
+ styleTag.id = styleId;
290
+ applyNonce(styleTag);
291
+ document.head.appendChild(styleTag);
292
+ }
293
+ const mediaExpr = media ?? (typeof maxWidth === 'number' ? `(max-width: ${maxWidth}px)` : null);
294
+ styleTag.textContent = mediaExpr
295
+ ? `@media ${mediaExpr} { :root { ${name}: ${value}; } }`
296
+ : `:root { ${name}: ${value}; }`;
297
+ return;
298
+ }
299
+
300
+ if (event.data?.type === IFRAME_MESSAGE_TYPES.CSS_VARIABLE_SCOPED_CLEAR) {
301
+ document.getElementById('meno-scrub-preview')?.remove();
261
302
  return;
262
303
  }
263
304
 
@@ -265,19 +306,24 @@ export function Router(props: RouterProps = {}): ReactElement {
265
306
  const css = event.data.css as string;
266
307
  const styleId = 'interactive-styles';
267
308
 
268
- // Remove existing interactive styles tag if it exists
269
- const existingStyle = document.getElementById(styleId);
270
- if (existingStyle) {
271
- existingStyle.remove();
272
- }
273
-
274
- // Inject new CSS if provided
275
- if (css && document.head) {
276
- const styleTag = document.createElement('style');
309
+ // Empty payload is a no-op: an in-flight HMR transition can briefly
310
+ // post an empty InteractiveStylesRegistry (cleared in onLoadStart
311
+ // before the new tree renders), and tearing down the existing tag
312
+ // would drop all hover/focus/responsive CSS until the next render.
313
+ if (!css) return;
314
+ if (!document.head) return;
315
+
316
+ // Update in place when the tag already exists — avoids a
317
+ // remove-then-create gap during which the page renders unstyled.
318
+ const rewritten = rewriteViewportUnits(css);
319
+ let styleTag = document.getElementById(styleId) as HTMLStyleElement | null;
320
+ if (!styleTag) {
321
+ styleTag = document.createElement('style');
277
322
  styleTag.id = styleId;
278
- styleTag.textContent = css;
323
+ applyNonce(styleTag);
279
324
  document.head.appendChild(styleTag);
280
325
  }
326
+ styleTag.textContent = rewritten;
281
327
  }
282
328
  };
283
329
 
@@ -285,6 +331,18 @@ export function Router(props: RouterProps = {}): ReactElement {
285
331
  return () => window.removeEventListener('message', handleMessage);
286
332
  }, []);
287
333
 
334
+ // Drop the live scrub-preview tag once the canonical HMR refresh lands.
335
+ // Defence in depth in case CSS_VARIABLE_SCOPED_CLEAR was missed (e.g. the
336
+ // editor unmounted mid-scrub).
337
+ useEffect(() => {
338
+ if (typeof window === 'undefined') return;
339
+ const clearScrubPreview = () => {
340
+ document.getElementById('meno-scrub-preview')?.remove();
341
+ };
342
+ document.addEventListener('hmr-variables-update', clearScrubPreview);
343
+ return () => document.removeEventListener('hmr-variables-update', clearScrubPreview);
344
+ }, []);
345
+
288
346
  // Listen for page data preview updates from parent window (editor)
289
347
  // Used for component hover preview in the components tab
290
348
  useEffect(() => {
@@ -303,6 +361,7 @@ export function Router(props: RouterProps = {}): ReactElement {
303
361
  } else if (event.data?.type === IFRAME_MESSAGE_TYPES.PAGE_DATA_COMMITTED) {
304
362
  // Update the real tree with committed editor mutations (instant preview)
305
363
  lastCommitTimestampRef.current = Date.now();
364
+ lastCommitStructureChangedRef.current = event.data.structureChanged !== false;
306
365
  const pageData = event.data.pageData;
307
366
  if (pageData?.root) {
308
367
  setComponentTree(pageData.root);
@@ -311,6 +370,7 @@ export function Router(props: RouterProps = {}): ReactElement {
311
370
  }
312
371
  } else if (event.data?.type === IFRAME_MESSAGE_TYPES.COMPONENT_DEFINITION_COMMITTED) {
313
372
  lastCommitTimestampRef.current = Date.now();
373
+ lastCommitStructureChangedRef.current = event.data.structureChanged !== false;
314
374
  const { componentName, definition } = event.data;
315
375
  if (componentName && definition) {
316
376
  services.componentRegistry.register(componentName, definition);
@@ -360,8 +420,14 @@ export function Router(props: RouterProps = {}): ReactElement {
360
420
  }, '*');
361
421
  }
362
422
 
363
- // Wait for React to render before executing JavaScript (unless disabled for editor)
364
- if (!disableScripts) {
423
+ // Wait for React to render before executing JavaScript (unless disabled for editor).
424
+ // Skip for leaf-only edits (prop/style changes): no new elements appeared,
425
+ // so there's nothing new for the scriptExecutor to bind to. Always reset
426
+ // the ref back to true after the effect so unrelated re-renders (e.g.
427
+ // cmsContext changes) keep getting their script pass.
428
+ const shouldRunScripts = !disableScripts && lastCommitStructureChangedRef.current;
429
+ lastCommitStructureChangedRef.current = true;
430
+ if (shouldRunScripts) {
365
431
  const timeoutId = setTimeout(() => {
366
432
  services.scriptExecutor.execute();
367
433
  }, 100);
@@ -421,6 +421,149 @@ describe("ScriptExecutor", () => {
421
421
  });
422
422
  });
423
423
 
424
+ describe("per-element bind dedup (defineVars path)", () => {
425
+ // Helper: counter on window that the executed user JS bumps. Reset between
426
+ // tests so cross-test pollution can't mask a stuck observer / repeated bind.
427
+ const COUNTER_KEY = "__bindCounter";
428
+ const win = window as unknown as Record<string, unknown>;
429
+
430
+ beforeEach(() => {
431
+ win[COUNTER_KEY] = 0;
432
+ document.body.innerHTML = "";
433
+ });
434
+
435
+ afterEach(() => {
436
+ delete win[COUNTER_KEY];
437
+ delete (window as unknown as { __menoShouldBind?: unknown }).__menoShouldBind;
438
+ document.body.innerHTML = "";
439
+ });
440
+
441
+ const registerCounterComponent = (name = "BoundComp") => {
442
+ const def: ComponentDefinition = {
443
+ type: "component",
444
+ component: {
445
+ interface: {},
446
+ structure: { type: "node", tag: "div" },
447
+ javascript: `window.${COUNTER_KEY} = (window.${COUNTER_KEY} || 0) + 1;`,
448
+ defineVars: true,
449
+ },
450
+ };
451
+ componentRegistry.register(name, def);
452
+ return def;
453
+ };
454
+
455
+ const mountInstance = (componentName: string, propsByComponent: Record<string, unknown> = {}) => {
456
+ const el = document.createElement("div");
457
+ el.setAttribute("data-component", componentName);
458
+ el.setAttribute("data-props", JSON.stringify({ [componentName]: propsByComponent }));
459
+ document.body.appendChild(el);
460
+ return el;
461
+ };
462
+
463
+ test("re-executing execute() does not re-run JS on the same element", () => {
464
+ registerCounterComponent();
465
+ mountInstance("BoundComp");
466
+ // ScriptExecutor under test is the one from outer beforeEach — it
467
+ // installed its host hook on window during construction.
468
+
469
+ scriptExecutor.execute();
470
+ scriptExecutor.execute();
471
+ scriptExecutor.execute();
472
+
473
+ expect(win[COUNTER_KEY]).toBe(1);
474
+ });
475
+
476
+ test("a newly mounted element binds on the next execute(), already-bound ones don't re-run", () => {
477
+ registerCounterComponent();
478
+ mountInstance("BoundComp");
479
+
480
+ scriptExecutor.execute();
481
+ expect(win[COUNTER_KEY]).toBe(1);
482
+
483
+ // Simulate a structural commit that added another instance of the same component.
484
+ mountInstance("BoundComp");
485
+ scriptExecutor.execute();
486
+
487
+ // Only the new element should have run; the original is already bound.
488
+ expect(win[COUNTER_KEY]).toBe(2);
489
+ });
490
+
491
+ test("two component names on the same element each bind once independently", () => {
492
+ // Register A and B, each with defineVars and its own counter.
493
+ const defA: ComponentDefinition = {
494
+ type: "component",
495
+ component: {
496
+ interface: {},
497
+ structure: { type: "node", tag: "div" },
498
+ javascript: `window.__counterA = (window.__counterA || 0) + 1;`,
499
+ defineVars: true,
500
+ },
501
+ };
502
+ const defB: ComponentDefinition = {
503
+ type: "component",
504
+ component: {
505
+ interface: {},
506
+ structure: { type: "node", tag: "div" },
507
+ javascript: `window.__counterB = (window.__counterB || 0) + 1;`,
508
+ defineVars: true,
509
+ },
510
+ };
511
+ componentRegistry.register("A", defA);
512
+ componentRegistry.register("B", defB);
513
+
514
+ // Compose A and B on a single element via the space-separated data-component
515
+ // token list. querySelectorAll('[data-component~="A"]') matches this too.
516
+ const el = document.createElement("div");
517
+ el.setAttribute("data-component", "A B");
518
+ el.setAttribute("data-props", JSON.stringify({ A: {}, B: {} }));
519
+ document.body.appendChild(el);
520
+
521
+ scriptExecutor.execute();
522
+ scriptExecutor.execute();
523
+
524
+ const w = window as unknown as Record<string, number>;
525
+ expect(w.__counterA).toBe(1);
526
+ expect(w.__counterB).toBe(1);
527
+
528
+ delete (window as unknown as { __counterA?: unknown }).__counterA;
529
+ delete (window as unknown as { __counterB?: unknown }).__counterB;
530
+ });
531
+
532
+ test("executeForElement bypasses the bind dedup (prop-edit reactivity must still re-run)", () => {
533
+ registerCounterComponent();
534
+ const el = mountInstance("BoundComp");
535
+
536
+ scriptExecutor.execute();
537
+ expect(win[COUNTER_KEY]).toBe(1);
538
+
539
+ // Prop edit on the same element — the editor calls executeForElement
540
+ // to push fresh props. It MUST run the body again even though
541
+ // boundElements already marks (el, "BoundComp") as bound.
542
+ scriptExecutor.executeForElement("BoundComp", el, {});
543
+ expect(win[COUNTER_KEY]).toBe(2);
544
+
545
+ scriptExecutor.executeForElement("BoundComp", el, {});
546
+ expect(win[COUNTER_KEY]).toBe(3);
547
+ });
548
+
549
+ test("with no host hook installed the wrapper binds on every execute() (legacy fallback)", () => {
550
+ // Tear down the host hook the constructor installed. This is what
551
+ // direct-browser / non-runtime contexts look like — no editor host.
552
+ delete (window as unknown as { __menoShouldBind?: unknown }).__menoShouldBind;
553
+
554
+ registerCounterComponent();
555
+ mountInstance("BoundComp");
556
+
557
+ scriptExecutor.execute();
558
+ scriptExecutor.execute();
559
+
560
+ // Without the hook the wrapper can't dedup, so the JS body runs
561
+ // once per execute(). This preserves how a built site behaves when
562
+ // the wrapper happens to be re-evaluated.
563
+ expect(win[COUNTER_KEY]).toBe(2);
564
+ });
565
+ });
566
+
424
567
  describe("integration", () => {
425
568
  test.skip("should handle full execution cycle with multiple components and instances (requires @meno/studio ElementRegistry)", () => {
426
569
  const componentDef1: ComponentDefinition = {