meno-core 1.0.5 → 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++;
@@ -445,8 +445,8 @@ describe("ScriptExecutor", () => {
445
445
  componentRegistry.register("DynamicComponent", componentDef2);
446
446
 
447
447
  // Register multiple instances of dynamic component
448
- const mockElement1 = originalDocument.createElement("div");
449
- const mockElement2 = originalDocument.createElement("div");
448
+ const mockElement1 = document.createElement("div");
449
+ const mockElement2 = document.createElement("div");
450
450
  elementRegistry.register([0], mockElement1, "DynamicComponent", true, {
451
451
  id: "1",
452
452
  });
@@ -456,9 +456,9 @@ describe("ScriptExecutor", () => {
456
456
 
457
457
  scriptExecutor.execute();
458
458
 
459
- // StaticComponent: 1 script tag (no templates)
460
- // DynamicComponent: 2 script tags (with templates, 2 instances)
461
- 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);
462
462
  });
463
463
  });
464
464
  });
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meno-core",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "meno": "./bin/cli.ts"