@uniweb/build 0.4.8 → 0.4.10

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.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/content-reader": "1.1.2",
54
- "@uniweb/runtime": "0.5.9"
54
+ "@uniweb/runtime": "0.5.11"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -9,7 +9,7 @@
9
9
  * - `meta` - Per-component runtime metadata extracted from meta.js files
10
10
  *
11
11
  * The `meta` export contains only properties needed at runtime:
12
- * - `background` - Engine-level background image handling
12
+ * - `background` - 'self' opt-out when component handles its own background
13
13
  * - `data` - CMS entity binding ({ type, limit })
14
14
  * - `defaults` - Param default values
15
15
  * - `context` - Static capabilities for cross-block coordination
package/src/prerender.js CHANGED
@@ -11,13 +11,13 @@
11
11
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
12
12
  import { existsSync, readdirSync, statSync } from 'node:fs'
13
13
  import { join, dirname, resolve } from 'node:path'
14
- import { pathToFileURL } from 'node:url'
15
14
  import { createRequire } from 'node:module'
15
+ import { pathToFileURL } from 'node:url'
16
16
  import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
17
17
 
18
18
  // Lazily loaded dependencies
19
19
  let React, renderToString, createUniweb
20
- let preparePropsSSR, getComponentMetaSSR, guaranteeContentStructureSSR
20
+ let preparePropsSSR, getComponentMetaSSR
21
21
 
