@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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. 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.17",
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.12"
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
- * The output includes full HTML with hydration support.
6
- *
7
- * Uses @uniweb/runtime/ssr for rendering components, ensuring
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 dependencies from site's context (ensures same React instance as foundation)
693
- onProgress('Loading dependencies...')
694
- await loadDependencies(siteDir)
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 for this locale
757
- onProgress('Initializing runtime...')
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
- // Pre-populate DataStore so EntityStore can resolve data during prerender
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
- uniweb.setFoundation(foundation)
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
- // Set this as the active page
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
- if (msg.includes('Invalid hook call') || msg.includes('useState') || msg.includes('useEffect')) {
516
+ if (result.error) {
517
+ if (result.error.type === 'hooks' || result.error.type === 'null-component') {
847
518
  console.warn(
848
- ` Skipped SSG for ${outputRoute} — contains components with React hooks ` +
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}: ${msg}`)
523
+ console.warn(` Warning: Failed to render ${outputRoute}: ${result.error.message}`)
860
524
  }
861
525
 
862
526
  if (process.env.DEBUG) {
863
- console.error(err.stack)
527
+ // renderPage swallows the stack, but the classification message is informative
864
528
  }
865
529
  continue
866
530
  }
867
531
 
868
- // Inject into shell
869
- const html = injectContent(htmlShell, renderedContent, page, siteContent)
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, '&amp;')
1002
- .replace(/</g, '&lt;')
1003
- .replace(/>/g, '&gt;')
1004
- .replace(/"/g, '&quot;')
1005
- .replace(/'/g, '&#39;')
1006
- }
1007
-
1008
558
  export default prerenderSite