@uniweb/runtime 0.6.13 → 0.6.15

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 (36) hide show
  1. package/dist/app/_importmap/@uniweb-core.js +2 -0
  2. package/dist/app/_importmap/@uniweb-core.js.map +1 -0
  3. package/dist/app/_importmap/react-dom.js +2 -0
  4. package/dist/app/_importmap/react-dom.js.map +1 -0
  5. package/dist/app/_importmap/react-jsx-dev-runtime.js +2 -0
  6. package/dist/app/_importmap/react-jsx-dev-runtime.js.map +1 -0
  7. package/dist/app/_importmap/react-jsx-runtime.js +2 -0
  8. package/dist/app/_importmap/react-jsx-runtime.js.map +1 -0
  9. package/dist/app/_importmap/react.js +2 -0
  10. package/dist/app/_importmap/react.js.map +1 -0
  11. package/dist/app/assets/_commonjsHelpers-CqkleIqs.js +2 -0
  12. package/dist/app/assets/_commonjsHelpers-CqkleIqs.js.map +1 -0
  13. package/dist/app/assets/_importmap_react-dWoQamCw.js +2 -0
  14. package/dist/app/assets/_importmap_react-dWoQamCw.js.map +1 -0
  15. package/dist/app/assets/index-C0udIITE.js +9 -0
  16. package/dist/app/assets/index-C0udIITE.js.map +1 -0
  17. package/dist/app/assets/index-C6TPxGbh.js +133 -0
  18. package/dist/app/assets/index-C6TPxGbh.js.map +1 -0
  19. package/dist/app/assets/index-CsyMBO9p.js +8 -0
  20. package/dist/app/assets/index-CsyMBO9p.js.map +1 -0
  21. package/dist/app/assets/index-kA4PVysc.js +2 -0
  22. package/dist/app/assets/index-kA4PVysc.js.map +1 -0
  23. package/dist/app/assets/jsx-runtime-C3x2e0aW.js +2 -0
  24. package/dist/app/assets/jsx-runtime-C3x2e0aW.js.map +1 -0
  25. package/dist/app/index.html +31 -0
  26. package/dist/app/manifest.json +19 -0
  27. package/dist/ssr.js +319 -327
  28. package/dist/ssr.js.map +1 -1
  29. package/package.json +7 -4
  30. package/src/components/PageRenderer.jsx +12 -9
  31. package/src/foundation-loader.js +3 -0
  32. package/src/index.jsx +3 -4
  33. package/src/shell/index.html +12 -0
  34. package/src/shell/main.js +16 -0
  35. package/src/ssr-renderer.js +591 -0
  36. package/src/ssr.js +25 -28
