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
@@ -147,6 +147,46 @@ export class PageService {
147
147
  this.pageCache.set(path, content, lineMap);
148
148
  }
149
149
 
150
+ /**
151
+ * Re-read a single page from disk via the provider and refresh the cache.
152
+ *
153
+ * Used by the file watcher for live reload. Provider-based (not a hardcoded
154
+ * JSON read), so it works for any format — the FileSystem provider returns the
155
+ * raw JSON and the Astro provider returns the parsed model serialized to JSON.
156
+ *
157
+ * @returns true if the cache changed (page updated or confirmed-deleted) and a
158
+ * reload broadcast is warranted; false if the read failed transiently (e.g. a
159
+ * partial mid-write or parse error), in which case the cache is left intact and
160
+ * the next watcher event retries.
161
+ */
162
+ async reloadPageFromDisk(path: string): Promise<boolean> {
163
+ if (!this.provider) return false;
164
+
165
+ let content: string | null = null;
166
+ try {
167
+ content = await this.provider.get(path);
168
+ } catch {
169
+ // Transient (mid-write / partial .astro parse error) — keep the cache as-is.
170
+ return false;
171
+ }
172
+
173
+ if (content) {
174
+ this.pageCache.set(path, content, buildLineMap(content));
175
+ return true;
176
+ }
177
+
178
+ // No content: only drop from cache if the page is truly gone, not mid-write.
179
+ try {
180
+ if (!(await this.provider.exists(path))) {
181
+ this.pageCache.delete(path);
182
+ return true;
183
+ }
184
+ } catch {
185
+ /* leave cache intact */
186
+ }
187
+ return false;
188
+ }
189
+
150
190
  /**
151
191
  * Delete page from cache and optionally from storage
152
192
  *
@@ -230,6 +270,21 @@ export class PageService {
230
270
  return this.pageCache.getLineMap(path);
231
271
  }
232
272
 
273
+ /**
274
+ * Pages base directory — provider-aware (e.g. `src/pages` for astro projects),
275
+ * falling back to `projectPaths.pages()` when the provider doesn't expose
276
+ * `baseDir()` (or no provider is set). Used by the folder/move/rename ops that
277
+ * touch the filesystem directly.
278
+ */
279
+ private pagesBaseDir(): string {
280
+ return this.provider?.baseDir?.() ?? projectPaths.pages();
281
+ }
282
+
283
+ /** Page file extension (e.g. `.astro` for astro projects), defaulting to `.json`. */
284
+ private pageExt(): string {
285
+ return this.provider?.extension?.() ?? '.json';
286
+ }
287
+
233
288
  /**
234
289
  * Get all page folders
235
290
  *
@@ -238,7 +293,7 @@ export class PageService {
238
293
  * @returns Sorted array of folder names
239
294
  */
240
295
  getAllFolders(): string[] {
241
- const pagesDir = projectPaths.pages();
296
+ const pagesDir = this.pagesBaseDir();
242
297
  if (!existsSync(pagesDir)) {
243
298
  return [];
244
299
  }
@@ -284,7 +339,7 @@ export class PageService {
284
339
  }
285
340
  }
286
341
 
287
- const pagesDir = projectPaths.pages();
342
+ const pagesDir = this.pagesBaseDir();
288
343
  const folderPath = join(pagesDir, trimmed);
289
344
 
