meno-core 1.0.50 → 1.0.52

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 (32) hide show
  1. package/build-static.ts +8 -1
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/build-static.js +3 -3
  4. package/dist/chunks/{chunk-PQ2HRXDR.js → chunk-2MHDV5BF.js} +11 -1
  5. package/dist/chunks/chunk-2MHDV5BF.js.map +7 -0
  6. package/dist/chunks/{chunk-YWJJD5D6.js → chunk-A725KYFK.js} +36 -17
  7. package/dist/chunks/{chunk-YWJJD5D6.js.map → chunk-A725KYFK.js.map} +3 -3
  8. package/dist/chunks/{chunk-CVLFID6V.js → chunk-CXCBV2M7.js} +65 -14
  9. package/dist/chunks/chunk-CXCBV2M7.js.map +7 -0
  10. package/dist/chunks/{chunk-4OFZP5NQ.js → chunk-HNLUO36W.js} +15 -4
  11. package/dist/chunks/chunk-HNLUO36W.js.map +7 -0
  12. package/dist/chunks/{chunk-56EUSC6D.js → chunk-LHLHPYSP.js} +4 -4
  13. package/dist/chunks/{chunk-56EUSC6D.js.map → chunk-LHLHPYSP.js.map} +2 -2
  14. package/dist/chunks/{configService-VOY2MY2K.js → configService-R3OGU2UD.js} +2 -2
  15. package/dist/entries/server-router.js +3 -3
  16. package/dist/lib/server/index.js +5 -5
  17. package/lib/server/routes/pages.ts +37 -2
  18. package/lib/server/services/cmsService.ts +21 -0
  19. package/lib/server/services/configService.ts +20 -0
  20. package/lib/server/ssr/buildErrorOverlay.ts +22 -4
  21. package/lib/server/ssr/errorOverlay.ts +11 -3
  22. package/lib/server/ssr/htmlGenerator.nonce.test.ts +165 -0
  23. package/lib/server/ssr/htmlGenerator.ts +25 -6
  24. package/lib/server/ssr/liveReloadIntegration.test.ts +3 -1
  25. package/lib/server/ssr/metaTagGenerator.ts +54 -6
  26. package/lib/server/ssr/ssrRenderer.ts +1 -0
  27. package/lib/server/ssrRenderer.test.ts +157 -2
  28. package/package.json +1 -1
  29. package/dist/chunks/chunk-4OFZP5NQ.js.map +0 -7
  30. package/dist/chunks/chunk-CVLFID6V.js.map +0 -7
  31. package/dist/chunks/chunk-PQ2HRXDR.js.map +0 -7
  32. /package/dist/chunks/{configService-VOY2MY2K.js.map → configService-R3OGU2UD.js.map} +0 -0
@@ -3,6 +3,7 @@
3
3
  * Handles page route requests with SSR, i18n, and CMS support
4
4
  */
5
5
 
6
+ import { randomBytes } from 'crypto';
6
7
  import type { RouteContext } from './index';
7
8
  import { generateSSRHTML, type SSRHTMLResult } from '../ssrRenderer';
8
9
  import { getStaticFilePath } from '../../shared/pathUtils';
@@ -18,6 +19,24 @@ import { readTextFile, fileExists, serveFile } from '../runtime';
18
19
  /** HTTP header sent by the studio's /__static__/ proxy to flag editor-preview requests. */
19
20
  const EDITOR_HEADER = 'x-meno-editor';
20
21
 
22
+ /**
23
+ * Response header carrying the per-request CSP nonce. The Electron main
24
+ * process (electron-app/main/window.js) reads this in onHeadersReceived and
25
+ * splices `'nonce-${nonce}'` into the script-src directive while dropping
26
+ * `'unsafe-inline'`, so the inline scripts the SSR pipeline emits (config,
27
+ * CMS context, live reload, scroll sync, component script) keep executing
28
+ * without re-enabling unrestricted inline JavaScript.
29
+ */
30
+ const CSP_NONCE_HEADER = 'x-meno-csp-nonce';
31
+
32
+ /**
33
+ * Generate a fresh per-request CSP nonce.
34
+ * 16 bytes of randomness → 24 base64 chars, well above CSP3's 128-bit floor.
35
+ */
36
+ function generateCspNonce(): string {
37
+ return randomBytes(16).toString('base64');
38
+ }
39
+
21
40
  /**
22
41
  * Handle page route requests
23
42
  */
