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
@@ -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["${componentName}"] || {};
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
- const combinedCSS = processedCSSBlocks.join('\n\n');
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 if component CSS hasn't changed
128
- if (hash === this.previousComponentCSSHash) return;
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 = document.getElementById(this.styleId) as HTMLStyleElement | null;
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 = generateAllInteractiveCSS(interactiveStylesMap, breakpointConfig, remConversionConfig, responsiveScalesConfig);
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
- this.injectedRules.set(name, css);
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 escapedMeta = escapeTemplateLiteral(meta).replace(
369
- /\{\{cms\.([^}]+)\}\}/g,
370
- (_, fieldPath) => `\${${wrapFn}(${binding}.data.${fieldPath.trim()})}`
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
  }