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.
Files changed (231) hide show
  1. package/bin/cli.ts +281 -0
  2. package/build-static.ts +298 -0
  3. package/bunfig.toml +39 -0
  4. package/entries/client-router.tsx +111 -0
  5. package/entries/server-router.tsx +71 -0
  6. package/lib/client/ClientInitializer.test.ts +9 -0
  7. package/lib/client/ClientInitializer.test.ts.skip +92 -0
  8. package/lib/client/ClientInitializer.ts +60 -0
  9. package/lib/client/ErrorBoundary.test.tsx +595 -0
  10. package/lib/client/ErrorBoundary.tsx +230 -0
  11. package/lib/client/componentRegistry.test.ts +165 -0
  12. package/lib/client/componentRegistry.ts +18 -0
  13. package/lib/client/contexts/ThemeContext.tsx +73 -0
  14. package/lib/client/core/ComponentBuilder.test.ts +677 -0
  15. package/lib/client/core/ComponentBuilder.ts +660 -0
  16. package/lib/client/core/ComponentRenderer.test.tsx +176 -0
  17. package/lib/client/core/ComponentRenderer.tsx +83 -0
  18. package/lib/client/core/cmsTemplateProcessor.ts +129 -0
  19. package/lib/client/elementRegistry.ts +81 -0
  20. package/lib/client/hmr/HMRManager.tsx +179 -0
  21. package/lib/client/hmr/index.ts +5 -0
  22. package/lib/client/hmrWebSocket.test.ts +9 -0
  23. package/lib/client/hmrWebSocket.ts +250 -0
  24. package/lib/client/hooks/useColorVariables.test.ts +166 -0
  25. package/lib/client/hooks/useColorVariables.ts +249 -0
  26. package/lib/client/hooks/usePropertyAutocomplete.test.ts +9 -0
  27. package/lib/client/hooks/usePropertyAutocomplete.ts +40 -0
  28. package/lib/client/hydration/HydrationUtils.test.ts +154 -0
  29. package/lib/client/hydration/HydrationUtils.ts +35 -0
  30. package/lib/client/i18nConfigService.test.ts +74 -0
  31. package/lib/client/i18nConfigService.ts +78 -0
  32. package/lib/client/index.ts +56 -0
  33. package/lib/client/navigation.test.ts +441 -0
  34. package/lib/client/navigation.ts +23 -0
  35. package/lib/client/responsiveStyleResolver.test.ts +491 -0
  36. package/lib/client/responsiveStyleResolver.ts +184 -0
  37. package/lib/client/routing/RouteLoader.test.ts +635 -0
  38. package/lib/client/routing/RouteLoader.ts +347 -0
  39. package/lib/client/routing/Router.tsx +382 -0
  40. package/lib/client/scripts/ScriptExecutor.test.ts +489 -0
  41. package/lib/client/scripts/ScriptExecutor.ts +171 -0
  42. package/lib/client/scripts/formHandler.ts +103 -0
  43. package/lib/client/styleProcessor.test.ts +126 -0
  44. package/lib/client/styleProcessor.ts +92 -0
  45. package/lib/client/styles/StyleInjector.test.ts +354 -0
  46. package/lib/client/styles/StyleInjector.ts +154 -0
  47. package/lib/client/templateEngine.test.ts +660 -0
  48. package/lib/client/templateEngine.ts +667 -0
  49. package/lib/client/theme.test.ts +173 -0
  50. package/lib/client/theme.ts +159 -0
  51. package/lib/client/utils/toast.ts +46 -0
  52. package/lib/server/createServer.ts +170 -0
  53. package/lib/server/cssGenerator.test.ts +172 -0
  54. package/lib/server/cssGenerator.ts +58 -0
  55. package/lib/server/fileWatcher.ts +134 -0
  56. package/lib/server/index.ts +55 -0
  57. package/lib/server/jsonLoader.test.ts +103 -0
  58. package/lib/server/jsonLoader.ts +350 -0
  59. package/lib/server/middleware/cors.test.ts +177 -0
  60. package/lib/server/middleware/cors.ts +69 -0
  61. package/lib/server/middleware/errorHandler.test.ts +208 -0
  62. package/lib/server/middleware/errorHandler.ts +63 -0
  63. package/lib/server/middleware/index.ts +9 -0
  64. package/lib/server/middleware/logger.test.ts +233 -0
  65. package/lib/server/middleware/logger.ts +99 -0
  66. package/lib/server/pageCache.test.ts +167 -0
  67. package/lib/server/pageCache.ts +97 -0
  68. package/lib/server/projectContext.ts +51 -0
  69. package/lib/server/providers/fileSystemCMSProvider.test.ts +292 -0
  70. package/lib/server/providers/fileSystemCMSProvider.ts +227 -0
  71. package/lib/server/providers/fileSystemPageProvider.ts +83 -0
  72. package/lib/server/routes/api/cms.test.ts +177 -0
  73. package/lib/server/routes/api/cms.ts +82 -0
  74. package/lib/server/routes/api/colors.ts +59 -0
  75. package/lib/server/routes/api/components.ts +70 -0
  76. package/lib/server/routes/api/config.test.ts +9 -0
  77. package/lib/server/routes/api/config.ts +28 -0
  78. package/lib/server/routes/api/core-routes.ts +182 -0
  79. package/lib/server/routes/api/functions.ts +170 -0
  80. package/lib/server/routes/api/index.ts +69 -0
  81. package/lib/server/routes/api/pages.ts +95 -0
  82. package/lib/server/routes/api/shared.test.ts +81 -0
  83. package/lib/server/routes/api/shared.ts +31 -0
  84. package/lib/server/routes/editor.test.ts +9 -0
  85. package/lib/server/routes/index.ts +104 -0
  86. package/lib/server/routes/pages.ts +161 -0
  87. package/lib/server/routes/static.ts +107 -0
  88. package/lib/server/services/ColorService.ts +193 -0
  89. package/lib/server/services/cmsService.test.ts +388 -0
  90. package/lib/server/services/cmsService.ts +296 -0
  91. package/lib/server/services/componentService.test.ts +276 -0
  92. package/lib/server/services/componentService.ts +346 -0
  93. package/lib/server/services/configService.ts +156 -0
  94. package/lib/server/services/fileWatcherService.ts +67 -0
  95. package/lib/server/services/index.ts +10 -0
  96. package/lib/server/services/pageService.test.ts +258 -0
  97. package/lib/server/services/pageService.ts +240 -0
  98. package/lib/server/ssrRenderer.test.ts +1005 -0
  99. package/lib/server/ssrRenderer.ts +878 -0
  100. package/lib/server/utilityClassGenerator.ts +11 -0
  101. package/lib/server/utils/index.ts +5 -0
  102. package/lib/server/utils/jsonLineMapper.test.ts +100 -0
  103. package/lib/server/utils/jsonLineMapper.ts +166 -0
  104. package/lib/server/validateStyleCoverage.test.ts +9 -0
  105. package/lib/server/validateStyleCoverage.ts +167 -0
  106. package/lib/server/websocketManager.test.ts +9 -0
  107. package/lib/server/websocketManager.ts +95 -0
  108. package/lib/shared/attributeNodeUtils.test.ts +152 -0
  109. package/lib/shared/attributeNodeUtils.ts +50 -0
  110. package/lib/shared/breakpoints.test.ts +166 -0
  111. package/lib/shared/breakpoints.ts +65 -0
  112. package/lib/shared/colorProperties.test.ts +111 -0
  113. package/lib/shared/colorProperties.ts +40 -0
  114. package/lib/shared/colorVariableUtils.test.ts +319 -0
  115. package/lib/shared/colorVariableUtils.ts +97 -0
  116. package/lib/shared/constants.test.ts +175 -0
  117. package/lib/shared/constants.ts +116 -0
  118. package/lib/shared/cssGeneration.ts +481 -0
  119. package/lib/shared/cssProperties.test.ts +252 -0
  120. package/lib/shared/cssProperties.ts +338 -0
  121. package/lib/shared/elementUtils.test.ts +245 -0
  122. package/lib/shared/elementUtils.ts +90 -0
  123. package/lib/shared/fontLoader.ts +97 -0
  124. package/lib/shared/i18n.test.ts +313 -0
  125. package/lib/shared/i18n.ts +286 -0
  126. package/lib/shared/index.ts +50 -0
  127. package/lib/shared/interfaces/contentProvider.test.ts +9 -0
  128. package/lib/shared/interfaces/contentProvider.ts +121 -0
  129. package/lib/shared/nodeUtils.test.ts +320 -0
  130. package/lib/shared/nodeUtils.ts +220 -0
  131. package/lib/shared/pathArrayUtils.test.ts +315 -0
  132. package/lib/shared/pathArrayUtils.ts +17 -0
  133. package/lib/shared/pathUtils.test.ts +260 -0
  134. package/lib/shared/pathUtils.ts +244 -0
  135. package/lib/shared/paths/Path.test.ts +74 -0
  136. package/lib/shared/paths/Path.ts +23 -0
  137. package/lib/shared/paths/PathConverter.test.ts +232 -0
  138. package/lib/shared/paths/PathConverter.ts +141 -0
  139. package/lib/shared/paths/PathUtils.ts +290 -0
  140. package/lib/shared/paths/PathValidator.test.ts +193 -0
  141. package/lib/shared/paths/PathValidator.ts +53 -0
  142. package/lib/shared/paths/index.ts +48 -0
  143. package/lib/shared/propResolver.test.ts +639 -0
  144. package/lib/shared/propResolver.ts +124 -0
  145. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +190 -0
  146. package/lib/shared/registry/BaseNodeTypeRegistry.ts +200 -0
  147. package/lib/shared/registry/ClientNodeTypeRegistry.ts +34 -0
  148. package/lib/shared/registry/ClientRegistry.test.ts +26 -0
  149. package/lib/shared/registry/ClientRegistry.ts +15 -0
  150. package/lib/shared/registry/ComponentRegistry.test.ts +293 -0
  151. package/lib/shared/registry/ComponentRegistry.ts +100 -0
  152. package/lib/shared/registry/NodeTypeDefinition.ts +198 -0
  153. package/lib/shared/registry/NodeTypeManager.ts +94 -0
  154. package/lib/shared/registry/RegistryManager.test.ts +58 -0
  155. package/lib/shared/registry/RegistryManager.ts +60 -0
  156. package/lib/shared/registry/SSRNodeTypeRegistry.ts +33 -0
  157. package/lib/shared/registry/SSRRegistry.test.ts +26 -0
  158. package/lib/shared/registry/SSRRegistry.ts +15 -0
  159. package/lib/shared/registry/createNodeType.ts +175 -0
  160. package/lib/shared/registry/defineNodeType.ts +73 -0
  161. package/lib/shared/registry/fieldPresets.ts +109 -0
  162. package/lib/shared/registry/index.ts +50 -0
  163. package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +71 -0
  164. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +61 -0
  165. package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +88 -0
  166. package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +66 -0
  167. package/lib/shared/registry/nodeTypes/ObjectLinkNodeType.ts +75 -0
  168. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +49 -0
  169. package/lib/shared/registry/nodeTypes/TextNodeType.ts +52 -0
  170. package/lib/shared/registry/nodeTypes/index.ts +75 -0
  171. package/lib/shared/responsiveScaling.test.ts +268 -0
  172. package/lib/shared/responsiveScaling.ts +194 -0
  173. package/lib/shared/responsiveStyleUtils.test.ts +300 -0
  174. package/lib/shared/responsiveStyleUtils.ts +139 -0
  175. package/lib/shared/slugTranslator.test.ts +325 -0
  176. package/lib/shared/slugTranslator.ts +177 -0
  177. package/lib/shared/styleNodeUtils.test.ts +132 -0
  178. package/lib/shared/styleNodeUtils.ts +102 -0
  179. package/lib/shared/styleUtils.test.ts +238 -0
  180. package/lib/shared/styleUtils.ts +63 -0
  181. package/lib/shared/themeDefaults.test.ts +113 -0
  182. package/lib/shared/themeDefaults.ts +103 -0
  183. package/lib/shared/tree/PathBuilder.ts +383 -0
  184. package/lib/shared/treePathUtils.test.ts +539 -0
  185. package/lib/shared/treePathUtils.ts +339 -0
  186. package/lib/shared/types/api.ts +58 -0
  187. package/lib/shared/types/cms.ts +95 -0
  188. package/lib/shared/types/colors.ts +45 -0
  189. package/lib/shared/types/components.ts +121 -0
  190. package/lib/shared/types/errors.test.ts +103 -0
  191. package/lib/shared/types/errors.ts +69 -0
  192. package/lib/shared/types/index.ts +96 -0
  193. package/lib/shared/types/nodes.ts +20 -0
  194. package/lib/shared/types/rendering.ts +61 -0
  195. package/lib/shared/types/styles.ts +38 -0
  196. package/lib/shared/types.ts +11 -0
  197. package/lib/shared/utilityClassConfig.ts +287 -0
  198. package/lib/shared/utilityClassMapper.test.ts +140 -0
  199. package/lib/shared/utilityClassMapper.ts +229 -0
  200. package/lib/shared/utils/fileUtils.test.ts +99 -0
  201. package/lib/shared/utils/fileUtils.ts +56 -0
  202. package/lib/shared/utils.test.ts +261 -0
  203. package/lib/shared/utils.ts +84 -0
  204. package/lib/shared/validation/index.ts +7 -0
  205. package/lib/shared/validation/propValidator.test.ts +178 -0
  206. package/lib/shared/validation/propValidator.ts +238 -0
  207. package/lib/shared/validation/schemas.test.ts +177 -0
  208. package/lib/shared/validation/schemas.ts +401 -0
  209. package/lib/shared/validation/validators.test.ts +109 -0
  210. package/lib/shared/validation/validators.ts +304 -0
  211. package/lib/test-utils/dom-setup.ts +55 -0
  212. package/lib/test-utils/factories/ConsoleMockFactory.ts +200 -0
  213. package/lib/test-utils/factories/DomMockFactory.ts +487 -0
  214. package/lib/test-utils/factories/EventMockFactory.ts +244 -0
  215. package/lib/test-utils/factories/FetchMockFactory.ts +210 -0
  216. package/lib/test-utils/factories/ServerMockFactory.ts +223 -0
  217. package/lib/test-utils/factories/StoreMockFactory.ts +370 -0
  218. package/lib/test-utils/factories/index.ts +11 -0
  219. package/lib/test-utils/fixtures.ts +134 -0
  220. package/lib/test-utils/helpers/asyncHelpers.test.ts +112 -0
  221. package/lib/test-utils/helpers/asyncHelpers.ts +196 -0
  222. package/lib/test-utils/helpers/index.ts +6 -0
  223. package/lib/test-utils/helpers.test.ts +73 -0
  224. package/lib/test-utils/helpers.ts +90 -0
  225. package/lib/test-utils/index.ts +17 -0
  226. package/lib/test-utils/mockFactories.ts +92 -0
  227. package/lib/test-utils/mocks.ts +341 -0
  228. package/package.json +38 -0
  229. package/templates/index-router.html +34 -0
  230. package/tsconfig.json +14 -0
  231. 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, '&amp;')
62
+ .replace(/</g, '&lt;')
63
+ .replace(/>/g, '&gt;')
64
+ .replace(/"/g, '&quot;')
65
+ .replace(/'/g, '&#039;');
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
+ }