@@ -33,6 +52,12 @@ export async function handlePageRoute(
33
52
  // Direct access to the SSR preview server (e.g., http://localhost:8082/) gets clean output.
34
53
  const injectEditorAttrs = req?.headers.get(EDITOR_HEADER) === '1';
35
54
 
55
+ // Fresh CSP nonce per request: stamped onto every inline <script> emitted
56
+ // by the SSR pipeline AND sent back to the Electron main process via the
57
+ // x-meno-csp-nonce header so it can splice `'nonce-XXX'` into the BrowserWindow
58
+ // CSP in place of `'unsafe-inline'`. See electron-app/main/window.js.
59
+ const cspNonce = generateCspNonce();
60
+
36
61
  // Load i18n config for locale extraction
37
62
  const i18nConfig = await loadI18nConfig();
38
63
 
@@ -101,6 +126,7 @@ export async function handlePageRoute(
101
126
  isEditor,
102
127
  serverPort,
103
128
  returnSeparateJS: true,
129
+ cspNonce,
104
130
  }) as SSRHTMLResult;
105
131
 
106
132
  let finalHtml = result.html;
@@ -116,6 +142,7 @@ export async function handlePageRoute(
116
142
  'Cache-Control': 'no-store, max-age=0',
117
143
  'Pragma': 'no-cache',
118
144
  'Expires': '0',
145
+ [CSP_NONCE_HEADER]: cspNonce,
119
146
  },
120
147
  });
121
148
  }
@@ -136,6 +163,7 @@ export async function handlePageRoute(
136
163
  pageCustomCode: typedPageData.meta?.customCode,
137
164
  injectEditorAttrs,
138
165
  isEditor,
166
+ cspNonce,
139
167
  });
140
168
 
141
169
  return new Response(ssrHTML, {
@@ -144,15 +172,17 @@ export async function handlePageRoute(
144
172
  'Cache-Control': 'no-store, max-age=0',
145
173
  'Pragma': 'no-cache',
146
174
  'Expires': '0',
175
+ [CSP_NONCE_HEADER]: cspNonce,
147
176
  },
148
177
  });
149
178
  } catch (error) {
150
179
  console.error('Error rendering CMS page:', error);
151
180
  // Show error overlay in preview instead of silent fallback
152
- return new Response(generateErrorPage(error, `Error rendering template: ${cmsTemplatePath}`), {
181
+ return new Response(generateErrorPage(error, `Error rendering template: ${cmsTemplatePath}`, cspNonce), {
153
182
  headers: {
154
183
  'Content-Type': 'text/html; charset=utf-8',
155
184
  'Cache-Control': 'no-store',
185
+ [CSP_NONCE_HEADER]: cspNonce,
156
186
  },
157
187
  });
158
188
  }
@@ -224,6 +254,7 @@ export async function handlePageRoute(
224
254
  isEditor,
225
255
  serverPort,
226
256
  returnSeparateJS: true,
257
+ cspNonce,
227
258
  }) as SSRHTMLResult;
228
259
 
229
260
  let finalHtml = result.html;
@@ -239,6 +270,7 @@ export async function handlePageRoute(
239
270
  'Cache-Control': 'no-store, max-age=0',
240
271
  'Pragma': 'no-cache',
241
272
  'Expires': '0',
273
+ [CSP_NONCE_HEADER]: cspNonce,
242
274
  },
243
275
  });
244
276
  }
@@ -257,6 +289,7 @@ export async function handlePageRoute(
257
289
  pageCustomCode: pageData.meta?.customCode,
258
290
  injectEditorAttrs,
259
291
  isEditor,
292
+ cspNonce,
260
293
  });
261
294
 
262
295
  return new Response(ssrHTML, {
@@ -265,15 +298,17 @@ export async function handlePageRoute(
265
298
  'Cache-Control': 'no-store, max-age=0',
266
299
  'Pragma': 'no-cache',
267
300
  'Expires': '0',
301
+ [CSP_NONCE_HEADER]: cspNonce,
268
302
  },
269
303
  });
