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.
- package/build-static.ts +8 -1
- package/dist/bin/cli.js +1 -1
- package/dist/build-static.js +3 -3
- package/dist/chunks/{chunk-PQ2HRXDR.js → chunk-2MHDV5BF.js} +11 -1
- package/dist/chunks/chunk-2MHDV5BF.js.map +7 -0
- package/dist/chunks/{chunk-YWJJD5D6.js → chunk-A725KYFK.js} +36 -17
- package/dist/chunks/{chunk-YWJJD5D6.js.map → chunk-A725KYFK.js.map} +3 -3
- package/dist/chunks/{chunk-CVLFID6V.js → chunk-CXCBV2M7.js} +65 -14
- package/dist/chunks/chunk-CXCBV2M7.js.map +7 -0
- package/dist/chunks/{chunk-4OFZP5NQ.js → chunk-HNLUO36W.js} +15 -4
- package/dist/chunks/chunk-HNLUO36W.js.map +7 -0
- package/dist/chunks/{chunk-56EUSC6D.js → chunk-LHLHPYSP.js} +4 -4
- package/dist/chunks/{chunk-56EUSC6D.js.map → chunk-LHLHPYSP.js.map} +2 -2
- package/dist/chunks/{configService-VOY2MY2K.js → configService-R3OGU2UD.js} +2 -2
- package/dist/entries/server-router.js +3 -3
- package/dist/lib/server/index.js +5 -5
- package/lib/server/routes/pages.ts +37 -2
- package/lib/server/services/cmsService.ts +21 -0
- package/lib/server/services/configService.ts +20 -0
- package/lib/server/ssr/buildErrorOverlay.ts +22 -4
- package/lib/server/ssr/errorOverlay.ts +11 -3
- package/lib/server/ssr/htmlGenerator.nonce.test.ts +165 -0
- package/lib/server/ssr/htmlGenerator.ts +25 -6
- package/lib/server/ssr/liveReloadIntegration.test.ts +3 -1
- package/lib/server/ssr/metaTagGenerator.ts +54 -6
- package/lib/server/ssr/ssrRenderer.ts +1 -0
- package/lib/server/ssrRenderer.test.ts +157 -2
- package/package.json +1 -1
- package/dist/chunks/chunk-4OFZP5NQ.js.map +0 -7
- package/dist/chunks/chunk-CVLFID6V.js.map +0 -7
- package/dist/chunks/chunk-PQ2HRXDR.js.map +0 -7
- /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"
|
|
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
|
-
? `<
|
|
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
|
-
|
|
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
|
-
*
|
|
43
|
+
* Site-wide social configuration
|
|
44
44
|
*/
|
|
45
|
-
export interface
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
119
|
-
const { slugMappings, pagePath = '/', baseUrl = '' } =
|
|
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
|
|