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
|
-
|
|
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++;
|
|
@@ -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 =
|
|
449
|
-
const mockElement2 =
|
|
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
|
|
460
|
-
// DynamicComponent: 2
|
|
461
|
-
expect(
|
|
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
|
-
*
|
|
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';
|