270
304
  } catch (error) {
271
305
  console.error('Error rendering page:', error);
272
306
  // Show error overlay in preview instead of silent fallback
273
- return new Response(generateErrorPage(error, `Error rendering page: ${lookupPath}`), {
307
+ return new Response(generateErrorPage(error, `Error rendering page: ${lookupPath}`, cspNonce), {
274
308
  headers: {
275
309
  'Content-Type': 'text/html; charset=utf-8',
276
310
  'Cache-Control': 'no-store',
311
+ [CSP_NONCE_HEADER]: cspNonce,
277
312
  },
278
313
  });
279
314
  }
@@ -508,6 +508,27 @@ export class CMSService {
508
508
  this.itemsCache.delete(collection);
509
509
  }
510
510
 
511
+ /**
512
+ * Save a published item. Wraps the provider so the items cache is invalidated
513
+ * immediately — without this, the editor's post-save refetch could hit a
514
+ * stale snapshot inside the 5s TTL window and overwrite an optimistic preview.
515
+ */
516
+ async saveItem(collection: string, item: CMSItem): Promise<void> {
517
+ if (!this.provider) throw new Error('CMS provider not configured');
518
+ await this.provider.saveItem(collection, item);
519
+ this.itemsCache.delete(collection);
520
+ }
521
+
522
+ /**
523
+ * Delete an item (and its draft sibling). Cache invalidation, same rationale
524
+ * as `saveItem`.
525
+ */
526
+ async deleteItem(collection: string, filename: string): Promise<void> {
527
+ if (!this.provider) throw new Error('CMS provider not configured');
528
+ await this.provider.deleteItem(collection, filename);
529
+ this.itemsCache.delete(collection);
530
+ }
531
+
511
532
  async discardDraft(collection: string, filename: string): Promise<void> {
512
533
  if (!this.provider) throw new Error('CMS provider not configured');
513
534
  await this.provider.discardDraft(collection, filename);
@@ -29,6 +29,13 @@ export interface IconsConfig {
29
29
  appleTouchIcon?: string;
30
30
  }
31
31
 
32
+ /**
33
+ * Site-wide social configuration
34
+ */
35
+ export interface SocialConfig {
36
+ twitterHandle?: string;
37
+ }
38
+
32
39
  /**
33
40
  * Raw project config structure from project.config.json
34
41
  */
@@ -39,6 +46,7 @@ interface RawProjectConfig {
39
46
  responsiveScales?: Partial<ResponsiveScales>;
40
47
  i18n?: unknown;
41
48
  icons?: IconsConfig;
49
+ social?: SocialConfig;
42
50
  libraries?: LibrariesConfig;
43
51
  csp?: CSPConfig;
44
52
  baseComponent?: string;
@@ -234,6 +242,18 @@ export class ConfigService {
234
242
  return this.config.icons;
235
243
  }
236
244
 
245
+ /**
246
+ * Get site-wide social configuration
247
+ * Returns empty object if not configured
248
+ */
249
+ getSocial(): SocialConfig {
250
+ if (!this.config?.social || typeof this.config.social !== 'object') {
251
+ return {};
252
+ }
253
+
254
+ return this.config.social;
255
+ }
256
+
237
257
  /**
238
258
  * Get libraries configuration
239
259
  * Returns empty arrays if not configured
@@ -37,11 +37,19 @@ function safeJsonForScript(data: unknown): string {
37
37
  }
38
38
 
39
39
  /**
40
- * Generate HTML page showing build errors
40
+ * Generate HTML page showing build errors.
41
+ *
42
+ * @param data Build errors payload to render
43
+ * @param cspNonce Per-request CSP nonce — when set, stamped on the inline
44
+ * `<script>` so it executes under a strict (nonce-based)
45
+ * script-src policy without `'unsafe-inline'`. Callers in a
46
+ * plain static-serve context may omit it (no CSP is enforced
47
+ * by the static server itself).
41
48
  */
42
- export function generateBuildErrorPage(data: BuildErrorsData): string {
49
+ export function generateBuildErrorPage(data: BuildErrorsData, cspNonce?: string): string {
43
50
  const { errors, timestamp } = data;
44
51
  const timeStr = new Date(timestamp).toLocaleTimeString();
52
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
45
53
 
46
54
  // Generate plain text for copying to AI
47
55
  const plainTextErrors = errors.map(err =>
@@ -304,7 +312,7 @@ export function generateBuildErrorPage(data: BuildErrorsData): string {
304
312
  </svg>
305
313
  <span>Copy</span>
306
314
  </button>
307
- <button class="btn btn-primary" onclick="location.reload()">
315
+ <button class="btn btn-primary" id="refreshBtn">
308
316
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
309
317
  <polyline points="23 4 23 10 17 10"></polyline>
310
318
  <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
@@ -315,7 +323,7 @@ export function generateBuildErrorPage(data: BuildErrorsData): string {
315
323
  </div>
316
324
  </div>
317
325
  </div>
318
- <script>
326
+ <script${nonceAttr}>
319
327
  (function() {
320
328
  var copyBtn = document.getElementById('copyBtn');
321
329
  var copyText = ${safeJsonForScript(copyText)};
@@ -334,6 +342,16 @@ export function generateBuildErrorPage(data: BuildErrorsData): string {
334
342
  console.error('Failed to copy:', err);
335
343
  });
336
344
  });
345
+
346
+ // Refresh button — handler attached here (instead of onclick) so the
347
+ // page works under a strict (nonce-based) script-src CSP that bars
348
+ // inline event-handler attributes.
349
+ var refreshBtn = document.getElementById('refreshBtn');
350
+ if (refreshBtn) {
351
+ refreshBtn.addEventListener('click', function() {
352
+ location.reload();
353
+ });
354
+ }
337
355
  })();
338
356
  </script>
339
357
  </body>
@@ -49,12 +49,20 @@ function safeJsonForScript(data: unknown): string {
49
49
  }
50
50
 
51
51
  /**
52
- * Generate HTML page with error overlay
52
+ * Generate HTML page with error overlay.
53
+ *
54
+ * @param error Error to display
55
+ * @param context Optional context label shown above the error message
56
+ * @param cspNonce Per-request CSP nonce — when set, stamped on the inline
57
+ * `<script>` so it executes under a strict (nonce-based)
58
+ * script-src policy without `'unsafe-inline'`. Pass through
59
+ * from the route handler that produces this response.
53
60
  */
54
- export function generateErrorPage(error: unknown, context?: string): string {
61
+ export function generateErrorPage(error: unknown, context?: string, cspNonce?: string): string {
55
62
  const errorInfo = extractErrorInfo(error);
56
63
  const errorMessage = escapeHtml(errorInfo.message);
57
64
  const errorStack = errorInfo.stack ? escapeHtml(errorInfo.stack) : '';
65
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
58
66
 
59
67
  // Safely encode error data for script
60
68
  const errorDataJson = safeJsonForScript({
@@ -219,7 +227,7 @@ export function generateErrorPage(error: unknown, context?: string): string {
219
227
  </button>
220
228
  </div>
221
229
  </div>
222
- <script>
230
+ <script${nonceAttr}>
223
231
  (function() {
224
232
  // Send error to parent editor
225
233
  if (window.parent && window.parent !== window) {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Tests for the CSP-nonce contract in the SSR pipeline.
3
+ *
4
+ * This is a static-analysis-style test: it scans the source of each file that
5
+ * emits inline <script> tags, finds every opening <script tag in the
6
+ * template-literal output, and asserts that the very next characters are the
7
+ * `${nonceAttr}` interpolation. Without this, a future inline script could
8
+ * be added without a nonce and silently break under the production CSP.
9
+ *
10
+ * Exceptions:
11
+ * - `<script type="application/json">` data islands — CSP doesn't apply to
12
+ * non-executable script types, so no nonce is required.
13
+ * - `<script src="…">` external script tags — `'self'` / external-origin
14
+ * allow-listing covers these; nonces aren't needed and would be ignored.
15
+ *
16
+ * Pair this with the runtime check (pages.ts threads `cspNonce` into the
17
+ * `x-meno-csp-nonce` header) verified separately below.
18
+ */
19
+
20
+ import { test, expect, describe } from 'bun:test';
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+
24
+ const CORE = path.resolve(import.meta.dir, '../..', '..');
25
+ const SSR_DIR = path.join(CORE, 'lib/server/ssr');
26
+ const ROUTES_DIR = path.join(CORE, 'lib/server/routes');
27
+
28
+ // Files that emit inline <script> tags as part of the response body.
29
+ const FILES_THAT_EMIT_INLINE_SCRIPTS = [
30
+ path.join(SSR_DIR, 'htmlGenerator.ts'),
31
+ path.join(SSR_DIR, 'errorOverlay.ts'),
32
+ path.join(SSR_DIR, 'buildErrorOverlay.ts'),
33
+ ];
34
+
35
+ /**
36
+ * Match an opening `<script…>` tag (TypeScript template-literal source).
37
+ * Captures the attribute block between `<script` and `>`.
38
+ *
39
+ * Notes:
40
+ * - Negative-lookahead skips `<script\n` line wraps mid-comment by matching
41
+ * only attributes-then-`>`; the line-content of inline JS bodies is never
42
+ * touched.
43
+ * - The capture is non-greedy so we don't span multiple tags.
44
+ */
45
+ const SCRIPT_OPEN_TAG = /<script\b([^>]*)>/g;
46
+
47
+ /**
48
+ * Strip `/* … *\/` block comments and `// …` line comments from TypeScript
49
+ * source. JSDoc examples often contain markdown-quoted `<script>` strings
50
+ * that aren't real tag emissions; without stripping comments the regex
51
+ * scan flags them as missing nonces.
52
+ *
53
+ * Replacement preserves line counts (so the index → lineNo math in the
54
+ * caller's error message stays accurate) by substituting spaces and
55
+ * newlines for the comment content.
56
+ */
57
+ function stripComments(src: string): string {
58
+ const blockStripped = src.replace(/\/\*[\s\S]*?\*\//g, (m) =>
59
+ m.replace(/[^\n]/g, ' ')
60
+ );
61
+ return blockStripped.replace(/(^|[^:])\/\/[^\n]*/g, (m, prefix) =>
62
+ prefix + ' '.repeat(m.length - prefix.length)
63
+ );
64
+ }
65
+
66
+ function findInlineScripts(src: string): { attrs: string; index: number }[] {
67
+ const clean = stripComments(src);
68
+ const out: { attrs: string; index: number }[] = [];
69
+ let match: RegExpExecArray | null;
70
+ SCRIPT_OPEN_TAG.lastIndex = 0;
71
+ while ((match = SCRIPT_OPEN_TAG.exec(clean))) {
72
+ out.push({ attrs: match[1], index: match.index });
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function isDataIsland(attrs: string): boolean {
78
+ return /\btype\s*=\s*["']application\/json["']/.test(attrs);
79
+ }
80
+
81
+ function isExternal(attrs: string): boolean {
82
+ return /\bsrc\s*=/.test(attrs);
83
+ }
84
+
85
+ function hasNonceSlot(attrs: string): boolean {
86
+ return /\$\{nonceAttr\}/.test(attrs);
87
+ }
88
+
89
+ describe('SSR inline script nonce contract', () => {
90
+ for (const file of FILES_THAT_EMIT_INLINE_SCRIPTS) {
91
+ describe(path.basename(file), () => {
92
+ const src = fs.readFileSync(file, 'utf8');
93
+ const scripts = findInlineScripts(src);
94
+
95
+ test('has at least one inline <script> tag', () => {
96
+ // htmlGenerator emits 5, errorOverlay emits 1, buildErrorOverlay emits 1.
97
+ expect(scripts.length).toBeGreaterThan(0);
98
+ });
99
+
100
+ test('every inline executable <script> carries ${nonceAttr}', () => {
101
+ const offenders: string[] = [];
102
+ for (const { attrs, index } of scripts) {
103
+ if (isDataIsland(attrs) || isExternal(attrs)) continue;
104
+ if (!hasNonceSlot(attrs)) {
105
+ // Give a useful context window in the failure message.
106
+ const lineNo = src.slice(0, index).split('\n').length;
107
+ offenders.push(`line ${lineNo}: <script${attrs}>`);
108
+ }
109
+ }
110
+ if (offenders.length > 0) {
111
+ throw new Error(
112
+ `Inline <script> tags missing \${nonceAttr}:\n ${offenders.join('\n ')}`,
113
+ );
114
+ }
115
+ });
116
+ });
117
+ }
118
+ });
119
+
120
+ describe('pages.ts nonce threading', () => {
121
+ const src = fs.readFileSync(path.join(ROUTES_DIR, 'pages.ts'), 'utf8');
122
+
123
+ test('declares CSP_NONCE_HEADER as x-meno-csp-nonce', () => {
124
+ expect(src).toMatch(/CSP_NONCE_HEADER\s*=\s*['"]x-meno-csp-nonce['"]/);
125
+ });
126
+
127
+ test('generates a fresh nonce per request', () => {
128
+ // Must call generateCspNonce() inside the request handler, not at module load.
129
+ expect(src).toMatch(/const\s+cspNonce\s*=\s*generateCspNonce\(\)/);
130
+ });
131
+
132
+ test('uses crypto.randomBytes for nonce generation', () => {
133
+ // Anything weaker (Math.random, timestamps) would defeat the purpose.
134
+ expect(src).toMatch(/randomBytes\s*\(\s*\d+\s*\)/);
135
+ });
136
+
137
+ test('every Response includes the nonce header', () => {
138
+ // Count `[CSP_NONCE_HEADER]:` occurrences — must be present on each emitted Response.
139
+ const headerSetters = (src.match(/\[CSP_NONCE_HEADER\]:/g) ?? []).length;
140
+ expect(headerSetters).toBeGreaterThan(0);
141
+ });
142
+
143
+ test('threads cspNonce into every generateSSRHTML / generateErrorPage call', () => {
144
+ // Sanity: at least one ssr call and one error call should pass cspNonce.
145
+ expect(src).toMatch(/generateSSRHTML\s*\(\s*\{[\s\S]*?cspNonce/);
146
+ expect(src).toMatch(/generateErrorPage\s*\([\s\S]*?cspNonce/);
147
+ });
148
+ });
149
+
150
+ describe('htmlGenerator.ts nonceAttr semantics', () => {
151
+ const src = fs.readFileSync(path.join(SSR_DIR, 'htmlGenerator.ts'), 'utf8');
152
+
153
+ test('nonceAttr is empty when no nonce supplied (back-compat)', () => {
154
+ // `cspNonce ? ` nonce="${cspNonce}"` : ''` — both branches present.
155
+ expect(src).toMatch(/const\s+nonceAttr\s*=\s*cspNonce\s*\?[\s\S]*?:\s*['"]['"]/);
156
+ });
157
+
158
+ test('nonceAttr stamps the cspNonce value when supplied', () => {
159
+ expect(src).toMatch(/nonce="\$\{cspNonce\}"/);
160
+ });
161
+
162
+ test('GenerateSSRHTMLOptions declares cspNonce as optional', () => {
163
+ expect(src).toMatch(/cspNonce\s*\?:\s*string/);
164
+ });
165
+ });
@@ -147,6 +147,15 @@ export interface GenerateSSRHTMLOptions {
147
147
  isEditor?: boolean;
148
148
  /** Actual bound server port for live reload WS (connects directly to SSR server) */
149
149
  serverPort?: number;
150
+ /**
151
+ * Per-request CSP nonce. When set, every inline `<script>` emitted by this
152
+ * generator carries `nonce="${nonce}"`. The HTTP route handler must send
153
+ * the same nonce in the `script-src` directive of the response CSP header
154
+ * (and the Electron main process splices it into the BrowserWindow CSP).
155
+ * When unset, scripts are emitted without a nonce attribute — callers that
156
+ * still rely on `'unsafe-inline'` (legacy / direct CLI usage) keep working.
157
+ */
158
+ cspNonce?: string;
150
159
  }
151
160
 
152
161
  /**
@@ -235,7 +244,12 @@ export async function generateSSRHTML(
235
244
  isEditor = false,
236
245
  isProductionBuild = false,
237
246
  serverPort,
247
+ cspNonce,
238
248
  } = options;
249
+
250
+ // Attribute fragment for stamping nonce on inline <script> tags. Empty when
251
+ // no nonce is supplied so legacy ('unsafe-inline') paths keep working.
252
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
239
253
  // Editor selection attributes (data-element-path, data-cms-context, ...) are gated
240
254
  // on injectEditorAttrs — set ONLY when the request comes from the editor preview iframe
241
255
  // via the studio's /__static__/ proxy (which sends an `x-meno-editor` header).
@@ -285,8 +299,13 @@ export async function generateSSRHTML(
285
299
  await configService.load();
286
300
  const globalLibraries = configService.getLibraries() || { js: [], css: [] };
287
301
  const globalCustomCode = configService.getCustomCode();
302
+ // The Meno badge previously used inline event handlers (onmouseenter /
303
+ // onmouseleave) to drive its opacity hover, which CSP with `'nonce-…'` and
304
+ // no `'unsafe-inline'` rejects. Move the hover to a stylesheet rule using a
305
+ // dedicated class so nonces aren't needed for this purely-presentational
306
+ // effect.
288
307
  const menoBadgeHtml = configService.getShowMenoBadge()
289
- ? `<a href="https://meno.so" target="_blank" rel="noopener" style="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" onmouseenter="this.style.opacity='1'" onmouseleave="this.style.opacity='0.8'">Made in Meno</a>`
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>`
290
309
  : '';
291
310
  const mergedCustomCode = {
292
311
  head: [globalCustomCode.head, pageCustomCode?.head].filter(Boolean).join('\n'),
@@ -378,7 +397,7 @@ export async function generateSSRHTML(
378
397
  // Legacy inline mode (dev server)
379
398
  // Escape </script> sequences to prevent premature script tag closure
380
399
  const escapedJavaScript = allJavaScript.replace(/<\/script>/gi, '<\\/script>');
381
- componentScript = `\n <script>\n${escapedJavaScript}\n </script>`;
400
+ componentScript = `\n <script${nonceAttr}>\n${escapedJavaScript}\n </script>`;
382
401
  }
383
402
  }
384
403
 
@@ -472,7 +491,7 @@ picture {
472
491
  // Config script - inline for dev, include in external JS for static build
473
492
  const hasConfig = Object.keys(menoConfig).length > 0;
474
493
  const configInlineScript = hasConfig && !extScriptPath && !returnSeparateJS
475
- ? `<script>window.__MENO_CONFIG__=${JSON.stringify(menoConfig)}</script>\n `
494
+ ? `<script${nonceAttr}>window.__MENO_CONFIG__=${JSON.stringify(menoConfig)}</script>\n `
476
495
  : '';
477
496
  // Add config to external JS if using external scripts
478
497
  if (hasConfig && externalJavaScript !== null) {
@@ -482,7 +501,7 @@ picture {
482
501
  // CMS context script - needed when a client-side Router is present (dev mode or studio preview)
483
502
  // Production builds are pure HTML with no client routing, so excluded
484
503
  const cmsInlineScript = cmsTemplatePath && cms && (!useBundled || injectLiveReload)
485
- ? `<script>window.__MENO_CMS__=${JSON.stringify({ item: cms.cms, templatePath: cmsTemplatePath })}</script>\n `
504
+ ? `<script${nonceAttr}>window.__MENO_CMS__=${JSON.stringify({ item: cms.cms, templatePath: cmsTemplatePath })}</script>\n `
486
505
  : '';
487
506
 
488
507
  // Client data scripts - inline JSON for MenoFilter (production builds only)
@@ -545,12 +564,12 @@ picture {
545
564
  // diff (different element counts or unknown paths) it falls back to a
546
565
  // straight innerHTML replace.
547
566
  const liveReloadScript = injectLiveReload
548
- ? `<script>(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>`
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>`
549
568
  : '';
550
569
 
551
570
  // Scroll position handlers for preview mode iframe switching
552
571
  const scrollHandlerScript = injectLiveReload
553
- ? `<script>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){window.scrollTo(e.data.scrollX,e.data.scrollY)}})})()</script>`
572
+ ? `<script${nonceAttr}>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){window.scrollTo(e.data.scrollX,e.data.scrollY)}})})()</script>`
554
573
  : '';
555
574
 
556
575
  // In production, output minified CSS on single line; in dev, preserve formatting
@@ -17,7 +17,9 @@ function loadLiveReloadScript(): string {
17
17
  const src = fs.readFileSync(path.join(import.meta.dir, 'htmlGenerator.ts'), 'utf8');
18
18
  const idx = src.indexOf('const liveReloadScript');
19
19
  if (idx === -1) throw new Error('liveReloadScript not found');
20
- const match = src.slice(idx).match(/<script>([\s\S]*?)<\/script>/);
20
+ // The live-reload script tag may carry a per-request CSP nonce attribute
21
+ // — `<script${nonceAttr}>` in the source. Match either form.
22
+ const match = src.slice(idx).match(/<script(?:\$\{nonceAttr\})?>([\s\S]*?)<\/script>/);
21
23
  if (!match) throw new Error('liveReloadScript script tag not found');
22
24
  // The script lives inside a TS template literal; reverse the template's
23
25
  // runtime transformations so the executable form matches what a browser sees.
@@ -40,12 +40,37 @@ export function extractPageMeta(pageData: JSONPage): PageMeta {
40
40
  }
41
41
 
42
42
  /**
43
- * Options for hreflang tag generation
43
+ * Site-wide social configuration
44
44
  */
45
- export interface HreflangOptions {
45
+ export interface SocialOptions {
46
+ twitterHandle?: string;
47
+ }
48
+
49
+ /**
50
+ * Normalize an og:image URL for social-card crawlers:
51
+ * - Swap project-local /images/*.webp|.avif → .jpg (companion produced at upload time)
52
+ * - Prepend baseUrl when the value is a project-relative path
53
+ */
54
+ function normalizeOgImageUrl(value: string, baseUrl?: string): string {
55
+ if (!value) return value;
56
+ let out = value;
57
+ if (/^\/images\/[^?#]+\.(webp|avif)(\?|#|$)/i.test(out)) {
58
+ out = out.replace(/\.(webp|avif)(?=\?|#|$)/i, '.jpg');
59
+ }
60
+ if (baseUrl && out.startsWith('/')) {
61
+ out = `${baseUrl.replace(/\/$/, '')}${out}`;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Options for meta tag generation (hreflang + social)
68
+ */
69
+ export interface MetaTagOptions {
46
70
  slugMappings?: SlugMap[];
47
71
  pagePath?: string;
48
72
  baseUrl?: string;
73
+ social?: SocialOptions;
49
74
  }
50
75
 
51
76
  /**
@@ -58,7 +83,7 @@ export function generateMetaTags(
58
83
  url: string = '',
59
84
  locale: string = 'en',
60
85
  config: I18nConfig = DEFAULT_I18N_CONFIG,
61
- hreflangOptions?: HreflangOptions
86
+ options?: MetaTagOptions
62
87
  ): string {
63
88
  const tags: string[] = [];
64
89
 
@@ -98,7 +123,8 @@ export function generateMetaTags(
98
123
  }
99
124
 
100
125
  if (ogImage) {
101
- tags.push(`<meta property="og:image" content="${escapeHtml(ogImage)}" />`);
126
+ const normalizedOgImage = normalizeOgImageUrl(ogImage, options?.baseUrl);
127
+ tags.push(`<meta property="og:image" content="${escapeHtml(normalizedOgImage)}" />`);
102
128
  }
103
129
 
104
130
  if (ogType) {
@@ -109,14 +135,36 @@ export function generateMetaTags(
109
135
  tags.push(`<meta property="og:url" content="${escapeHtml(url)}" />`);
110
136
  }
111
137
 
138
+ // Twitter Card tags - Twitter falls back to og:image/og:title/og:description when twitter:* is absent
139
+ const hasAnyMeta = title || description || ogImage || ogTitle || ogDescription;
140
+ if (hasAnyMeta) {
141
+ const cardType = ogImage ? 'summary_large_image' : 'summary';
142
+ tags.push(`<meta name="twitter:card" content="${cardType}" />`);
143
+ }
144
+
145
+ if (ogTitle) {
146
+ tags.push(`<meta name="twitter:title" content="${escapeHtml(ogTitle)}" />`);
147
+ }
148
+
149
+ if (ogDescription) {
150
+ tags.push(`<meta name="twitter:description" content="${escapeHtml(ogDescription)}" />`);
151
+ }
152
+
153
+ const rawHandle = options?.social?.twitterHandle?.trim();
154
+ if (rawHandle) {
155
+ const handle = rawHandle.startsWith('@') ? rawHandle : `@${rawHandle}`;
156
+ tags.push(`<meta name="twitter:site" content="${escapeHtml(handle)}" />`);
157
+ tags.push(`<meta name="twitter:creator" content="${escapeHtml(handle)}" />`);
158
+ }
159
+
112
160
  // Canonical URL
113
161
  if (url) {
114
162
  tags.push(`<link rel="canonical" href="${escapeHtml(url)}" />`);
115
163
  }
116
164
 
117
165
  // Hreflang tags for multilingual pages
118
- if (hreflangOptions?.slugMappings && hreflangOptions.slugMappings.length > 0 && config.locales.length > 1) {
119
- const { slugMappings, pagePath = '/', baseUrl = '' } = hreflangOptions;
166
+ if (options?.slugMappings && options.slugMappings.length > 0 && config.locales.length > 1) {
167
+ const { slugMappings, pagePath = '/', baseUrl = '' } = options;
120
168
  const slugIndex = buildSlugIndex(slugMappings);
121
169
  const localeLinks = getLocaleLinks(pagePath, locale, config, slugIndex);
122
170