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 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
- const html = await generateSSRHTML(
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, html, 'utf-8');
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
- const html = await generateSSRHTML(pageData, globalComponents, urlPath, baseUrl, true, locale, slugMappings, undefined, cmsService);
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, html, "utf-8");
484
+ await writeFile(outputPath, finalHtml, "utf-8");
412
485
 
413
486
  console.log(`✅ Built: ${urlPath} → ${outputPath}`);
414
487
  successCount++;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @meno/core/client exports
2
+ * meno-core/client exports
3
3
  * Core client-side utilities for page rendering and routing
4
4
  * Does NOT include editor-specific code (that's in @meno/studio)
5
5
  */
@@ -8,13 +8,11 @@ describe("ScriptExecutor", () => {
8
8
  let componentRegistry: ComponentRegistry;
9
9
  let elementRegistry: ElementRegistry;
10
10
  let scriptExecutor: ScriptExecutor;
11
- let injectedScripts: Map<string, { id: string; textContent: string }>;
12
- let originalDocument: typeof document;
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 injected script tags
26
- injectedScripts = new Map();
27
-
28
- // Mock document for script tag injection
29
- global.document = {
30
- createElement: mock((tag: string) => {
31
- const element = {
32
- tagName: tag.toUpperCase(),
33
- id: '',
34
- type: '',
35
- textContent: '',
36
- };
37
- return element;
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
- injectedScripts.clear();
55
- // Restore original document
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(injectedScripts.size).toBe(0);
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(injectedScripts.size).toBe(1);
101
- const script = injectedScripts.get("meno-script-TestComponent");
102
- expect(script).toBeDefined();
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 = originalDocument.createElement("div");
122
- const mockElement2 = originalDocument.createElement("div");
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 script tag
133
- expect(injectedScripts.size).toBe(2);
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 = originalDocument.createElement("div");
150
- const mockElement2 = originalDocument.createElement("div");
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 inject one script (no templates - executes once per component type)
161
- expect(injectedScripts.size).toBe(1);
162
- const script = injectedScripts.get("meno-script-TestComponent");
163
- expect(script).toBeDefined();
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 = originalDocument.createElement("div");
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 script tag (no templates)
198
- // Component2: 1 script tag (with templates, 1 instance)
199
- expect(injectedScripts.size).toBe(2);
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(injectedScripts.size).toBe(1);
330
- const script = injectedScripts.get("meno-script-TestComponent");
331
- expect(script).toBeDefined();
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 injected
363
- expect(injectedScripts.size).toBe(1);
364
- const script = injectedScripts.get("meno-script-Component1");
365
- expect(script).toBeDefined();
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 = originalDocument.createElement("div");
383
- const mockElement2 = originalDocument.createElement("div");
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 script tag
394
- expect(injectedScripts.size).toBe(2);
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(injectedScripts.size).toBe(0);
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 = originalDocument.createElement("div");
472
- const mockElement2 = originalDocument.createElement("div");
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 script tag (no templates)
483
- // DynamicComponent: 2 script tags (with templates, 2 instances)
484
- expect(injectedScripts.size).toBe(3);
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
- * Inject JavaScript as a separate script tag for browser-level isolation
54
- * Each component gets its own script tag - syntax errors in one don't affect others
55
- * @param jsCode - JavaScript code to inject
56
- * @param componentName - Component name for identification
57
- * @param instanceId - Optional instance ID for per-instance scripts
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 injectScriptTag(jsCode: string, componentName: string, instanceId?: string): void {
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 scriptId = instanceId
65
- ? `meno-script-${componentName}-${instanceId}`
66
- : `meno-script-${componentName}`;
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
- // Remove existing script with same ID to allow re-execution
69
- const existing = document.getElementById(scriptId);
70
- if (existing) {
71
- existing.remove();
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, rootPath);
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, rootPath);
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 initJS = `var elements = document.querySelectorAll('[data-component="${componentName}"]');
148
- elements.forEach(function(el) {
149
- var propsStr = el.getAttribute('data-props');
150
- var props = propsStr ? JSON.parse(propsStr) : {};
151
- (function(el, props) {
152
- ${destructure}
153
- ${js}
154
- })(el, props);
155
- });`;
156
-
157
- this.injectScriptTag(initJS, componentName + '-init');
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
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Server Factory
3
- * Creates a configurable Bun server for @meno/core
3
+ * Creates a configurable Bun server for meno-core
4
4
  * Can be extended by @meno/studio for editor functionality
5
5
  */
6
6
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @meno/core/server exports
2
+ * meno-core/server exports
3
3
  * Core server-side utilities for SSR and read APIs
4
4
  * Does NOT include editor-specific routes (that's in @meno/studio)
5
5
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Core API Routes (Read-only)
3
- * These routes are part of @meno/core and handle read operations
3
+ * These routes are part of meno-core and handle read operations
4
4
  */
5
5
 
6
6
  import { API_ROUTES } from '../../../shared/constants';
@@ -50,7 +50,7 @@ export async function handleApiRoutes(
50
50
  }
51
51
 
52
52
  /**
53
- * Handle only core API routes (for @meno/core without editor)
53
+ * Handle only core API routes (for meno-core without editor)
54
54
  */
55
55
  export async function handleCoreOnlyApiRoutes(
56
56
  req: Request,
@@ -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
- * Generate complete HTML document with SSR content
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
- ): Promise<string> {
34
- const rendered = await renderPageSSR(pageData, globalComponents, pagePath, baseUrl, locale, undefined, slugMappings, cmsContext, cmsService);
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 = useBuiltBundle
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
- // Render component JavaScript if any
42
- // Escape </script> sequences to prevent premature script tag closure
43
- const escapedJavaScript = rendered.javascript
44
- ? rendered.javascript.replace(/<\/script>/gi, '<\\/script>')
45
- : '';
46
- const componentScript = escapedJavaScript
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
- // Add form handler script if page contains fetch-handled forms
51
- const formScript = needsFormHandler(rendered.html)
52
- ? `\n <script>\n${formHandlerScript}\n </script>`
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
- const configScript = Object.keys(menoConfig).length > 0
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
- return `<!DOCTYPE html>
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
- ${configScript}<style>
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}${formScript}
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
  }
@@ -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';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @meno/core shared exports
2
+ * meno-core shared exports
3
3
  * Re-exports all types, constants, and utilities from shared modules
4
4
  */
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./bin/cli.ts"
package/tsconfig.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "outDir": "./dist",
6
6
  "baseUrl": ".",
7
7
  "paths": {
8
- "@meno/core": ["./lib/shared/index.ts"],
9
- "@meno/core/*": ["./lib/*"]
8
+ "meno-core": ["./lib/shared/index.ts"],
9
+ "meno-core/*": ["./lib/*"]
10
10
  }
11
11
  },
12
12
  "include": ["lib/**/*", "entries/**/*", "bin/**/*"],
package/vite.config.ts CHANGED
@@ -37,7 +37,7 @@ export default defineConfig({
37
37
  },
38
38
  resolve: {
39
39
  alias: {
40
- '@meno/core': path.resolve(__dirname, 'lib/shared/index.ts'),
40
+ 'meno-core': path.resolve(__dirname, 'lib/shared/index.ts'),
41
41
  },
42
42
  },
43
43
  })