@uniweb/build 0.8.18 → 0.8.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,8 @@
17
17
  "./dev": "./src/dev/index.js",
18
18
  "./prerender": "./src/prerender.js",
19
19
  "./i18n": "./src/i18n/index.js",
20
- "./search": "./src/search/index.js"
20
+ "./search": "./src/search/index.js",
21
+ "./import-map-plugin": "./src/import-map-plugin.js"
21
22
  },
22
23
  "files": [
23
24
  "src"
@@ -52,9 +53,9 @@
52
53
  "@uniweb/theming": "0.1.2"
53
54
  },
54
55
  "optionalDependencies": {
55
- "@uniweb/runtime": "0.6.13",
56
- "@uniweb/schemas": "0.2.1",
57
- "@uniweb/content-reader": "1.1.4"
56
+ "@uniweb/content-reader": "1.1.4",
57
+ "@uniweb/runtime": "0.6.15",
58
+ "@uniweb/schemas": "0.2.1"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -63,7 +64,7 @@
63
64
  "@tailwindcss/vite": "^4.0.0",
64
65
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
65
66
  "vite-plugin-svgr": "^4.0.0",
66
- "@uniweb/core": "0.5.12"
67
+ "@uniweb/core": "0.5.14"
67
68
  },
68
69
  "peerDependenciesMeta": {
69
70
  "vite": {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Import Map Plugin
3
+ *
4
+ * Shared Vite plugin that emits import-map bridge modules so that
5
+ * foundations loaded via dynamic import() can resolve bare specifiers
6
+ * (react, @uniweb/core, etc.) to the same instances used by the host app.
7
+ *
8
+ * Production: emits deterministic chunks at _importmap/*.js with explicit
9
+ * named re-exports, and injects a <script type="importmap"> into the HTML.
10
+ *
11
+ * Used by:
12
+ * - Site builds (runtime mode + extensions) — packages/build/src/site/config.js
13
+ * - Runtime shell build — packages/runtime/vite.config.app.js
14
+ * - Dynamic-runtime (editor preview) — packages/uniweb-editor/dynamic-runtime/
15
+ *
16
+ * @module @uniweb/build/import-map-plugin
17
+ */
18
+
19
+ /** Default externals shared between foundations and hosts */
20
+ const DEFAULT_EXTERNALS = [
21
+ 'react',
22
+ 'react-dom',
23
+ 'react/jsx-runtime',
24
+ 'react/jsx-dev-runtime',
25
+ '@uniweb/core',
26
+ ]
27
+
28
+ const IMPORT_MAP_PREFIX = '\0importmap:'
29
+
30
+ /** Valid JS identifier — filters out non-identifier keys from CJS modules */
31
+ const isValidId = (k) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k)
32
+
33
+ /**
34
+ * Create the import map Vite plugin.
35
+ *
36
+ * @param {Object} [options]
37
+ * @param {string[]} [options.externals] - Package specifiers to bridge (default: react, react-dom, @uniweb/core, etc.)
38
+ * @param {string} [options.name] - Plugin name (default: 'uniweb:import-map')
39
+ * @param {string} [options.basePath] - Base path prefix for import map URLs in HTML (default: '/')
40
+ * @param {string} [options.resolveFrom] - Absolute path to resolve bare specifiers from inside virtual modules.
41
+ * Needed when the host project doesn't have the externals as direct dependencies (e.g., site builds
42
+ * under pnpm strict mode resolve from the foundation directory instead).
43
+ * @param {Object} [options.devBridges] - Map of specifier → dev-mode URL for import map injection in dev.
44
+ * When provided, the import map is injected in both dev and prod (with different URLs).
45
+ * When omitted, the import map is only injected in prod (dev uses other mechanisms like transformRequest).
46
+ * @returns {import('vite').Plugin}
47
+ */
48
+ export function importMapPlugin({
49
+ externals = DEFAULT_EXTERNALS,
50
+ name = 'uniweb:import-map',
51
+ basePath = '/',
52
+ resolveFrom,
53
+ devBridges,
54
+ } = {}) {
55
+ let isBuild = false
56
+
57
+ return {
58
+ name,
59
+
60
+ configResolved(config) {
61
+ isBuild = config.command === 'build'
62
+ },
63
+
64
+ resolveId(id, importer) {
65
+ if (id.startsWith(IMPORT_MAP_PREFIX)) return id
66
+ // Bare specifiers inside our virtual modules (e.g. '@uniweb/core' re-exported
67
+ // from '\0importmap:@uniweb/core') can't be resolved by Rollup because virtual
68
+ // modules have no filesystem context. When a resolveFrom path is provided,
69
+ // resolve from there (e.g. the foundation directory under pnpm strict mode).
70
+ if (resolveFrom && importer?.startsWith(IMPORT_MAP_PREFIX) && externals.includes(id)) {
71
+ return this.resolve(id, resolveFrom, { skipSelf: true })
72
+ }
73
+ },
74
+
75
+ async load(id) {
76
+ if (!id.startsWith(IMPORT_MAP_PREFIX)) return
77
+ const pkg = id.slice(IMPORT_MAP_PREFIX.length)
78
+
79
+ // Generate explicit named re-exports (not `export *`) because CJS
80
+ // packages like React only expose a default via `export *`, losing
81
+ // individual named exports (useState, jsx, etc.) that foundations need.
82
+ try {
83
+ const mod = await import(pkg)
84
+ const names = Object.keys(mod).filter((k) => k !== '__esModule' && isValidId(k))
85
+ const hasDefault = 'default' in mod
86
+ const named = names.filter((k) => k !== 'default')
87
+ const lines = []
88
+ if (named.length) {
89
+ lines.push(`export { ${named.join(', ')} } from '${pkg}'`)
90
+ }
91
+ if (hasDefault) {
92
+ lines.push(`export { default } from '${pkg}'`)
93
+ }
94
+ return lines.join('\n') || 'export {}'
95
+ } catch {
96
+ // Fallback: generic re-export (may not preserve named exports for CJS)
97
+ return `export * from '${pkg}'`
98
+ }
99
+ },
100
+
101
+ // Emit deterministic chunks for each external (production only).
102
+ // preserveSignature: 'exports-only' tells Rollup to preserve the original
103
+ // export names (useState, jsx, etc.) instead of mangling them.
104
+ buildStart() {
105
+ if (!isBuild) return
106
+ for (const ext of externals) {
107
+ this.emitFile({
108
+ type: 'chunk',
109
+ id: `${IMPORT_MAP_PREFIX}${ext}`,
110
+ fileName: `_importmap/${ext.replace(/\//g, '-')}.js`,
111
+ preserveSignature: 'exports-only',
112
+ })
113
+ }
114
+ },
115
+
116
+ // Inject the import map into the HTML.
117
+ // In prod: always injects with basePath-prefixed _importmap/ URLs.
118
+ // In dev: only injects if devBridges are provided (otherwise, the consumer
119
+ // handles dev-mode resolution via other mechanisms like transformRequest).
120
+ transformIndexHtml: {
121
+ order: 'pre',
122
+ handler(html) {
123
+ const imports = {}
124
+
125
+ if (isBuild) {
126
+ for (const ext of externals) {
127
+ imports[ext] = `${basePath}_importmap/${ext.replace(/\//g, '-')}.js`
128
+ }
129
+ } else if (devBridges) {
130
+ Object.assign(imports, devBridges)
131
+ } else {
132
+ // No dev injection — consumer handles dev mode separately
133
+ return html
134
+ }
135
+
136
+ const importMap = JSON.stringify({ imports }, null, 2)
137
+ const script = ` <script type="importmap">\n${importMap}\n </script>\n`
138
+ // Import map must appear before any module scripts
139
+ return html.replace('<head>', '<head>\n' + script)
140
+ },
141
+ },
142
+ }
143
+ }
144
+
145
+ export { DEFAULT_EXTERNALS }
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
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from 'node:fs'
24
24
  import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
  import { generateEntryPoint, shouldRegenerateForFile } from '../generate-entry.js'
27
+ import { importMapPlugin } from '../import-map-plugin.js'
27
28
 
28
29
  /**
29
30
  * Normalize a base path for Vite compatibility
@@ -343,105 +344,73 @@ export async function defineSiteConfig(options = {}) {
343
344
 
344
345
  if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
345
346
 
346
- // Import map plugin for runtime mode production builds
347
+ // Import map plugin for runtime mode production builds.
347
348
  // Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
348
- // so the browser can resolve bare specifiers in the dynamically-imported foundation
349
- const IMPORT_MAP_EXTERNALS = [
350
- 'react',
351
- 'react-dom',
352
- 'react/jsx-runtime',
353
- 'react/jsx-dev-runtime',
354
- '@uniweb/core'
355
- ]
356
- const IMPORT_MAP_PREFIX = '\0importmap:'
357
-
358
- const importMapPlugin = needsImportMap ? (() => {
359
- let isBuild = false
360
-
361
- return {
362
- name: 'uniweb:import-map',
363
-
364
- configResolved(config) {
365
- isBuild = config.command === 'build'
366
- },
367
-
368
- resolveId(id, importer) {
369
- if (id.startsWith(IMPORT_MAP_PREFIX)) return id
370
- // Bare specifiers inside our virtual modules (e.g. '@uniweb/core' re-exported
371
- // from '\0importmap:@uniweb/core') can't be resolved by Rollup because virtual
372
- // modules have no filesystem context. Resolve from the foundation directory where
373
- // @uniweb/core is a direct dependency (the site may not have it under pnpm strict).
374
- if (importer?.startsWith(IMPORT_MAP_PREFIX) && IMPORT_MAP_EXTERNALS.includes(id)) {
375
- const resolveFrom = foundationInfo.path
376
- ? resolve(foundationInfo.path, 'package.json')
377
- : resolve(siteRoot, 'main.js')
378
- return this.resolve(id, resolveFrom, { skipSelf: true })
379
- }
380
- },
349
+ // so the browser can resolve bare specifiers in the dynamically-imported foundation.
350
+ // In dev mode, Vite's transformRequest() handles bare specifier resolution instead.
351
+ if (needsImportMap) {
352
+ plugins.push(importMapPlugin({
353
+ basePath: base || '/',
354
+ // Under pnpm strict mode, the site may not have @uniweb/core in its own
355
+ // node_modules. Resolve from the foundation directory where it's a direct dep.
356
+ resolveFrom: foundationInfo.path
357
+ ? resolve(foundationInfo.path, 'package.json')
358
+ : resolve(siteRoot, 'main.js'),
359
+ }))
360
+ }
381
361
 
382
- async load(id) {
383
- if (!id.startsWith(IMPORT_MAP_PREFIX)) return
384
- const pkg = id.slice(IMPORT_MAP_PREFIX.length)
385
- // Dynamically discover exports at build time by importing the package.
386
- // We generate explicit named re-exports (not `export *`) because CJS
387
- // packages like React only expose a default via `export *`, losing
388
- // individual named exports (useState, jsx, etc.) that foundations need.
389
- try {
390
- const mod = await import(pkg)
391
- const names = Object.keys(mod).filter(k => k !== '__esModule')
392
- const hasDefault = 'default' in mod
393
- const named = names.filter(k => k !== 'default')
394
- const lines = []
395
- if (named.length) {
396
- lines.push(`export { ${named.join(', ')} } from '${pkg}'`)
397
- }
398
- if (hasDefault) {
399
- lines.push(`export { default } from '${pkg}'`)
362
+ // Preload hints for runtime-loaded foundations and extensions.
363
+ // In runtime mode, foundation JS is loaded via import() and CSS is injected
364
+ // dynamically in JavaScript — the browser doesn't discover them until JS executes.
365
+ // These <link> tags let the browser start fetching during HTML parsing.
366
+ // Shell mode is excluded: URLs come from __DATA__ at serve time (unicloud handles it).
367
+ if (isRuntimeMode && !isShellMode) {
368
+ plugins.push({
369
+ name: 'uniweb:foundation-preload',
370
+ transformIndexHtml: {
371
+ order: 'post',
372
+ handler() {
373
+ const tags = []
374
+
375
+ // Foundation JS modulepreload
376
+ if (foundationConfig.url) {
377
+ tags.push({
378
+ tag: 'link',
379
+ attrs: { rel: 'modulepreload', href: foundationConfig.url },
380
+ injectTo: 'head',
381
+ })
400
382
  }
401
- return lines.join('\n') || `export {}`
402
- } catch {
403
- // Fallback: generic re-export (may not preserve named exports for CJS)
404
- return `export * from '${pkg}'`
405
- }
406
- },
407
383
 
408
- // Emit deterministic chunks for each external (production only).
409
- // preserveSignature: 'exports-only' tells Rollup to preserve the original
410
- // export names (useState, jsx, etc.) instead of mangling them.
411
- // In dev mode, Vite's transformRequest() resolves bare specifiers instead.
412
- buildStart() {
413
- if (!isBuild) return
414
- for (const ext of IMPORT_MAP_EXTERNALS) {
415
- this.emitFile({
416
- type: 'chunk',
417
- id: `${IMPORT_MAP_PREFIX}${ext}`,
418
- fileName: `_importmap/${ext.replace(/\//g, '-')}.js`,
419
- preserveSignature: 'exports-only'
420
- })
421
- }
422
- },
384
+ // Foundation CSS injected as a real <link> so the browser fetches it
385
+ // during HTML parsing instead of waiting for loadFoundationCSS() in JS.
386
+ // The runtime's dynamic <link> deduplicates (same URL, already cached).
387
+ if (foundationConfig.cssUrl) {
388
+ tags.push({
389
+ tag: 'link',
390
+ attrs: { rel: 'stylesheet', href: foundationConfig.cssUrl },
391
+ injectTo: 'head',
392
+ })
393
+ }
423
394
 
424
- // Inject the import map into the HTML (production only).
425
- // In dev mode, Vite's transformRequest() handles bare specifier resolution.
426
- transformIndexHtml: {
427
- order: 'pre',
428
- handler(html) {
429
- if (!isBuild) return html
430
- const basePath = base || '/'
431
- const imports = {}
432
- for (const ext of IMPORT_MAP_EXTERNALS) {
433
- imports[ext] = `${basePath}_importmap/${ext.replace(/\//g, '-')}.js`
395
+ // Extension JS modulepreload (CSS left to runtime we can't reliably
396
+ // derive CSS URLs for all extension formats)
397
+ const extensions = siteConfig.extensions || []
398
+ for (const ext of extensions) {
399
+ const url = typeof ext === 'string' ? ext : ext?.url
400
+ if (url) {
401
+ tags.push({
402
+ tag: 'link',
403
+ attrs: { rel: 'modulepreload', href: url },
404
+ injectTo: 'head',
405
+ })
406
+ }
434
407
  }
435
- const importMap = JSON.stringify({ imports }, null, 2)
436
- const script = ` <script type="importmap">\n${importMap}\n </script>\n`
437
- // Import map must appear before any module scripts
438
- return html.replace('<head>', '<head>\n' + script)
439
- }
440
- }
441
- }
442
- })() : null
443
408
 
444
- if (importMapPlugin) plugins.push(importMapPlugin)
409
+ return tags
410
+ },
411
+ },
412
+ })
413
+ }
445
414
 
446
415
  // Build foundation config for runtime
447
416
  const foundationConfig = {