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
|
@@ -42,10 +42,42 @@ function generateDestructure(
|
|
|
42
42
|
export class ScriptExecutor {
|
|
43
43
|
private componentRegistry: ComponentRegistry;
|
|
44
44
|
private elementRegistry: ElementRegistry;
|
|
45
|
+
// Tracks which (element, componentName) pairs have already had their user
|
|
46
|
+
// JS executed. Prevents stacking side effects (IntersectionObservers,
|
|
47
|
+
// event listeners, intervals) when execute() is re-called on the same
|
|
48
|
+
// DOM nodes after a structural commit. WeakMap stays out of the DOM and
|
|
49
|
+
// GCs cleanly when React unmounts elements.
|
|
50
|
+
private boundElements: WeakMap<Element, Set<string>> = new WeakMap();
|
|
45
51
|
|
|
46
52
|
constructor(config: ScriptExecutorConfig) {
|
|
47
53
|
this.componentRegistry = config.componentRegistry;
|
|
48
54
|
this.elementRegistry = config.elementRegistry;
|
|
55
|
+
|
|
56
|
+
// Install a host-side hook on `window` so the generated IIFE in
|
|
57
|
+
// executeWithInitPattern (which runs through `new Function`) can ask
|
|
58
|
+
// whether a given (element, componentName) should be bound. The
|
|
59
|
+
// wrapper degrades gracefully when the hook is absent (e.g. direct
|
|
60
|
+
// <script> use outside the runtime) — see executeWithInitPattern.
|
|
61
|
+
if (typeof window !== 'undefined') {
|
|
62
|
+
(window as { __menoShouldBind?: (el: Element, name: string) => boolean }).__menoShouldBind =
|
|
63
|
+
(el: Element, name: string) => this.shouldBind(el, name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether (element, componentName) has already been bound this
|
|
69
|
+
* session. Records the bind on first call. Returns `true` if the caller
|
|
70
|
+
* should run the user JS, `false` if it should skip.
|
|
71
|
+
*/
|
|
72
|
+
private shouldBind(el: Element, componentName: string): boolean {
|
|
73
|
+
let names = this.boundElements.get(el);
|
|
74
|
+
if (names && names.has(componentName)) return false;
|
|
75
|
+
if (!names) {
|
|
76
|
+
names = new Set();
|
|
77
|
+
this.boundElements.set(el, names);
|
|
78
|
+
}
|
|
79
|
+
names.add(componentName);
|
|
80
|
+
return true;
|
|
49
81
|
}
|
|
50
82
|
|
|
51
83
|
/**
|
|
@@ -91,6 +123,13 @@ export class ScriptExecutor {
|
|
|
91
123
|
return;
|
|
92
124
|
}
|
|
93
125
|
|
|
126
|
+
// Skip re-execution if this element already had its JS bound for this
|
|
127
|
+
// component. Prevents stacked side effects on structural-edit re-runs.
|
|
128
|
+
const instanceEl = this.elementRegistry.get(rootPath);
|
|
129
|
+
if (instanceEl && !this.shouldBind(instanceEl, componentName)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
try {
|
|
95
134
|
const processedJS = processCodeTemplates(originalJS, props);
|
|
96
135
|
this.executeWrappedJS(processedJS, `${componentName} (instance: ${rootPath})`);
|
|
@@ -124,10 +163,20 @@ export class ScriptExecutor {
|
|
|
124
163
|
// Component: ${componentName} (defineVars)
|
|
125
164
|
try {
|
|
126
165
|
var elements = document.querySelectorAll('[data-component~="${componentName}"]');
|
|
166
|
+
var shouldBind = (typeof window !== 'undefined' && typeof window.__menoShouldBind === 'function')
|
|
167
|
+
? window.__menoShouldBind
|
|
168
|
+
: null;
|
|
127
169
|
elements.forEach(function(el) {
|
|
170
|
+
// Skip elements already bound for this component. When the host
|
|
171
|
+
// hook is absent (direct <script> use, test harness), bind
|
|
172
|
+
// unconditionally — preserves legacy "runs once at page load"
|
|
173
|
+
// semantics for non-runtime contexts.
|
|
174
|
+
if (shouldBind && !shouldBind(el, ${JSON.stringify(componentName)})) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
128
177
|
var propsStr = el.getAttribute('data-props');
|
|
129
178
|
var allProps = propsStr ? JSON.parse(propsStr) : {};
|
|
130
|
-
var props = allProps[
|
|
179
|
+
var props = allProps[${JSON.stringify(componentName)}] || {};
|
|
131
180
|
(function(el, props) {
|
|
132
181
|
${destructure}
|
|
133
182
|
${js}
|
|
@@ -256,7 +305,12 @@ export class ScriptExecutor {
|
|
|
256
305
|
|
|
257
306
|
/**
|
|
258
307
|
* Re-execute JavaScript for a specific element instance (used for editor reactivity)
|
|
259
|
-
* Updates data-props attribute and re-runs the component JS for that element
|
|
308
|
+
* Updates data-props attribute and re-runs the component JS for that element.
|
|
309
|
+
*
|
|
310
|
+
* NOTE: This path intentionally bypasses the per-element bind dedup
|
|
311
|
+
* (`boundElements` / `shouldBind`). It exists so live prop edits in the
|
|
312
|
+
* editor can re-run the user JS on demand with new prop values — the
|
|
313
|
+
* caller is signaling "re-bind this element now," not "bind once."
|
|
260
314
|
* @param componentName - Component name
|
|
261
315
|
* @param element - The DOM element to re-execute for
|
|
262
316
|
* @param newProps - New props to inject
|
|
@@ -12,11 +12,13 @@ import type { ComponentRegistry } from '../componentRegistry';
|
|
|
12
12
|
import type { ElementRegistry } from '../elementRegistry';
|
|
13
13
|
import { hasTemplates, processCodeTemplates } from '../templateEngine';
|
|
14
14
|
import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
|
|
15
|
+
import { rewriteViewportUnits } from '../../shared/viewportUnits';
|
|
15
16
|
import { getCachedBreakpointConfig, getCachedRemConversionConfig, getCachedResponsiveScalesConfig } from '../responsiveStyleResolver';
|
|
16
17
|
import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
|
|
17
18
|
import { InteractiveStylesRegistry } from '../InteractiveStylesRegistry';
|
|
18
19
|
import { UtilityClassCollector } from './UtilityClassCollector';
|
|
19
20
|
import { logRuntimeError } from '../../shared/errorLogger';
|
|
21
|
+
import { applyNonce } from './cspNonce';
|
|
20
22
|
|
|
21
23
|
export interface StyleInjectorConfig {
|
|
22
24
|
componentRegistry: ComponentRegistry;
|
|
@@ -121,19 +123,29 @@ export class StyleInjector {
|
|
|
121
123
|
|
|
122
124
|
if (processedCSSBlocks.length === 0) return;
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
// Rewrite vh/vw → calc(var(--design-vh, 1vh) * N) so design-canvas mode
|
|
127
|
+
// can pin viewport units to a stable height. No-op when --design-vh is
|
|
128
|
+
// unset (see viewportUnits.ts).
|
|
129
|
+
const combinedCSS = rewriteViewportUnits(processedCSSBlocks.join('\n\n'));
|
|
125
130
|
const hash = simpleHash(combinedCSS);
|
|
126
131
|
|
|
127
|
-
// Skip
|
|
128
|
-
|
|
132
|
+
// Skip only when the hash matches AND the style tag is still in the DOM.
|
|
133
|
+
// If something removed the tag externally (e.g. a hot-reload pass that
|
|
134
|
+
// wiped <head>), the hash short-circuit would otherwise leave the page
|
|
135
|
+
// unstyled until the CSS itself changed.
|
|
136
|
+
const existing = typeof document !== 'undefined'
|
|
137
|
+
? document.getElementById(this.styleId) as HTMLStyleElement | null
|
|
138
|
+
: null;
|
|
139
|
+
if (hash === this.previousComponentCSSHash && existing) return;
|
|
129
140
|
this.previousComponentCSSHash = hash;
|
|
130
141
|
|
|
131
142
|
// Update textContent in-place if tag exists, otherwise create
|
|
132
143
|
if (document.head) {
|
|
133
|
-
let styleTag =
|
|
144
|
+
let styleTag = existing;
|
|
134
145
|
if (!styleTag) {
|
|
135
146
|
styleTag = document.createElement('style');
|
|
136
147
|
styleTag.id = this.styleId;
|
|
148
|
+
applyNonce(styleTag);
|
|
137
149
|
document.head.appendChild(styleTag);
|
|
138
150
|
}
|
|
139
151
|
styleTag.textContent = combinedCSS;
|
|
@@ -154,7 +166,9 @@ export class StyleInjector {
|
|
|
154
166
|
const breakpointConfig = getCachedBreakpointConfig() || DEFAULT_BREAKPOINTS;
|
|
155
167
|
const remConversionConfig = getCachedRemConversionConfig() || undefined;
|
|
156
168
|
const responsiveScalesConfig = getCachedResponsiveScalesConfig() || undefined;
|
|
157
|
-
const interactiveCSS =
|
|
169
|
+
const interactiveCSS = rewriteViewportUnits(
|
|
170
|
+
generateAllInteractiveCSS(interactiveStylesMap, breakpointConfig, remConversionConfig, responsiveScalesConfig)
|
|
171
|
+
);
|
|
158
172
|
if (!interactiveCSS) return;
|
|
159
173
|
|
|
160
174
|
if (document.head) {
|
|
@@ -162,6 +176,7 @@ export class StyleInjector {
|
|
|
162
176
|
if (!styleTag) {
|
|
163
177
|
styleTag = document.createElement('style');
|
|
164
178
|
styleTag.id = interactiveStyleId;
|
|
179
|
+
applyNonce(styleTag);
|
|
165
180
|
document.head.appendChild(styleTag);
|
|
166
181
|
}
|
|
167
182
|
styleTag.textContent = interactiveCSS;
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { generateSingleClassCSS, sortClassesByPropertyOrder, generateRuleForClass } from '../../shared/cssGeneration';
|
|
17
|
+
import { rewriteViewportUnits } from '../../shared/viewportUnits';
|
|
17
18
|
import { getCachedBreakpointConfig, getCachedResponsiveScalesConfig, getCachedRemConversionConfig } from '../responsiveStyleResolver';
|
|
18
19
|
import { DEFAULT_BREAKPOINTS, getBreakpointValues } from '../../shared/breakpoints';
|
|
19
20
|
import type { BreakpointConfig } from '../../shared/breakpoints';
|
|
20
21
|
import { DEFAULT_RESPONSIVE_SCALES } from '../../shared/responsiveScaling';
|
|
22
|
+
import { applyNonce } from './cspNonce';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Build a map from responsive prefix (e.g. 't', 'mob') to breakpoint value.
|
|
@@ -128,6 +130,7 @@ class UtilityClassCollectorImpl {
|
|
|
128
130
|
if (document.head) {
|
|
129
131
|
this.styleEl = document.createElement('style');
|
|
130
132
|
this.styleEl.id = 'utility-css';
|
|
133
|
+
applyNonce(this.styleEl);
|
|
131
134
|
document.head.appendChild(this.styleEl);
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -156,7 +159,10 @@ class UtilityClassCollectorImpl {
|
|
|
156
159
|
|
|
157
160
|
const css = generateSingleClassCSS(name, breakpointConfig, responsiveScalesConfig, remConversionConfig);
|
|
158
161
|
if (css) {
|
|
159
|
-
|
|
162
|
+
// Rewrite vh/vw so design-canvas mode can pin viewport units to a
|
|
163
|
+
// stable px value (see viewportUnits.ts). No-op when --design-vh
|
|
164
|
+
// isn't set on documentElement.
|
|
165
|
+
this.injectedRules.set(name, rewriteViewportUnits(css));
|
|
160
166
|
hasNew = true;
|
|
161
167
|
}
|
|
162
168
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { getCspNonce, applyNonce, _resetCspNonceCacheForTests } from './cspNonce';
|
|
3
|
+
|
|
4
|
+
// Minimal DOM mock — just enough for querySelector('meta[name="csp-nonce"]')
|
|
5
|
+
// and for setting `el.nonce`. Bun's runtime does not ship a DOM.
|
|
6
|
+
function installMockDocument(metaContent: string | null) {
|
|
7
|
+
const meta = metaContent === null
|
|
8
|
+
? null
|
|
9
|
+
: { getAttribute: (k: string) => (k === 'content' ? metaContent : null) };
|
|
10
|
+
(globalThis as any).document = {
|
|
11
|
+
querySelector: (selector: string) =>
|
|
12
|
+
selector === 'meta[name="csp-nonce"]' ? meta : null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('cspNonce', () => {
|
|
17
|
+
let originalDocument: unknown;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
originalDocument = (globalThis as any).document;
|
|
21
|
+
_resetCspNonceCacheForTests();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
(globalThis as any).document = originalDocument;
|
|
26
|
+
_resetCspNonceCacheForTests();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns the meta content when present', () => {
|
|
30
|
+
installMockDocument('abc123==');
|
|
31
|
+
expect(getCspNonce()).toBe('abc123==');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('returns null when no meta is present', () => {
|
|
35
|
+
installMockDocument(null);
|
|
36
|
+
expect(getCspNonce()).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('returns null when document is undefined (SSR path)', () => {
|
|
40
|
+
(globalThis as any).document = undefined;
|
|
41
|
+
expect(getCspNonce()).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('caches the lookup across calls', () => {
|
|
45
|
+
installMockDocument('first-nonce');
|
|
46
|
+
expect(getCspNonce()).toBe('first-nonce');
|
|
47
|
+
// Replace the meta after the first read; cached value should stick.
|
|
48
|
+
installMockDocument('second-nonce');
|
|
49
|
+
expect(getCspNonce()).toBe('first-nonce');
|
|
50
|
+
_resetCspNonceCacheForTests();
|
|
51
|
+
expect(getCspNonce()).toBe('second-nonce');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('applyNonce stamps the IDL `nonce` property when a nonce is present', () => {
|
|
55
|
+
installMockDocument('the-nonce');
|
|
56
|
+
const el: any = { tagName: 'STYLE' };
|
|
57
|
+
applyNonce(el);
|
|
58
|
+
expect(el.nonce).toBe('the-nonce');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('applyNonce is a no-op when no nonce is present', () => {
|
|
62
|
+
installMockDocument(null);
|
|
63
|
+
const el: any = { tagName: 'STYLE' };
|
|
64
|
+
applyNonce(el);
|
|
65
|
+
expect(el.nonce).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the per-page CSP nonce that SSR emits via
|
|
3
|
+
* `<meta name="csp-nonce" content="…">` and applies it to dynamically
|
|
4
|
+
* created `<style>` (or other) elements.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: when Electron's preview CSP is `style-src 'self'
|
|
7
|
+
* 'nonce-X'`, CSP3 ignores the `'unsafe-inline'` fallback even if it is
|
|
8
|
+
* also listed. That means every `document.createElement('style')` call we
|
|
9
|
+
* make in the preview runtime MUST stamp `el.nonce = X` or Chromium will
|
|
10
|
+
* drop the element. The nonce is server-rendered once per page load and
|
|
11
|
+
* does not mutate during the page's lifetime, so the lookup is cached.
|
|
12
|
+
*
|
|
13
|
+
* SSR pipeline (in case you're chasing a missing nonce):
|
|
14
|
+
* 1. `packages/core/lib/server/routes/pages.ts` generates the nonce and
|
|
15
|
+
* sets the `x-meno-csp-nonce` response header.
|
|
16
|
+
* 2. `packages/core/lib/server/ssr/htmlGenerator.ts` emits the meta tag
|
|
17
|
+
* in `<head>` and stamps inline <script>/<style> tags with the nonce.
|
|
18
|
+
* 3. `electron-app/main/window.js` reads the header and emits CSP with
|
|
19
|
+
* `'nonce-X'` in script-src and style-src.
|
|
20
|
+
*
|
|
21
|
+
* When the meta is absent (non-SSR responses, tests, server-rendered HTML
|
|
22
|
+
* that bypassed the route handler), `applyNonce` is a no-op — the
|
|
23
|
+
* corresponding CSP also falls back to `'unsafe-inline'`, so unstamped
|
|
24
|
+
* elements still load.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
let cached: string | null | undefined;
|
|
28
|
+
|
|
29
|
+
export function getCspNonce(): string | null {
|
|
30
|
+
if (cached !== undefined) return cached;
|
|
31
|
+
if (typeof document === 'undefined') {
|
|
32
|
+
cached = null;
|
|
33
|
+
return cached;
|
|
34
|
+
}
|
|
35
|
+
// Wrap in try/catch — some test DOMs (happy-dom in particular) throw
|
|
36
|
+
// from inside querySelector for selectors they fail to parse. We treat
|
|
37
|
+
// any DOM-level error here as "no nonce available" — the corresponding
|
|
38
|
+
// CSP path falls back to 'unsafe-inline', so unstamped elements still
|
|
39
|
+
// load and no production behavior is affected.
|
|
40
|
+
try {
|
|
41
|
+
const meta = document.querySelector('meta[name="csp-nonce"]');
|
|
42
|
+
cached = meta?.getAttribute('content') ?? null;
|
|
43
|
+
} catch {
|
|
44
|
+
cached = null;
|
|
45
|
+
}
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function applyNonce(el: HTMLElement): void {
|
|
50
|
+
const n = getCspNonce();
|
|
51
|
+
if (n) {
|
|
52
|
+
// Both the `nonce` IDL attribute and the `nonce` content attribute work,
|
|
53
|
+
// but only the IDL attribute is readable post-render (the content
|
|
54
|
+
// attribute is wiped by the browser to prevent CSS exfiltration of the
|
|
55
|
+
// nonce itself). Setting the IDL property is the right move.
|
|
56
|
+
(el as HTMLStyleElement & { nonce: string }).nonce = n;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test-only: reset the module-level cache between cases. */
|
|
61
|
+
export function _resetCspNonceCacheForTests(): void {
|
|
62
|
+
cached = undefined;
|
|
63
|
+
}
|
|
@@ -712,6 +712,86 @@ describe("Template Engine - processStructure", () => {
|
|
|
712
712
|
});
|
|
713
713
|
});
|
|
714
714
|
|
|
715
|
+
describe("Template Engine - parentProps cascade", () => {
|
|
716
|
+
const mockComponentDef: StructuredComponentDefinition = {
|
|
717
|
+
interface: {},
|
|
718
|
+
structure: { type: 'node' as const, tag: 'div' }
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
test("falls back to parentProps when child doesn't declare the prop", () => {
|
|
722
|
+
const structure: ComponentNode = {
|
|
723
|
+
type: "node",
|
|
724
|
+
tag: "span",
|
|
725
|
+
children: ["{{ctaText}}"]
|
|
726
|
+
};
|
|
727
|
+
const ctx: TemplateContext = {
|
|
728
|
+
props: {},
|
|
729
|
+
parentProps: { ctaText: "Take the assessment" },
|
|
730
|
+
componentDef: mockComponentDef
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
const result = processStructure(structure, ctx) as ComponentNode;
|
|
734
|
+
expect(result.children).toEqual(["Take the assessment"]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("child props win over parentProps on key collision", () => {
|
|
738
|
+
const structure: ComponentNode = {
|
|
739
|
+
type: "node",
|
|
740
|
+
tag: "span",
|
|
741
|
+
children: ["{{label}}"]
|
|
742
|
+
};
|
|
743
|
+
const ctx: TemplateContext = {
|
|
744
|
+
props: { label: "child" },
|
|
745
|
+
parentProps: { label: "parent" },
|
|
746
|
+
componentDef: mockComponentDef
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const result = processStructure(structure, ctx) as ComponentNode;
|
|
750
|
+
expect(result.children).toEqual(["child"]);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("partial templates (prefix-{{x}}) also cascade", () => {
|
|
754
|
+
const structure: ComponentNode = {
|
|
755
|
+
type: "node",
|
|
756
|
+
tag: "div",
|
|
757
|
+
attributes: { class: "btn-{{variant}}" }
|
|
758
|
+
};
|
|
759
|
+
const ctx: TemplateContext = {
|
|
760
|
+
props: {},
|
|
761
|
+
parentProps: { variant: "primary" },
|
|
762
|
+
componentDef: mockComponentDef
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const result = processStructure(structure, ctx) as ComponentNode;
|
|
766
|
+
expect((result as any).attributes?.class).toBe("btn-primary");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("style mappings cascade against parentProps", () => {
|
|
770
|
+
const structure: ComponentNode = {
|
|
771
|
+
type: "node",
|
|
772
|
+
tag: "div",
|
|
773
|
+
style: {
|
|
774
|
+
base: {
|
|
775
|
+
color: {
|
|
776
|
+
_mapping: true,
|
|
777
|
+
prop: "variant",
|
|
778
|
+
values: { default: "black", featured: "white" }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
} as ComponentNode;
|
|
783
|
+
const ctx: TemplateContext = {
|
|
784
|
+
props: {},
|
|
785
|
+
parentProps: { variant: "featured" },
|
|
786
|
+
componentDef: mockComponentDef
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const result = processStructure(structure, ctx) as ComponentNode;
|
|
790
|
+
const baseStyle = (result.style as any)?.base;
|
|
791
|
+
expect(baseStyle?.color).toBe("white");
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
715
795
|
describe("Template Engine - Slot Default Values", () => {
|
|
716
796
|
const mockComponentDef: StructuredComponentDefinition = {
|
|
717
797
|
interface: {},
|
|
@@ -263,8 +263,13 @@ function buildEvalContext(
|
|
|
263
263
|
context: TemplateContext,
|
|
264
264
|
includeItemContext: boolean = false
|
|
265
265
|
): Record<string, unknown> {
|
|
266
|
+
// Layer order (lowest → highest precedence): global → parent component
|
|
267
|
+
// props → this component's own props. The middle layer lets a `{{x}}` in
|
|
268
|
+
// a child component's structure cascade to the host's `x` when the child
|
|
269
|
+
// didn't declare the prop. Mirrors `buildListResolutionScope` in ssrRenderer.
|
|
266
270
|
const evalContext: Record<string, unknown> = {
|
|
267
271
|
...getGlobalTemplateContext(),
|
|
272
|
+
...(context.parentProps ?? {}),
|
|
268
273
|
...context.props
|
|
269
274
|
};
|
|
270
275
|
|
|
@@ -16,6 +16,13 @@ import type { RemConversionConfig } from '../../shared/pxToRem';
|
|
|
16
16
|
import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
|
|
17
17
|
import { astroComponentName } from './astroEmitHelpers';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Slug token baked into the SSR metadata pass's dummy URL (build-astro renders
|
|
21
|
+
* each CMS template once with this stand-in slug to collect meta). The emitter
|
|
22
|
+
* swaps it for the per-entry slug expression so canonical/og:url are correct.
|
|
23
|
+
*/
|
|
24
|
+
export const CMS_SLUG_PLACEHOLDER = '__placeholder__';
|
|
25
|
+
|
|
19
26
|
// ---------------------------------------------------------------------------
|
|
20
27
|
// Types
|
|
21
28
|
// ---------------------------------------------------------------------------
|
|
@@ -71,6 +78,10 @@ export interface CMSPageEmitOptions {
|
|
|
71
78
|
processedRawHtml?: Map<string, string>;
|
|
72
79
|
/** Rem conversion config for interactive CSS generation */
|
|
73
80
|
remConfig?: RemConversionConfig;
|
|
81
|
+
/** Components that consume the CMS entry (thread `cms={entry}` into them) */
|
|
82
|
+
cmsConsumers?: Set<string>;
|
|
83
|
+
/** collectionId → `_url` template expression for flattened collection lists */
|
|
84
|
+
collectionUrlExpr?: Map<string, string>;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
// ---------------------------------------------------------------------------
|
|
@@ -326,6 +337,11 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
|
|
|
326
337
|
imageImports: new Map<string, string>(),
|
|
327
338
|
fileDepth,
|
|
328
339
|
collectedInteractiveStyles: new Map<string, InteractiveStyles>(),
|
|
340
|
+
// Frontmatter sink for any page-level collection-sourced lists.
|
|
341
|
+
frontmatterLines: [],
|
|
342
|
+
astroImports: new Set<string>(),
|
|
343
|
+
cmsConsumers: options.cmsConsumers,
|
|
344
|
+
collectionUrlExpr: options.collectionUrlExpr,
|
|
329
345
|
};
|
|
330
346
|
|
|
331
347
|
// Emit the template body
|
|
@@ -365,10 +381,18 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
|
|
|
365
381
|
const libraryTagsLiteral = `{ headCSS: \`${escapeTemplateLiteral(libraryTags.headCSS || '')}\`, headJS: \`${escapeTemplateLiteral(libraryTags.headJS || '')}\`, bodyEndJS: \`${escapeTemplateLiteral(libraryTags.bodyEndJS || '')}\` }`;
|
|
366
382
|
|
|
367
383
|
// Escape meta first, then transform CMS templates ({{cms.X}} survives escaping intact)
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
384
|
+
const slugField = cmsSchema.slugField || 'slug';
|
|
385
|
+
const escapedMeta = escapeTemplateLiteral(meta)
|
|
386
|
+
.replace(
|
|
387
|
+
/\{\{cms\.([^}]+)\}\}/g,
|
|
388
|
+
(_, fieldPath) => `\${${wrapFn}(${binding}.data.${fieldPath.trim()})}`
|
|
389
|
+
)
|
|
390
|
+
// Replace the metadata-pass stand-in slug with the per-entry slug so
|
|
391
|
+
// canonical/og:url point at the real page instead of "__placeholder__".
|
|
392
|
+
.replace(
|
|
393
|
+
new RegExp(CMS_SLUG_PLACEHOLDER, 'g'),
|
|
394
|
+
`\${${wrapFn}(${binding}.data.${slugField}) || ${binding}.id}`
|
|
395
|
+
);
|
|
372
396
|
const escapedFontPreloads = escapeTemplateLiteral(fontPreloads);
|
|
373
397
|
|
|
374
398
|
// Transform title for CMS entry data
|
|
@@ -380,6 +404,12 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
|
|
|
380
404
|
return v ?? '';
|
|
381
405
|
}`;
|
|
382
406
|
|
|
407
|
+
// Page-level collection queries emitted by collection-sourced list nodes
|
|
408
|
+
// (getCollection import is already present in importLines above).
|
|
409
|
+
const extraFrontmatter = ctx.frontmatterLines && ctx.frontmatterLines.length > 0
|
|
410
|
+
? '\n' + ctx.frontmatterLines.join('\n')
|
|
411
|
+
: '';
|
|
412
|
+
|
|
383
413
|
// Generate interactive style block for page-level interactive styles
|
|
384
414
|
const interactiveStyleSection = ctx.collectedInteractiveStyles!.size > 0
|
|
385
415
|
? `\n<style is:global>\n${generateAllInteractiveCSS(ctx.collectedInteractiveStyles!, breakpoints, remConfig, responsiveScales)}\n</style>\n`
|
|
@@ -390,7 +420,7 @@ ${importLines.join('\n')}
|
|
|
390
420
|
|
|
391
421
|
${staticPaths}
|
|
392
422
|
|
|
393
|
-
${resolverHelper}
|
|
423
|
+
${resolverHelper}${extraFrontmatter}
|
|
394
424
|
---
|
|
395
425
|
<BaseLayout
|
|
396
426
|
title=${titleExpr}
|
|
@@ -129,6 +129,16 @@ function mergeClassNameOntoRoot(template: string): string {
|
|
|
129
129
|
/**
|
|
130
130
|
* Generate a .astro file string from a component definition
|
|
131
131
|
*/
|
|
132
|
+
/** CMS-aware emission options threaded from build-astro. */
|
|
133
|
+
export interface ComponentCmsOptions {
|
|
134
|
+
/** Names of components that consume the CMS entry (transitive). */
|
|
135
|
+
cmsConsumers?: Set<string>;
|
|
136
|
+
/** Rich-text field names across all collections (for `set:html`). */
|
|
137
|
+
cmsRichTextFields?: Set<string>;
|
|
138
|
+
/** collectionId → `_url` template expression for flattened lists. */
|
|
139
|
+
collectionUrlExpr?: Map<string, string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
export function emitAstroComponent(
|
|
133
143
|
name: string,
|
|
134
144
|
def: ComponentDefinition,
|
|
@@ -136,7 +146,8 @@ export function emitAstroComponent(
|
|
|
136
146
|
breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
|
|
137
147
|
defaultLocale: string = 'en',
|
|
138
148
|
responsiveScales?: ResponsiveScales,
|
|
139
|
-
remConfig?: RemConversionConfig
|
|
149
|
+
remConfig?: RemConversionConfig,
|
|
150
|
+
cmsOptions?: ComponentCmsOptions
|
|
140
151
|
): string {
|
|
141
152
|
const comp = def.component;
|
|
142
153
|
const propDefs = comp.interface || {};
|
|
@@ -147,6 +158,11 @@ export function emitAstroComponent(
|
|
|
147
158
|
return buildNoStructureComponent(name, comp);
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
// A "CMS consumer" references {{cms.*}} (transitively). It receives the CMS
|
|
162
|
+
// entry via a `cms` prop and resolves field refs against it (binding 'cms'),
|
|
163
|
+
// mirroring how the CMS template page resolves them against `entry`.
|
|
164
|
+
const isCmsConsumer = cmsOptions?.cmsConsumers?.has(name) ?? false;
|
|
165
|
+
|
|
150
166
|
// Build the Astro context for template emission
|
|
151
167
|
const ctx: AstroEmitContext = {
|
|
152
168
|
imports: new Set<string>(),
|
|
@@ -164,6 +180,19 @@ export function emitAstroComponent(
|
|
|
164
180
|
imageImports: new Map<string, string>(),
|
|
165
181
|
fileDepth: 0, // components live at src/components/
|
|
166
182
|
collectedInteractiveStyles: new Map<string, InteractiveStyles>(),
|
|
183
|
+
// Frontmatter sink for collection queries (getCollection) emitted by lists.
|
|
184
|
+
frontmatterLines: [],
|
|
185
|
+
astroImports: new Set<string>(),
|
|
186
|
+
cmsConsumers: cmsOptions?.cmsConsumers,
|
|
187
|
+
collectionUrlExpr: cmsOptions?.collectionUrlExpr,
|
|
188
|
+
...(isCmsConsumer
|
|
189
|
+
? {
|
|
190
|
+
cmsMode: true,
|
|
191
|
+
cmsEntryBinding: 'cms',
|
|
192
|
+
cmsWrapFn: 'r',
|
|
193
|
+
cmsRichTextFields: cmsOptions?.cmsRichTextFields,
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
167
196
|
};
|
|
168
197
|
|
|
169
198
|
// Emit the template body
|
|
@@ -172,14 +201,18 @@ export function emitAstroComponent(
|
|
|
172
201
|
// Merge instance className onto the root element (for acceptsStyles support)
|
|
173
202
|
templateBody = mergeClassNameOntoRoot(templateBody);
|
|
174
203
|
|
|
175
|
-
// Build frontmatter (includes class prop for instance style support)
|
|
204
|
+
// Build frontmatter (includes class prop for instance style support).
|
|
205
|
+
// Consumers always get the `r` resolver (the cms wrap fn) and a `cms` prop.
|
|
176
206
|
const frontmatter = buildFrontmatter(
|
|
177
207
|
name,
|
|
178
208
|
propDefs,
|
|
179
209
|
ctx.imports,
|
|
180
210
|
ctx.dynamicTags,
|
|
181
|
-
ctx.needsI18nResolver ? defaultLocale : undefined,
|
|
182
|
-
ctx.imageImports
|
|
211
|
+
(ctx.needsI18nResolver || isCmsConsumer) ? defaultLocale : undefined,
|
|
212
|
+
ctx.imageImports,
|
|
213
|
+
ctx.astroImports,
|
|
214
|
+
ctx.frontmatterLines,
|
|
215
|
+
isCmsConsumer
|
|
183
216
|
);
|
|
184
217
|
|
|
185
218
|
// Build style/script sections
|
|
@@ -203,10 +236,18 @@ function buildFrontmatter(
|
|
|
203
236
|
imports: Set<string>,
|
|
204
237
|
dynamicTags?: Map<string, string>,
|
|
205
238
|
i18nDefaultLocale?: string,
|
|
206
|
-
imageImports?: Map<string, string
|
|
239
|
+
imageImports?: Map<string, string>,
|
|
240
|
+
astroImports?: Set<string>,
|
|
241
|
+
frontmatterLines?: string[],
|
|
242
|
+
isCmsConsumer?: boolean
|
|
207
243
|
): string {
|
|
208
244
|
const lines: string[] = [];
|
|
209
245
|
|
|
246
|
+
// Astro API imports (e.g., getCollection) needed by collection-sourced lists
|
|
247
|
+
if (astroImports && astroImports.size > 0) {
|
|
248
|
+
lines.push(`import { ${Array.from(astroImports).sort().join(', ')} } from 'astro:content';`);
|
|
249
|
+
}
|
|
250
|
+
|
|
210
251
|
// Component imports
|
|
211
252
|
for (const imp of Array.from(imports).sort()) {
|
|
212
253
|
lines.push(`import ${astroComponentName(imp)} from './${imp}.astro';`);
|
|
@@ -236,6 +277,10 @@ function buildFrontmatter(
|
|
|
236
277
|
const optional = 'default' in propDef && propDef.default !== undefined;
|
|
237
278
|
lines.push(` ${propName}${optional ? '?' : ''}: ${tsType};`);
|
|
238
279
|
}
|
|
280
|
+
// CMS consumers receive the current entry as a `cms` prop
|
|
281
|
+
if (isCmsConsumer) {
|
|
282
|
+
lines.push(' cms?: any;');
|
|
283
|
+
}
|
|
239
284
|
// Always include class prop for instance style support
|
|
240
285
|
lines.push(' class?: string;');
|
|
241
286
|
lines.push('}');
|
|
@@ -255,6 +300,10 @@ function buildFrontmatter(
|
|
|
255
300
|
}
|
|
256
301
|
}
|
|
257
302
|
|
|
303
|
+
// CMS consumers destructure the entry prop
|
|
304
|
+
if (isCmsConsumer) {
|
|
305
|
+
destructParts.push('cms');
|
|
306
|
+
}
|
|
258
307
|
// Always include class prop (renamed to className to avoid reserved word)
|
|
259
308
|
destructParts.push('class: className = ""');
|
|
260
309
|
|
|
@@ -289,6 +338,13 @@ function buildFrontmatter(
|
|
|
289
338
|
lines.push(`};`);
|
|
290
339
|
}
|
|
291
340
|
|
|
341
|
+
// Collection queries emitted by collection-sourced list nodes
|
|
342
|
+
// (e.g. `const blogList = await getCollection('blog')...`).
|
|
343
|
+
if (frontmatterLines && frontmatterLines.length > 0) {
|
|
344
|
+
lines.push('');
|
|
345
|
+
for (const line of frontmatterLines) lines.push(line);
|
|
346
|
+
}
|
|
347
|
+
|
|
292
348
|
if (lines.length > 0) lines.push('');
|
|
293
349
|
return lines.join('\n');
|
|
294
350
|
}
|