@uniweb/build 0.8.17 → 0.8.19
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/package.json +4 -4
- package/src/prerender.js +107 -557
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.19",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"@uniweb/theming": "0.1.2"
|
|
53
53
|
},
|
|
54
54
|
"optionalDependencies": {
|
|
55
|
-
"@uniweb/runtime": "0.6.12",
|
|
56
55
|
"@uniweb/schemas": "0.2.1",
|
|
57
|
-
"@uniweb/content-reader": "1.1.4"
|
|
56
|
+
"@uniweb/content-reader": "1.1.4",
|
|
57
|
+
"@uniweb/runtime": "0.6.14"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@tailwindcss/vite": "^4.0.0",
|
|
64
64
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
65
65
|
"vite-plugin-svgr": "^4.0.0",
|
|
66
|
-
"@uniweb/core": "0.5.
|
|
66
|
+
"@uniweb/core": "0.5.13"
|
|
67
67
|
},
|
|
68
68
|
"peerDependenciesMeta": {
|
|
69
69
|
"vite": {
|
package/src/prerender.js
CHANGED
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
* SSG Prerendering for Uniweb Sites
|
|
3
3
|
*
|
|
4
4
|
* Renders each page to static HTML at build time.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the same code path for both SSG and client-side rendering.
|
|
5
|
+
* Uses @uniweb/runtime/ssr for the rendering pipeline (init, render, inject).
|
|
6
|
+
* This file handles build-specific orchestration: data fetching, locale discovery,
|
|
7
|
+
* dynamic route expansion, extension loading, and build-specific HTML injections.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
12
11
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
13
12
|
import { join, dirname, resolve } from 'node:path'
|
|
14
|
-
import { createRequire } from 'node:module'
|
|
15
13
|
import { pathToFileURL } from 'node:url'
|
|
16
14
|
import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
|
|
17
15
|
|
|
@@ -46,10 +44,6 @@ function resolveExtensionPath(url, distDir, projectRoot) {
|
|
|
46
44
|
return url
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
// Lazily loaded dependencies
|
|
50
|
-
let React, renderToString, createUniweb
|
|
51
|
-
let preparePropsSSR, getComponentMetaSSR
|
|
52
|
-
|
|
53
47
|
/**
|
|
54
48
|
* Execute all data fetches for prerender
|
|
55
49
|
* Processes site, page, and section level fetches, merging data appropriately
|
|
@@ -246,371 +240,6 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
|
|
|
246
240
|
}
|
|
247
241
|
}
|
|
248
242
|
|
|
249
|
-
/**
|
|
250
|
-
* Load dependencies dynamically from the site's context
|
|
251
|
-
* This ensures we use the same React instance as the foundation
|
|
252
|
-
*
|
|
253
|
-
* @param {string} siteDir - Path to the site directory
|
|
254
|
-
*/
|
|
255
|
-
async function loadDependencies(siteDir) {
|
|
256
|
-
if (React) return // Already loaded
|
|
257
|
-
|
|
258
|
-
// Load React from the site's node_modules using createRequire.
|
|
259
|
-
// This ensures we get the same React instance as the foundation
|
|
260
|
-
// components (which are loaded via pathToFileURL and externalize React
|
|
261
|
-
// to the same node_modules). Using bare import('react') would resolve
|
|
262
|
-
// from @uniweb/build's context, creating a dual-React instance problem.
|
|
263
|
-
const absoluteSiteDir = resolve(siteDir)
|
|
264
|
-
const siteRequire = createRequire(join(absoluteSiteDir, 'package.json'))
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const reactMod = siteRequire('react')
|
|
268
|
-
const serverMod = siteRequire('react-dom/server')
|
|
269
|
-
React = reactMod.default || reactMod
|
|
270
|
-
renderToString = serverMod.renderToString
|
|
271
|
-
} catch {
|
|
272
|
-
const [reactMod, serverMod] = await Promise.all([
|
|
273
|
-
import('react'),
|
|
274
|
-
import('react-dom/server')
|
|
275
|
-
])
|
|
276
|
-
React = reactMod.default || reactMod
|
|
277
|
-
renderToString = serverMod.renderToString
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Load @uniweb/core
|
|
281
|
-
const coreMod = await import('@uniweb/core')
|
|
282
|
-
createUniweb = coreMod.createUniweb
|
|
283
|
-
|
|
284
|
-
// Load pure utility functions from runtime SSR bundle.
|
|
285
|
-
// These are plain functions (no hooks), so they work even if the SSR
|
|
286
|
-
// bundle resolves a different React instance internally.
|
|
287
|
-
const runtimeMod = await import('@uniweb/runtime/ssr')
|
|
288
|
-
preparePropsSSR = runtimeMod.prepareProps
|
|
289
|
-
getComponentMetaSSR = runtimeMod.getComponentMeta
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Pre-fetch icons from CDN and populate the Uniweb icon cache.
|
|
294
|
-
* This allows the Icon component to render SVGs synchronously during SSR
|
|
295
|
-
* instead of producing empty placeholders.
|
|
296
|
-
*/
|
|
297
|
-
async function prefetchIcons(siteContent, uniweb, onProgress) {
|
|
298
|
-
const icons = siteContent.icons?.used || []
|
|
299
|
-
if (icons.length === 0) return
|
|
300
|
-
|
|
301
|
-
const cdnBase = siteContent.config?.icons?.cdnUrl || 'https://uniweb.github.io/icons'
|
|
302
|
-
|
|
303
|
-
onProgress(`Fetching ${icons.length} icons for SSR...`)
|
|
304
|
-
|
|
305
|
-
const results = await Promise.allSettled(
|
|
306
|
-
icons.map(async (iconRef) => {
|
|
307
|
-
const [family, name] = iconRef.split(':')
|
|
308
|
-
const url = `${cdnBase}/${family}/${family}-${name}.svg`
|
|
309
|
-
const response = await fetch(url)
|
|
310
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
311
|
-
const svg = await response.text()
|
|
312
|
-
uniweb.iconCache.set(`${family}:${name}`, svg)
|
|
313
|
-
})
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
|
317
|
-
const failed = results.filter(r => r.status === 'rejected').length
|
|
318
|
-
if (failed > 0) {
|
|
319
|
-
console.warn(`[prerender] Fetched ${succeeded}/${icons.length} icons (${failed} failed)`)
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Store icon cache on siteContent for embedding in HTML
|
|
323
|
-
// This allows the client runtime to populate the cache before rendering
|
|
324
|
-
if (uniweb.iconCache.size > 0) {
|
|
325
|
-
siteContent._iconCache = Object.fromEntries(uniweb.iconCache)
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Valid color contexts for section theming
|
|
331
|
-
*/
|
|
332
|
-
const VALID_CONTEXTS = ['light', 'medium', 'dark']
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Build wrapper props from block configuration
|
|
336
|
-
* Mirrors getWrapperProps in BlockRenderer.jsx
|
|
337
|
-
*/
|
|
338
|
-
function getWrapperProps(block) {
|
|
339
|
-
const theme = block.themeName
|
|
340
|
-
const blockClassName = block.state?.className || ''
|
|
341
|
-
|
|
342
|
-
let contextClass = ''
|
|
343
|
-
if (theme && VALID_CONTEXTS.includes(theme)) {
|
|
344
|
-
contextClass = `context-${theme}`
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
let className = contextClass
|
|
348
|
-
if (blockClassName) {
|
|
349
|
-
className = className ? `${className} ${blockClassName}` : blockClassName
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const { background = {} } = block.standardOptions
|
|
353
|
-
const style = {}
|
|
354
|
-
if (background.mode) {
|
|
355
|
-
style.position = 'relative'
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Apply context overrides as inline CSS custom properties (mirrors BlockRenderer.jsx)
|
|
359
|
-
if (block.contextOverrides) {
|
|
360
|
-
for (const [key, value] of Object.entries(block.contextOverrides)) {
|
|
361
|
-
style[`--${key}`] = value
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const sectionId = block.stableId || block.id
|
|
366
|
-
return { id: `section-${sectionId}`, style, className, background }
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Render a background element for SSR
|
|
371
|
-
* Mirrors the Background component in Background.jsx (image, color, gradient only)
|
|
372
|
-
* Video backgrounds are skipped in SSR (they require JS for autoplay)
|
|
373
|
-
*/
|
|
374
|
-
function renderBackground(background) {
|
|
375
|
-
if (!background?.mode) return null
|
|
376
|
-
|
|
377
|
-
const containerStyle = {
|
|
378
|
-
position: 'absolute',
|
|
379
|
-
inset: '0',
|
|
380
|
-
overflow: 'hidden',
|
|
381
|
-
zIndex: 0,
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const children = []
|
|
385
|
-
|
|
386
|
-
// Resolve URL against basePath for subdirectory deployments
|
|
387
|
-
const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
|
|
388
|
-
function resolveUrl(url) {
|
|
389
|
-
if (!url || !url.startsWith('/')) return url
|
|
390
|
-
if (!basePath) return url
|
|
391
|
-
if (url.startsWith(basePath + '/') || url === basePath) return url
|
|
392
|
-
return basePath + url
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if (background.mode === 'color' && background.color) {
|
|
396
|
-
children.push(
|
|
397
|
-
React.createElement('div', {
|
|
398
|
-
key: 'bg-color',
|
|
399
|
-
className: 'background-color',
|
|
400
|
-
style: { position: 'absolute', inset: '0', backgroundColor: background.color },
|
|
401
|
-
'aria-hidden': 'true'
|
|
402
|
-
})
|
|
403
|
-
)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (background.mode === 'gradient' && background.gradient) {
|
|
407
|
-
const g = background.gradient
|
|
408
|
-
// Raw CSS gradient string (e.g., "linear-gradient(to bottom, #000, #333)")
|
|
409
|
-
const bgValue = typeof g === 'string' ? g
|
|
410
|
-
: `linear-gradient(${g.angle || 0}deg, ${g.start || 'transparent'} ${g.startPosition || 0}%, ${g.end || 'transparent'} ${g.endPosition || 100}%)`
|
|
411
|
-
children.push(
|
|
412
|
-
React.createElement('div', {
|
|
413
|
-
key: 'bg-gradient',
|
|
414
|
-
className: 'background-gradient',
|
|
415
|
-
style: {
|
|
416
|
-
position: 'absolute', inset: '0',
|
|
417
|
-
background: bgValue
|
|
418
|
-
},
|
|
419
|
-
'aria-hidden': 'true'
|
|
420
|
-
})
|
|
421
|
-
)
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (background.mode === 'image' && background.image?.src) {
|
|
425
|
-
const img = background.image
|
|
426
|
-
children.push(
|
|
427
|
-
React.createElement('div', {
|
|
428
|
-
key: 'bg-image',
|
|
429
|
-
className: 'background-image',
|
|
430
|
-
style: {
|
|
431
|
-
position: 'absolute', inset: '0',
|
|
432
|
-
backgroundImage: `url(${resolveUrl(img.src)})`,
|
|
433
|
-
backgroundPosition: img.position || 'center',
|
|
434
|
-
backgroundSize: img.size || 'cover',
|
|
435
|
-
backgroundRepeat: 'no-repeat'
|
|
436
|
-
},
|
|
437
|
-
'aria-hidden': 'true'
|
|
438
|
-
})
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Overlay
|
|
443
|
-
if (background.overlay?.enabled) {
|
|
444
|
-
const ov = background.overlay
|
|
445
|
-
let overlayStyle
|
|
446
|
-
|
|
447
|
-
if (ov.gradient) {
|
|
448
|
-
const g = ov.gradient
|
|
449
|
-
overlayStyle = {
|
|
450
|
-
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
451
|
-
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}%)`,
|
|
452
|
-
opacity: ov.opacity ?? 0.5
|
|
453
|
-
}
|
|
454
|
-
} else {
|
|
455
|
-
const baseColor = ov.type === 'light' ? '255, 255, 255' : '0, 0, 0'
|
|
456
|
-
overlayStyle = {
|
|
457
|
-
position: 'absolute', inset: '0', pointerEvents: 'none',
|
|
458
|
-
backgroundColor: `rgba(${baseColor}, ${ov.opacity ?? 0.5})`
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
children.push(
|
|
463
|
-
React.createElement('div', {
|
|
464
|
-
key: 'bg-overlay',
|
|
465
|
-
className: 'background-overlay',
|
|
466
|
-
style: overlayStyle,
|
|
467
|
-
'aria-hidden': 'true'
|
|
468
|
-
})
|
|
469
|
-
)
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (children.length === 0) return null
|
|
473
|
-
|
|
474
|
-
return React.createElement('div', {
|
|
475
|
-
className: `background background--${background.mode}`,
|
|
476
|
-
style: containerStyle,
|
|
477
|
-
'aria-hidden': 'true'
|
|
478
|
-
}, ...children)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Render a single block for SSR
|
|
483
|
-
* Mirrors BlockRenderer.jsx but without hooks (no runtime data fetching in SSR).
|
|
484
|
-
* block.dataLoading is always false at prerender time — runtime fetches only happen client-side.
|
|
485
|
-
*/
|
|
486
|
-
function renderBlock(block, { pure = false } = {}) {
|
|
487
|
-
const Component = block.initComponent()
|
|
488
|
-
|
|
489
|
-
if (!Component) {
|
|
490
|
-
return React.createElement('div', {
|
|
491
|
-
className: 'block-error',
|
|
492
|
-
style: { padding: '1rem', background: '#fef2f2', color: '#dc2626' }
|
|
493
|
-
}, `Component not found: ${block.type}`)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Build content and params with runtime guarantees
|
|
497
|
-
const meta = getComponentMetaSSR(block.type)
|
|
498
|
-
const prepared = preparePropsSSR(block, meta)
|
|
499
|
-
let params = prepared.params
|
|
500
|
-
let content = {
|
|
501
|
-
...prepared.content,
|
|
502
|
-
...block.properties,
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Resolve inherited entity data (mirrors BlockRenderer.jsx)
|
|
506
|
-
// EntityStore walks page/site hierarchy to find data matching meta.inheritData
|
|
507
|
-
const entityStore = block.website?.entityStore
|
|
508
|
-
if (entityStore) {
|
|
509
|
-
const resolved = entityStore.resolve(block, meta)
|
|
510
|
-
if (resolved.status === 'ready' && resolved.data) {
|
|
511
|
-
const merged = { ...content.data }
|
|
512
|
-
for (const key of Object.keys(resolved.data)) {
|
|
513
|
-
if (merged[key] === undefined) {
|
|
514
|
-
merged[key] = resolved.data[key]
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
content.data = merged
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const componentProps = { content, params, block }
|
|
522
|
-
|
|
523
|
-
// Pure mode: render component without section wrapper (used by ChildBlocks)
|
|
524
|
-
if (pure) {
|
|
525
|
-
return React.createElement(Component, componentProps)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Background handling (mirrors BlockRenderer.jsx)
|
|
529
|
-
const { background, ...wrapperProps } = getWrapperProps(block)
|
|
530
|
-
|
|
531
|
-
// Merge Component.className (static classes declared on the component function)
|
|
532
|
-
// Order: context-{theme} + block.state.className + Component.className
|
|
533
|
-
const componentClassName = Component.className
|
|
534
|
-
if (componentClassName) {
|
|
535
|
-
wrapperProps.className = wrapperProps.className
|
|
536
|
-
? `${wrapperProps.className} ${componentClassName}`
|
|
537
|
-
: componentClassName
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const hasBackground = background?.mode && meta?.background !== 'self'
|
|
541
|
-
|
|
542
|
-
block.hasBackground = hasBackground
|
|
543
|
-
|
|
544
|
-
// Use Component.as as the wrapper tag (default: 'section')
|
|
545
|
-
const wrapperTag = Component.as || 'section'
|
|
546
|
-
|
|
547
|
-
if (hasBackground) {
|
|
548
|
-
return React.createElement(wrapperTag, wrapperProps,
|
|
549
|
-
renderBackground(background),
|
|
550
|
-
React.createElement('div', { className: 'relative z-10' },
|
|
551
|
-
React.createElement(Component, componentProps)
|
|
552
|
-
)
|
|
553
|
-
)
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return React.createElement(wrapperTag, wrapperProps,
|
|
557
|
-
React.createElement(Component, componentProps)
|
|
558
|
-
)
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Render an array of blocks for SSR
|
|
563
|
-
*/
|
|
564
|
-
function renderBlocks(blocks) {
|
|
565
|
-
if (!blocks || blocks.length === 0) return null
|
|
566
|
-
return blocks.map((block, index) =>
|
|
567
|
-
React.createElement(React.Fragment, { key: block.id || index },
|
|
568
|
-
renderBlock(block)
|
|
569
|
-
)
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Render page layout for SSR
|
|
575
|
-
*/
|
|
576
|
-
function renderLayout(page, website) {
|
|
577
|
-
const layoutName = page.getLayoutName()
|
|
578
|
-
const RemoteLayout = website.getRemoteLayout(layoutName)
|
|
579
|
-
const layoutMeta = website.getLayoutMeta(layoutName)
|
|
580
|
-
|
|
581
|
-
const bodyBlocks = page.getBodyBlocks()
|
|
582
|
-
const areas = page.getLayoutAreas()
|
|
583
|
-
|
|
584
|
-
const bodyElement = bodyBlocks ? renderBlocks(bodyBlocks) : null
|
|
585
|
-
const areaElements = {}
|
|
586
|
-
for (const [name, blocks] of Object.entries(areas)) {
|
|
587
|
-
areaElements[name] = renderBlocks(blocks)
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (RemoteLayout) {
|
|
591
|
-
const params = { ...(layoutMeta?.defaults || {}), ...(page.getLayoutParams() || {}) }
|
|
592
|
-
|
|
593
|
-
return React.createElement(RemoteLayout, {
|
|
594
|
-
page, website, params,
|
|
595
|
-
body: bodyElement,
|
|
596
|
-
...areaElements,
|
|
597
|
-
})
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
return React.createElement(React.Fragment, null,
|
|
601
|
-
areaElements.header && React.createElement('header', null, areaElements.header),
|
|
602
|
-
bodyElement && React.createElement('main', null, bodyElement),
|
|
603
|
-
areaElements.footer && React.createElement('footer', null, areaElements.footer)
|
|
604
|
-
)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Create a page element for SSR
|
|
609
|
-
*/
|
|
610
|
-
function createPageElement(page, website) {
|
|
611
|
-
return renderLayout(page, website)
|
|
612
|
-
}
|
|
613
|
-
|
|
614
243
|
/**
|
|
615
244
|
* Discover all locale content files in the dist directory
|
|
616
245
|
* Returns an array of { locale, contentPath, htmlPath, isDefault }
|
|
@@ -667,6 +296,85 @@ async function discoverLocaleContents(distDir, defaultContent) {
|
|
|
667
296
|
return locales
|
|
668
297
|
}
|
|
669
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Inject build-specific data into HTML (theme CSS, __SITE_CONTENT__, icon cache).
|
|
301
|
+
* Called after the shared injectPageContent for build-specific additions.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} html - HTML with prerendered content already injected
|
|
304
|
+
* @param {Object} siteContent - Site content JSON
|
|
305
|
+
* @returns {string} HTML with build-specific data injected
|
|
306
|
+
*/
|
|
307
|
+
function injectBuildData(html, siteContent) {
|
|
308
|
+
let result = html
|
|
309
|
+
|
|
310
|
+
// Inject theme CSS if not already present
|
|
311
|
+
if (siteContent?.theme?.css && !result.includes('id="uniweb-theme"')) {
|
|
312
|
+
result = result.replace(
|
|
313
|
+
'</head>',
|
|
314
|
+
` <style id="uniweb-theme">\n${siteContent.theme.css}\n </style>\n </head>`
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Inject site content as JSON for hydration
|
|
319
|
+
// Strip CSS from theme (it's already in a <style> tag)
|
|
320
|
+
const contentForJson = { ...siteContent }
|
|
321
|
+
if (contentForJson.theme?.css) {
|
|
322
|
+
contentForJson.theme = { ...contentForJson.theme }
|
|
323
|
+
delete contentForJson.theme.css
|
|
324
|
+
}
|
|
325
|
+
const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson).replace(/</g, '\\u003c')}</script>`
|
|
326
|
+
if (result.includes('__SITE_CONTENT__')) {
|
|
327
|
+
// Replace existing site content with updated version (includes expanded dynamic routes)
|
|
328
|
+
result = result.replace(
|
|
329
|
+
/<script[^>]*id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
|
|
330
|
+
contentScript
|
|
331
|
+
)
|
|
332
|
+
} else {
|
|
333
|
+
result = result.replace(
|
|
334
|
+
'</head>',
|
|
335
|
+
` ${contentScript}\n </head>`
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Inject icon cache so client can render icons immediately without CDN fetches
|
|
340
|
+
if (siteContent._iconCache) {
|
|
341
|
+
const iconScript = `<script id="__ICON_CACHE__" type="application/json">${JSON.stringify(siteContent._iconCache).replace(/</g, '\\u003c')}</script>`
|
|
342
|
+
if (result.includes('__ICON_CACHE__')) {
|
|
343
|
+
result = result.replace(
|
|
344
|
+
/<script[^>]*id="__ICON_CACHE__"[^>]*>[\s\S]*?<\/script>/,
|
|
345
|
+
iconScript
|
|
346
|
+
)
|
|
347
|
+
} else {
|
|
348
|
+
result = result.replace(
|
|
349
|
+
'</head>',
|
|
350
|
+
` ${iconScript}\n </head>`
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return result
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get output path for a route
|
|
360
|
+
*/
|
|
361
|
+
function getOutputPath(distDir, route) {
|
|
362
|
+
let normalizedRoute = route
|
|
363
|
+
|
|
364
|
+
// Handle root route
|
|
365
|
+
if (normalizedRoute === '/' || normalizedRoute === '') {
|
|
366
|
+
return join(distDir, 'index.html')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Remove leading slash
|
|
370
|
+
if (normalizedRoute.startsWith('/')) {
|
|
371
|
+
normalizedRoute = normalizedRoute.slice(1)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Create directory structure: /about -> /about/index.html
|
|
375
|
+
return join(distDir, normalizedRoute, 'index.html')
|
|
376
|
+
}
|
|
377
|
+
|
|
670
378
|
/**
|
|
671
379
|
* Pre-render all pages in a built site to static HTML
|
|
672
380
|
*
|
|
@@ -689,9 +397,13 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
689
397
|
throw new Error(`Site must be built first. No dist directory found at: ${distDir}`)
|
|
690
398
|
}
|
|
691
399
|
|
|
692
|
-
// Load
|
|
693
|
-
|
|
694
|
-
|
|
400
|
+
// Load shared SSR functions from runtime (lazy — only when prerendering)
|
|
401
|
+
const {
|
|
402
|
+
initPrerender,
|
|
403
|
+
prefetchIcons,
|
|
404
|
+
renderPage,
|
|
405
|
+
injectPageContent,
|
|
406
|
+
} = await import('@uniweb/runtime/ssr')
|
|
695
407
|
|
|
696
408
|
// Load default site content
|
|
697
409
|
onProgress('Loading site content...')
|
|
@@ -753,20 +465,17 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
753
465
|
const shellPath = existsSync(htmlPath) ? htmlPath : join(distDir, 'index.html')
|
|
754
466
|
const htmlShell = await readFile(shellPath, 'utf8')
|
|
755
467
|
|
|
756
|
-
// Initialize the Uniweb runtime
|
|
757
|
-
|
|
758
|
-
const uniweb = createUniweb(siteContent)
|
|
468
|
+
// Initialize the Uniweb runtime using the shared SSR module
|
|
469
|
+
const uniweb = initPrerender(siteContent, foundation, { onProgress })
|
|
759
470
|
|
|
760
|
-
//
|
|
471
|
+
// Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender
|
|
761
472
|
if (fetchedData.length > 0 && uniweb.activeWebsite?.dataStore) {
|
|
762
473
|
for (const entry of fetchedData) {
|
|
763
474
|
uniweb.activeWebsite.dataStore.set(entry.config, entry.data)
|
|
764
475
|
}
|
|
765
476
|
}
|
|
766
477
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// Load extensions (secondary foundations via URL)
|
|
478
|
+
// Build-specific: load extensions (secondary foundations via URL)
|
|
770
479
|
const extensions = siteContent.config?.extensions
|
|
771
480
|
if (extensions?.length) {
|
|
772
481
|
onProgress(`Loading ${extensions.length} extension(s)...`)
|
|
@@ -784,41 +493,13 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
784
493
|
}
|
|
785
494
|
}
|
|
786
495
|
|
|
787
|
-
// Set base path from site config so components can access it during SSR
|
|
788
|
-
// (e.g., <Link reload> needs basePath to prefix hrefs for subdirectory deployments)
|
|
789
|
-
if (siteContent.config?.base && uniweb.activeWebsite?.setBasePath) {
|
|
790
|
-
uniweb.activeWebsite.setBasePath(siteContent.config.base)
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Set foundation capabilities (Layout, props, etc.)
|
|
794
|
-
if (foundation.default?.capabilities) {
|
|
795
|
-
uniweb.setFoundationConfig(foundation.default.capabilities)
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Attach layout metadata (areas, transitions, defaults)
|
|
799
|
-
if (foundation.default?.layoutMeta && uniweb.foundationConfig) {
|
|
800
|
-
uniweb.foundationConfig.layoutMeta = foundation.default.layoutMeta
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Set childBlockRenderer so foundation components using ChildBlocks/Visual
|
|
804
|
-
// can render child blocks and insets during prerender (inline, no hooks)
|
|
805
|
-
uniweb.childBlockRenderer = function InlineChildBlocks({ blocks, from, pure = false }) {
|
|
806
|
-
const blockList = blocks || from?.childBlocks || []
|
|
807
|
-
return blockList.map((childBlock, index) =>
|
|
808
|
-
React.createElement(React.Fragment, { key: childBlock.id || index },
|
|
809
|
-
renderBlock(childBlock, { pure })
|
|
810
|
-
)
|
|
811
|
-
)
|
|
812
|
-
}
|
|
813
|
-
|
|
814
496
|
// Pre-fetch icons for SSR embedding
|
|
815
497
|
await prefetchIcons(siteContent, uniweb, onProgress)
|
|
816
498
|
|
|
817
499
|
// Pre-render each page
|
|
818
|
-
const pages = uniweb.activeWebsite.pages
|
|
819
500
|
const website = uniweb.activeWebsite
|
|
820
501
|
|
|
821
|
-
for (const page of pages) {
|
|
502
|
+
for (const page of website.pages) {
|
|
822
503
|
// Skip dynamic template pages — they exist in the content for runtime
|
|
823
504
|
// route matching but can't be pre-rendered (no concrete route)
|
|
824
505
|
if (page.route.includes(':')) continue
|
|
@@ -830,43 +511,31 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
830
511
|
|
|
831
512
|
onProgress(`Rendering ${outputRoute}...`)
|
|
832
513
|
|
|
833
|
-
|
|
834
|
-
uniweb.activeWebsite.setActivePage(page.route)
|
|
835
|
-
|
|
836
|
-
// Create the page element for SSR
|
|
837
|
-
const element = createPageElement(page, website)
|
|
838
|
-
|
|
839
|
-
// Render to HTML string
|
|
840
|
-
let renderedContent
|
|
841
|
-
try {
|
|
842
|
-
renderedContent = renderToString(element)
|
|
843
|
-
} catch (err) {
|
|
844
|
-
const msg = err.message || ''
|
|
514
|
+
const result = renderPage(page, website)
|
|
845
515
|
|
|
846
|
-
|
|
516
|
+
if (result.error) {
|
|
517
|
+
if (result.error.type === 'hooks' || result.error.type === 'null-component') {
|
|
847
518
|
console.warn(
|
|
848
|
-
` Skipped SSG for ${outputRoute} —
|
|
849
|
-
`(useState/useEffect) that cannot render during pre-rendering. ` +
|
|
850
|
-
`The page will render correctly client-side.`
|
|
851
|
-
)
|
|
852
|
-
} else if (msg.includes('Element type is invalid') && msg.includes('null')) {
|
|
853
|
-
console.warn(
|
|
854
|
-
` Skipped SSG for ${outputRoute} — a component resolved to null during pre-rendering. ` +
|
|
855
|
-
`This often happens with components that use React hooks. ` +
|
|
519
|
+
` Skipped SSG for ${outputRoute} — ${result.error.message}. ` +
|
|
856
520
|
`The page will render correctly client-side.`
|
|
857
521
|
)
|
|
858
522
|
} else {
|
|
859
|
-
console.warn(` Warning: Failed to render ${outputRoute}: ${
|
|
523
|
+
console.warn(` Warning: Failed to render ${outputRoute}: ${result.error.message}`)
|
|
860
524
|
}
|
|
861
525
|
|
|
862
526
|
if (process.env.DEBUG) {
|
|
863
|
-
|
|
527
|
+
// renderPage swallows the stack, but the classification message is informative
|
|
864
528
|
}
|
|
865
529
|
continue
|
|
866
530
|
}
|
|
867
531
|
|
|
868
|
-
//
|
|
869
|
-
|
|
532
|
+
// Shared injection: #root, title, meta, section override CSS
|
|
533
|
+
let html = injectPageContent(htmlShell, result.renderedContent, page, {
|
|
534
|
+
sectionOverrideCSS: result.sectionOverrideCSS,
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
// Build-specific: theme CSS, __SITE_CONTENT__, icon cache
|
|
538
|
+
html = injectBuildData(html, siteContent)
|
|
870
539
|
|
|
871
540
|
// Output to the locale-prefixed route
|
|
872
541
|
const outputPath = getOutputPath(distDir, outputRoute)
|
|
@@ -886,123 +555,4 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
886
555
|
}
|
|
887
556
|
}
|
|
888
557
|
|
|
889
|
-
/**
|
|
890
|
-
* Inject rendered content into HTML shell
|
|
891
|
-
*/
|
|
892
|
-
function injectContent(shell, renderedContent, page, siteContent) {
|
|
893
|
-
let html = shell
|
|
894
|
-
|
|
895
|
-
// Inject theme CSS if not already present
|
|
896
|
-
if (siteContent?.theme?.css && !html.includes('id="uniweb-theme"')) {
|
|
897
|
-
html = html.replace(
|
|
898
|
-
'</head>',
|
|
899
|
-
` <style id="uniweb-theme">\n${siteContent.theme.css}\n </style>\n </head>`
|
|
900
|
-
)
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Replace the empty root div with pre-rendered content
|
|
904
|
-
html = html.replace(
|
|
905
|
-
/<div id="root">[\s\S]*?<\/div>/,
|
|
906
|
-
`<div id="root">${renderedContent}</div>`
|
|
907
|
-
)
|
|
908
|
-
|
|
909
|
-
// Update page title
|
|
910
|
-
if (page.title) {
|
|
911
|
-
html = html.replace(
|
|
912
|
-
/<title>.*?<\/title>/,
|
|
913
|
-
`<title>${escapeHtml(page.title)}</title>`
|
|
914
|
-
)
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Add meta description if available
|
|
918
|
-
if (page.description) {
|
|
919
|
-
const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
|
|
920
|
-
if (html.includes('<meta name="description"')) {
|
|
921
|
-
html = html.replace(
|
|
922
|
-
/<meta name="description"[^>]*>/,
|
|
923
|
-
metaDesc
|
|
924
|
-
)
|
|
925
|
-
} else {
|
|
926
|
-
html = html.replace(
|
|
927
|
-
'</head>',
|
|
928
|
-
` ${metaDesc}\n </head>`
|
|
929
|
-
)
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// Inject site content as JSON for hydration
|
|
934
|
-
// Replace existing content if present, otherwise add it
|
|
935
|
-
// Strip CSS from theme (it's already in a <style> tag)
|
|
936
|
-
const contentForJson = { ...siteContent }
|
|
937
|
-
if (contentForJson.theme?.css) {
|
|
938
|
-
contentForJson.theme = { ...contentForJson.theme }
|
|
939
|
-
delete contentForJson.theme.css
|
|
940
|
-
}
|
|
941
|
-
const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson).replace(/</g, '\\u003c')}</script>`
|
|
942
|
-
if (html.includes('__SITE_CONTENT__')) {
|
|
943
|
-
// Replace existing site content with updated version (includes expanded dynamic routes)
|
|
944
|
-
// Match script tag with attributes in any order
|
|
945
|
-
html = html.replace(
|
|
946
|
-
/<script[^>]*id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
|
|
947
|
-
contentScript
|
|
948
|
-
)
|
|
949
|
-
} else {
|
|
950
|
-
html = html.replace(
|
|
951
|
-
'</head>',
|
|
952
|
-
` ${contentScript}\n </head>`
|
|
953
|
-
)
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Inject icon cache so client can render icons immediately without CDN fetches
|
|
957
|
-
if (siteContent._iconCache) {
|
|
958
|
-
const iconScript = `<script id="__ICON_CACHE__" type="application/json">${JSON.stringify(siteContent._iconCache).replace(/</g, '\\u003c')}</script>`
|
|
959
|
-
if (html.includes('__ICON_CACHE__')) {
|
|
960
|
-
html = html.replace(
|
|
961
|
-
/<script[^>]*id="__ICON_CACHE__"[^>]*>[\s\S]*?<\/script>/,
|
|
962
|
-
iconScript
|
|
963
|
-
)
|
|
964
|
-
} else {
|
|
965
|
-
html = html.replace(
|
|
966
|
-
'</head>',
|
|
967
|
-
` ${iconScript}\n </head>`
|
|
968
|
-
)
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
return html
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/**
|
|
976
|
-
* Get output path for a route
|
|
977
|
-
*/
|
|
978
|
-
function getOutputPath(distDir, route) {
|
|
979
|
-
let normalizedRoute = route
|
|
980
|
-
|
|
981
|
-
// Handle root route
|
|
982
|
-
if (normalizedRoute === '/' || normalizedRoute === '') {
|
|
983
|
-
return join(distDir, 'index.html')
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// Remove leading slash
|
|
987
|
-
if (normalizedRoute.startsWith('/')) {
|
|
988
|
-
normalizedRoute = normalizedRoute.slice(1)
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Create directory structure: /about -> /about/index.html
|
|
992
|
-
return join(distDir, normalizedRoute, 'index.html')
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* Escape HTML special characters
|
|
997
|
-
*/
|
|
998
|
-
function escapeHtml(str) {
|
|
999
|
-
if (!str) return ''
|
|
1000
|
-
return String(str)
|
|
1001
|
-
.replace(/&/g, '&')
|
|
1002
|
-
.replace(/</g, '<')
|
|
1003
|
-
.replace(/>/g, '>')
|
|
1004
|
-
.replace(/"/g, '"')
|
|
1005
|
-
.replace(/'/g, ''')
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
558
|
export default prerenderSite
|