22
22
  /**
23
23
  * Execute all data fetches for prerender
@@ -229,25 +229,24 @@ async function processSectionFetches(sections, cascadedData, fetchOptions, onPro
229
229
  async function loadDependencies(siteDir) {
230
230
  if (React) return // Already loaded
231
231
 
232
- // Create a require function that resolves from the site's perspective
233
- // This ensures we get the same React instance that the foundation uses
232
+ // Load React from the site's node_modules using createRequire.
233
+ // This ensures we get the same React instance as the foundation
234
+ // components (which are loaded via pathToFileURL and externalize React
235
+ // to the same node_modules). Using bare import('react') would resolve
236
+ // from @uniweb/build's context, creating a dual-React instance problem.
234
237
  const absoluteSiteDir = resolve(siteDir)
235
238
  const siteRequire = createRequire(join(absoluteSiteDir, 'package.json'))
236
239
 
237
240
  try {
238
- // Try to load React from site's node_modules
239
241
  const reactMod = siteRequire('react')
240
242
  const serverMod = siteRequire('react-dom/server')
241
-
242
243
  React = reactMod.default || reactMod
243
244
  renderToString = serverMod.renderToString
244
245
  } catch {
245
- // Fallback to dynamic import if require fails
246
246
  const [reactMod, serverMod] = await Promise.all([
247
247
  import('react'),
248
248
  import('react-dom/server')
249
249
  ])
250
-
251
250
  React = reactMod.default || reactMod
252
251
  renderToString = serverMod.renderToString
253
252
  }
@@ -256,11 +255,12 @@ async function loadDependencies(siteDir) {
256
255
  const coreMod = await import('@uniweb/core')
257
256
  createUniweb = coreMod.createUniweb
258
257
 
259
- // Load runtime utilities (prepare-props doesn't use React)
258
+ // Load pure utility functions from runtime SSR bundle.
259
+ // These are plain functions (no hooks), so they work even if the SSR
260
+ // bundle resolves a different React instance internally.
260
261
  const runtimeMod = await import('@uniweb/runtime/ssr')
261
262
  preparePropsSSR = runtimeMod.prepareProps
262
263
  getComponentMetaSSR = runtimeMod.getComponentMeta
263
- guaranteeContentStructureSSR = runtimeMod.guaranteeContentStructure
264
264
  }
265
265
 
266
266
  /**
@@ -301,8 +301,155 @@ async function prefetchIcons(siteContent, uniweb, onProgress) {
301
301
  }
302
302
 
303
303
  /**
304
- * Inline BlockRenderer for SSR
305
- * Uses React from prerender's scope to avoid module resolution issues
304
+ * Valid color contexts for section theming
305
+ */
306
+ const VALID_CONTEXTS = ['light', 'medium', 'dark']
307
+
308
+ /**
309
+ * Build wrapper props from block configuration
310
+ * Mirrors getWrapperProps in BlockRenderer.jsx
311
+ */
312
+ function getWrapperProps(block) {
313
+ const theme = block.themeName
314
+ const blockClassName = block.state?.className || ''
315
+
316
+ let contextClass = ''
317
+ if (theme && VALID_CONTEXTS.includes(theme)) {
318
+ contextClass = `context-${theme}`
319
+ }
320
+
321
+ let className = contextClass
322
+ if (blockClassName) {
323
+ className = className ? `${className} ${blockClassName}` : blockClassName
324
+ }
325
+
326
+ const { background = {} } = block.standardOptions
327
+ const style = {}
328
+ if (background.mode) {
329
+ style.position = 'relative'
330
+ }
331
+
332
+ const sectionId = block.stableId || block.id
333
+ return { id: `section-${sectionId}`, style, className, background }
334
+ }
335
+
336
+ /**
337
+ * Render a background element for SSR
338
+ * Mirrors the Background component in Background.jsx (image, color, gradient only)
339
+ * Video backgrounds are skipped in SSR (they require JS for autoplay)
340
+ */
341
+ function renderBackground(background) {
342
+ if (!background?.mode) return null
343
+
344
+ const containerStyle = {
345
+ position: 'absolute',
346
+ inset: '0',
347
+ overflow: 'hidden',
348
+ zIndex: 0,
349
+ }
350
+
351
+ const children = []
352
+
353
+ // Resolve URL against basePath for subdirectory deployments
354
+ const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
355
+ function resolveUrl(url) {
356
+ if (!url || !url.startsWith('/')) return url
357
+ if (!basePath) return url
358
+ if (url.startsWith(basePath + '/') || url === basePath) return url
359
+ return basePath + url
360
+ }
361
+
362
+ if (background.mode === 'color' && background.color) {
363
+ children.push(
364
+ React.createElement('div', {
365
+ key: 'bg-color',
366
+ className: 'background-color',
367
+ style: { position: 'absolute', inset: '0', backgroundColor: background.color },
368
+ 'aria-hidden': 'true'
369
+ })
370
+ )
371
+ }
372
+
373
+ if (background.mode === 'gradient' && background.gradient) {
374
+ const g = background.gradient
375
+ const angle = g.angle || 0
376
+ const start = g.start || 'transparent'
377
+ const end = g.end || 'transparent'
378
+ const startPos = g.startPosition || 0
379
+ const endPos = g.endPosition || 100
380
+ children.push(
381
+ React.createElement('div', {
382
+ key: 'bg-gradient',
383
+ className: 'background-gradient',
384
+ style: {
385
+ position: 'absolute', inset: '0',
386
+ background: `linear-gradient(${angle}deg, ${start} ${startPos}%, ${end} ${endPos}%)`
387
+ },
388
+ 'aria-hidden': 'true'
389
+ })
390
+ )
391
+ }
392
+
393
+ if (background.mode === 'image' && background.image?.src) {
394
+ const img = background.image
395
+ children.push(
396
+ React.createElement('div', {
397
+ key: 'bg-image',
398
+ className: 'background-image',
399
+ style: {
400
+ position: 'absolute', inset: '0',
401
+ backgroundImage: `url(${resolveUrl(img.src)})`,
402
+ backgroundPosition: img.position || 'center',
403
+ backgroundSize: img.size || 'cover',
404
+ backgroundRepeat: 'no-repeat'
405
+ },
406
+ 'aria-hidden': 'true'
407
+ })
408
+ )
409
+ }
410
+
411
+ // Overlay
412
+ if (background.overlay?.enabled) {
413
+ const ov = background.overlay
414
+ let overlayStyle
415
+
416
+ if (ov.gradient) {
417
+ const g = ov.gradient
418
+ overlayStyle = {
419
+ position: 'absolute', inset: '0', pointerEvents: 'none',
420
+ 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}%)`,
421
+ opacity: ov.opacity ?? 0.5
422
+ }
423
+ } else {
424
+ const baseColor = ov.type === 'light' ? '255, 255, 255' : '0, 0, 0'
425
+ overlayStyle = {
426
+ position: 'absolute', inset: '0', pointerEvents: 'none',
427
+ backgroundColor: `rgba(${baseColor}, ${ov.opacity ?? 0.5})`
428
+ }
429
+ }
430
+
431
+ children.push(
432
+ React.createElement('div', {
433
+ key: 'bg-overlay',
434
+ className: 'background-overlay',
435
+ style: overlayStyle,
436
+ 'aria-hidden': 'true'
437
+ })
438
+ )
439
+ }
440
+
441
+ if (children.length === 0) return null
442
+
443
+ return React.createElement('div', {
444
+ className: `background background--${background.mode}`,
445
+ style: containerStyle,
446
+ 'aria-hidden': 'true'
447
+ }, ...children)
448
+ }
449
+
450
+ /**
451
+ * Render a single block for SSR
452
+ * Mirrors BlockRenderer.jsx but without hooks (no runtime data fetching in SSR)
306
453
  */
307
454
  function renderBlock(block) {
308
455
  const Component = block.initComponent()
@@ -318,14 +465,10 @@ function renderBlock(block) {
318
465
  let content, params
319
466
 
320
467
  if (block.parsedContent?._isPoc) {
321
- // Simple PoC format - content was passed directly
322
468
  content = block.parsedContent._pocContent
323
469
  params = block.properties
324
470
  } else {
325
- // Get runtime metadata for this component
326
471
  const meta = getComponentMetaSSR(block.type)
327
-
328
- // Prepare props with runtime guarantees
329
472
  const prepared = preparePropsSSR(block, meta)
330
473
  params = prepared.params
331
474
  content = {
@@ -335,28 +478,33 @@ function renderBlock(block) {
335
478
  }
336
479
  }
337
480
 
338
- const componentProps = {
339
- content,
340
- params,
341
- block
481
+ // Background handling (mirrors BlockRenderer.jsx)
482
+ const { background, ...wrapperProps } = getWrapperProps(block)
483
+ const meta = getComponentMetaSSR(block.type)
484
+ const hasBackground = background?.mode && meta?.background !== 'self'
485
+
486
+ if (hasBackground) {
487
+ params = { ...params, _hasBackground: true }
342
488
  }
343
489
 
344
- // Wrapper props
345
- // Use stableId for DOM ID if available (stable across reordering)
346
- const theme = block.themeName
347
- const sectionId = block.stableId || block.id
348
- const wrapperProps = {
349
- id: `section-${sectionId}`,
350
- className: theme || ''
490
+ const componentProps = { content, params, block }
491
+
492
+ if (hasBackground) {
493
+ return React.createElement('section', wrapperProps,
494
+ renderBackground(background),
495
+ React.createElement('div', { className: 'relative z-10' },
496
+ React.createElement(Component, componentProps)
497
+ )
498
+ )
351
499
  }
352
500
 
353
- return React.createElement('div', wrapperProps,
501
+ return React.createElement('section', wrapperProps,
354
502
  React.createElement(Component, componentProps)
355
503
  )
356
504
  }
357
505
 
358
506
  /**
359
- * Inline Blocks renderer for SSR
507
+ * Render an array of blocks for SSR
360
508
  */
361
509
  function renderBlocks(blocks) {
362
510
  if (!blocks || blocks.length === 0) return null
@@ -368,7 +516,7 @@ function renderBlocks(blocks) {
368
516
  }
369
517
 
370
518
  /**
371
- * Inline Layout renderer for SSR
519
+ * Render page layout for SSR
372
520
  */
373
521
  function renderLayout(page, website) {
374
522
  const RemoteLayout = website.getRemoteLayout()
@@ -387,34 +535,25 @@ function renderLayout(page, website) {
387
535
 
388
536
  if (RemoteLayout) {
389
537
  return React.createElement(RemoteLayout, {
390
- page,
391
- website,
392
- header: headerElement,
393
- body: bodyElement,
394
- footer: footerElement,
395
- left: leftElement,
396
- right: rightElement,
397
- leftPanel: leftElement,
398
- rightPanel: rightElement
538
+ page, website,
539
+ header: headerElement, body: bodyElement, footer: footerElement,
540
+ left: leftElement, right: rightElement,
541
+ leftPanel: leftElement, rightPanel: rightElement
399
542
  })
400
543
  }
401
544
 
402
- // Default layout
403
545
  return React.createElement(React.Fragment, null,
404
- headerElement,
405
- bodyElement,
406
- footerElement
546
+ headerElement && React.createElement('header', null, headerElement),
547
+ bodyElement && React.createElement('main', null, bodyElement),
548
+ footerElement && React.createElement('footer', null, footerElement)
407
549
  )
408
550
  }
409
551
 
410
552
  /**
411
- * Inline PageElement for SSR
412
- * Uses React from prerender's scope
553
+ * Create a page element for SSR
413
554
  */
414
555
  function createPageElement(page, website) {
415
- return React.createElement('main', null,
416
- renderLayout(page, website)
417
- )
556
+ return renderLayout(page, website)
418
557
  }
419
558
 
420
559
  /**
@@ -585,7 +724,7 @@ export async function prerenderSite(siteDir, options = {}) {
585
724
  // Set this as the active page
586
725
  uniweb.activeWebsite.setActivePage(page.route)
587
726
 
588
- // Create the page element using inline SSR rendering
727
+ // Create the page element for SSR
589
728
  const element = createPageElement(page, website)
590
729
 
591
730
  // Render to HTML string
@@ -5,7 +5,7 @@
5
5
  * The runtime schema is optimized for size and contains only what's
6
6
  * needed at render time:
7
7
  *
8
- * - background: boolean for engine-level background handling
8
+ * - background: 'self' when component handles its own background
9
9
  * - data: { type, limit } for CMS entity binding
10
10
  * - defaults: param default values
11
11
  * - context: static capabilities for cross-block coordination
@@ -190,7 +190,8 @@ export function extractRuntimeSchema(fullMeta) {
190
190
 
191
191
  const runtime = {}
192
192
 
193
- // Background handling (boolean or 'auto'/'manual')
193
+ // Background opt-out: 'self' means the component renders its own background
194
+ // layer (solid colors, insets, effects), so the runtime skips its Background.
194
195
  if (fullMeta.background) {
195
196
  runtime.background = fullMeta.background
196
197
  }
@@ -261,6 +261,18 @@ export function rewriteParamPaths(params, pathMapping) {
261
261
  }
262
262
  }
263
263
 
264
+ // Rewrite nested paths in structured background object
265
+ if (result.background && typeof result.background === 'object') {
266
+ const bg = { ...result.background }
267
+ if (bg.image?.src && pathMapping[bg.image.src]) {
268
+ bg.image = { ...bg.image, src: pathMapping[bg.image.src] }
269
+ }
270
+ if (bg.video?.src && pathMapping[bg.video.src]) {
271
+ bg.video = { ...bg.video, src: pathMapping[bg.video.src] }
272
+ }
273
+ result.background = bg
274
+ }
275
+
264
276
  return result
265
277
  }
266
278
 
@@ -130,8 +130,9 @@ export function resolveAssetPath(src, contextPath, siteRoot) {
130
130
  return { src, resolved: null, external: true }
131
131
  }
132
132
 
133
- // Already absolute path on filesystem
134
- if (isAbsolute(src)) {
133
+ // Already absolute path on filesystem (e.g., /Users/foo/bar.jpg)
134
+ // Must actually exist — otherwise it's a site-relative path like /images/hero.jpg
135
+ if (isAbsolute(src) && existsSync(src)) {
135
136
  return { src, resolved: src, external: false }
136
137
  }
137
138
 
@@ -281,6 +282,27 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
281
282
  }
282
283
  }
283
284
 
285
+ // Collect from structured background object
286
+ // Background can be { image: { src }, video: { src } } with nested asset paths
287
+ const bg = section.params?.background
288
+ if (bg && typeof bg === 'object') {
289
+ const bgSources = [bg.image?.src, bg.video?.src].filter(Boolean)
290
+ for (const src of bgSources) {
291
+ if (typeof src === 'string') {
292
+ const result = resolveAssetPath(src, markdownPath, siteRoot)
293
+ if (!result.external && result.resolved) {
294
+ assets[src] = {
295
+ original: src,
296
+ resolved: result.resolved,
297
+ isImage: result.isImage,
298
+ isVideo: result.isVideo,
299
+ isPdf: result.isPdf
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+
284
306
  // Collect from tagged code blocks (JSON/YAML data)
285
307
  if (section.content) {
286
308
  walkDataBlockAssets(section.content, (assetPath) => {