290
345
  if (existsSync(folderPath)) {
@@ -305,7 +360,8 @@ export class PageService {
305
360
  */
306
361
  async movePage(pagePath: string, newFolder: string): Promise<void> {
307
362
  const { rename } = await import('fs/promises');
308
- const pagesDir = projectPaths.pages();
363
+ const pagesDir = this.pagesBaseDir();
364
+ const ext = this.pageExt();
309
365
 
310
366
  // Convert page path to file name: "/blog/post" -> "blog/post", "/" -> "index"
311
367
  const pageName = pagePath === '/' ? 'index' : pagePath.substring(1);
@@ -313,10 +369,10 @@ export class PageService {
313
369
  const slashIndex = pageName.lastIndexOf('/');
314
370
  const baseName = slashIndex >= 0 ? pageName.substring(slashIndex + 1) : pageName;
315
371
 
316
- const sourceFile = join(pagesDir, `${pageName}.json`);
372
+ const sourceFile = join(pagesDir, `${pageName}${ext}`);
317
373
  const targetFile = newFolder
318
- ? join(pagesDir, newFolder, `${baseName}.json`)
319
- : join(pagesDir, `${baseName}.json`);
374
+ ? join(pagesDir, newFolder, `${baseName}${ext}`)
375
+ : join(pagesDir, `${baseName}${ext}`);
320
376
 
321
377
  // Create target directory if needed
322
378
  if (newFolder) {
@@ -359,6 +415,68 @@ export class PageService {
359
415
  }
360
416
  }
361
417
 
418
+ /**
419
+ * Rename a page (file rename + cache update).
420
+ *
421
+ * Renames the underlying .json file from `oldPath`'s filename to `newPath`'s
422
+ * filename, keeping the same folder. Updates the in-memory cache so the
423
+ * page is now keyed at `newPath`. Folder moves stay with `movePage()` —
424
+ * this is purely "change the URL slug of an existing page" for
425
+ * single-locale projects.
426
+ *
427
+ * @param oldPath - Current page path (e.g., "/about" or "/blog/post")
428
+ * @param newPath - New page path with same folder (e.g., "/about-us" or "/blog/new-slug")
429
+ * @throws {Error} If oldPath doesn't exist, newPath already exists, or paths
430
+ * reference different folders.
431
+ */
432
+ async renamePage(oldPath: string, newPath: string): Promise<void> {
433
+ if (oldPath === newPath) return;
434
+ if (newPath === '/') {
435
+ throw new Error('Cannot rename a page to the index path');
436
+ }
437
+ if (!newPath.startsWith('/')) {
438
+ throw new Error('newPath must start with "/"');
439
+ }
440
+
441
+ const oldName = oldPath === '/' ? 'index' : oldPath.substring(1);
442
+ const newName = newPath.substring(1);
443
+
444
+ const oldSlash = oldName.lastIndexOf('/');
445
+ const newSlash = newName.lastIndexOf('/');
446
+ const oldFolder = oldSlash >= 0 ? oldName.substring(0, oldSlash) : '';
447
+ const newFolder = newSlash >= 0 ? newName.substring(0, newSlash) : '';
448
+ if (oldFolder !== newFolder) {
449
+ throw new Error('renamePage only changes the filename; use movePage to change folder');
450
+ }
451
+
452
+ const baseSegment = newSlash >= 0 ? newName.substring(newSlash + 1) : newName;
453
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(baseSegment)) {
454
+ throw new Error('Page slug can only contain lowercase letters, numbers, dashes, and underscores');
455
+ }
456
+
457
+ const { rename } = await import('fs/promises');
458
+ const pagesDir = this.pagesBaseDir();
459
+ const ext = this.pageExt();
460
+ const sourceFile = join(pagesDir, `${oldName}${ext}`);
461
+ const targetFile = join(pagesDir, `${newName}${ext}`);
462
+
463
+ if (!existsSync(sourceFile)) {
464
+ throw new Error(`Page not found: ${oldPath}`);
465
+ }
466
+ if (existsSync(targetFile)) {
467
+ throw new Error(`Page already exists: ${newPath}`);
468
+ }
469
+
470
+ await rename(sourceFile, targetFile);
471
+
472
+ const content = this.pageCache.getContent(oldPath);
473
+ if (content) {
474
+ this.pageCache.delete(oldPath);
475
+ const lineMap = buildLineMap(content);
476
+ this.pageCache.set(newPath, content, lineMap);
477
+ }
478
+ }
479
+
362
480
  /**
363
481
  * Get slug mappings for all pages
364
482
  *
@@ -3,6 +3,8 @@
3
3
  * Pure functions with no external dependencies
4
4
  */
5
5
 
6
+ import { rewriteViewportUnits } from '../../shared/viewportUnits';
7
+
6
8
  /**
7
9
  * Escape HTML special characters to prevent XSS
8
10
  */
@@ -127,13 +129,17 @@ export function styleToString(style: Record<string, string | number> | undefined
127
129
  for (const [key, value] of Object.entries(style)) {
128
130
  if (value === null || value === undefined) continue;
129
131
 
132
+ // Route every value through the viewport-unit rewriter so design-canvas
133
+ // mode can pin vh/vw to a stable px value. See viewportUnits.ts.
134
+ const rewritten = rewriteViewportUnits(String(value));
135
+
130
136
  // Handle CSS variables (already in kebab-case with -- prefix)
131
137
  if (key.startsWith('--')) {
132
- declarations.push(`${key}: ${escapeHtml(String(value))}`);
138
+ declarations.push(`${key}: ${escapeHtml(rewritten)}`);
133
139
  } else {
134
140
  // Convert camelCase to kebab-case for regular CSS properties
135
141
  const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase();
136
- declarations.push(`${cssProperty}: ${escapeHtml(String(value))}`);
142
+ declarations.push(`${cssProperty}: ${escapeHtml(rewritten)}`);
137
143
  }
138
144
  }
139
145
 
@@ -73,7 +73,7 @@ export function generateBuildErrorPage(data: BuildErrorsData, cspNonce?: string)
73
73
  <meta charset="UTF-8">
74
74
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
75
75
  <title>Build Failed</title>
76
- <style>
76
+ <style${nonceAttr}>
77
77
  * { margin: 0; padding: 0; box-sizing: border-box; }
78
78
 
79
79
  body {
@@ -49,12 +49,31 @@ describe('generateErrorPage', () => {
49
49
  expect(html).toContain('[object Object]');
50
50
  });
51
51
 
52
- it('includes stack trace toggle when error has stack', () => {
52
+ it('includes technical-details toggle when error has stack', () => {
53
53
  const error = new Error('Test error');
54
54
  const html = generateErrorPage(error);
55
55
 
56
56
  expect(html).toContain('stackToggle');
57
- expect(html).toContain('Show Stack Trace');
57
+ expect(html).toContain('Show technical details');
58
+ });
59
+
60
+ it('shows a friendly title/message and keeps the raw message available', () => {
61
+ const html = generateErrorPage(new Error("Cannot read properties of null (reading 'length')"));
62
+
63
+ // Friendly, plain-language copy is shown...
64
+ expect(html).toContain("A section couldn't load its content");
65
+ expect(html).toContain('error-friendly');
66
+ // ...while the raw message is still present (under technical details / copy).
67
+ expect(html).toContain("Cannot read properties of null (reading &#039;length&#039;)");
68
+ });
69
+
70
+ it('sends friendly + raw fields in the PREVIEW_ERROR payload', () => {
71
+ const html = generateErrorPage(new Error("Cannot read properties of null (reading 'length')"));
72
+
73
+ expect(html).toContain('"title"');
74
+ expect(html).toContain('"raw"');
75
+ // The visible message field carries the friendly text, not the raw JS error.
76
+ expect(html).toContain("A section couldn't load its content");
58
77
  });
59
78
 
60
79
  it('sends postMessage to parent window', () => {
@@ -4,6 +4,8 @@
4
4
  * Also sends error details to parent editor via postMessage
5
5
  */
6
6
 
7
+ import { toFriendlyError } from '../../shared/friendlyError';
8
+
7
9
  interface ErrorInfo {
8
10
  message: string;
9
11
  stack?: string;
@@ -60,15 +62,27 @@ function safeJsonForScript(data: unknown): string {
60
62
  */
61
63
  export function generateErrorPage(error: unknown, context?: string, cspNonce?: string): string {
62
64
  const errorInfo = extractErrorInfo(error);
65
+ const friendly = toFriendlyError(errorInfo.message);
63
66
  const errorMessage = escapeHtml(errorInfo.message);
64
67
  const errorStack = errorInfo.stack ? escapeHtml(errorInfo.stack) : '';
68
+ // Escape first (XSS-safe), then turn `backtick` spans into <code> for display.
69
+ const renderInline = (s: string) =>
70
+ escapeHtml(s).replace(/`([^`]+)`/g, '<code>$1</code>');
71
+ const friendlyTitle = escapeHtml(friendly.title);
72
+ const friendlyMessage = renderInline(friendly.friendlyMessage);
73
+ const friendlyHint = friendly.hint ? renderInline(friendly.hint) : '';
65
74
  const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
66
75
 
67
- // Safely encode error data for script
76
+ // Safely encode error data for script. Includes friendly fields so the parent
77
+ // editor can show plain-language text without re-mapping, plus the raw message
78
+ // and stack so "Copy" still yields the exact original for debugging.
68
79
  const errorDataJson = safeJsonForScript({
69
80
  type: 'PREVIEW_ERROR',
70
81
  error: {
71
- message: errorInfo.message,
82
+ message: friendly.friendlyMessage,
83
+ title: friendly.title,
84
+ hint: friendly.hint,
85
+ raw: errorInfo.message,
72
86
  stack: errorInfo.stack,
73
87
  context,
74
88
  },
@@ -85,7 +99,7 @@ export function generateErrorPage(error: unknown, context?: string, cspNonce?: s
85
99
  <meta charset="UTF-8">
86
100
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
87
101
  <title>Preview Error</title>
88
- <style>
102
+ <style${nonceAttr}>
89
103
  * { margin: 0; padding: 0; box-sizing: border-box; }
90
104
  body {
91
105
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -122,6 +136,21 @@ export function generateErrorPage(error: unknown, context?: string, cspNonce?: s
122
136
  text-transform: uppercase;
123
137
  letter-spacing: 0.5px;
124
138
  }
139
+ .error-friendly {
140
+ font-size: 15px;
141
+ line-height: 1.6;
142
+ color: #e8e8e8;
143
+ margin-bottom: 16px;
144
+ }
145
+ .error-friendly code {
146
+ font-family: 'SF Mono', Menlo, Monaco, 'Courier New', monospace;
147
+ font-size: 13px;
148
+ background: #1a1a1a;
149
+ border: 1px solid #444;
150
+ border-radius: 4px;
151
+ padding: 1px 5px;
152
+ color: #ffb86b;
153
+ }
125
154
  .error-message {
126
155
  background: #1a1a1a;
127
156
  border: 1px solid #444;
@@ -199,25 +228,23 @@ export function generateErrorPage(error: unknown, context?: string, cspNonce?: s
199
228
  <div class="error-container">
200
229
  <div class="error-header">
201
230
  <span class="error-icon">⚠️</span>
202
- <span class="error-title">Render Error</span>
231
+ <span class="error-title">${friendlyTitle}</span>
203
232
  </div>
204
233
  <div class="error-body">
205
234
  ${context ? `<div class="error-context">${escapeHtml(context)}</div>` : ''}
206
- <div class="error-message">${errorMessage}</div>
207
- ${errorStack ? `
235
+ <p class="error-friendly">${friendlyMessage}</p>
208
236
  <div class="error-stack">
209
237
  <button class="stack-toggle" id="stackToggle">
210
238
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
211
239
  <polyline points="9 18 15 12 9 6"></polyline>
212
240
  </svg>
213
- <span>Show Stack Trace</span>
241
+ <span>Show technical details</span>
214
242
  </button>
215
- <div class="stack-content" id="stackContent">${errorStack}</div>
243
+ <div class="stack-content" id="stackContent"><div class="error-message">${errorMessage}</div>${errorStack ? `\n${errorStack}` : ''}</div>
216
244
  </div>
217
- ` : ''}
218
245
  </div>
219
246
  <div class="error-footer">
220
- <span class="error-hint">Check your component JavaScript for errors</span>
247
+ <span class="error-hint">${friendlyHint}</span>
221
248
  <button class="copy-btn" id="copyBtn">
222
249
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
223
250
  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
@@ -245,7 +272,7 @@ export function generateErrorPage(error: unknown, context?: string, cspNonce?: s
245
272
  stackToggle.addEventListener('click', function() {
246
273
  var isVisible = stackContent.classList.toggle('visible');
247
274
  stackToggle.classList.toggle('expanded', isVisible);
248
- stackToggle.querySelector('span').textContent = isVisible ? 'Hide Stack Trace' : 'Show Stack Trace';
275
+ stackToggle.querySelector('span').textContent = isVisible ? 'Hide technical details' : 'Show technical details';
249
276
  });
250
277
  }
251
278
 
@@ -685,44 +685,84 @@ describe('generateSSRHTML', () => {
685
685
  // which is what preserves runtime JS state (e.g. open dropdowns) across
686
686
  // pure style edits in the editor.
687
687
  describe('hotReload is content-aware', () => {
688
- test('compares meno-styles textContent before replacing', async () => {
688
+ test('updates #meno-styles in place, never replacing the node (CSP nonce safety)', async () => {
689
689
  const result = (await generateSSRHTML({
690
690
  pageData: minimalPage as any,
691
691
  injectLiveReload: true,
692
692
  })) as string;
693
+ // Only swap when the CSS actually changed...
693
694
  expect(result).toContain('os.textContent!==ns.textContent');
695
+ // ...and do it by mutating the existing (CSP-approved) node's text.
696
+ expect(result).toContain('os.textContent=ns.textContent');
697
+ // Replacing the <style> node would insert one carrying the freshly
698
+ // fetched request's rotated nonce, which the iframe's locked-in CSP
699
+ // rejects — dropping all CSS. Guard against a regression to node swap.
700
+ expect(result).not.toContain('replaceChild(ns.cloneNode');
694
701
  });
695
702
 
696
- test('compares /_scripts/ src (cache-buster stripped) before reloading', async () => {
703
+ test('stamps the original document nonce on recreated CMS context scripts', async () => {
697
704
  const result = (await generateSSRHTML({
698
705
  pageData: minimalPage as any,
699
706
  injectLiveReload: true,
700
707
  })) as string;
701
- // strip() removes ?_r=... so reloads triggered only by content-hash change
708
+ // Read the original page nonce once from the SSR-emitted meta tag...
709
+ expect(result).toContain(`document.querySelector('meta[name="csp-nonce"]')`);
710
+ // ...and stamp it on the inline <script id="meno-cms-*"> nodes the
711
+ // hot-reload recreates, so nonce-only script-src doesn't drop them
712
+ // (which would silently break CMS data hot-reload in the preview).
713
+ expect(result).toContain('if(docNonce)c.nonce=docNonce');
714
+ });
715
+
716
+ test('compares /_scripts/ src (cache-buster stripped) to detect a JS edit', async () => {
717
+ const result = (await generateSSRHTML({
718
+ pageData: minimalPage as any,
719
+ injectLiveReload: true,
720
+ })) as string;
721
+ // strip() removes ?_r=... so a script reload is keyed on a real content-hash change
702
722
  expect(result).toContain('strip(oscr.getAttribute');
703
723
  expect(result).toContain('strip(nscr.getAttribute');
704
- expect(result).toContain('oss===nss');
724
+ // a changed hash is one of the two triggers for a hard reset
725
+ expect(result).toContain('oss!==nss');
726
+ });
727
+
728
+ test('hard-resets #root via innerHTML only on a server-structure or JS change', async () => {
729
+ const result = (await generateSSRHTML({
730
+ pageData: minimalPage as any,
731
+ injectLiveReload: true,
732
+ })) as string;
733
+ // Structure is judged old-server vs new-server (structKey), NOT live-vs-server,
734
+ // so runtime DOM mutations (slider clones, injected backdrops) don't trigger a
735
+ // rebuild. The wholesale innerHTML replace lives only in the hard-reset branch.
736
+ expect(result).toContain('structKey(lastSrvRoot)!==structKey(nr)');
737
+ expect(result).toContain('or.innerHTML=nr.innerHTML');
738
+ expect(result).toContain('hardReset');
705
739
  });
706
740
 
707
- test('falls back to innerHTML replace only on structural changes', async () => {
741
+ test('keys the diff on data-element-path + data-cms-item-index (CMS list disambiguation)', async () => {
708
742
  const result = (await generateSSRHTML({
709
743
  pageData: minimalPage as any,
710
744
  injectLiveReload: true,
711
745
  })) as string;
712
- // smartUpdate's fallback branch (different element counts or
713
- // unmatched data-element-path) is the only place innerHTML is touched.
714
- expect(result).toContain('curR.innerHTML!==srvR.innerHTML');
746
+ // CMS list items share a data-element-path; the item index must be
747
+ // folded into the diff key or one item's data leaks into siblings.
748
+ expect(result).toContain('data-cms-item-index');
749
+ // ek() composes path + item-index; the map is built and read via ek().
750
+ expect(result).toContain("getAttribute('data-cms-item-index')");
751
+ expect(result).toContain('sbp[ek(se[i])]=se[i]');
752
+ expect(result).toContain('p=ek(c)');
753
+ // Must NOT regress to keying the server map on path alone.
754
+ expect(result).not.toContain("sbp[se[i].getAttribute('data-element-path')]");
715
755
  });
716
756
 
717
- test('patches #root in place via smartUpdate (not innerHTML replace)', async () => {
757
+ test('soft-patches #root in place via softSync (no innerHTML replace)', async () => {
718
758
  const result = (await generateSSRHTML({
719
759
  pageData: minimalPage as any,
720
760
  injectLiveReload: true,
721
761
  })) as string;
722
- // smartUpdate is what runs on every hot reload; it walks the tree by
723
- // data-element-path and updates attrs in place so event handlers and
724
- // DOM identity survive.
725
- expect(result).toContain('smartUpdate(or,nr,lastSrvRoot)');
762
+ // On a pure style/content edit softSync walks the tree by data-element-path
763
+ // and updates attrs/classes/text in place, so event handlers, DOM identity
764
+ // and JS-built nodes survive.
765
+ expect(result).toContain('softSync(or,nr,lastSrvRoot)');
726
766
  expect(result).toContain('querySelectorAll(\'[data-element-path]\')');
727
767
  });
728
768
 
@@ -14,6 +14,7 @@ import { colorService } from '../services/ColorService';
14
14
  import { generateThemeColorVariablesCSS, generateVariablesCSS } from '../cssGenerator';
15
15
  import { variableService } from '../services/VariableService';
16
16
  import { generateUtilityCSS, extractUtilityClassesFromHTML, generateAllInteractiveCSS } from '../../shared/cssGeneration';
17
+ import { rewriteViewportUnits } from '../../shared/viewportUnits';
17
18
  import { printMissingStyleWarnings } from '../validateStyleCoverage';
18
19
  import { formHandlerScript, needsFormHandler } from '../../client/scripts/formHandler';
19
20
  import { menoFilterScript, needsMenoFilter } from '../../client/meno-filter/script.generated';
@@ -305,7 +306,7 @@ export async function generateSSRHTML(
305
306
  // dedicated class so nonces aren't needed for this purely-presentational
306
307
  // effect.
307
308
  const menoBadgeHtml = configService.getShowMenoBadge()
308
- ? `<style>.meno-badge{position:fixed;bottom:12px;left:12px;z-index:9999;background:#000;color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;font-family:system-ui,sans-serif;text-decoration:none;opacity:0.8;transition:opacity 0.2s}.meno-badge:hover,.meno-badge:focus{opacity:1}</style><a class="meno-badge" href="https://meno.so" target="_blank" rel="noopener">Made in Meno</a>`
309
+ ? `<style${nonceAttr}>.meno-badge{position:fixed;bottom:12px;left:12px;z-index:9999;background:#000;color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;font-family:system-ui,sans-serif;text-decoration:none;opacity:0.8;transition:opacity 0.2s}.meno-badge:hover,.meno-badge:focus{opacity:1}</style><a class="meno-badge" href="https://meno.so" target="_blank" rel="noopener">Made in Meno</a>`
309
310
  : '';
310
311
  const mergedCustomCode = {
311
312
  head: [globalCustomCode.head, pageCustomCode?.head].filter(Boolean).join('\n'),
@@ -481,8 +482,14 @@ picture {
481
482
  interactiveCSS
482
483
  ].filter(Boolean).join('\n');
483
484
 
485
+ // Rewrite vh/vw to calc(var(--design-vh, 1vh) * N) so the studio's design
486
+ // canvas can pin viewport units to a stable height (avoids the
487
+ // iframe.height ↔ 100vh feedback loop). var() fallback to 1vh means this
488
+ // is a no-op in production / page mode where --design-vh is unset.
489
+ const cssWithStableViewport = rewriteViewportUnits(combinedCSS);
490
+
484
491
  // Minify CSS in production mode
485
- const finalCSS = useBundled ? minifyCSS(combinedCSS) : combinedCSS;
492
+ const finalCSS = useBundled ? minifyCSS(cssWithStableViewport) : cssWithStableViewport;
486
493
 
487
494
  // Load prefetch config for client-side prefetching
488
495
  const prefetchConfig = await loadPrefetchConfig();
@@ -540,31 +547,68 @@ picture {
540
547
  : `location.origin.replace('http','ws')+'/hmr'`;
541
548
  // True hot reload for the studio's static preview iframe.
542
549
  //
543
- // hotReload() is content-aware on every level sections are rebuilt only
544
- // when their serialized content actually differs from the freshly fetched
545
- // HTML, and #root is patched in place via smartUpdate() instead of being
546
- // wholesale-replaced. That means:
547
- // * DOM elements keep their identity across edits, so attached event
548
- // listeners (e.g. NavDropdown's click handler) survive.
549
- // * Classes/attributes that user JS added at runtime (e.g.
550
- // `nav-dropdown-open`) are preserved by diffing the live DOM against
551
- // a cached snapshot of the previous server HTML anything in current
552
- // that wasn't in that snapshot is treated as a runtime addition and
553
- // re-applied on top of the new server attrs.
554
- // * The `/_scripts/{hash}.js` URL is content-addressed (see
555
- // scriptCache.hashContent), so an unchanged hash means user JS hasn't
556
- // changed we skip both the script reload and the DOMContentLoaded
557
- // re-dispatch, leaving JS module state (closures, isOpen flags, etc.)
558
- // intact.
550
+ // The central question on every edit is: did the EDITOR change page
551
+ // *structure*, or only styles/content? We answer it by comparing the
552
+ // previous server render (lastSrvRoot) against the freshly fetched one via
553
+ // structKey() — the sorted set of `data-element-path` keys. This is
554
+ // deliberately independent of the LIVE DOM, which user JS routinely mutates
555
+ // at runtime (a slider clones slides, a nav injects a backdrop); those
556
+ // runtime mutations must NOT be mistaken for editor structural changes.
557
+ //
558
+ // * SOFT update (server structure unchanged AND /_scripts hash unchanged):
559
+ // a pure style/content/attribute edit. softSync() patches matching
560
+ // elements in place (attributes, classes, text) WITHOUT replacing #root
561
+ // and WITHOUT re-running component JS. DOM identity, event listeners,
562
+ // JS-built children (slider clones) and runtime state (open menus,
563
+ // carousel position, counters) all survive. `#meno-styles` is updated in
564
+ // place so the new CSS applies live. This is what lets you tweak styles
565
+ // with the navigation open without it snapping shut.
566
+ // * HARD reset (server structure changed — nodes added/removed/reparented —
567
+ // OR the /_scripts/{hash}.js URL changed because user JS was edited; the
568
+ // URL is content-addressed, see scriptCache.hashContent): #root is
569
+ // wholesale `innerHTML`-replaced from the server markup and the component
570
+ // script is re-run (old `/_scripts` tag removed, cache-busted copy
571
+ // appended, DOMContentLoaded re-dispatched). Interactive state resets,
572
+ // which is expected when structure or behavior actually changed. Because
573
+ // the whole subtree is replaced first there are no surviving listeners to
574
+ // double-bind, so the re-run is clean.
575
+ //
576
+ // softSync() preserves runtime mutations the same way for classes AND
577
+ // attributes: a value that differs from the previous server snapshot but
578
+ // that the new server render left unchanged is treated as a runtime addition
579
+ // (e.g. `nav-dropdown-open`, `aria-expanded="true"`, a JS-set inline `style`)
580
+ // and kept; a value the server itself changed is an editor edit and wins.
581
+ // Element identity relies on `data-element-path`, emitted on every element
582
+ // when the request comes through the studio's `/__static__/` proxy (which
583
+ // sets `x-meno-editor: 1`).
584
+ //
585
+ // CMS list items all share the SAME `data-element-path` — processList()
586
+ // advances the per-item index in `cmsItemIndexPath`, not in `elementPath` —
587
+ // so keying the diff on path alone collapses every card onto the LAST item
588
+ // sharing that path, and editing one item leaks its data into its siblings'
589
+ // cards. ek() therefore composes the path with `data-cms-item-index` (the
590
+ // disambiguator the SSR already emits per item) so each card patches from
591
+ // its own server counterpart, regardless of any client-side (MenoFilter)
592
+ // reordering of the live list DOM. Non-list elements have no item-index, so
593
+ // their key — and behavior — is unchanged.
594
+ //
595
+ // The `#meno-styles` stylesheet is updated IN PLACE (`os.textContent=...`),
596
+ // never by replacing the node. Under the editor preview's hardened CSP,
597
+ // `style-src` is nonce-only and the nonce rotates every request (see
598
+ // pages.ts / cspNonce.ts). Swapping in a `<style>` parsed from the freshly
599
+ // fetched HTML would carry that request's new nonce — which no longer
600
+ // matches the iframe document's locked-in CSP — so Chromium would drop the
601
+ // node and ALL CSS would vanish. Mutating the already-approved node's text
602
+ // doesn't re-trigger nonce validation (same trick StyleInjector uses in dev).
559
603
  //
560
- // Element identity for the smartUpdate() walk relies on
561
- // `data-element-path`, which the SSR pipeline emits on every element when
562
- // the request comes through the studio's `/__static__/` proxy (which sets
563
- // `x-meno-editor: 1`). On structural changes that smartUpdate can't safely
564
- // diff (different element counts or unknown paths) it falls back to a
565
- // straight innerHTML replace.
604
+ // For the same reason, the `<script id="meno-cms-*">` context scripts (which
605
+ // ARE recreated, since an inline script must be a fresh node to re-execute)
606
+ // are stamped with `docNonce` the ORIGINAL page nonce read once from
607
+ // `<meta name="csp-nonce">`, not the rotated nonce in the freshly fetched
608
+ // HTML. Without it, nonce-only `script-src` would drop the recreated script
609
+ // and CMS hot-reload (updated `window.__MENO_CMS__` data) would silently fail.
566
610
  const liveReloadScript = injectLiveReload
567
- ? `<script${nonceAttr}>(function(){var ws,timer,gen=0,lastSrvRoot=null;function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function smartUpdate(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]'),se=srvR.querySelectorAll('[data-element-path]');if(ce.length!==se.length){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}var sbp={};for(var i=0;i<se.length;i++)sbp[se[i].getAttribute('data-element-path')]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[oe[i].getAttribute('data-element-path')]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=c.getAttribute('data-element-path'),s=sbp[p];if(!s){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');if(or&&nr)smartUpdate(or,nr,lastSrvRoot);if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.parentNode.replaceChild(ns.cloneNode(true),os);var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';if(oss===nss){window.scrollTo(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
611
+ ? `<script${nonceAttr}>(function(){var ws,timer,gen=0,lastSrvRoot=null,dn=document.querySelector('meta[name="csp-nonce"]'),docNonce=dn?dn.getAttribute('content'):'';function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;var ov=old?old.getAttribute(a.name):null;if(a.value!==ov){if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function ek(el){var p=el.getAttribute('data-element-path'),ci=el.getAttribute('data-cms-item-index');return ci?p+'|'+ci:p}function structKey(root){var e=root.querySelectorAll('[data-element-path]'),k=[];for(var i=0;i<e.length;i++)k.push(ek(e[i]));return k.sort().join('~')}function softSync(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]');var sbp={},se=srvR.querySelectorAll('[data-element-path]');for(var i=0;i<se.length;i++)sbp[ek(se[i])]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[ek(oe[i])]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=ek(c),s=sbp[p];if(!s)continue;syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';var hardReset=(oss!==nss)||!lastSrvRoot||!or||!nr||structKey(lastSrvRoot)!==structKey(nr);if(or&&nr){if(hardReset){if(or.innerHTML!==nr.innerHTML)or.innerHTML=nr.innerHTML}else{softSync(or,nr,lastSrvRoot)}}if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.textContent=ns.textContent;var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;if(docNonce)c.nonce=docNonce;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}if(!hardReset){window.scrollTo(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
568
612
  : '';
569
613
 
570
614
  // Scroll position handlers for preview mode iframe switching
@@ -582,8 +626,8 @@ picture {
582
626
  <head>
583
627
  <meta charset="UTF-8">
584
628
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
585
- ${iconTags ? iconTags + '\n ' : ''}${scriptPreloadTag ? scriptPreloadTag + '\n ' : ''}${imagePreloadTags ? imagePreloadTags + '\n ' : ''}${fontPreloadTags ? fontPreloadTags + '\n ' : ''}${libraryTags.headCSS ? libraryTags.headCSS + '\n ' : ''}${libraryTags.headJS ? libraryTags.headJS + '\n ' : ''}${rendered.meta}
586
- ${configInlineScript}${cmsInlineScript}${clientDataScripts}<style id="meno-styles">${styleContent}</style>${mergedCustomCode.head ? '\n ' + mergedCustomCode.head : ''}
629
+ ${cspNonce ? `<meta name="csp-nonce" content="${cspNonce}">\n ` : ''}${iconTags ? iconTags + '\n ' : ''}${scriptPreloadTag ? scriptPreloadTag + '\n ' : ''}${imagePreloadTags ? imagePreloadTags + '\n ' : ''}${fontPreloadTags ? fontPreloadTags + '\n ' : ''}${libraryTags.headCSS ? libraryTags.headCSS + '\n ' : ''}${libraryTags.headJS ? libraryTags.headJS + '\n ' : ''}${rendered.meta}
630
+ ${configInlineScript}${cmsInlineScript}${clientDataScripts}<style id="meno-styles"${nonceAttr}>${styleContent}</style>${mergedCustomCode.head ? '\n ' + mergedCustomCode.head : ''}
587
631
  </head>
588
632
  <body>${mergedCustomCode.bodyStart ? '\n ' + mergedCustomCode.bodyStart : ''}
589
633
  <div id="root">