@@ -0,0 +1,591 @@
1
+ /**
2
+ * SSR Renderer
3
+ *
4
+ * Hook-free rendering pipeline for SSG (build) and cloud SSR (unicloud).
5
+ * Mirrors BlockRenderer.jsx + Background.jsx using React.createElement
6
+ * directly — no hooks, no JSX, no browser APIs.
7
+ *
8
+ * This is the single source of truth for how blocks render during prerender.
9
+ * When modifying BlockRenderer.jsx or Background.jsx, update this file to match.
10
+ *
11
+ * Exports three layers:
12
+ * 1. Rendering functions (renderBlock, renderBlocks, renderLayout, renderBackground)
13
+ * 2. Initialization (initPrerender, prefetchIcons)
14
+ * 3. Per-page rendering (renderPage, classifyRenderError, injectPageContent, escapeHtml)
15
+ */
16
+
17
+ import React from 'react'
18
+ import { renderToString } from 'react-dom/server'
19
+ import { createUniweb } from '@uniweb/core'
20
+ import { buildSectionOverrides } from '@uniweb/theming'
21
+ import { prepareProps, getComponentMeta } from './prepare-props.js'
22
+
23
+ // ============================================================================
24
+ // Layer 1: Rendering functions
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Valid color contexts for section theming
29
+ */
30
+ const VALID_CONTEXTS = ['light', 'medium', 'dark']
31
+
32
+ /**
33
+ * Build wrapper props from block configuration.
34
+ * Mirrors getWrapperProps in BlockRenderer.jsx.
35
+ */
36
+ export function getWrapperProps(block) {
37
+ const theme = block.themeName
38
+ const blockClassName = block.state?.className || ''
39
+
40
+ // Empty themeName = Auto → no context class → inherits tokens from :root
41
+ // Non-empty = Pinned → context class sets tokens directly on the element
42
+ let contextClass = ''
43
+ if (theme && VALID_CONTEXTS.includes(theme)) {
44
+ contextClass = `context-${theme}`
45
+ }
46
+
47
+ let className = contextClass
48
+ if (blockClassName) {
49
+ className = className ? `${className} ${blockClassName}` : blockClassName
50
+ }
51
+
52
+ const { background = {} } = block.standardOptions
53
+ const style = {}
54
+
55
+ // If background has content, ensure relative positioning and a stacking context
56
+ // so the background's z-index stays contained within this section.
57
+ if (background.mode) {
58
+ style.position = 'relative'
59
+ style.isolation = 'isolate'
60
+ }
61
+
62
+ // Apply context overrides as inline CSS custom properties
63
+ if (block.contextOverrides) {
64
+ for (const [key, value] of Object.entries(block.contextOverrides)) {
65
+ style[`--${key}`] = value
66
+ }
67
+ }
68
+
69
+ // Use stableId for DOM ID if available (stable across reordering)
70
+ const sectionId = block.stableId || block.id
71
+
72
+ return { id: `section-${sectionId}`, style, className, background }
73
+ }
74
+
75
+ /**
76
+ * Convert hex/rgb color to rgba with opacity.
77
+ * Mirrors withOpacity() in Background.jsx.
78
+ */
79
+ function withOpacity(color, opacity) {
80
+ if (color.startsWith('#')) {
81
+ const r = parseInt(color.slice(1, 3), 16)
82
+ const g = parseInt(color.slice(3, 5), 16)
83
+ const b = parseInt(color.slice(5, 7), 16)
84
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
85
+ }
86
+ if (color.startsWith('rgb')) {
87
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
88
+ if (match) {
89
+ return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
90
+ }
91
+ }
92
+ return color
93
+ }
94
+
95
+ /**
96
+ * Resolve a URL against the site's base path.
97
+ * Mirrors resolveUrl() in Background.jsx.
98
+ */
99
+ function resolveUrl(url) {
100
+ if (!url || !url.startsWith('/')) return url
101
+ const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
102
+ if (!basePath) return url
103
+ if (url.startsWith(basePath + '/') || url === basePath) return url
104
+ return basePath + url
105
+ }
106
+
107
+ /**
108
+ * Render a background element for SSR.
109
+ * Mirrors Background.jsx (color, gradient, image — not video).
110
+ * Video backgrounds require JS for autoplay and are skipped during SSR.
111
+ */
112
+ export function renderBackground(background) {
113
+ if (!background?.mode) return null
114
+
115
+ const containerStyle = {
116
+ position: 'absolute',
117
+ inset: '0',
118
+ overflow: 'hidden',
119
+ zIndex: 0,
120
+ }
121
+
122
+ const children = []
123
+
124
+ // Color background
125
+ if (background.mode === 'color' && background.color) {
126
+ children.push(
127
+ React.createElement('div', {
128
+ key: 'bg-color',
129
+ className: 'background-color',
130
+ style: { position: 'absolute', inset: '0', backgroundColor: background.color },
131
+ 'aria-hidden': 'true',
132
+ })
133
+ )
134
+ }
135
+
136
+ // Gradient background (supports string or object with opacity)
137
+ if (background.mode === 'gradient' && background.gradient) {
138
+ const g = background.gradient
139
+
140
+ let bgValue
141
+ if (typeof g === 'string') {
142
+ bgValue = g
143
+ } else {
144
+ const {
145
+ start = 'transparent',
146
+ end = 'transparent',
147
+ angle = 0,
148
+ startPosition = 0,
149
+ endPosition = 100,
150
+ startOpacity = 1,
151
+ endOpacity = 1,
152
+ } = g
153
+ const startColor = startOpacity < 1 ? withOpacity(start, startOpacity) : start
154
+ const endColor = endOpacity < 1 ? withOpacity(end, endOpacity) : end
155
+ bgValue = `linear-gradient(${angle}deg, ${startColor} ${startPosition}%, ${endColor} ${endPosition}%)`
156
+ }
157
+
158
+ children.push(
159
+ React.createElement('div', {
160
+ key: 'bg-gradient',
161
+ className: 'background-gradient',
162
+ style: { position: 'absolute', inset: '0', background: bgValue },
163
+ 'aria-hidden': 'true',
164
+ })
165
+ )
166
+ }
167
+
168
+ // Image background
169
+ if (background.mode === 'image' && background.image?.src) {
170
+ const img = background.image
171
+ children.push(
172
+ React.createElement('div', {
173
+ key: 'bg-image',
174
+ className: 'background-image',
175
+ style: {
176
+ position: 'absolute',
177
+ inset: '0',
178
+ backgroundImage: `url(${resolveUrl(img.src)})`,
179
+ backgroundPosition: img.position || 'center',
180
+ backgroundSize: img.size || 'cover',
181
+ backgroundRepeat: 'no-repeat',
182
+ },
183
+ 'aria-hidden': 'true',
184
+ })
185
+ )
186
+ }
187
+
188
+ // Overlay (gradient or solid)
189
+ if (background.overlay?.enabled) {
190
+ const ov = background.overlay
191
+ let overlayStyle
192
+
193
+ if (ov.gradient) {
194
+ const g = ov.gradient
195
+ overlayStyle = {
196
+ position: 'absolute', inset: '0', pointerEvents: 'none',
197
+ background: `linear-gradient(${g.angle || 180}deg, ${g.start || 'rgba(0,0,0,0.7)'} ${g.startPosition || 0}%, ${g.end || 'rgba(0,0,0,0)'} ${g.endPosition || 100}%)`,
198
+ opacity: ov.opacity ?? 0.5,
199
+ }
200
+ } else {
201
+ const baseColor = ov.type === 'light' ? '255, 255, 255' : '0, 0, 0'
202
+ overlayStyle = {
203
+ position: 'absolute', inset: '0', pointerEvents: 'none',
204
+ backgroundColor: `rgba(${baseColor}, ${ov.opacity ?? 0.5})`,
205
+ }
206
+ }
207
+
208
+ children.push(
209
+ React.createElement('div', {
210
+ key: 'bg-overlay',
211
+ className: ov.gradient ? 'background-overlay background-overlay--gradient' : 'background-overlay background-overlay--solid',
212
+ style: overlayStyle,
213
+ 'aria-hidden': 'true',
214
+ })
215
+ )
216
+ }
217
+
218
+ if (children.length === 0) return null
219
+
220
+ return React.createElement('div', {
221
+ className: `background background--${background.mode}`,
222
+ style: containerStyle,
223
+ 'aria-hidden': 'true',
224
+ }, ...children)
225
+ }
226
+
227
+ /**
228
+ * Render a single block for SSR.
229
+ * Mirrors BlockRenderer.jsx but without hooks (no runtime data fetching).
230
+ *
231
+ * @param {Block} block - Block instance to render
232
+ * @param {Object} [options]
233
+ * @param {boolean} [options.pure=false] - Render component without section wrapper (used by ChildBlocks)
234
+ * @returns {React.ReactElement}
235
+ */
236
+ export function renderBlock(block, { pure = false, as = undefined } = {}) {
237
+ const Component = block.initComponent()
238
+
239
+ if (!Component) {
240
+ return React.createElement('div', {
241
+ className: 'block-error',
242
+ style: { padding: '1rem', background: '#fef2f2', color: '#dc2626' },
243
+ }, `Component not found: ${block.type}`)
244
+ }
245
+
246
+ // Build content and params with runtime guarantees
247
+ const meta = getComponentMeta(block.type)
248
+ const prepared = prepareProps(block, meta)
249
+ const params = prepared.params
250
+ const content = { ...prepared.content, ...block.properties }
251
+
252
+ // Resolve inherited entity data (mirrors BlockRenderer.jsx)
253
+ // EntityStore walks page/site hierarchy to find data matching meta.inheritData
254
+ const entityStore = block.website?.entityStore
255
+ if (entityStore) {
256
+ const resolved = entityStore.resolve(block, meta)
257
+ if (resolved.status === 'ready' && resolved.data) {
258
+ const merged = { ...content.data }
259
+ for (const key of Object.keys(resolved.data)) {
260
+ if (merged[key] === undefined) {
261
+ merged[key] = resolved.data[key]
262
+ }
263
+ }
264
+ content.data = merged
265
+ }
266
+ }
267
+
268
+ const componentProps = { content, params, block }
269
+
270
+ // Pure mode: render component without section wrapper (used by ChildBlocks)
271
+ if (pure) {
272
+ return React.createElement(Component, componentProps)
273
+ }
274
+
275
+ // Background handling (mirrors BlockRenderer.jsx)
276
+ const { background, ...wrapperProps } = getWrapperProps(block)
277
+
278
+ // Merge Component.className (static classes declared on the component function)
279
+ const componentClassName = Component.className
280
+ if (componentClassName) {
281
+ wrapperProps.className = wrapperProps.className
282
+ ? `${wrapperProps.className} ${componentClassName}`
283
+ : componentClassName
284
+ }
285
+
286
+ // Check if component handles its own background
287
+ const hasBackground = background?.mode && meta?.background !== 'self'
288
+ block.hasBackground = hasBackground
289
+
290
+ // Use Component.as as the wrapper tag (default: 'section').
291
+ // An explicit `as` prop (e.g. 'div' from ChildBlocks) overrides Component.as,
292
+ // mirroring the BlockRenderer.jsx Wrapper resolution logic.
293
+ const wrapperTag = (as !== undefined && as !== 'section') ? as : (Component.as || 'section')
294
+
295
+ if (hasBackground) {
296
+ return React.createElement(wrapperTag, wrapperProps,
297
+ renderBackground(background),
298
+ React.createElement('div', { style: { position: 'relative', zIndex: 10 } },
299
+ React.createElement(Component, componentProps)
300
+ )
301
+ )
302
+ }
303
+
304
+ return React.createElement(wrapperTag, wrapperProps,
305
+ React.createElement(Component, componentProps)
306
+ )
307
+ }
308
+
309
+ /**
310
+ * Render an array of blocks for SSR.
311
+ */
312
+ export function renderBlocks(blocks) {
313
+ if (!blocks || blocks.length === 0) return null
314
+ return blocks.map((block, index) =>
315
+ React.createElement(React.Fragment, { key: block.id || index },
316
+ renderBlock(block)
317
+ )
318
+ )
319
+ }
320
+
321
+ /**
322
+ * Render page layout for SSR.
323
+ * Mirrors Layout.jsx but without hooks.
324
+ */
325
+ export function renderLayout(page, website) {
326
+ const layoutName = page.getLayoutName()
327
+ const RemoteLayout = website.getRemoteLayout(layoutName)
328
+ const layoutMeta = website.getLayoutMeta(layoutName)
329
+
330
+ const bodyBlocks = page.getBodyBlocks()
331
+ const areas = page.getLayoutAreas()
332
+
333
+ const bodyElement = bodyBlocks ? renderBlocks(bodyBlocks) : null
334
+ const areaElements = {}
335
+ for (const [name, blocks] of Object.entries(areas)) {
336
+ areaElements[name] = renderBlocks(blocks)
337
+ }
338
+
339
+ if (RemoteLayout) {
340
+ const params = { ...(layoutMeta?.defaults || {}), ...(page.getLayoutParams() || {}) }
341
+ return React.createElement(RemoteLayout, {
342
+ page, website, params,
343
+ body: bodyElement,
344
+ ...areaElements,
345
+ })
346
+ }
347
+
348
+ // Default layout
349
+ return React.createElement(React.Fragment, null,
350
+ areaElements.header && React.createElement('header', null, areaElements.header),
351
+ bodyElement && React.createElement('main', null, bodyElement),
352
+ areaElements.footer && React.createElement('footer', null, areaElements.footer)
353
+ )
354
+ }
355
+
356
+ // ============================================================================
357
+ // Layer 2: Initialization
358
+ // ============================================================================
359
+
360
+ /**
361
+ * Create and configure the Uniweb runtime for prerendering.
362
+ *
363
+ * Handles the full initialization sequence in the correct order:
364
+ * createUniweb → setFoundation → capabilities → layoutMeta → basePath → childBlockRenderer.
365
+ *
366
+ * Returns the configured uniweb instance. Consumers can add extras after:
367
+ * - Build: pre-populate DataStore, load extensions
368
+ * - Unicloud: (none needed — payload is complete)
369
+ *
370
+ * NOTE: Does NOT clone content. Cloning is the consumer's responsibility
371
+ * (build modifies content before init; unicloud clones upfront).
372
+ *
373
+ * @param {Object} content - Site content JSON (pages, config, hierarchy)
374
+ * @param {Object} foundation - Loaded foundation module
375
+ * @param {Object} [options]
376
+ * @param {function} [options.onProgress] - Progress callback
377
+ * @returns {Object} Configured uniweb instance
378
+ */
379
+ export function initPrerender(content, foundation, options = {}) {
380
+ const { onProgress = () => {} } = options
381
+
382
+ onProgress('Initializing runtime...')
383
+ const uniweb = createUniweb(content)
384
+ uniweb.setFoundation(foundation)
385
+
386
+ // Set foundation capabilities (Layout, props, etc.)
387
+ if (foundation.default?.capabilities) {
388
+ uniweb.setFoundationConfig(foundation.default.capabilities)
389
+ }
390
+
391
+ // Attach layout metadata (areas, transitions, defaults)
392
+ if (foundation.default?.layoutMeta && uniweb.foundationConfig) {
393
+ uniweb.foundationConfig.layoutMeta = foundation.default.layoutMeta
394
+ }
395
+
396
+ // Set base path from site config for subdirectory deployments
397
+ if (content.config?.base && uniweb.activeWebsite?.setBasePath) {
398
+ uniweb.activeWebsite.setBasePath(content.config.base)
399
+ }
400
+
401
+ // Set childBlockRenderer so ChildBlocks/Visual/Render work during prerender.
402
+ // Mirrors the client's ChildBlocks component in PageRenderer.jsx:
403
+ // - default as='div' so nested blocks use <div> wrapper (not <section>)
404
+ // matching the client and avoiding React hydration mismatch (error #418)
405
+ uniweb.childBlockRenderer = function InlineChildBlocks({ blocks, from, pure = false, as = 'div' }) {
406
+ const blockList = blocks || from?.childBlocks || []
407
+ return blockList.map((childBlock, index) =>
408
+ React.createElement(React.Fragment, { key: childBlock.id || index },
409
+ renderBlock(childBlock, { pure, as })
410
+ )
411
+ )
412
+ }
413
+
414
+ return uniweb
415
+ }
416
+
417
+ /**
418
+ * Pre-fetch icons from CDN and populate the Uniweb icon cache.
419
+ * Stores the cache on siteContent._iconCache for embedding in HTML.
420
+ *
421
+ * @param {Object} siteContent - Site content JSON (mutated: _iconCache added)
422
+ * @param {Object} uniweb - Configured uniweb instance
423
+ * @param {function} [onProgress] - Progress callback
424
+ */
425
+ export async function prefetchIcons(siteContent, uniweb, onProgress = () => {}) {
426
+ const icons = siteContent.icons?.used || []
427
+ if (icons.length === 0) return
428
+
429
+ const cdnBase = siteContent.config?.icons?.cdnUrl || 'https://uniweb.github.io/icons'
430
+
431
+ onProgress(`Fetching ${icons.length} icons for SSR...`)
432
+
433
+ const results = await Promise.allSettled(
434
+ icons.map(async (iconRef) => {
435
+ const [family, name] = iconRef.split(':')
436
+ const url = `${cdnBase}/${family}/${family}-${name}.svg`
437
+ const response = await fetch(url)
438
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
439
+ const svg = await response.text()
440
+ uniweb.iconCache.set(`${family}:${name}`, svg)
441
+ })
442
+ )
443
+
444
+ const succeeded = results.filter(r => r.status === 'fulfilled').length
445
+ const failed = results.filter(r => r.status === 'rejected').length
446
+ if (failed > 0) {
447
+ const msg = `Fetched ${succeeded}/${icons.length} icons (${failed} failed)`
448
+ console.warn(`[prerender] ${msg}`)
449
+ onProgress(` ${msg}`)
450
+ }
451
+
452
+ // Store icon cache on siteContent for embedding in HTML
453
+ if (uniweb.iconCache.size > 0) {
454
+ siteContent._iconCache = Object.fromEntries(uniweb.iconCache)
455
+ }
456
+ }
457
+
458
+ // ============================================================================
459
+ // Layer 3: Per-page rendering
460
+ // ============================================================================
461
+
462
+ /**
463
+ * Classify an SSR rendering error.
464
+ *
465
+ * @param {Error} err
466
+ * @returns {{ type: 'hooks'|'null-component'|'unknown', message: string }}
467
+ */
468
+ export function classifyRenderError(err) {
469
+ const msg = err.message || ''
470
+
471
+ if (msg.includes('Invalid hook call') || msg.includes('useState') || msg.includes('useEffect')) {
472
+ return {
473
+ type: 'hooks',
474
+ message: 'contains components with React hooks (renders client-side)',
475
+ }
476
+ }
477
+
478
+ if (msg.includes('Element type is invalid') && msg.includes('null')) {
479
+ return {
480
+ type: 'null-component',
481
+ message: 'a component resolved to null (often hook-related, renders client-side)',
482
+ }
483
+ }
484
+
485
+ return {
486
+ type: 'unknown',
487
+ message: msg,
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Render a single page to HTML.
493
+ *
494
+ * Handles the full per-page pipeline:
495
+ * setActivePage → renderLayout → renderToString → error handling → section override CSS.
496
+ *
497
+ * @param {Page} page - Page instance to render
498
+ * @param {Website} website - Website instance
499
+ * @returns {{ renderedContent: string, sectionOverrideCSS: string } | { error: { type: string, message: string } }}
500
+ */
501
+ export function renderPage(page, website) {
502
+ website.setActivePage(page.route)
503
+
504
+ const element = renderLayout(page, website)
505
+
506
+ let renderedContent
507
+ try {
508
+ renderedContent = renderToString(element)
509
+ } catch (err) {
510
+ return { error: classifyRenderError(err) }
511
+ }
512
+
513
+ // Build per-page section override CSS (theme pinning, component vars)
514
+ const appearance = website.themeData?.appearance
515
+ const sectionOverrideCSS = buildSectionOverrides(page.getPageBlocks(), appearance)
516
+
517
+ return { renderedContent, sectionOverrideCSS }
518
+ }
519
+
520
+ // ============================================================================
521
+ // HTML injection
522
+ // ============================================================================
523
+
524
+ /**
525
+ * Escape HTML special characters.
526
+ */
527
+ export function escapeHtml(str) {
528
+ if (!str) return ''
529
+ return String(str)
530
+ .replace(/&/g, '&amp;')
531
+ .replace(/</g, '&lt;')
532
+ .replace(/>/g, '&gt;')
533
+ .replace(/"/g, '&quot;')
534
+ .replace(/'/g, '&#39;')
535
+ }
536
+
537
+ /**
538
+ * Inject prerendered content into an HTML shell.
539
+ *
540
+ * Common operations shared by both build and cloud:
541
+ * - Replace #root div with rendered HTML
542
+ * - Update page title
543
+ * - Add/update meta description
544
+ * - Inject section override CSS
545
+ *
546
+ * Build layers its additional injections on top of this return value:
547
+ * __SITE_CONTENT__ JSON, icon cache, theme CSS (build-specific).
548
+ *
549
+ * @param {string} html - HTML shell
550
+ * @param {string} renderedContent - React renderToString output
551
+ * @param {Object} page - Page data { title, description, route }
552
+ * @param {Object} [options]
553
+ * @param {string} [options.sectionOverrideCSS] - Per-page section override CSS
554
+ * @returns {string} HTML with injected content
555
+ */
556
+ export function injectPageContent(html, renderedContent, page, options = {}) {
557
+ let result = html
558
+
559
+ // Inject per-page section override CSS before </head>
560
+ if (options.sectionOverrideCSS) {
561
+ const overrideStyle = `<style id="uniweb-page-overrides">\n${options.sectionOverrideCSS}\n</style>`
562
+ result = result.replace('</head>', `${overrideStyle}\n</head>`)
563
+ }
564
+
565
+ // Replace the empty root div with pre-rendered content
566
+ result = result.replace(
567
+ /<div id="root">[\s\S]*?<\/div>/,
568
+ `<div id="root">${renderedContent}</div>`
569
+ )
570
+
571
+ // Update page title (use getTitle() so isIndex pages inherit parent title)
572
+ const pageTitle = page.getTitle?.() || page.title
573
+ if (pageTitle) {
574
+ result = result.replace(
575
+ /<title>.*?<\/title>/,
576
+ `<title>${escapeHtml(pageTitle)}</title>`
577
+ )
578
+ }
579
+
580
+ // Add/update meta description
581
+ if (page.description) {
582
+ const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
583
+ if (result.includes('<meta name="description"')) {
584
+ result = result.replace(/<meta name="description"[^>]*>/, metaDesc)
585
+ } else {
586
+ result = result.replace('</head>', `${metaDesc}\n</head>`)
587
+ }
588
+ }
589
+
590
+ return result
591
+ }
package/src/ssr.js CHANGED
@@ -5,12 +5,14 @@
5
5
  * This module is built to a standalone bundle that can be imported
6
6
  * directly by Node.js without Vite transpilation.
7
7
  *
8
- * Usage in prerender.js:
9
- * import { renderPage, Blocks, BlockRenderer } from '@uniweb/runtime/ssr'
8
+ * Provides three layers:
9
+ * 1. Rendering functions (renderBlock, renderBlocks, renderLayout, renderBackground)
10
+ * 2. Initialization (initPrerender, prefetchIcons)
11
+ * 3. Per-page rendering (renderPage, classifyRenderError, injectPageContent, escapeHtml)
12
+ *
13
+ * Plus the existing prepare-props utilities (prepareProps, getComponentMeta, etc.)
10
14
  */
11
15
 
12
- import React from 'react'
13
-
14
16
  // Props preparation (no browser APIs)
15
17
  export {
16
18
  prepareProps,
@@ -21,29 +23,24 @@ export {
21
23
  getComponentDefaults
22
24
  } from './prepare-props.js'
23
25
 
24
- // Components for rendering
25
- export { default as BlockRenderer } from './components/BlockRenderer.jsx'
26
- export { default as Blocks } from './components/Blocks.jsx'
27
- export { default as Layout } from './components/Layout.jsx'
26
+ // SSR rendering pipeline (no hooks, no JSX)
27
+ export {
28
+ // Layer 1: Rendering
29
+ getWrapperProps,
30
+ renderBackground,
31
+ renderBlock,
32
+ renderBlocks,
33
+ renderLayout,
28
34
 
29
- // Re-export Layout's DefaultLayout for direct use
30
- import LayoutComponent from './components/Layout.jsx'
35
+ // Layer 2: Initialization
36
+ initPrerender,
37
+ prefetchIcons,
31
38
 
32
- /**
33
- * Render a page to React elements
34
- *
35
- * This is the main entry point for SSG. It returns a React element
36
- * that can be passed to renderToString().
37
- *
38
- * @param {Object} props
39
- * @param {Page} props.page - The page instance to render
40
- * @param {Website} props.website - The website instance
41
- * @returns {React.ReactElement}
42
- */
43
- export function PageElement({ page, website }) {
44
- return React.createElement(
45
- 'main',
46
- null,
47
- React.createElement(LayoutComponent, { page, website })
48
- )
49
- }
39
+ // Layer 3: Per-page rendering
40
+ renderPage,
41
+ classifyRenderError,
42
+
43
+ // HTML injection
44
+ injectPageContent,
45
+ escapeHtml,
46
+ } from './ssr-renderer.js'