@uniweb/runtime 0.6.13 → 0.6.14

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.
@@ -0,0 +1,585 @@
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 } = {}) {
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
+ const wrapperTag = Component.as || 'section'
292
+
293
+ if (hasBackground) {
294
+ return React.createElement(wrapperTag, wrapperProps,
295
+ renderBackground(background),
296
+ React.createElement('div', { style: { position: 'relative', zIndex: 10 } },
297
+ React.createElement(Component, componentProps)
298
+ )
299
+ )
300
+ }
301
+
302
+ return React.createElement(wrapperTag, wrapperProps,
303
+ React.createElement(Component, componentProps)
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Render an array of blocks for SSR.
309
+ */
310
+ export function renderBlocks(blocks) {
311
+ if (!blocks || blocks.length === 0) return null
312
+ return blocks.map((block, index) =>
313
+ React.createElement(React.Fragment, { key: block.id || index },
314
+ renderBlock(block)
315
+ )
316
+ )
317
+ }
318
+
319
+ /**
320
+ * Render page layout for SSR.
321
+ * Mirrors Layout.jsx but without hooks.
322
+ */
323
+ export function renderLayout(page, website) {
324
+ const layoutName = page.getLayoutName()
325
+ const RemoteLayout = website.getRemoteLayout(layoutName)
326
+ const layoutMeta = website.getLayoutMeta(layoutName)
327
+
328
+ const bodyBlocks = page.getBodyBlocks()
329
+ const areas = page.getLayoutAreas()
330
+
331
+ const bodyElement = bodyBlocks ? renderBlocks(bodyBlocks) : null
332
+ const areaElements = {}
333
+ for (const [name, blocks] of Object.entries(areas)) {
334
+ areaElements[name] = renderBlocks(blocks)
335
+ }
336
+
337
+ if (RemoteLayout) {
338
+ const params = { ...(layoutMeta?.defaults || {}), ...(page.getLayoutParams() || {}) }
339
+ return React.createElement(RemoteLayout, {
340
+ page, website, params,
341
+ body: bodyElement,
342
+ ...areaElements,
343
+ })
344
+ }
345
+
346
+ // Default layout
347
+ return React.createElement(React.Fragment, null,
348
+ areaElements.header && React.createElement('header', null, areaElements.header),
349
+ bodyElement && React.createElement('main', null, bodyElement),
350
+ areaElements.footer && React.createElement('footer', null, areaElements.footer)
351
+ )
352
+ }
353
+
354
+ // ============================================================================
355
+ // Layer 2: Initialization
356
+ // ============================================================================
357
+
358
+ /**
359
+ * Create and configure the Uniweb runtime for prerendering.
360
+ *
361
+ * Handles the full initialization sequence in the correct order:
362
+ * createUniweb → setFoundation → capabilities → layoutMeta → basePath → childBlockRenderer.
363
+ *
364
+ * Returns the configured uniweb instance. Consumers can add extras after:
365
+ * - Build: pre-populate DataStore, load extensions
366
+ * - Unicloud: (none needed — payload is complete)
367
+ *
368
+ * NOTE: Does NOT clone content. Cloning is the consumer's responsibility
369
+ * (build modifies content before init; unicloud clones upfront).
370
+ *
371
+ * @param {Object} content - Site content JSON (pages, config, hierarchy)
372
+ * @param {Object} foundation - Loaded foundation module
373
+ * @param {Object} [options]
374
+ * @param {function} [options.onProgress] - Progress callback
375
+ * @returns {Object} Configured uniweb instance
376
+ */
377
+ export function initPrerender(content, foundation, options = {}) {
378
+ const { onProgress = () => {} } = options
379
+
380
+ onProgress('Initializing runtime...')
381
+ const uniweb = createUniweb(content)
382
+ uniweb.setFoundation(foundation)
383
+
384
+ // Set foundation capabilities (Layout, props, etc.)
385
+ if (foundation.default?.capabilities) {
386
+ uniweb.setFoundationConfig(foundation.default.capabilities)
387
+ }
388
+
389
+ // Attach layout metadata (areas, transitions, defaults)
390
+ if (foundation.default?.layoutMeta && uniweb.foundationConfig) {
391
+ uniweb.foundationConfig.layoutMeta = foundation.default.layoutMeta
392
+ }
393
+
394
+ // Set base path from site config for subdirectory deployments
395
+ if (content.config?.base && uniweb.activeWebsite?.setBasePath) {
396
+ uniweb.activeWebsite.setBasePath(content.config.base)
397
+ }
398
+
399
+ // Set childBlockRenderer so ChildBlocks/Visual/Render work during prerender
400
+ uniweb.childBlockRenderer = function InlineChildBlocks({ blocks, from, pure = false }) {
401
+ const blockList = blocks || from?.childBlocks || []
402
+ return blockList.map((childBlock, index) =>
403
+ React.createElement(React.Fragment, { key: childBlock.id || index },
404
+ renderBlock(childBlock, { pure })
405
+ )
406
+ )
407
+ }
408
+
409
+ return uniweb
410
+ }
411
+
412
+ /**
413
+ * Pre-fetch icons from CDN and populate the Uniweb icon cache.
414
+ * Stores the cache on siteContent._iconCache for embedding in HTML.
415
+ *
416
+ * @param {Object} siteContent - Site content JSON (mutated: _iconCache added)
417
+ * @param {Object} uniweb - Configured uniweb instance
418
+ * @param {function} [onProgress] - Progress callback
419
+ */
420
+ export async function prefetchIcons(siteContent, uniweb, onProgress = () => {}) {
421
+ const icons = siteContent.icons?.used || []
422
+ if (icons.length === 0) return
423
+
424
+ const cdnBase = siteContent.config?.icons?.cdnUrl || 'https://uniweb.github.io/icons'
425
+
426
+ onProgress(`Fetching ${icons.length} icons for SSR...`)
427
+
428
+ const results = await Promise.allSettled(
429
+ icons.map(async (iconRef) => {
430
+ const [family, name] = iconRef.split(':')
431
+ const url = `${cdnBase}/${family}/${family}-${name}.svg`
432
+ const response = await fetch(url)
433
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
434
+ const svg = await response.text()
435
+ uniweb.iconCache.set(`${family}:${name}`, svg)
436
+ })
437
+ )
438
+
439
+ const succeeded = results.filter(r => r.status === 'fulfilled').length
440
+ const failed = results.filter(r => r.status === 'rejected').length
441
+ if (failed > 0) {
442
+ const msg = `Fetched ${succeeded}/${icons.length} icons (${failed} failed)`
443
+ console.warn(`[prerender] ${msg}`)
444
+ onProgress(` ${msg}`)
445
+ }
446
+
447
+ // Store icon cache on siteContent for embedding in HTML
448
+ if (uniweb.iconCache.size > 0) {
449
+ siteContent._iconCache = Object.fromEntries(uniweb.iconCache)
450
+ }
451
+ }
452
+
453
+ // ============================================================================
454
+ // Layer 3: Per-page rendering
455
+ // ============================================================================
456
+
457
+ /**
458
+ * Classify an SSR rendering error.
459
+ *
460
+ * @param {Error} err
461
+ * @returns {{ type: 'hooks'|'null-component'|'unknown', message: string }}
462
+ */
463
+ export function classifyRenderError(err) {
464
+ const msg = err.message || ''
465
+
466
+ if (msg.includes('Invalid hook call') || msg.includes('useState') || msg.includes('useEffect')) {
467
+ return {
468
+ type: 'hooks',
469
+ message: 'contains components with React hooks (renders client-side)',
470
+ }
471
+ }
472
+
473
+ if (msg.includes('Element type is invalid') && msg.includes('null')) {
474
+ return {
475
+ type: 'null-component',
476
+ message: 'a component resolved to null (often hook-related, renders client-side)',
477
+ }
478
+ }
479
+
480
+ return {
481
+ type: 'unknown',
482
+ message: msg,
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Render a single page to HTML.
488
+ *
489
+ * Handles the full per-page pipeline:
490
+ * setActivePage → renderLayout → renderToString → error handling → section override CSS.
491
+ *
492
+ * @param {Page} page - Page instance to render
493
+ * @param {Website} website - Website instance
494
+ * @returns {{ renderedContent: string, sectionOverrideCSS: string } | { error: { type: string, message: string } }}
495
+ */
496
+ export function renderPage(page, website) {
497
+ website.setActivePage(page.route)
498
+
499
+ const element = renderLayout(page, website)
500
+
501
+ let renderedContent
502
+ try {
503
+ renderedContent = renderToString(element)
504
+ } catch (err) {
505
+ return { error: classifyRenderError(err) }
506
+ }
507
+
508
+ // Build per-page section override CSS (theme pinning, component vars)
509
+ const appearance = website.themeData?.appearance
510
+ const sectionOverrideCSS = buildSectionOverrides(page.getPageBlocks(), appearance)
511
+
512
+ return { renderedContent, sectionOverrideCSS }
513
+ }
514
+
515
+ // ============================================================================
516
+ // HTML injection
517
+ // ============================================================================
518
+
519
+ /**
520
+ * Escape HTML special characters.
521
+ */
522
+ export function escapeHtml(str) {
523
+ if (!str) return ''
524
+ return String(str)
525
+ .replace(/&/g, '&amp;')
526
+ .replace(/</g, '&lt;')
527
+ .replace(/>/g, '&gt;')
528
+ .replace(/"/g, '&quot;')
529
+ .replace(/'/g, '&#39;')
530
+ }
531
+
532
+ /**
533
+ * Inject prerendered content into an HTML shell.
534
+ *
535
+ * Common operations shared by both build and cloud:
536
+ * - Replace #root div with rendered HTML
537
+ * - Update page title
538
+ * - Add/update meta description
539
+ * - Inject section override CSS
540
+ *
541
+ * Build layers its additional injections on top of this return value:
542
+ * __SITE_CONTENT__ JSON, icon cache, theme CSS (build-specific).
543
+ *
544
+ * @param {string} html - HTML shell
545
+ * @param {string} renderedContent - React renderToString output
546
+ * @param {Object} page - Page data { title, description, route }
547
+ * @param {Object} [options]
548
+ * @param {string} [options.sectionOverrideCSS] - Per-page section override CSS
549
+ * @returns {string} HTML with injected content
550
+ */
551
+ export function injectPageContent(html, renderedContent, page, options = {}) {
552
+ let result = html
553
+
554
+ // Inject per-page section override CSS before </head>
555
+ if (options.sectionOverrideCSS) {
556
+ const overrideStyle = `<style id="uniweb-page-overrides">\n${options.sectionOverrideCSS}\n</style>`
557
+ result = result.replace('</head>', `${overrideStyle}\n</head>`)
558
+ }
559
+
560
+ // Replace the empty root div with pre-rendered content
561
+ result = result.replace(
562
+ /<div id="root">[\s\S]*?<\/div>/,
563
+ `<div id="root">${renderedContent}</div>`
564
+ )
565
+
566
+ // Update page title
567
+ if (page.title) {
568
+ result = result.replace(
569
+ /<title>.*?<\/title>/,
570
+ `<title>${escapeHtml(page.title)}</title>`
571
+ )
572
+ }
573
+
574
+ // Add/update meta description
575
+ if (page.description) {
576
+ const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
577
+ if (result.includes('<meta name="description"')) {
578
+ result = result.replace(/<meta name="description"[^>]*>/, metaDesc)
579
+ } else {
580
+ result = result.replace('</head>', `${metaDesc}\n</head>`)
581
+ }
582
+ }
583
+
584
+ return result
585
+ }
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'