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.
- package/build-astro.ts +183 -13
- package/build-next.ts +1361 -0
- package/build-static.ts +7 -5
- package/dist/bin/cli.js +2 -2
- package/dist/build-static.js +6 -6
- package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
- package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
- package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
- package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
- package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
- package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
- package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
- package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
- package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
- package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
- package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
- package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
- package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
- package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
- package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
- package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
- package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
- package/dist/chunks/chunk-X754AHS5.js.map +7 -0
- package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
- package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
- package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +354 -59
- package/dist/lib/client/index.js.map +4 -4
- package/dist/lib/server/index.js +1458 -190
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +202 -34
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/test-utils/index.js +1 -1
- package/entries/client-router.tsx +5 -165
- package/lib/client/ErrorBoundary.test.tsx +27 -25
- package/lib/client/ErrorBoundary.tsx +34 -19
- package/lib/client/core/ComponentBuilder.ts +19 -2
- package/lib/client/core/builders/embedBuilder.ts +8 -4
- package/lib/client/core/builders/listBuilder.ts +23 -4
- package/lib/client/fontFamiliesService.test.ts +76 -0
- package/lib/client/fontFamiliesService.ts +69 -0
- package/lib/client/hmrCssReload.ts +160 -0
- package/lib/client/hooks/useColorVariables.ts +2 -0
- package/lib/client/index.ts +4 -0
- package/lib/client/meno-filter/ui.ts +2 -0
- package/lib/client/routing/RouteLoader.test.ts +2 -2
- package/lib/client/routing/RouteLoader.ts +8 -2
- package/lib/client/routing/Router.tsx +81 -15
- package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
- package/lib/client/scripts/ScriptExecutor.ts +56 -2
- package/lib/client/styles/StyleInjector.ts +20 -5
- package/lib/client/styles/UtilityClassCollector.ts +7 -1
- package/lib/client/styles/cspNonce.test.ts +67 -0
- package/lib/client/styles/cspNonce.ts +63 -0
- package/lib/client/templateEngine.test.ts +80 -0
- package/lib/client/templateEngine.ts +5 -0
- package/lib/server/astro/cmsPageEmitter.ts +35 -5
- package/lib/server/astro/componentEmitter.ts +61 -5
- package/lib/server/astro/nodeToAstro.ts +149 -11
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
- package/lib/server/createServer.ts +11 -0
- package/lib/server/draftPageStore.ts +49 -0
- package/lib/server/fileWatcher.ts +62 -2
- package/lib/server/index.ts +13 -1
- package/lib/server/providers/fileSystemPageProvider.ts +8 -0
- package/lib/server/routes/api/components.ts +9 -4
- package/lib/server/routes/api/core-routes.ts +2 -2
- package/lib/server/routes/api/pages.ts +14 -22
- package/lib/server/routes/api/shared.ts +56 -0
- package/lib/server/routes/index.ts +90 -0
- package/lib/server/routes/pages.ts +13 -6
- package/lib/server/services/componentService.test.ts +199 -2
- package/lib/server/services/componentService.ts +354 -49
- package/lib/server/services/fileWatcherService.ts +4 -24
- package/lib/server/services/pageService.test.ts +23 -0
- package/lib/server/services/pageService.ts +124 -6
- package/lib/server/ssr/attributeBuilder.ts +8 -2
- package/lib/server/ssr/buildErrorOverlay.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +21 -2
- package/lib/server/ssr/errorOverlay.ts +38 -11
- package/lib/server/ssr/htmlGenerator.test.ts +53 -13
- package/lib/server/ssr/htmlGenerator.ts +71 -27
- package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
- package/lib/server/ssr/ssrRenderer.test.ts +67 -0
- package/lib/server/ssr/ssrRenderer.ts +94 -9
- package/lib/server/websocketManager.ts +0 -1
- package/lib/shared/componentRefs.ts +45 -0
- package/lib/shared/constants.ts +8 -0
- package/lib/shared/cssGeneration.ts +2 -0
- package/lib/shared/cssProperties.ts +184 -0
- package/lib/shared/expressionEvaluator.ts +54 -0
- package/lib/shared/fontCss.ts +101 -0
- package/lib/shared/fontLoader.ts +8 -86
- package/lib/shared/friendlyError.test.ts +87 -0
- package/lib/shared/friendlyError.ts +121 -0
- package/lib/shared/hrefRefs.test.ts +130 -0
- package/lib/shared/hrefRefs.ts +100 -0
- package/lib/shared/index.ts +52 -0
- package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
- package/lib/shared/inlineSvgStyleRules.ts +134 -0
- package/lib/shared/interfaces/contentProvider.ts +13 -0
- package/lib/shared/itemTemplateUtils.test.ts +14 -0
- package/lib/shared/itemTemplateUtils.ts +4 -1
- package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
- package/lib/shared/slugTranslator.test.ts +24 -0
- package/lib/shared/slugTranslator.ts +24 -0
- package/lib/shared/styleNodeUtils.ts +4 -1
- package/lib/shared/tree/PathBuilder.test.ts +128 -1
- package/lib/shared/tree/PathBuilder.ts +83 -31
- package/lib/shared/types/comment.ts +99 -0
- package/lib/shared/types/index.ts +12 -0
- package/lib/shared/types/rendering.ts +8 -0
- package/lib/shared/utilityClassConfig.ts +4 -2
- package/lib/shared/utilityClassMapper.test.ts +24 -0
- package/lib/shared/validation/commentValidators.ts +69 -0
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/viewportUnits.integration.test.ts +42 -0
- package/lib/shared/viewportUnits.test.ts +103 -0
- package/lib/shared/viewportUnits.ts +63 -0
- package/lib/test-utils/dom-setup.ts +6 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
- package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
- package/dist/chunks/chunk-A725KYFK.js.map +0 -7
- package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
- package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
- package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
- package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
- package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
- package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
- package/dist/chunks/chunk-LPVETICS.js.map +0 -7
- /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) {
|
package/lib/client/index.ts
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|