meno-core 1.0.4 → 1.0.6
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 +82 -9
- package/lib/client/index.ts +1 -1
- package/lib/client/scripts/ScriptExecutor.test.ts +54 -77
- package/lib/client/scripts/ScriptExecutor.ts +43 -71
- package/lib/server/createServer.ts +1 -1
- package/lib/server/index.ts +1 -1
- package/lib/server/routes/api/core-routes.ts +1 -1
- package/lib/server/routes/api/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.ts +155 -21
- package/lib/server/ssr/index.ts +1 -0
- package/lib/shared/index.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.json +2 -2
- package/vite.config.ts +1 -1
package/build-static.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static Site Generation Build Script
|
|
3
3
|
* Pre-generates HTML files for all pages at build time
|
|
4
|
+
* CSP-compliant: Extracts JavaScript to external files
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { existsSync, readdirSync, mkdirSync, rmSync, statSync, copyFileSync } from "fs";
|
|
7
8
|
import { writeFile } from "fs/promises";
|
|
8
9
|
import { join } from "path";
|
|
10
|
+
import { createHash } from "crypto";
|
|
9
11
|
import {
|
|
10
12
|
loadJSONFile,
|
|
11
13
|
loadComponentDirectory,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
loadI18nConfig
|
|
15
17
|
} from "./lib/server/jsonLoader";
|
|
16
18
|
import { generateSSRHTML } from "./lib/server/ssrRenderer";
|
|
19
|
+
import type { SSRHTMLResult } from "./lib/server/ssr/htmlGenerator";
|
|
17
20
|
import { projectPaths } from "./lib/server/projectContext";
|
|
18
21
|
import { loadProjectConfig } from "./lib/shared/fontLoader";
|
|
19
22
|
import { FileSystemCMSProvider } from "./lib/server/providers/fileSystemCMSProvider";
|
|
@@ -22,6 +25,48 @@ import { isI18nValue, resolveI18nValue } from "./lib/shared/i18n";
|
|
|
22
25
|
import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig } from "./lib/shared/types";
|
|
23
26
|
import type { SlugMap } from "./lib/shared/slugTranslator";
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Generate short hash from content for file naming
|
|
30
|
+
*/
|
|
31
|
+
function hashContent(content: string): string {
|
|
32
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Track JavaScript files to avoid duplicates
|
|
37
|
+
* Maps content hash -> script path
|
|
38
|
+
*/
|
|
39
|
+
const jsFileCache = new Map<string, string>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get or create script file path for given JS content
|
|
43
|
+
* Returns the path to reference in HTML
|
|
44
|
+
*/
|
|
45
|
+
async function getScriptPath(jsContent: string, distDir: string): Promise<string> {
|
|
46
|
+
const hash = hashContent(jsContent);
|
|
47
|
+
|
|
48
|
+
// Check if we already wrote this content
|
|
49
|
+
if (jsFileCache.has(hash)) {
|
|
50
|
+
return jsFileCache.get(hash)!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create scripts directory if needed
|
|
54
|
+
const scriptsDir = join(distDir, '_scripts');
|
|
55
|
+
if (!existsSync(scriptsDir)) {
|
|
56
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Write script file
|
|
60
|
+
const scriptPath = `/_scripts/${hash}.js`;
|
|
61
|
+
const fullPath = join(distDir, '_scripts', `${hash}.js`);
|
|
62
|
+
await writeFile(fullPath, jsContent, 'utf-8');
|
|
63
|
+
|
|
64
|
+
// Cache for reuse
|
|
65
|
+
jsFileCache.set(hash, scriptPath);
|
|
66
|
+
|
|
67
|
+
return scriptPath;
|
|
68
|
+
}
|
|
69
|
+
|
|
25
70
|
/**
|
|
26
71
|
* Recursively copy directory contents
|
|
27
72
|
*/
|
|
@@ -235,17 +280,26 @@ async function buildCMSTemplates(
|
|
|
235
280
|
const baseUrl = "";
|
|
236
281
|
const itemPath = buildCMSItemPath(cmsSchema.urlPattern, item, cmsSchema.slugField, locale, i18nConfig);
|
|
237
282
|
|
|
238
|
-
|
|
283
|
+
// Generate HTML with JS returned separately (CSP-compliant)
|
|
284
|
+
const result = await generateSSRHTML({
|
|
239
285
|
pageData,
|
|
240
286
|
globalComponents,
|
|
241
|
-
itemPath,
|
|
287
|
+
pagePath: itemPath,
|
|
242
288
|
baseUrl,
|
|
243
|
-
true,
|
|
289
|
+
useBuiltBundle: true,
|
|
244
290
|
locale,
|
|
245
291
|
slugMappings,
|
|
246
|
-
{ cms: item },
|
|
247
|
-
cmsService
|
|
248
|
-
|
|
292
|
+
cmsContext: { cms: item },
|
|
293
|
+
cmsService,
|
|
294
|
+
returnSeparateJS: true
|
|
295
|
+
}) as SSRHTMLResult;
|
|
296
|
+
|
|
297
|
+
// If there's JavaScript, write to external file and update HTML
|
|
298
|
+
let finalHtml = result.html;
|
|
299
|
+
if (result.javascript) {
|
|
300
|
+
const scriptPath = await getScriptPath(result.javascript, distDir);
|
|
301
|
+
finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
|
|
302
|
+
}
|
|
249
303
|
|
|
250
304
|
const outputPath = locale === i18nConfig.defaultLocale
|
|
251
305
|
? `${distDir}${itemPath}.html`
|
|
@@ -256,7 +310,7 @@ async function buildCMSTemplates(
|
|
|
256
310
|
mkdirSync(outputDir, { recursive: true });
|
|
257
311
|
}
|
|
258
312
|
|
|
259
|
-
await writeFile(outputPath,
|
|
313
|
+
await writeFile(outputPath, finalHtml, 'utf-8');
|
|
260
314
|
|
|
261
315
|
const displayPath = locale === i18nConfig.defaultLocale ? itemPath : `/${locale}${itemPath}`;
|
|
262
316
|
console.log(` ✅ ${displayPath}`);
|
|
@@ -397,7 +451,26 @@ async function buildStaticPages(): Promise<void> {
|
|
|
397
451
|
// Build the URL path that will be used for this locale
|
|
398
452
|
const urlPath = getDisplayPath(basePath, locale, i18nConfig.defaultLocale, slugs);
|
|
399
453
|
|
|
400
|
-
|
|
454
|
+
// Generate HTML with JS returned separately (CSP-compliant)
|
|
455
|
+
const result = await generateSSRHTML({
|
|
456
|
+
pageData,
|
|
457
|
+
globalComponents,
|
|
458
|
+
pagePath: urlPath,
|
|
459
|
+
baseUrl,
|
|
460
|
+
useBuiltBundle: true,
|
|
461
|
+
locale,
|
|
462
|
+
slugMappings,
|
|
463
|
+
cmsService,
|
|
464
|
+
returnSeparateJS: true
|
|
465
|
+
}) as SSRHTMLResult;
|
|
466
|
+
|
|
467
|
+
// If there's JavaScript, write to external file and update HTML
|
|
468
|
+
let finalHtml = result.html;
|
|
469
|
+
if (result.javascript) {
|
|
470
|
+
const scriptPath = await getScriptPath(result.javascript, distDir);
|
|
471
|
+
// Insert script reference before </body>
|
|
472
|
+
finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
|
|
473
|
+
}
|
|
401
474
|
|
|
402
475
|
// Determine locale-specific output path with translated slug
|
|
403
476
|
const outputPath = getLocalizedOutputPath(basePath, locale, i18nConfig.defaultLocale, distDir, slugs);
|
|
@@ -408,7 +481,7 @@ async function buildStaticPages(): Promise<void> {
|
|
|
408
481
|
mkdirSync(outputDir, { recursive: true });
|
|
409
482
|
}
|
|
410
483
|
|
|
411
|
-
await writeFile(outputPath,
|
|
484
|
+
await writeFile(outputPath, finalHtml, "utf-8");
|
|
412
485
|
|
|
413
486
|
console.log(`✅ Built: ${urlPath} → ${outputPath}`);
|
|
414
487
|
successCount++;
|
package/lib/client/index.ts
CHANGED
|
@@ -8,13 +8,11 @@ describe("ScriptExecutor", () => {
|
|
|
8
8
|
let componentRegistry: ComponentRegistry;
|
|
9
9
|
let elementRegistry: ElementRegistry;
|
|
10
10
|
let scriptExecutor: ScriptExecutor;
|
|
11
|
-
let
|
|
12
|
-
let
|
|
11
|
+
let executedCode: string[];
|
|
12
|
+
let originalFunction: typeof Function;
|
|
13
|
+
let functionSpy: ReturnType<typeof spyOn>;
|
|
13
14
|
|
|
14
15
|
beforeEach(() => {
|
|
15
|
-
// Save original document
|
|
16
|
-
originalDocument = global.document;
|
|
17
|
-
|
|
18
16
|
componentRegistry = new ComponentRegistry();
|
|
19
17
|
elementRegistry = new ElementRegistry();
|
|
20
18
|
scriptExecutor = new ScriptExecutor({
|
|
@@ -22,38 +20,25 @@ describe("ScriptExecutor", () => {
|
|
|
22
20
|
elementRegistry,
|
|
23
21
|
});
|
|
24
22
|
|
|
25
|
-
// Track
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
getElementById: mock((id: string) => {
|
|
40
|
-
const script = injectedScripts.get(id);
|
|
41
|
-
return script ? { id: script.id, remove: () => injectedScripts.delete(id) } : null;
|
|
42
|
-
}),
|
|
43
|
-
body: {
|
|
44
|
-
appendChild: mock((script: any) => {
|
|
45
|
-
if (script.tagName === 'SCRIPT') {
|
|
46
|
-
injectedScripts.set(script.id, { id: script.id, textContent: script.textContent });
|
|
47
|
-
}
|
|
48
|
-
}),
|
|
49
|
-
},
|
|
50
|
-
} as any;
|
|
23
|
+
// Track executed code by intercepting Function constructor
|
|
24
|
+
executedCode = [];
|
|
25
|
+
originalFunction = global.Function;
|
|
26
|
+
functionSpy = spyOn(global, "Function").mockImplementation(((...args: any[]) => {
|
|
27
|
+
if (args.length > 0 && typeof args[args.length - 1] === 'string') {
|
|
28
|
+
const code = args[args.length - 1];
|
|
29
|
+
// Only track ScriptExecutor calls (they contain "Component:" comment)
|
|
30
|
+
if (code.includes('Component:')) {
|
|
31
|
+
executedCode.push(code);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Return a function that executes the code
|
|
35
|
+
return originalFunction(...args) as any;
|
|
36
|
+
}) as unknown as FunctionConstructor);
|
|
51
37
|
});
|
|
52
38
|
|
|
53
39
|
afterEach(() => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
global.document = originalDocument;
|
|
40
|
+
functionSpy.mockRestore();
|
|
41
|
+
executedCode = [];
|
|
57
42
|
});
|
|
58
43
|
|
|
59
44
|
describe("constructor", () => {
|
|
@@ -80,7 +65,7 @@ describe("ScriptExecutor", () => {
|
|
|
80
65
|
|
|
81
66
|
scriptExecutor.execute();
|
|
82
67
|
|
|
83
|
-
expect(
|
|
68
|
+
expect(executedCode.length).toBe(0);
|
|
84
69
|
});
|
|
85
70
|
|
|
86
71
|
test("should execute JavaScript without templates once", () => {
|
|
@@ -97,11 +82,9 @@ describe("ScriptExecutor", () => {
|
|
|
97
82
|
|
|
98
83
|
scriptExecutor.execute();
|
|
99
84
|
|
|
100
|
-
expect(
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(script!.textContent).toContain("Component: TestComponent");
|
|
104
|
-
expect(script!.textContent).toContain("console.log('test');");
|
|
85
|
+
expect(executedCode.length).toBe(1);
|
|
86
|
+
expect(executedCode[0]).toContain("Component: TestComponent");
|
|
87
|
+
expect(executedCode[0]).toContain("console.log('test');");
|
|
105
88
|
});
|
|
106
89
|
|
|
107
90
|
// Template-based tests require full ElementRegistry from @meno/studio
|
|
@@ -118,8 +101,8 @@ describe("ScriptExecutor", () => {
|
|
|
118
101
|
componentRegistry.register("TestComponent", componentDef);
|
|
119
102
|
|
|
120
103
|
// Register component instances with props
|
|
121
|
-
const mockElement1 =
|
|
122
|
-
const mockElement2 =
|
|
104
|
+
const mockElement1 = document.createElement("div");
|
|
105
|
+
const mockElement2 = document.createElement("div");
|
|
123
106
|
elementRegistry.register([0], mockElement1, "TestComponent", true, {
|
|
124
107
|
name: "Instance1",
|
|
125
108
|
});
|
|
@@ -129,8 +112,8 @@ describe("ScriptExecutor", () => {
|
|
|
129
112
|
|
|
130
113
|
scriptExecutor.execute();
|
|
131
114
|
|
|
132
|
-
// Each instance gets its own
|
|
133
|
-
expect(
|
|
115
|
+
// Each instance gets its own execution
|
|
116
|
+
expect(executedCode.length).toBe(2);
|
|
134
117
|
});
|
|
135
118
|
|
|
136
119
|
test("should execute JavaScript without templates only once for multiple instances", () => {
|
|
@@ -146,8 +129,8 @@ describe("ScriptExecutor", () => {
|
|
|
146
129
|
componentRegistry.register("TestComponent", componentDef);
|
|
147
130
|
|
|
148
131
|
// Register multiple instances
|
|
149
|
-
const mockElement1 =
|
|
150
|
-
const mockElement2 =
|
|
132
|
+
const mockElement1 = document.createElement("div");
|
|
133
|
+
const mockElement2 = document.createElement("div");
|
|
151
134
|
elementRegistry.register([0], mockElement1, "TestComponent", true, {
|
|
152
135
|
name: "Instance1",
|
|
153
136
|
});
|
|
@@ -157,12 +140,10 @@ describe("ScriptExecutor", () => {
|
|
|
157
140
|
|
|
158
141
|
scriptExecutor.execute();
|
|
159
142
|
|
|
160
|
-
// Should only
|
|
161
|
-
expect(
|
|
162
|
-
|
|
163
|
-
expect(
|
|
164
|
-
expect(script!.textContent).toContain("Component: TestComponent");
|
|
165
|
-
expect(script!.textContent).toContain("console.log('test');");
|
|
143
|
+
// Should only execute once (no templates - executes once per component type)
|
|
144
|
+
expect(executedCode.length).toBe(1);
|
|
145
|
+
expect(executedCode[0]).toContain("Component: TestComponent");
|
|
146
|
+
expect(executedCode[0]).toContain("console.log('test');");
|
|
166
147
|
});
|
|
167
148
|
|
|
168
149
|
test.skip("should handle multiple components with mixed template/non-template JavaScript (requires @meno/studio ElementRegistry)", () => {
|
|
@@ -187,16 +168,16 @@ describe("ScriptExecutor", () => {
|
|
|
187
168
|
componentRegistry.register("Component1", componentDef1);
|
|
188
169
|
componentRegistry.register("Component2", componentDef2);
|
|
189
170
|
|
|
190
|
-
const mockElement =
|
|
171
|
+
const mockElement = document.createElement("div");
|
|
191
172
|
elementRegistry.register([0], mockElement, "Component2", true, {
|
|
192
173
|
name: "Component2Instance",
|
|
193
174
|
});
|
|
194
175
|
|
|
195
176
|
scriptExecutor.execute();
|
|
196
177
|
|
|
197
|
-
// Component1: 1
|
|
198
|
-
// Component2: 1
|
|
199
|
-
expect(
|
|
178
|
+
// Component1: 1 execution (no templates)
|
|
179
|
+
// Component2: 1 execution (with templates, 1 instance)
|
|
180
|
+
expect(executedCode.length).toBe(2);
|
|
200
181
|
});
|
|
201
182
|
|
|
202
183
|
test("should handle fallback for components without instances", () => {
|
|
@@ -326,11 +307,9 @@ describe("ScriptExecutor", () => {
|
|
|
326
307
|
|
|
327
308
|
scriptExecutor.execute();
|
|
328
309
|
|
|
329
|
-
expect(
|
|
330
|
-
|
|
331
|
-
expect(
|
|
332
|
-
expect(script!.textContent).toContain("(function()");
|
|
333
|
-
expect(script!.textContent).toContain("})();");
|
|
310
|
+
expect(executedCode.length).toBe(1);
|
|
311
|
+
expect(executedCode[0]).toContain("(function()");
|
|
312
|
+
expect(executedCode[0]).toContain("})();");
|
|
334
313
|
});
|
|
335
314
|
});
|
|
336
315
|
|
|
@@ -359,12 +338,10 @@ describe("ScriptExecutor", () => {
|
|
|
359
338
|
|
|
360
339
|
scriptExecutor.executeForComponent("Component1");
|
|
361
340
|
|
|
362
|
-
// Only Component1 should be
|
|
363
|
-
expect(
|
|
364
|
-
|
|
365
|
-
expect(
|
|
366
|
-
expect(script!.textContent).toContain("Component: Component1");
|
|
367
|
-
expect(script!.textContent).toContain("console.log('Component1');");
|
|
341
|
+
// Only Component1 should be executed
|
|
342
|
+
expect(executedCode.length).toBe(1);
|
|
343
|
+
expect(executedCode[0]).toContain("Component: Component1");
|
|
344
|
+
expect(executedCode[0]).toContain("console.log('Component1');");
|
|
368
345
|
});
|
|
369
346
|
|
|
370
347
|
test.skip("should execute JavaScript with templates for specific component (requires @meno/studio ElementRegistry)", () => {
|
|
@@ -379,8 +356,8 @@ describe("ScriptExecutor", () => {
|
|
|
379
356
|
|
|
380
357
|
componentRegistry.register("TestComponent", componentDef);
|
|
381
358
|
|
|
382
|
-
const mockElement1 =
|
|
383
|
-
const mockElement2 =
|
|
359
|
+
const mockElement1 = document.createElement("div");
|
|
360
|
+
const mockElement2 = document.createElement("div");
|
|
384
361
|
elementRegistry.register([0], mockElement1, "TestComponent", true, {
|
|
385
362
|
name: "Instance1",
|
|
386
363
|
});
|
|
@@ -390,8 +367,8 @@ describe("ScriptExecutor", () => {
|
|
|
390
367
|
|
|
391
368
|
scriptExecutor.executeForComponent("TestComponent");
|
|
392
369
|
|
|
393
|
-
// Each instance gets its own
|
|
394
|
-
expect(
|
|
370
|
+
// Each instance gets its own execution
|
|
371
|
+
expect(executedCode.length).toBe(2);
|
|
395
372
|
});
|
|
396
373
|
|
|
397
374
|
test("should skip if component has no JavaScript", () => {
|
|
@@ -407,7 +384,7 @@ describe("ScriptExecutor", () => {
|
|
|
407
384
|
|
|
408
385
|
scriptExecutor.executeForComponent("TestComponent");
|
|
409
386
|
|
|
410
|
-
expect(
|
|
387
|
+
expect(executedCode.length).toBe(0);
|
|
411
388
|
});
|
|
412
389
|
|
|
413
390
|
test("should handle non-existent component gracefully", () => {
|
|
@@ -468,8 +445,8 @@ describe("ScriptExecutor", () => {
|
|
|
468
445
|
componentRegistry.register("DynamicComponent", componentDef2);
|
|
469
446
|
|
|
470
447
|
// Register multiple instances of dynamic component
|
|
471
|
-
const mockElement1 =
|
|
472
|
-
const mockElement2 =
|
|
448
|
+
const mockElement1 = document.createElement("div");
|
|
449
|
+
const mockElement2 = document.createElement("div");
|
|
473
450
|
elementRegistry.register([0], mockElement1, "DynamicComponent", true, {
|
|
474
451
|
id: "1",
|
|
475
452
|
});
|
|
@@ -479,9 +456,9 @@ describe("ScriptExecutor", () => {
|
|
|
479
456
|
|
|
480
457
|
scriptExecutor.execute();
|
|
481
458
|
|
|
482
|
-
// StaticComponent: 1
|
|
483
|
-
// DynamicComponent: 2
|
|
484
|
-
expect(
|
|
459
|
+
// StaticComponent: 1 execution (no templates)
|
|
460
|
+
// DynamicComponent: 2 executions (with templates, 2 instances)
|
|
461
|
+
expect(executedCode.length).toBe(3);
|
|
485
462
|
});
|
|
486
463
|
});
|
|
487
464
|
});
|
|
@@ -42,7 +42,6 @@ function generateDestructure(
|
|
|
42
42
|
export class ScriptExecutor {
|
|
43
43
|
private componentRegistry: ComponentRegistry;
|
|
44
44
|
private elementRegistry: ElementRegistry;
|
|
45
|
-
private injectedScripts: Set<string> = new Set();
|
|
46
45
|
|
|
47
46
|
constructor(config: ScriptExecutorConfig) {
|
|
48
47
|
this.componentRegistry = config.componentRegistry;
|
|
@@ -50,54 +49,31 @@ export class ScriptExecutor {
|
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* @param
|
|
57
|
-
* @param
|
|
52
|
+
* Execute JavaScript code via new Function() with error isolation
|
|
53
|
+
* Uses new Function() instead of script tags to avoid CSP violations
|
|
54
|
+
* Each component is wrapped in try-catch for isolation
|
|
55
|
+
* @param jsCode - JavaScript code to execute
|
|
56
|
+
* @param componentName - Component name for error reporting
|
|
58
57
|
* @private
|
|
59
58
|
*/
|
|
60
|
-
private
|
|
59
|
+
private executeWrappedJS(jsCode: string, componentName: string): void {
|
|
61
60
|
if (!jsCode || !jsCode.trim()) return;
|
|
62
|
-
if (typeof document === 'undefined') return;
|
|
63
61
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
const wrappedJS = `(function() {
|
|
63
|
+
// Component: ${componentName}
|
|
64
|
+
try {
|
|
65
|
+
${jsCode}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error('[Meno] Runtime error in ' + ${JSON.stringify(componentName)} + ':', e);
|
|
68
|
+
}
|
|
69
|
+
})();`;
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
try {
|
|
72
|
+
new Function(wrappedJS)();
|
|
73
|
+
} catch (syntaxError) {
|
|
74
|
+
// Syntax errors caught here - doesn't affect other components
|
|
75
|
+
console.error(`[Meno] Syntax error in ${componentName}:`, syntaxError);
|
|
72
76
|
}
|
|
73
|
-
|
|
74
|
-
const script = document.createElement('script');
|
|
75
|
-
script.id = scriptId;
|
|
76
|
-
script.type = 'text/javascript';
|
|
77
|
-
script.textContent = `(function() {
|
|
78
|
-
// Component: ${componentName}${instanceId ? ` (instance: ${instanceId})` : ''}
|
|
79
|
-
try {
|
|
80
|
-
${jsCode}
|
|
81
|
-
} catch (e) {
|
|
82
|
-
console.error('[Meno] Error in ${componentName}:', e);
|
|
83
|
-
}
|
|
84
|
-
})();`;
|
|
85
|
-
|
|
86
|
-
document.body.appendChild(script);
|
|
87
|
-
this.injectedScripts.add(scriptId);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Execute JavaScript code via script tag injection for browser-level isolation
|
|
92
|
-
* @param jsCode - JavaScript code to execute
|
|
93
|
-
* @param componentName - Component name for identification
|
|
94
|
-
* @param rootPath - Optional root path for instance-specific scripts
|
|
95
|
-
* @private
|
|
96
|
-
*/
|
|
97
|
-
private executeWrappedJS(jsCode: string, componentName: string, rootPath?: string): void {
|
|
98
|
-
// Convert rootPath to a safe ID (replace special chars)
|
|
99
|
-
const instanceId = rootPath ? rootPath.replace(/[^a-zA-Z0-9]/g, '-') : undefined;
|
|
100
|
-
this.injectScriptTag(jsCode, componentName, instanceId);
|
|
101
77
|
}
|
|
102
78
|
|
|
103
79
|
/**
|
|
@@ -117,10 +93,10 @@ export class ScriptExecutor {
|
|
|
117
93
|
|
|
118
94
|
try {
|
|
119
95
|
const processedJS = processCodeTemplates(originalJS, props);
|
|
120
|
-
this.executeWrappedJS(processedJS, componentName
|
|
96
|
+
this.executeWrappedJS(processedJS, `${componentName} (instance: ${rootPath})`);
|
|
121
97
|
} catch (templateError) {
|
|
122
98
|
// Fallback to original JS if template processing fails (similar to StyleInjector)
|
|
123
|
-
this.executeWrappedJS(originalJS, componentName
|
|
99
|
+
this.executeWrappedJS(originalJS, `${componentName} (instance: ${rootPath})`);
|
|
124
100
|
}
|
|
125
101
|
} catch (e) {
|
|
126
102
|
logRuntimeError('ScriptExecutor.executeInstanceJS', e, { componentName, rootPath });
|
|
@@ -144,17 +120,28 @@ export class ScriptExecutor {
|
|
|
144
120
|
): void {
|
|
145
121
|
const destructure = generateDestructure(defineVars, interfaceDef);
|
|
146
122
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
123
|
+
const wrappedJS = `(function() {
|
|
124
|
+
// Component: ${componentName} (defineVars)
|
|
125
|
+
try {
|
|
126
|
+
var elements = document.querySelectorAll('[data-component="${componentName}"]');
|
|
127
|
+
elements.forEach(function(el) {
|
|
128
|
+
var propsStr = el.getAttribute('data-props');
|
|
129
|
+
var props = propsStr ? JSON.parse(propsStr) : {};
|
|
130
|
+
(function(el, props) {
|
|
131
|
+
${destructure}
|
|
132
|
+
${js}
|
|
133
|
+
})(el, props);
|
|
134
|
+
});
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error('[Meno] Runtime error in ${componentName}:', e);
|
|
137
|
+
}
|
|
138
|
+
})();`;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
new Function(wrappedJS)();
|
|
142
|
+
} catch (syntaxError) {
|
|
143
|
+
console.error(`[Meno] Syntax error in ${componentName}:`, syntaxError);
|
|
144
|
+
}
|
|
158
145
|
}
|
|
159
146
|
|
|
160
147
|
/**
|
|
@@ -317,20 +304,5 @@ elements.forEach(function(el) {
|
|
|
317
304
|
logRuntimeError('ScriptExecutor.executeForElement', e, { componentName });
|
|
318
305
|
}
|
|
319
306
|
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Remove all injected script tags (for cleanup on unmount/navigation)
|
|
323
|
-
*/
|
|
324
|
-
cleanup(): void {
|
|
325
|
-
if (typeof document === 'undefined') return;
|
|
326
|
-
|
|
327
|
-
for (const scriptId of this.injectedScripts) {
|
|
328
|
-
const script = document.getElementById(scriptId);
|
|
329
|
-
if (script) {
|
|
330
|
-
script.remove();
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
this.injectedScripts.clear();
|
|
334
|
-
}
|
|
335
307
|
}
|
|
336
308
|
|
package/lib/server/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTML document generation for SSR
|
|
3
3
|
* Generates complete HTML documents with SSR content
|
|
4
|
+
* Supports CSP-compliant external scripts for static builds
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { ComponentDefinition, JSONPage } from '../../shared/types';
|
|
@@ -18,10 +19,80 @@ import { renderPageSSR } from './ssrRenderer';
|
|
|
18
19
|
import type { CMSContext } from './cmsSSRProcessor';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* Result of SSR HTML generation with separate JS for CSP compliance
|
|
23
|
+
*/
|
|
24
|
+
export interface SSRHTMLResult {
|
|
25
|
+
/** Complete HTML document */
|
|
26
|
+
html: string;
|
|
27
|
+
/** JavaScript code to be written to external file (for CSP compliance) */
|
|
28
|
+
javascript: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options for SSR HTML generation
|
|
33
|
+
*/
|
|
34
|
+
export interface GenerateSSRHTMLOptions {
|
|
35
|
+
/** Page data to render */
|
|
36
|
+
pageData: JSONPage;
|
|
37
|
+
/** Global component definitions */
|
|
38
|
+
globalComponents?: Record<string, ComponentDefinition>;
|
|
39
|
+
/** URL path for the page */
|
|
40
|
+
pagePath?: string;
|
|
41
|
+
/** Base URL for assets */
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
/** Use built bundle (production) vs dev server */
|
|
44
|
+
useBuiltBundle?: boolean;
|
|
45
|
+
/** Locale for i18n */
|
|
46
|
+
locale?: string;
|
|
47
|
+
/** Slug mappings for locale switching */
|
|
48
|
+
slugMappings?: SlugMap[];
|
|
49
|
+
/** CMS context for template rendering */
|
|
50
|
+
cmsContext?: CMSContext;
|
|
51
|
+
/** CMS service for CMSList queries */
|
|
52
|
+
cmsService?: CMSService;
|
|
53
|
+
/**
|
|
54
|
+
* Path to external scripts file (for CSP compliance).
|
|
55
|
+
* When provided, JS is NOT inlined but referenced via this path.
|
|
56
|
+
* Example: "/_scripts/abc123.js"
|
|
57
|
+
*/
|
|
58
|
+
externalScriptPath?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate complete HTML document with SSR content (legacy positional args)
|
|
22
63
|
*/
|
|
23
64
|
export async function generateSSRHTML(
|
|
24
65
|
pageData: JSONPage,
|
|
66
|
+
globalComponents?: Record<string, ComponentDefinition>,
|
|
67
|
+
pagePath?: string,
|
|
68
|
+
baseUrl?: string,
|
|
69
|
+
useBuiltBundle?: boolean,
|
|
70
|
+
locale?: string,
|
|
71
|
+
slugMappings?: SlugMap[],
|
|
72
|
+
cmsContext?: CMSContext,
|
|
73
|
+
cmsService?: CMSService,
|
|
74
|
+
externalScriptPath?: string
|
|
75
|
+
): Promise<string>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate complete HTML document with SSR content (options object, returns separate JS for CSP)
|
|
79
|
+
*/
|
|
80
|
+
export async function generateSSRHTML(
|
|
81
|
+
options: GenerateSSRHTMLOptions & { returnSeparateJS: true }
|
|
82
|
+
): Promise<SSRHTMLResult>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate complete HTML document with SSR content (options object)
|
|
86
|
+
*/
|
|
87
|
+
export async function generateSSRHTML(
|
|
88
|
+
options: GenerateSSRHTMLOptions & { returnSeparateJS?: false }
|
|
89
|
+
): Promise<string>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate complete HTML document with SSR content
|
|
93
|
+
*/
|
|
94
|
+
export async function generateSSRHTML(
|
|
95
|
+
pageDataOrOptions: JSONPage | (GenerateSSRHTMLOptions & { returnSeparateJS?: boolean }),
|
|
25
96
|
globalComponents: Record<string, ComponentDefinition> = {},
|
|
26
97
|
pagePath: string = '/',
|
|
27
98
|
baseUrl: string = '',
|
|
@@ -29,28 +100,75 @@ export async function generateSSRHTML(
|
|
|
29
100
|
locale?: string,
|
|
30
101
|
slugMappings?: SlugMap[],
|
|
31
102
|
cmsContext?: CMSContext,
|
|
32
|
-
cmsService?: CMSService
|
|
33
|
-
|
|
34
|
-
|
|
103
|
+
cmsService?: CMSService,
|
|
104
|
+
externalScriptPath?: string
|
|
105
|
+
): Promise<string | SSRHTMLResult> {
|
|
106
|
+
// Handle options object vs legacy positional args
|
|
107
|
+
let options: GenerateSSRHTMLOptions & { returnSeparateJS?: boolean };
|
|
108
|
+
|
|
109
|
+
if ('pageData' in pageDataOrOptions) {
|
|
110
|
+
options = pageDataOrOptions;
|
|
111
|
+
} else {
|
|
112
|
+
options = {
|
|
113
|
+
pageData: pageDataOrOptions,
|
|
114
|
+
globalComponents,
|
|
115
|
+
pagePath,
|
|
116
|
+
baseUrl,
|
|
117
|
+
useBuiltBundle,
|
|
118
|
+
locale,
|
|
119
|
+
slugMappings,
|
|
120
|
+
cmsContext,
|
|
121
|
+
cmsService,
|
|
122
|
+
externalScriptPath,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
pageData,
|
|
128
|
+
globalComponents: components = {},
|
|
129
|
+
pagePath: path = '/',
|
|
130
|
+
baseUrl: base = '',
|
|
131
|
+
useBuiltBundle: useBundled = false,
|
|
132
|
+
locale: loc,
|
|
133
|
+
slugMappings: slugs,
|
|
134
|
+
cmsContext: cms,
|
|
135
|
+
cmsService: cmsServ,
|
|
136
|
+
externalScriptPath: extScriptPath,
|
|
137
|
+
returnSeparateJS = false,
|
|
138
|
+
} = options;
|
|
139
|
+
const rendered = await renderPageSSR(pageData, components, path, base, loc, undefined, slugs, cms, cmsServ);
|
|
35
140
|
|
|
36
141
|
// Use built bundle in production, dev server in development
|
|
37
|
-
const clientScript =
|
|
142
|
+
const clientScript = useBundled
|
|
38
143
|
? '' // No client router in static build (true static HTML)
|
|
39
144
|
: '<script type="module" src="/client-router.tsx"></script>'; // Dev server (development)
|
|
40
145
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
: ''
|
|
46
|
-
|
|
47
|
-
? `\n <script>\n${escapedJavaScript}\n </script>`
|
|
48
|
-
: '';
|
|
146
|
+
// Collect all JavaScript (component JS + form handler if needed)
|
|
147
|
+
const needsForm = needsFormHandler(rendered.html);
|
|
148
|
+
const allJavaScript = [
|
|
149
|
+
rendered.javascript || '',
|
|
150
|
+
needsForm ? formHandlerScript : ''
|
|
151
|
+
].filter(Boolean).join('\n\n');
|
|
49
152
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
153
|
+
// Determine script output based on mode
|
|
154
|
+
let componentScript = '';
|
|
155
|
+
let externalJavaScript: string | null = null;
|
|
156
|
+
|
|
157
|
+
if (allJavaScript) {
|
|
158
|
+
if (extScriptPath) {
|
|
159
|
+
// CSP-compliant: reference external script file
|
|
160
|
+
componentScript = `\n <script src="${extScriptPath}"></script>`;
|
|
161
|
+
externalJavaScript = allJavaScript;
|
|
162
|
+
} else if (returnSeparateJS) {
|
|
163
|
+
// Return JS separately (for build-static to write to file)
|
|
164
|
+
externalJavaScript = allJavaScript;
|
|
165
|
+
} else {
|
|
166
|
+
// Legacy inline mode (dev server)
|
|
167
|
+
// Escape </script> sequences to prevent premature script tag closure
|
|
168
|
+
const escapedJavaScript = allJavaScript.replace(/<\/script>/gi, '<\\/script>');
|
|
169
|
+
componentScript = `\n <script>\n${escapedJavaScript}\n </script>`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
54
172
|
|
|
55
173
|
// Generate font CSS from project config
|
|
56
174
|
const fontCSS = generateFontCSS();
|
|
@@ -83,9 +201,15 @@ export async function generateSSRHTML(
|
|
|
83
201
|
const prefetchConfig = await loadPrefetchConfig();
|
|
84
202
|
// Only include non-default values to minimize payload
|
|
85
203
|
const menoConfig = prefetchConfig.enabled ? { prefetch: prefetchConfig } : {};
|
|
86
|
-
|
|
204
|
+
// Config script - inline for dev, include in external JS for static build
|
|
205
|
+
const hasConfig = Object.keys(menoConfig).length > 0;
|
|
206
|
+
const configInlineScript = hasConfig && !extScriptPath && !returnSeparateJS
|
|
87
207
|
? `<script>window.__MENO_CONFIG__=${JSON.stringify(menoConfig)}</script>\n `
|
|
88
208
|
: '';
|
|
209
|
+
// Add config to external JS if using external scripts
|
|
210
|
+
if (hasConfig && externalJavaScript !== null) {
|
|
211
|
+
externalJavaScript = `window.__MENO_CONFIG__=${JSON.stringify(menoConfig)};\n\n` + externalJavaScript;
|
|
212
|
+
}
|
|
89
213
|
|
|
90
214
|
// Generate favicon and apple touch icon link tags
|
|
91
215
|
const faviconTag = iconsConfig.favicon
|
|
@@ -96,13 +220,13 @@ export async function generateSSRHTML(
|
|
|
96
220
|
: '';
|
|
97
221
|
const iconTags = [faviconTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
98
222
|
|
|
99
|
-
|
|
223
|
+
const htmlDocument = `<!DOCTYPE html>
|
|
100
224
|
<html lang="${rendered.locale}" theme="${themeConfig.default}">
|
|
101
225
|
<head>
|
|
102
226
|
<meta charset="UTF-8">
|
|
103
227
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
104
228
|
${iconTags ? iconTags + '\n ' : ''}${rendered.meta}
|
|
105
|
-
${
|
|
229
|
+
${configInlineScript}<style>
|
|
106
230
|
${fontCSS ? fontCSS + '\n ' : ''}${themeColorVariablesCSS ? themeColorVariablesCSS + '\n ' : ''}* {
|
|
107
231
|
margin: 0;
|
|
108
232
|
padding: 0;
|
|
@@ -141,7 +265,17 @@ export async function generateSSRHTML(
|
|
|
141
265
|
<div id="root">
|
|
142
266
|
${rendered.html}
|
|
143
267
|
</div>
|
|
144
|
-
${clientScript}${componentScript}
|
|
268
|
+
${clientScript}${componentScript}
|
|
145
269
|
</body>
|
|
146
270
|
</html>`;
|
|
271
|
+
|
|
272
|
+
// Return based on mode
|
|
273
|
+
if (returnSeparateJS) {
|
|
274
|
+
return {
|
|
275
|
+
html: htmlDocument,
|
|
276
|
+
javascript: externalJavaScript
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return htmlDocument;
|
|
147
281
|
}
|
package/lib/server/ssr/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
export { renderPageSSR, extractPageMeta, generateMetaTags } from './ssrRenderer';
|
|
18
18
|
export type { CMSContext, PageMeta } from './ssrRenderer';
|
|
19
19
|
export { generateSSRHTML } from './htmlGenerator';
|
|
20
|
+
export type { SSRHTMLResult, GenerateSSRHTMLOptions } from './htmlGenerator';
|
|
20
21
|
|
|
21
22
|
// Attribute utilities
|
|
22
23
|
export { escapeHtml, buildAttributes, styleToString } from './attributeBuilder';
|
package/lib/shared/index.ts
CHANGED
package/package.json
CHANGED
package/tsconfig.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"outDir": "./dist",
|
|
6
6
|
"baseUrl": ".",
|
|
7
7
|
"paths": {
|
|
8
|
-
"
|
|
9
|
-
"
|
|
8
|
+
"meno-core": ["./lib/shared/index.ts"],
|
|
9
|
+
"meno-core/*": ["./lib/*"]
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"include": ["lib/**/*", "entries/**/*", "bin/**/*"],
|