meno-core 1.0.0
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/bin/cli.ts +281 -0
- package/build-static.ts +298 -0
- package/bunfig.toml +39 -0
- package/entries/client-router.tsx +111 -0
- package/entries/server-router.tsx +71 -0
- package/lib/client/ClientInitializer.test.ts +9 -0
- package/lib/client/ClientInitializer.test.ts.skip +92 -0
- package/lib/client/ClientInitializer.ts +60 -0
- package/lib/client/ErrorBoundary.test.tsx +595 -0
- package/lib/client/ErrorBoundary.tsx +230 -0
- package/lib/client/componentRegistry.test.ts +165 -0
- package/lib/client/componentRegistry.ts +18 -0
- package/lib/client/contexts/ThemeContext.tsx +73 -0
- package/lib/client/core/ComponentBuilder.test.ts +677 -0
- package/lib/client/core/ComponentBuilder.ts +660 -0
- package/lib/client/core/ComponentRenderer.test.tsx +176 -0
- package/lib/client/core/ComponentRenderer.tsx +83 -0
- package/lib/client/core/cmsTemplateProcessor.ts +129 -0
- package/lib/client/elementRegistry.ts +81 -0
- package/lib/client/hmr/HMRManager.tsx +179 -0
- package/lib/client/hmr/index.ts +5 -0
- package/lib/client/hmrWebSocket.test.ts +9 -0
- package/lib/client/hmrWebSocket.ts +250 -0
- package/lib/client/hooks/useColorVariables.test.ts +166 -0
- package/lib/client/hooks/useColorVariables.ts +249 -0
- package/lib/client/hooks/usePropertyAutocomplete.test.ts +9 -0
- package/lib/client/hooks/usePropertyAutocomplete.ts +40 -0
- package/lib/client/hydration/HydrationUtils.test.ts +154 -0
- package/lib/client/hydration/HydrationUtils.ts +35 -0
- package/lib/client/i18nConfigService.test.ts +74 -0
- package/lib/client/i18nConfigService.ts +78 -0
- package/lib/client/index.ts +56 -0
- package/lib/client/navigation.test.ts +441 -0
- package/lib/client/navigation.ts +23 -0
- package/lib/client/responsiveStyleResolver.test.ts +491 -0
- package/lib/client/responsiveStyleResolver.ts +184 -0
- package/lib/client/routing/RouteLoader.test.ts +635 -0
- package/lib/client/routing/RouteLoader.ts +347 -0
- package/lib/client/routing/Router.tsx +382 -0
- package/lib/client/scripts/ScriptExecutor.test.ts +489 -0
- package/lib/client/scripts/ScriptExecutor.ts +171 -0
- package/lib/client/scripts/formHandler.ts +103 -0
- package/lib/client/styleProcessor.test.ts +126 -0
- package/lib/client/styleProcessor.ts +92 -0
- package/lib/client/styles/StyleInjector.test.ts +354 -0
- package/lib/client/styles/StyleInjector.ts +154 -0
- package/lib/client/templateEngine.test.ts +660 -0
- package/lib/client/templateEngine.ts +667 -0
- package/lib/client/theme.test.ts +173 -0
- package/lib/client/theme.ts +159 -0
- package/lib/client/utils/toast.ts +46 -0
- package/lib/server/createServer.ts +170 -0
- package/lib/server/cssGenerator.test.ts +172 -0
- package/lib/server/cssGenerator.ts +58 -0
- package/lib/server/fileWatcher.ts +134 -0
- package/lib/server/index.ts +55 -0
- package/lib/server/jsonLoader.test.ts +103 -0
- package/lib/server/jsonLoader.ts +350 -0
- package/lib/server/middleware/cors.test.ts +177 -0
- package/lib/server/middleware/cors.ts +69 -0
- package/lib/server/middleware/errorHandler.test.ts +208 -0
- package/lib/server/middleware/errorHandler.ts +63 -0
- package/lib/server/middleware/index.ts +9 -0
- package/lib/server/middleware/logger.test.ts +233 -0
- package/lib/server/middleware/logger.ts +99 -0
- package/lib/server/pageCache.test.ts +167 -0
- package/lib/server/pageCache.ts +97 -0
- package/lib/server/projectContext.ts +51 -0
- package/lib/server/providers/fileSystemCMSProvider.test.ts +292 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +227 -0
- package/lib/server/providers/fileSystemPageProvider.ts +83 -0
- package/lib/server/routes/api/cms.test.ts +177 -0
- package/lib/server/routes/api/cms.ts +82 -0
- package/lib/server/routes/api/colors.ts +59 -0
- package/lib/server/routes/api/components.ts +70 -0
- package/lib/server/routes/api/config.test.ts +9 -0
- package/lib/server/routes/api/config.ts +28 -0
- package/lib/server/routes/api/core-routes.ts +182 -0
- package/lib/server/routes/api/functions.ts +170 -0
- package/lib/server/routes/api/index.ts +69 -0
- package/lib/server/routes/api/pages.ts +95 -0
- package/lib/server/routes/api/shared.test.ts +81 -0
- package/lib/server/routes/api/shared.ts +31 -0
- package/lib/server/routes/editor.test.ts +9 -0
- package/lib/server/routes/index.ts +104 -0
- package/lib/server/routes/pages.ts +161 -0
- package/lib/server/routes/static.ts +107 -0
- package/lib/server/services/ColorService.ts +193 -0
- package/lib/server/services/cmsService.test.ts +388 -0
- package/lib/server/services/cmsService.ts +296 -0
- package/lib/server/services/componentService.test.ts +276 -0
- package/lib/server/services/componentService.ts +346 -0
- package/lib/server/services/configService.ts +156 -0
- package/lib/server/services/fileWatcherService.ts +67 -0
- package/lib/server/services/index.ts +10 -0
- package/lib/server/services/pageService.test.ts +258 -0
- package/lib/server/services/pageService.ts +240 -0
- package/lib/server/ssrRenderer.test.ts +1005 -0
- package/lib/server/ssrRenderer.ts +878 -0
- package/lib/server/utilityClassGenerator.ts +11 -0
- package/lib/server/utils/index.ts +5 -0
- package/lib/server/utils/jsonLineMapper.test.ts +100 -0
- package/lib/server/utils/jsonLineMapper.ts +166 -0
- package/lib/server/validateStyleCoverage.test.ts +9 -0
- package/lib/server/validateStyleCoverage.ts +167 -0
- package/lib/server/websocketManager.test.ts +9 -0
- package/lib/server/websocketManager.ts +95 -0
- package/lib/shared/attributeNodeUtils.test.ts +152 -0
- package/lib/shared/attributeNodeUtils.ts +50 -0
- package/lib/shared/breakpoints.test.ts +166 -0
- package/lib/shared/breakpoints.ts +65 -0
- package/lib/shared/colorProperties.test.ts +111 -0
- package/lib/shared/colorProperties.ts +40 -0
- package/lib/shared/colorVariableUtils.test.ts +319 -0
- package/lib/shared/colorVariableUtils.ts +97 -0
- package/lib/shared/constants.test.ts +175 -0
- package/lib/shared/constants.ts +116 -0
- package/lib/shared/cssGeneration.ts +481 -0
- package/lib/shared/cssProperties.test.ts +252 -0
- package/lib/shared/cssProperties.ts +338 -0
- package/lib/shared/elementUtils.test.ts +245 -0
- package/lib/shared/elementUtils.ts +90 -0
- package/lib/shared/fontLoader.ts +97 -0
- package/lib/shared/i18n.test.ts +313 -0
- package/lib/shared/i18n.ts +286 -0
- package/lib/shared/index.ts +50 -0
- package/lib/shared/interfaces/contentProvider.test.ts +9 -0
- package/lib/shared/interfaces/contentProvider.ts +121 -0
- package/lib/shared/nodeUtils.test.ts +320 -0
- package/lib/shared/nodeUtils.ts +220 -0
- package/lib/shared/pathArrayUtils.test.ts +315 -0
- package/lib/shared/pathArrayUtils.ts +17 -0
- package/lib/shared/pathUtils.test.ts +260 -0
- package/lib/shared/pathUtils.ts +244 -0
- package/lib/shared/paths/Path.test.ts +74 -0
- package/lib/shared/paths/Path.ts +23 -0
- package/lib/shared/paths/PathConverter.test.ts +232 -0
- package/lib/shared/paths/PathConverter.ts +141 -0
- package/lib/shared/paths/PathUtils.ts +290 -0
- package/lib/shared/paths/PathValidator.test.ts +193 -0
- package/lib/shared/paths/PathValidator.ts +53 -0
- package/lib/shared/paths/index.ts +48 -0
- package/lib/shared/propResolver.test.ts +639 -0
- package/lib/shared/propResolver.ts +124 -0
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +190 -0
- package/lib/shared/registry/BaseNodeTypeRegistry.ts +200 -0
- package/lib/shared/registry/ClientNodeTypeRegistry.ts +34 -0
- package/lib/shared/registry/ClientRegistry.test.ts +26 -0
- package/lib/shared/registry/ClientRegistry.ts +15 -0
- package/lib/shared/registry/ComponentRegistry.test.ts +293 -0
- package/lib/shared/registry/ComponentRegistry.ts +100 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +198 -0
- package/lib/shared/registry/NodeTypeManager.ts +94 -0
- package/lib/shared/registry/RegistryManager.test.ts +58 -0
- package/lib/shared/registry/RegistryManager.ts +60 -0
- package/lib/shared/registry/SSRNodeTypeRegistry.ts +33 -0
- package/lib/shared/registry/SSRRegistry.test.ts +26 -0
- package/lib/shared/registry/SSRRegistry.ts +15 -0
- package/lib/shared/registry/createNodeType.ts +175 -0
- package/lib/shared/registry/defineNodeType.ts +73 -0
- package/lib/shared/registry/fieldPresets.ts +109 -0
- package/lib/shared/registry/index.ts +50 -0
- package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +71 -0
- package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +61 -0
- package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +88 -0
- package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +66 -0
- package/lib/shared/registry/nodeTypes/ObjectLinkNodeType.ts +75 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +49 -0
- package/lib/shared/registry/nodeTypes/TextNodeType.ts +52 -0
- package/lib/shared/registry/nodeTypes/index.ts +75 -0
- package/lib/shared/responsiveScaling.test.ts +268 -0
- package/lib/shared/responsiveScaling.ts +194 -0
- package/lib/shared/responsiveStyleUtils.test.ts +300 -0
- package/lib/shared/responsiveStyleUtils.ts +139 -0
- package/lib/shared/slugTranslator.test.ts +325 -0
- package/lib/shared/slugTranslator.ts +177 -0
- package/lib/shared/styleNodeUtils.test.ts +132 -0
- package/lib/shared/styleNodeUtils.ts +102 -0
- package/lib/shared/styleUtils.test.ts +238 -0
- package/lib/shared/styleUtils.ts +63 -0
- package/lib/shared/themeDefaults.test.ts +113 -0
- package/lib/shared/themeDefaults.ts +103 -0
- package/lib/shared/tree/PathBuilder.ts +383 -0
- package/lib/shared/treePathUtils.test.ts +539 -0
- package/lib/shared/treePathUtils.ts +339 -0
- package/lib/shared/types/api.ts +58 -0
- package/lib/shared/types/cms.ts +95 -0
- package/lib/shared/types/colors.ts +45 -0
- package/lib/shared/types/components.ts +121 -0
- package/lib/shared/types/errors.test.ts +103 -0
- package/lib/shared/types/errors.ts +69 -0
- package/lib/shared/types/index.ts +96 -0
- package/lib/shared/types/nodes.ts +20 -0
- package/lib/shared/types/rendering.ts +61 -0
- package/lib/shared/types/styles.ts +38 -0
- package/lib/shared/types.ts +11 -0
- package/lib/shared/utilityClassConfig.ts +287 -0
- package/lib/shared/utilityClassMapper.test.ts +140 -0
- package/lib/shared/utilityClassMapper.ts +229 -0
- package/lib/shared/utils/fileUtils.test.ts +99 -0
- package/lib/shared/utils/fileUtils.ts +56 -0
- package/lib/shared/utils.test.ts +261 -0
- package/lib/shared/utils.ts +84 -0
- package/lib/shared/validation/index.ts +7 -0
- package/lib/shared/validation/propValidator.test.ts +178 -0
- package/lib/shared/validation/propValidator.ts +238 -0
- package/lib/shared/validation/schemas.test.ts +177 -0
- package/lib/shared/validation/schemas.ts +401 -0
- package/lib/shared/validation/validators.test.ts +109 -0
- package/lib/shared/validation/validators.ts +304 -0
- package/lib/test-utils/dom-setup.ts +55 -0
- package/lib/test-utils/factories/ConsoleMockFactory.ts +200 -0
- package/lib/test-utils/factories/DomMockFactory.ts +487 -0
- package/lib/test-utils/factories/EventMockFactory.ts +244 -0
- package/lib/test-utils/factories/FetchMockFactory.ts +210 -0
- package/lib/test-utils/factories/ServerMockFactory.ts +223 -0
- package/lib/test-utils/factories/StoreMockFactory.ts +370 -0
- package/lib/test-utils/factories/index.ts +11 -0
- package/lib/test-utils/fixtures.ts +134 -0
- package/lib/test-utils/helpers/asyncHelpers.test.ts +112 -0
- package/lib/test-utils/helpers/asyncHelpers.ts +196 -0
- package/lib/test-utils/helpers/index.ts +6 -0
- package/lib/test-utils/helpers.test.ts +73 -0
- package/lib/test-utils/helpers.ts +90 -0
- package/lib/test-utils/index.ts +17 -0
- package/lib/test-utils/mockFactories.ts +92 -0
- package/lib/test-utils/mocks.ts +341 -0
- package/package.json +38 -0
- package/templates/index-router.html +34 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +43 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Side Rendering (SSR) Module
|
|
3
|
+
* Converts JSON component structures to HTML strings for SEO-friendly initial page loads
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ComponentNode, ComponentDefinition, JSONPage } from '../shared/types';
|
|
7
|
+
import type { ResponsiveStyleObject, StyleObject } from '../shared/types';
|
|
8
|
+
import type { BreakpointConfig } from '../shared/breakpoints';
|
|
9
|
+
import { evaluateTemplate, processStructure, isResponsiveStyle } from '../client/templateEngine';
|
|
10
|
+
import { resolvePropsFromDefinition } from '../shared/propResolver';
|
|
11
|
+
import { generateColorVariablesCSS, generateThemeColorVariablesCSS } from './cssGenerator';
|
|
12
|
+
import { loadBreakpointConfig, loadResponsiveScalesConfig, loadI18nConfig, loadIconsConfig } from './jsonLoader';
|
|
13
|
+
import type { I18nConfig } from '../shared/types/components';
|
|
14
|
+
import { extractLocaleFromPath, DEFAULT_I18N_CONFIG, resolveI18nValue, buildLocalizedPath } from '../shared/i18n';
|
|
15
|
+
import { NODE_TYPE } from '../shared/constants';
|
|
16
|
+
import { isComponentNode, isHtmlNode, isObjectLinkNode, isEmbedNode, isLocaleListNode } from '../shared/nodeUtils';
|
|
17
|
+
import { extractAttributesFromNode } from '../shared/attributeNodeUtils';
|
|
18
|
+
import { SSRRegistry } from '../shared/registry/SSRRegistry';
|
|
19
|
+
import { generateFontCSS } from '../shared/fontLoader';
|
|
20
|
+
import { colorService } from './services/ColorService';
|
|
21
|
+
import { mergeResponsiveStyles } from '../shared/responsiveStyleUtils';
|
|
22
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
23
|
+
import { generateUtilityCSS, extractUtilityClassesFromHTML } from '../shared/cssGeneration';
|
|
24
|
+
import { responsiveStylesToClasses } from '../shared/utilityClassMapper';
|
|
25
|
+
import { validateStyleCoverage, printMissingStyleWarnings } from './validateStyleCoverage';
|
|
26
|
+
import type { SlugMap } from '../shared/slugTranslator';
|
|
27
|
+
import { buildSlugIndex, getLocaleLinks, translatePath } from '../shared/slugTranslator';
|
|
28
|
+
import { formHandlerScript, needsFormHandler } from '../client/scripts/formHandler';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CMS context for template interpolation
|
|
32
|
+
*/
|
|
33
|
+
export interface CMSContext {
|
|
34
|
+
cms?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SSR rendering context - passed through the render chain
|
|
39
|
+
*/
|
|
40
|
+
interface SSRContext {
|
|
41
|
+
locale?: string;
|
|
42
|
+
i18nConfig?: I18nConfig;
|
|
43
|
+
slugMappings?: SlugMap[];
|
|
44
|
+
pagePath?: string;
|
|
45
|
+
breakpoints?: BreakpointConfig;
|
|
46
|
+
viewportWidth?: number;
|
|
47
|
+
cmsContext?: CMSContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Component registry for SSR (shared between requests)
|
|
52
|
+
* Uses the shared SSRRegistry for consistency
|
|
53
|
+
*/
|
|
54
|
+
const ssrComponentRegistry = new SSRRegistry();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Escape HTML special characters to prevent XSS
|
|
58
|
+
*/
|
|
59
|
+
function escapeHtml(unsafe: string): string {
|
|
60
|
+
return unsafe
|
|
61
|
+
.replace(/&/g, '&')
|
|
62
|
+
.replace(/</g, '<')
|
|
63
|
+
.replace(/>/g, '>')
|
|
64
|
+
.replace(/"/g, '"')
|
|
65
|
+
.replace(/'/g, ''');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Process CMS template strings like {{cms.title}} or {{cms.author}}
|
|
70
|
+
* Replaces template expressions with values from CMS item
|
|
71
|
+
*/
|
|
72
|
+
function processCMSTemplate(template: string, cmsItem: Record<string, unknown>): string {
|
|
73
|
+
return template.replace(/\{\{cms\.([^}]+)\}\}/g, (match, fieldPath) => {
|
|
74
|
+
// Support nested paths like cms.author.name
|
|
75
|
+
const parts = fieldPath.trim().split('.');
|
|
76
|
+
let value: unknown = cmsItem;
|
|
77
|
+
|
|
78
|
+
for (const part of parts) {
|
|
79
|
+
if (value && typeof value === 'object' && part in value) {
|
|
80
|
+
value = (value as Record<string, unknown>)[part];
|
|
81
|
+
} else {
|
|
82
|
+
// Field not found, return empty string
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Return string representation
|
|
88
|
+
if (value === null || value === undefined) {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
return String(value);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Process CMS templates in props object
|
|
97
|
+
* Recursively processes all string values in props
|
|
98
|
+
*/
|
|
99
|
+
function processCMSPropsTemplate(
|
|
100
|
+
props: Record<string, unknown>,
|
|
101
|
+
cmsItem: Record<string, unknown>
|
|
102
|
+
): Record<string, unknown> {
|
|
103
|
+
const result: Record<string, unknown> = {};
|
|
104
|
+
|
|
105
|
+
for (const [key, value] of Object.entries(props)) {
|
|
106
|
+
if (typeof value === 'string' && value.includes('{{cms.')) {
|
|
107
|
+
result[key] = processCMSTemplate(value, cmsItem);
|
|
108
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
109
|
+
// Recursively process nested objects (but not arrays or null)
|
|
110
|
+
result[key] = processCMSPropsTemplate(value as Record<string, unknown>, cmsItem);
|
|
111
|
+
} else {
|
|
112
|
+
result[key] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build HTML string from component node (server-side)
|
|
121
|
+
*/
|
|
122
|
+
async function buildComponentHTML(
|
|
123
|
+
node: ComponentNode | ComponentNode[] | string | number | null | undefined,
|
|
124
|
+
globalComponents: Record<string, ComponentDefinition> = {},
|
|
125
|
+
pageComponents: Record<string, ComponentDefinition> = {},
|
|
126
|
+
locale?: string,
|
|
127
|
+
i18nConfig?: I18nConfig,
|
|
128
|
+
slugMappings?: SlugMap[],
|
|
129
|
+
pagePath?: string,
|
|
130
|
+
cmsContext?: CMSContext
|
|
131
|
+
): Promise<{ html: string }> {
|
|
132
|
+
if (!node) return { html: '' };
|
|
133
|
+
|
|
134
|
+
// Register components for this render
|
|
135
|
+
ssrComponentRegistry.merge(globalComponents);
|
|
136
|
+
ssrComponentRegistry.merge(pageComponents);
|
|
137
|
+
|
|
138
|
+
// Load breakpoint config for responsive style resolution
|
|
139
|
+
const breakpoints = await loadBreakpointConfig();
|
|
140
|
+
|
|
141
|
+
// Render using desktop viewport (1920px) - same as editor system
|
|
142
|
+
const SSR_VIEWPORT_WIDTH = 1920;
|
|
143
|
+
|
|
144
|
+
// Build SSR context
|
|
145
|
+
const ctx: SSRContext = {
|
|
146
|
+
locale,
|
|
147
|
+
i18nConfig,
|
|
148
|
+
slugMappings,
|
|
149
|
+
pagePath,
|
|
150
|
+
breakpoints,
|
|
151
|
+
viewportWidth: SSR_VIEWPORT_WIDTH,
|
|
152
|
+
cmsContext,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const html = renderNode(node, ctx);
|
|
156
|
+
|
|
157
|
+
return { html };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderNode(
|
|
161
|
+
node: ComponentNode | ComponentNode[] | string | number | null | undefined,
|
|
162
|
+
ctx: SSRContext
|
|
163
|
+
): string {
|
|
164
|
+
const { breakpoints, viewportWidth, locale, i18nConfig, slugMappings, pagePath } = ctx;
|
|
165
|
+
|
|
166
|
+
if (node === null || node === undefined) return '';
|
|
167
|
+
|
|
168
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
169
|
+
let text = String(node);
|
|
170
|
+
// Process CMS template strings like {{cms.title}}
|
|
171
|
+
if (ctx.cmsContext?.cms && text.includes('{{cms.')) {
|
|
172
|
+
text = processCMSTemplate(text, ctx.cmsContext.cms);
|
|
173
|
+
}
|
|
174
|
+
return escapeHtml(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(node)) {
|
|
178
|
+
return node.map(child => renderNode(child, ctx)).join('');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof node !== 'object') return '';
|
|
182
|
+
|
|
183
|
+
const nodeType = 'type' in node ? node.type : undefined;
|
|
184
|
+
const nodeStyle = ('style' in node) ? node.style : undefined;
|
|
185
|
+
const children = ('children' in node) ? (node.children || []) : [];
|
|
186
|
+
|
|
187
|
+
// Handle embed nodes - render custom HTML content
|
|
188
|
+
if (isEmbedNode(node)) {
|
|
189
|
+
// Sanitize HTML with allowlist for SVG and common elements (same as client)
|
|
190
|
+
const sanitizedHtml = DOMPurify.sanitize(node.html, {
|
|
191
|
+
ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas'],
|
|
192
|
+
ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity'],
|
|
193
|
+
KEEP_CONTENT: true
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Extract attributes from node
|
|
197
|
+
const nodeAttributes = extractAttributesFromNode(node);
|
|
198
|
+
const attrs = buildAttributes(nodeAttributes);
|
|
199
|
+
|
|
200
|
+
// Convert styles to utility classes
|
|
201
|
+
let classAttr = '';
|
|
202
|
+
if (nodeStyle) {
|
|
203
|
+
const utilityClasses = responsiveStylesToClasses(nodeStyle);
|
|
204
|
+
if (utilityClasses.length > 0) {
|
|
205
|
+
classAttr = ` class="${escapeHtml(utilityClasses.join(' '))}"`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Wrap sanitized content in a div with utility classes and attributes
|
|
210
|
+
return `<div${classAttr}${attrs}>${sanitizedHtml}</div>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Handle object link nodes (render as <a> tag in SSR)
|
|
214
|
+
if (isObjectLinkNode(node)) {
|
|
215
|
+
let href = node.href || '#';
|
|
216
|
+
|
|
217
|
+
// Localize internal page links to current locale
|
|
218
|
+
if (href.startsWith('/') && !href.startsWith('//') && locale && i18nConfig) {
|
|
219
|
+
if (slugMappings) {
|
|
220
|
+
const slugIndex = buildSlugIndex(slugMappings);
|
|
221
|
+
href = translatePath(href, locale, i18nConfig.defaultLocale, i18nConfig.defaultLocale, slugIndex);
|
|
222
|
+
} else if (locale !== i18nConfig.defaultLocale) {
|
|
223
|
+
href = buildLocalizedPath(href, locale);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Extract attributes from node
|
|
228
|
+
const nodeAttributes = extractAttributesFromNode(node);
|
|
229
|
+
const attrs = buildAttributes(nodeAttributes, ['href']);
|
|
230
|
+
|
|
231
|
+
// Convert styles to utility classes and add olink base class
|
|
232
|
+
const utilityClasses = nodeStyle ? responsiveStylesToClasses(nodeStyle) : [];
|
|
233
|
+
const allClasses = ['olink', ...utilityClasses];
|
|
234
|
+
const classAttr = ` class="${escapeHtml(allClasses.join(' '))}"`;
|
|
235
|
+
|
|
236
|
+
const childrenHTML = Array.isArray(children)
|
|
237
|
+
? children.map((child) => renderNode(child, ctx)).join('')
|
|
238
|
+
: renderNode(children, ctx);
|
|
239
|
+
|
|
240
|
+
return `<a href="${escapeHtml(String(href))}"${classAttr}${attrs}>${childrenHTML}</a>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Handle locale-list node type (render locale switcher links)
|
|
244
|
+
if (isLocaleListNode(node)) {
|
|
245
|
+
if (slugMappings && pagePath && i18nConfig && locale) {
|
|
246
|
+
const slugIndex = buildSlugIndex(slugMappings);
|
|
247
|
+
const localeLinks = getLocaleLinks(pagePath, locale, i18nConfig, slugIndex);
|
|
248
|
+
const showCurrent = node.showCurrent !== false;
|
|
249
|
+
const showSeparator = node.showSeparator !== false;
|
|
250
|
+
const showFlag = node.showFlag !== false;
|
|
251
|
+
|
|
252
|
+
// Build a map of locale code to icon for quick lookup
|
|
253
|
+
const localeIconMap = new Map<string, string>();
|
|
254
|
+
for (const localeConfig of i18nConfig.locales) {
|
|
255
|
+
if (localeConfig.icon) {
|
|
256
|
+
localeIconMap.set(localeConfig.code, localeConfig.icon);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Convert container styles to utility classes
|
|
261
|
+
let containerClasses: string[] = [];
|
|
262
|
+
if (nodeStyle) {
|
|
263
|
+
containerClasses = responsiveStylesToClasses(nodeStyle);
|
|
264
|
+
}
|
|
265
|
+
const containerClassAttr = containerClasses.length > 0
|
|
266
|
+
? ` class="${escapeHtml(containerClasses.join(' '))}"`
|
|
267
|
+
: '';
|
|
268
|
+
|
|
269
|
+
// Convert item styles to utility classes
|
|
270
|
+
let itemClasses: string[] = [];
|
|
271
|
+
if (node.itemStyle) {
|
|
272
|
+
itemClasses = responsiveStylesToClasses(node.itemStyle);
|
|
273
|
+
}
|
|
274
|
+
const itemClassAttr = itemClasses.length > 0
|
|
275
|
+
? ` class="${escapeHtml(itemClasses.join(' '))}"`
|
|
276
|
+
: '';
|
|
277
|
+
|
|
278
|
+
// Convert active item styles to utility classes
|
|
279
|
+
let activeItemClasses: string[] = [];
|
|
280
|
+
if (node.activeItemStyle) {
|
|
281
|
+
activeItemClasses = responsiveStylesToClasses(node.activeItemStyle);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Convert separator styles to utility classes
|
|
285
|
+
let separatorClasses: string[] = [];
|
|
286
|
+
if (node.separatorStyle) {
|
|
287
|
+
separatorClasses = responsiveStylesToClasses(node.separatorStyle);
|
|
288
|
+
}
|
|
289
|
+
const separatorClassAttr = separatorClasses.length > 0
|
|
290
|
+
? ` class="${escapeHtml(separatorClasses.join(' '))}"`
|
|
291
|
+
: '';
|
|
292
|
+
|
|
293
|
+
// Convert flag styles to utility classes
|
|
294
|
+
let flagClasses: string[] = [];
|
|
295
|
+
if (node.flagStyle) {
|
|
296
|
+
flagClasses = responsiveStylesToClasses(node.flagStyle);
|
|
297
|
+
}
|
|
298
|
+
const flagClassAttr = flagClasses.length > 0
|
|
299
|
+
? ` class="${escapeHtml(flagClasses.join(' '))}"`
|
|
300
|
+
: '';
|
|
301
|
+
|
|
302
|
+
// Current item gets both itemStyle + activeItemStyle (additive/override)
|
|
303
|
+
const currentItemClasses = [...itemClasses, ...activeItemClasses];
|
|
304
|
+
const currentItemClassAttr = currentItemClasses.length > 0
|
|
305
|
+
? ` class="${escapeHtml(currentItemClasses.join(' '))}"`
|
|
306
|
+
: '';
|
|
307
|
+
|
|
308
|
+
// Build links HTML
|
|
309
|
+
const links: string[] = [];
|
|
310
|
+
for (const link of localeLinks) {
|
|
311
|
+
if (!showCurrent && link.isCurrent) continue;
|
|
312
|
+
const currentAttr = link.isCurrent ? ' data-current="true"' : ' data-current="false"';
|
|
313
|
+
const classAttr = link.isCurrent ? currentItemClassAttr : itemClassAttr;
|
|
314
|
+
const hreflangAttr = ` hreflang="${escapeHtml(link.langTag)}"`;
|
|
315
|
+
|
|
316
|
+
// Build link content with optional flag icon + text in div
|
|
317
|
+
let linkContent = '';
|
|
318
|
+
const localeIcon = localeIconMap.get(link.locale);
|
|
319
|
+
if (showFlag && localeIcon) {
|
|
320
|
+
linkContent += `<img src="${escapeHtml(localeIcon)}" alt="${escapeHtml(link.nativeName)} flag"${flagClassAttr}>`;
|
|
321
|
+
}
|
|
322
|
+
linkContent += `<div>${escapeHtml(link.nativeName)}</div>`;
|
|
323
|
+
|
|
324
|
+
links.push(`<a href="${escapeHtml(link.path)}"${hreflangAttr}${currentAttr} data-locale="${escapeHtml(link.locale)}"${classAttr}>${linkContent}</a>`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Join links with separator (empty span styled via separatorStyle) or concatenate directly
|
|
328
|
+
const linksHTML = showSeparator
|
|
329
|
+
? links.join(`<span${separatorClassAttr}></span>`)
|
|
330
|
+
: links.join('');
|
|
331
|
+
|
|
332
|
+
// Extract attributes from node
|
|
333
|
+
const nodeAttributes = extractAttributesFromNode(node);
|
|
334
|
+
const attrs = buildAttributes(nodeAttributes);
|
|
335
|
+
|
|
336
|
+
return `<div data-locale-list="true"${containerClassAttr}${attrs}>${linksHTML}</div>`;
|
|
337
|
+
}
|
|
338
|
+
// If context is missing, return empty div
|
|
339
|
+
return '<div data-locale-list="true"></div>';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Extract tag or component name based on node type
|
|
343
|
+
let tag = isHtmlNode(node) ? node.tag : undefined;
|
|
344
|
+
const componentName = isComponentNode(node) ? node.component : undefined;
|
|
345
|
+
let nodeProps = isComponentNode(node) ? (node.props || {}) : {};
|
|
346
|
+
|
|
347
|
+
// Process CMS templates in props
|
|
348
|
+
if (ctx.cmsContext?.cms && Object.keys(nodeProps).length > 0) {
|
|
349
|
+
nodeProps = processCMSPropsTemplate(nodeProps, ctx.cmsContext.cms);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Extract attributes from node
|
|
353
|
+
const nodeAttributes = extractAttributesFromNode(node);
|
|
354
|
+
|
|
355
|
+
if (!tag && !componentName) return '';
|
|
356
|
+
|
|
357
|
+
// Convert styles to utility classes instead of inline styles
|
|
358
|
+
let utilityClasses: string[] = [];
|
|
359
|
+
let resolvedStyle: StyleObject = {};
|
|
360
|
+
|
|
361
|
+
if (nodeStyle) {
|
|
362
|
+
// Validate that all styles can generate utility classes (build-time warnings)
|
|
363
|
+
validateStyleCoverage(nodeStyle, `Node: ${nodeType || 'unknown'}`);
|
|
364
|
+
|
|
365
|
+
// Convert style object to utility class names
|
|
366
|
+
utilityClasses = responsiveStylesToClasses(nodeStyle);
|
|
367
|
+
|
|
368
|
+
// Only keep inline styles from nodeProps, not from the style object
|
|
369
|
+
// (style objects are converted to classes instead)
|
|
370
|
+
// For backward compatibility with components that set styles via props
|
|
371
|
+
} else if (nodeProps.style) {
|
|
372
|
+
// If no node.style but props have style, keep it for backward compatibility
|
|
373
|
+
if (isResponsiveStyle(nodeProps.style) && breakpoints && viewportWidth) {
|
|
374
|
+
resolvedStyle = mergeResponsiveStyles(nodeProps.style as ResponsiveStyleObject, 'viewport', viewportWidth, breakpoints);
|
|
375
|
+
} else {
|
|
376
|
+
resolvedStyle = nodeProps.style as StyleObject;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Merge resolved style into props.style, and merge attributes
|
|
381
|
+
const propsWithStyleAndAttrs = {
|
|
382
|
+
...nodeProps,
|
|
383
|
+
...nodeAttributes,
|
|
384
|
+
...(utilityClasses.length > 0 ? { className: utilityClasses.join(' ') } : {}),
|
|
385
|
+
...(Object.keys(resolvedStyle).length > 0 ? { style: resolvedStyle } : {})
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Check if this is a custom component
|
|
389
|
+
if (nodeType === NODE_TYPE.COMPONENT && componentName && ssrComponentRegistry.has(componentName)) {
|
|
390
|
+
const componentDef = ssrComponentRegistry.get(componentName);
|
|
391
|
+
if (!componentDef) return '';
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const structuredComponentDef = componentDef.component;
|
|
395
|
+
if (!structuredComponentDef) return '';
|
|
396
|
+
|
|
397
|
+
// Resolve props with defaults from interface definition (with i18n support)
|
|
398
|
+
const resolvedProps = resolvePropsFromDefinition(
|
|
399
|
+
structuredComponentDef,
|
|
400
|
+
propsWithStyleAndAttrs,
|
|
401
|
+
children,
|
|
402
|
+
locale,
|
|
403
|
+
i18nConfig
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// Process the structure with resolved props
|
|
407
|
+
// Pass instance children to replace { type: "children" } markers
|
|
408
|
+
// Use preserveResponsiveStyles: true to keep responsive styles intact for SSR rendering
|
|
409
|
+
const processedStructure = processStructure(
|
|
410
|
+
structuredComponentDef.structure,
|
|
411
|
+
{ props: resolvedProps, componentDef: structuredComponentDef },
|
|
412
|
+
undefined,
|
|
413
|
+
children,
|
|
414
|
+
true
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (!processedStructure) return '';
|
|
418
|
+
|
|
419
|
+
// Type guard: ensure processedStructure is a ComponentNode
|
|
420
|
+
if (typeof processedStructure === 'string' || typeof processedStructure === 'number' || Array.isArray(processedStructure)) {
|
|
421
|
+
return renderNode(processedStructure, ctx);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Merge instance style overrides, className, and attributes
|
|
425
|
+
// processedStructure should be a ComponentInstanceNode at this point
|
|
426
|
+
if (isComponentNode(processedStructure)) {
|
|
427
|
+
if (!processedStructure.props) {
|
|
428
|
+
processedStructure.props = {};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (processedStructure.props.style && typeof processedStructure.props.style === 'object') {
|
|
432
|
+
processedStructure.props.style = {
|
|
433
|
+
...(processedStructure.props.style as Record<string, unknown>),
|
|
434
|
+
...(propsWithStyleAndAttrs.style as Record<string, unknown> || {})
|
|
435
|
+
};
|
|
436
|
+
} else if (propsWithStyleAndAttrs.style) {
|
|
437
|
+
processedStructure.props.style = propsWithStyleAndAttrs.style;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Merge className from component instance (includes responsive utility classes)
|
|
441
|
+
if (propsWithStyleAndAttrs.className) {
|
|
442
|
+
const existingClassName = processedStructure.props.className || '';
|
|
443
|
+
processedStructure.props.className = existingClassName
|
|
444
|
+
? `${existingClassName} ${propsWithStyleAndAttrs.className}`
|
|
445
|
+
: propsWithStyleAndAttrs.className;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Merge attributes into props
|
|
449
|
+
Object.assign(processedStructure.props, nodeAttributes);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Render the processed component structure
|
|
453
|
+
return renderNode(processedStructure, ctx);
|
|
454
|
+
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.error(`❌ SSR Error rendering component ${componentName}:`, error);
|
|
457
|
+
return '';
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Handle Link component for navigation (only for HTML nodes)
|
|
462
|
+
if (tag === 'Link') {
|
|
463
|
+
const to = 'to' in propsWithStyleAndAttrs ? propsWithStyleAndAttrs.to : undefined;
|
|
464
|
+
const restProps: Record<string, unknown> = { ...propsWithStyleAndAttrs };
|
|
465
|
+
delete restProps.to;
|
|
466
|
+
const href = (typeof to === 'string' ? to : undefined) || '#';
|
|
467
|
+
|
|
468
|
+
// Build class attribute from utility classes
|
|
469
|
+
const linkClassAttr = restProps.className
|
|
470
|
+
? ` class="${escapeHtml(String(restProps.className))}"`
|
|
471
|
+
: '';
|
|
472
|
+
|
|
473
|
+
const attrs = buildAttributes(restProps, ['style', 'className', 'to']);
|
|
474
|
+
const childrenHTML = Array.isArray(children)
|
|
475
|
+
? children.map((child) => renderNode(child, ctx)).join('')
|
|
476
|
+
: renderNode(children, ctx);
|
|
477
|
+
|
|
478
|
+
return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}>${childrenHTML}</a>`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Regular HTML element (must have tag)
|
|
482
|
+
if (!tag) {
|
|
483
|
+
console.error('Missing tag for HTML element in SSR');
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Build class attribute from utility classes
|
|
488
|
+
const classAttr = propsWithStyleAndAttrs.className
|
|
489
|
+
? ` class="${escapeHtml(String(propsWithStyleAndAttrs.className))}"`
|
|
490
|
+
: '';
|
|
491
|
+
|
|
492
|
+
const attrs = buildAttributes(propsWithStyleAndAttrs, ['style', 'className']);
|
|
493
|
+
const childrenHTML = Array.isArray(children)
|
|
494
|
+
? children.map((child) => renderNode(child, ctx)).join('')
|
|
495
|
+
: renderNode(children, ctx);
|
|
496
|
+
|
|
497
|
+
// Self-closing tags
|
|
498
|
+
const voidElements = ['img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'];
|
|
499
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
500
|
+
return `<${tag}${classAttr}${attrs} />`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return `<${tag}${classAttr}${attrs}>${childrenHTML}</${tag}>`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Build HTML attributes string from props (excluding style and other special props)
|
|
508
|
+
*/
|
|
509
|
+
function buildAttributes(props: Record<string, unknown>, exclude: string[] = []): string {
|
|
510
|
+
const attrs: string[] = [];
|
|
511
|
+
// Internal props that should never be rendered as HTML attributes
|
|
512
|
+
const internalProps = ['type', 'tag', 'component', 'props', 'children', 'src', 'alt', 'loading', 'width', 'height'];
|
|
513
|
+
const defaultExclude = [...internalProps, ...exclude];
|
|
514
|
+
|
|
515
|
+
// Regex to detect unresolved template strings like {{link.target}}
|
|
516
|
+
const unresolvedTemplatePattern = /^\{\{.+\}\}$/;
|
|
517
|
+
|
|
518
|
+
for (const [key, value] of Object.entries(props)) {
|
|
519
|
+
if (defaultExclude.includes(key)) continue;
|
|
520
|
+
if (value === null || value === undefined) continue;
|
|
521
|
+
if (typeof value === 'function') continue; // Skip event handlers in SSR
|
|
522
|
+
if (typeof value === 'object') continue; // Skip objects (prevents [object Object] in attributes)
|
|
523
|
+
// Skip unresolved template strings (e.g., {{link.target}} when link.target is undefined)
|
|
524
|
+
if (typeof value === 'string' && unresolvedTemplatePattern.test(value)) continue;
|
|
525
|
+
|
|
526
|
+
// Convert camelCase to kebab-case for HTML attributes
|
|
527
|
+
const attrName = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
528
|
+
const attrValue = typeof value === 'boolean'
|
|
529
|
+
? (value ? '' : undefined)
|
|
530
|
+
: escapeHtml(String(value));
|
|
531
|
+
|
|
532
|
+
if (attrValue !== undefined) {
|
|
533
|
+
if (typeof value === 'boolean' && value) {
|
|
534
|
+
attrs.push(attrName);
|
|
535
|
+
} else {
|
|
536
|
+
attrs.push(`${attrName}="${attrValue}"`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Extract meta information from page JSON for SEO
|
|
546
|
+
*/
|
|
547
|
+
export interface PageMeta {
|
|
548
|
+
title?: string;
|
|
549
|
+
description?: string;
|
|
550
|
+
keywords?: string;
|
|
551
|
+
ogTitle?: string;
|
|
552
|
+
ogDescription?: string;
|
|
553
|
+
ogImage?: string;
|
|
554
|
+
ogType?: string;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function extractPageMeta(pageData: JSONPage): PageMeta {
|
|
558
|
+
const meta: PageMeta = {};
|
|
559
|
+
|
|
560
|
+
if (pageData?.meta) {
|
|
561
|
+
meta.title = pageData.meta.title;
|
|
562
|
+
meta.description = pageData.meta.description;
|
|
563
|
+
meta.keywords = pageData.meta.keywords;
|
|
564
|
+
meta.ogTitle = pageData.meta.ogTitle || pageData.meta.title;
|
|
565
|
+
meta.ogDescription = pageData.meta.ogDescription || pageData.meta.description;
|
|
566
|
+
meta.ogImage = pageData.meta.ogImage;
|
|
567
|
+
meta.ogType = pageData.meta.ogType || 'website';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return meta;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Generate HTML meta tags string
|
|
575
|
+
* Resolves i18n values using the provided locale and config
|
|
576
|
+
*/
|
|
577
|
+
export function generateMetaTags(
|
|
578
|
+
meta: PageMeta,
|
|
579
|
+
url: string = '',
|
|
580
|
+
locale: string = 'en',
|
|
581
|
+
config: I18nConfig = DEFAULT_I18N_CONFIG
|
|
582
|
+
): string {
|
|
583
|
+
const tags: string[] = [];
|
|
584
|
+
|
|
585
|
+
// Helper to resolve i18n values and ensure string type
|
|
586
|
+
const resolve = (value: unknown): string => {
|
|
587
|
+
const resolved = resolveI18nValue(value, locale, config);
|
|
588
|
+
return typeof resolved === 'string' ? resolved : '';
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const title = resolve(meta.title);
|
|
592
|
+
const description = resolve(meta.description);
|
|
593
|
+
const keywords = resolve(meta.keywords);
|
|
594
|
+
const ogTitle = resolve(meta.ogTitle) || title;
|
|
595
|
+
const ogDescription = resolve(meta.ogDescription) || description;
|
|
596
|
+
const ogImage = resolve(meta.ogImage);
|
|
597
|
+
const ogType = resolve(meta.ogType);
|
|
598
|
+
|
|
599
|
+
if (title) {
|
|
600
|
+
tags.push(`<title>${escapeHtml(title)}</title>`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (description) {
|
|
604
|
+
tags.push(`<meta name="description" content="${escapeHtml(description)}" />`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (keywords) {
|
|
608
|
+
tags.push(`<meta name="keywords" content="${escapeHtml(keywords)}" />`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Open Graph tags
|
|
612
|
+
if (ogTitle) {
|
|
613
|
+
tags.push(`<meta property="og:title" content="${escapeHtml(ogTitle)}" />`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (ogDescription) {
|
|
617
|
+
tags.push(`<meta property="og:description" content="${escapeHtml(ogDescription)}" />`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (ogImage) {
|
|
621
|
+
tags.push(`<meta property="og:image" content="${escapeHtml(ogImage)}" />`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (ogType) {
|
|
625
|
+
tags.push(`<meta property="og:type" content="${escapeHtml(ogType)}" />`);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (url) {
|
|
629
|
+
tags.push(`<meta property="og:url" content="${escapeHtml(url)}" />`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Canonical URL
|
|
633
|
+
if (url) {
|
|
634
|
+
tags.push(`<link rel="canonical" href="${escapeHtml(url)}" />`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return tags.join('\n ');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Collect JavaScript code from all components (global and page-specific)
|
|
642
|
+
*/
|
|
643
|
+
function collectComponentJavaScript(
|
|
644
|
+
globalComponents: Record<string, ComponentDefinition> = {},
|
|
645
|
+
pageComponents: Record<string, ComponentDefinition> = {}
|
|
646
|
+
): string {
|
|
647
|
+
const jsCodeBlocks: string[] = [];
|
|
648
|
+
|
|
649
|
+
// Collect JS from global components
|
|
650
|
+
for (const [name, component] of Object.entries(globalComponents)) {
|
|
651
|
+
if (component?.component?.javascript) {
|
|
652
|
+
jsCodeBlocks.push(`// Component: ${name}\n${component.component.javascript}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Collect JS from page-specific components
|
|
657
|
+
for (const [name, component] of Object.entries(pageComponents)) {
|
|
658
|
+
// Skip if already collected from global components
|
|
659
|
+
if (!globalComponents[name] && component?.component?.javascript) {
|
|
660
|
+
jsCodeBlocks.push(`// Component: ${name}\n${component.component.javascript}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return jsCodeBlocks.join('\n\n');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Collect CSS code from all components (global and page-specific)
|
|
669
|
+
*/
|
|
670
|
+
function collectComponentCSS(
|
|
671
|
+
globalComponents: Record<string, ComponentDefinition> = {},
|
|
672
|
+
pageComponents: Record<string, ComponentDefinition> = {}
|
|
673
|
+
): string {
|
|
674
|
+
const cssBlocks: string[] = [];
|
|
675
|
+
|
|
676
|
+
// Collect CSS from global components
|
|
677
|
+
for (const [name, component] of Object.entries(globalComponents)) {
|
|
678
|
+
if (component?.component?.css) {
|
|
679
|
+
cssBlocks.push(`/* Component: ${name} */\n${component.component.css}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Collect CSS from page-specific components
|
|
684
|
+
for (const [name, component] of Object.entries(pageComponents)) {
|
|
685
|
+
// Skip if already collected from global components
|
|
686
|
+
if (!globalComponents[name] && component?.component?.css) {
|
|
687
|
+
cssBlocks.push(`/* Component: ${name} */\n${component.component.css}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return cssBlocks.join('\n\n');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Main SSR render function - generates complete HTML with pre-rendered content
|
|
696
|
+
*/
|
|
697
|
+
export async function renderPageSSR(
|
|
698
|
+
pageData: JSONPage,
|
|
699
|
+
globalComponents: Record<string, ComponentDefinition> = {},
|
|
700
|
+
pagePath: string = '/',
|
|
701
|
+
baseUrl: string = '',
|
|
702
|
+
locale?: string,
|
|
703
|
+
i18nConfig?: I18nConfig,
|
|
704
|
+
slugMappings?: SlugMap[],
|
|
705
|
+
cmsContext?: CMSContext
|
|
706
|
+
): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string }> {
|
|
707
|
+
// Extract page content
|
|
708
|
+
const rootNode = pageData?.root || undefined;
|
|
709
|
+
if (!rootNode) {
|
|
710
|
+
throw new Error('Page data must have a root node');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Load i18n config if not provided
|
|
714
|
+
const config = i18nConfig || await loadI18nConfig();
|
|
715
|
+
|
|
716
|
+
// Extract locale from path if not explicitly provided
|
|
717
|
+
const { locale: pathLocale, pathWithoutLocale } = extractLocaleFromPath(pagePath, config);
|
|
718
|
+
const effectiveLocale = locale || pathLocale || config.defaultLocale;
|
|
719
|
+
|
|
720
|
+
// Extract meta information and process CMS templates in meta
|
|
721
|
+
let meta = extractPageMeta(pageData);
|
|
722
|
+
if (cmsContext?.cms) {
|
|
723
|
+
// Process CMS templates in meta fields
|
|
724
|
+
if (typeof meta.title === 'string') {
|
|
725
|
+
meta = { ...meta, title: processCMSTemplate(meta.title, cmsContext.cms) };
|
|
726
|
+
}
|
|
727
|
+
if (typeof meta.description === 'string') {
|
|
728
|
+
meta = { ...meta, description: processCMSTemplate(meta.description, cmsContext.cms) };
|
|
729
|
+
}
|
|
730
|
+
if (typeof meta.ogTitle === 'string') {
|
|
731
|
+
meta = { ...meta, ogTitle: processCMSTemplate(meta.ogTitle, cmsContext.cms) };
|
|
732
|
+
}
|
|
733
|
+
if (typeof meta.ogDescription === 'string') {
|
|
734
|
+
meta = { ...meta, ogDescription: processCMSTemplate(meta.ogDescription, cmsContext.cms) };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const pageComponents = pageData?.components || {};
|
|
739
|
+
|
|
740
|
+
// Render the component tree to HTML with i18n and CMS support
|
|
741
|
+
const { html: contentHTML } = rootNode
|
|
742
|
+
? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext)
|
|
743
|
+
: { html: '' };
|
|
744
|
+
|
|
745
|
+
// Collect JavaScript and CSS from all components
|
|
746
|
+
const javascript = collectComponentJavaScript(globalComponents, pageComponents);
|
|
747
|
+
const componentCSS = collectComponentCSS(globalComponents, pageComponents);
|
|
748
|
+
|
|
749
|
+
// Build full URL for meta tags
|
|
750
|
+
const fullUrl = baseUrl ? `${baseUrl}${pagePath}` : pagePath;
|
|
751
|
+
|
|
752
|
+
// Generate meta tags with i18n resolution
|
|
753
|
+
const metaTags = generateMetaTags(meta, fullUrl, effectiveLocale, config);
|
|
754
|
+
|
|
755
|
+
// Resolve title for use in HTML template
|
|
756
|
+
const resolvedTitle = resolveI18nValue(meta.title, effectiveLocale, config);
|
|
757
|
+
|
|
758
|
+
// Return pre-rendered HTML (will be injected into template)
|
|
759
|
+
return {
|
|
760
|
+
html: contentHTML,
|
|
761
|
+
meta: metaTags,
|
|
762
|
+
title: typeof resolvedTitle === 'string' ? resolvedTitle : 'UPLO',
|
|
763
|
+
javascript,
|
|
764
|
+
// Component CSS is still included in the final HTML document
|
|
765
|
+
componentCSS,
|
|
766
|
+
locale: effectiveLocale
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Generate complete HTML document with SSR content
|
|
772
|
+
*/
|
|
773
|
+
export async function generateSSRHTML(
|
|
774
|
+
pageData: JSONPage,
|
|
775
|
+
globalComponents: Record<string, ComponentDefinition> = {},
|
|
776
|
+
pagePath: string = '/',
|
|
777
|
+
baseUrl: string = '',
|
|
778
|
+
useBuiltBundle: boolean = false,
|
|
779
|
+
locale?: string,
|
|
780
|
+
slugMappings?: SlugMap[],
|
|
781
|
+
cmsContext?: CMSContext
|
|
782
|
+
): Promise<string> {
|
|
783
|
+
const rendered = await renderPageSSR(pageData, globalComponents, pagePath, baseUrl, locale, undefined, slugMappings, cmsContext);
|
|
784
|
+
|
|
785
|
+
// Use built bundle in production, dev server in development
|
|
786
|
+
const clientScript = useBuiltBundle
|
|
787
|
+
? '' // No client router in static build (true static HTML)
|
|
788
|
+
: '<script type="module" src="/client-router.tsx"></script>'; // Dev server (development)
|
|
789
|
+
|
|
790
|
+
// Render component JavaScript if any
|
|
791
|
+
// Escape </script> sequences to prevent premature script tag closure
|
|
792
|
+
const escapedJavaScript = rendered.javascript
|
|
793
|
+
? rendered.javascript.replace(/<\/script>/gi, '<\\/script>')
|
|
794
|
+
: '';
|
|
795
|
+
const componentScript = escapedJavaScript
|
|
796
|
+
? `\n <script>\n${escapedJavaScript}\n </script>`
|
|
797
|
+
: '';
|
|
798
|
+
|
|
799
|
+
// Add form handler script if page contains fetch-handled forms
|
|
800
|
+
const formScript = needsFormHandler(rendered.html)
|
|
801
|
+
? `\n <script>\n${formHandlerScript}\n </script>`
|
|
802
|
+
: '';
|
|
803
|
+
|
|
804
|
+
// Generate font CSS from project config
|
|
805
|
+
const fontCSS = generateFontCSS();
|
|
806
|
+
|
|
807
|
+
// Load icons config for favicon and apple touch icon
|
|
808
|
+
const iconsConfig = await loadIconsConfig();
|
|
809
|
+
|
|
810
|
+
// Load and generate theme color variables CSS
|
|
811
|
+
const themeConfig = await colorService.loadThemeConfig();
|
|
812
|
+
const themeColorVariablesCSS = generateThemeColorVariablesCSS(themeConfig);
|
|
813
|
+
|
|
814
|
+
// Include component CSS (no longer generating inline style classes)
|
|
815
|
+
const allCSS = rendered.componentCSS || '';
|
|
816
|
+
|
|
817
|
+
// Extract and generate utility CSS from rendered HTML
|
|
818
|
+
const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
|
|
819
|
+
const breakpointConfig = await loadBreakpointConfig();
|
|
820
|
+
const responsiveScalesConfig = await loadResponsiveScalesConfig();
|
|
821
|
+
const utilityCSS = generateUtilityCSS(usedUtilityClasses, breakpointConfig, responsiveScalesConfig);
|
|
822
|
+
|
|
823
|
+
// Print warnings for any unmapped styles found during build
|
|
824
|
+
printMissingStyleWarnings(false);
|
|
825
|
+
|
|
826
|
+
// Generate favicon and apple touch icon link tags
|
|
827
|
+
const faviconTag = iconsConfig.favicon
|
|
828
|
+
? `<link rel="icon" href="${escapeHtml(iconsConfig.favicon)}" />`
|
|
829
|
+
: '';
|
|
830
|
+
const appleTouchIconTag = iconsConfig.appleTouchIcon
|
|
831
|
+
? `<link rel="apple-touch-icon" href="${escapeHtml(iconsConfig.appleTouchIcon)}" />`
|
|
832
|
+
: '';
|
|
833
|
+
const iconTags = [faviconTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
834
|
+
|
|
835
|
+
return `<!DOCTYPE html>
|
|
836
|
+
<html lang="${rendered.locale}" theme="${themeConfig.default}">
|
|
837
|
+
<head>
|
|
838
|
+
<meta charset="UTF-8">
|
|
839
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
840
|
+
${iconTags ? iconTags + '\n ' : ''}${rendered.meta}
|
|
841
|
+
<style>
|
|
842
|
+
${fontCSS ? fontCSS + '\n ' : ''}${themeColorVariablesCSS ? themeColorVariablesCSS + '\n ' : ''}* {
|
|
843
|
+
margin: 0;
|
|
844
|
+
padding: 0;
|
|
845
|
+
box-sizing: border-box;
|
|
846
|
+
}
|
|
847
|
+
body {
|
|
848
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
|
849
|
+
}
|
|
850
|
+
button {
|
|
851
|
+
background: none;
|
|
852
|
+
border: none;
|
|
853
|
+
padding: 0;
|
|
854
|
+
font: inherit;
|
|
855
|
+
cursor: pointer;
|
|
856
|
+
outline: inherit;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
img {
|
|
860
|
+
width: 100%;
|
|
861
|
+
height: 100%;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.olink {
|
|
865
|
+
text-decoration: none;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
${allCSS}
|
|
869
|
+
|
|
870
|
+
${utilityCSS}
|
|
871
|
+
</style>
|
|
872
|
+
</head>
|
|
873
|
+
<body>
|
|
874
|
+
<div id="root">${rendered.html}</div>
|
|
875
|
+
${clientScript}${componentScript}${formScript}
|
|
876
|
+
</body>
|
|
877
|
+
</html>`;
|
|
878
|
+
}
|