@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.
- package/dist/app/_importmap/@uniweb-core.js +2 -0
- package/dist/app/_importmap/@uniweb-core.js.map +1 -0
- package/dist/app/_importmap/react-dom.js +2 -0
- package/dist/app/_importmap/react-dom.js.map +1 -0
- package/dist/app/_importmap/react-jsx-dev-runtime.js +2 -0
- package/dist/app/_importmap/react-jsx-dev-runtime.js.map +1 -0
- package/dist/app/_importmap/react-jsx-runtime.js +2 -0
- package/dist/app/_importmap/react-jsx-runtime.js.map +1 -0
- package/dist/app/_importmap/react.js +2 -0
- package/dist/app/_importmap/react.js.map +1 -0
- package/dist/app/assets/_commonjsHelpers-CqkleIqs.js +2 -0
- package/dist/app/assets/_commonjsHelpers-CqkleIqs.js.map +1 -0
- package/dist/app/assets/_importmap_react-dWoQamCw.js +2 -0
- package/dist/app/assets/_importmap_react-dWoQamCw.js.map +1 -0
- package/dist/app/assets/index-C0udIITE.js +9 -0
- package/dist/app/assets/index-C0udIITE.js.map +1 -0
- package/dist/app/assets/index-C6TPxGbh.js +133 -0
- package/dist/app/assets/index-C6TPxGbh.js.map +1 -0
- package/dist/app/assets/index-CsyMBO9p.js +8 -0
- package/dist/app/assets/index-CsyMBO9p.js.map +1 -0
- package/dist/app/assets/index-kA4PVysc.js +2 -0
- package/dist/app/assets/index-kA4PVysc.js.map +1 -0
- package/dist/app/assets/jsx-runtime-C3x2e0aW.js +2 -0
- package/dist/app/assets/jsx-runtime-C3x2e0aW.js.map +1 -0
- package/dist/app/index.html +31 -0
- package/dist/app/manifest.json +19 -0
- package/dist/ssr.js +319 -327
- package/dist/ssr.js.map +1 -1
- package/package.json +7 -4
- package/src/components/PageRenderer.jsx +12 -9
- package/src/foundation-loader.js +3 -0
- package/src/index.jsx +3 -4
- package/src/shell/index.html +12 -0
- package/src/shell/main.js +16 -0
- package/src/ssr-renderer.js +591 -0
- 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, '&')
|
|
531
|
+
.replace(/</g, '<')
|
|
532
|
+
.replace(/>/g, '>')
|
|
533
|
+
.replace(/"/g, '"')
|
|
534
|
+
.replace(/'/g, ''')
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
//
|
|
25
|
-
export {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
35
|
+
// Layer 2: Initialization
|
|
36
|
+
initPrerender,
|
|
37
|
+
prefetchIcons,
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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'
|