@uniweb/build 0.10.2 → 0.11.2

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.10.2",
3
+ "version": "0.11.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,9 +57,9 @@
57
57
  "@uniweb/theming": "0.1.3"
58
58
  },
59
59
  "optionalDependencies": {
60
- "@uniweb/content-reader": "1.1.4",
61
- "@uniweb/schemas": "0.2.1",
62
- "@uniweb/runtime": "0.8.2"
60
+ "@uniweb/runtime": "0.8.4",
61
+ "@uniweb/content-reader": "1.1.5",
62
+ "@uniweb/schemas": "0.2.1"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -68,7 +68,7 @@
68
68
  "@tailwindcss/vite": "^4.0.0",
69
69
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
70
70
  "vite-plugin-svgr": "^4.0.0",
71
- "@uniweb/core": "0.7.1"
71
+ "@uniweb/core": "0.7.3"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "vite": {
@@ -37,6 +37,7 @@ import { foundationPlugin } from '../vite-foundation-plugin.js'
37
37
  const DEFAULT_EXTERNALS = [
38
38
  'react',
39
39
  'react-dom',
40
+ 'react-dom/server',
40
41
  'react/jsx-runtime',
41
42
  'react/jsx-dev-runtime',
42
43
  '@uniweb/core'
@@ -25,6 +25,53 @@ import { join, dirname } from 'node:path'
25
25
  import { discoverComponents, discoverLayoutsInPath } from './schema.js'
26
26
  import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
27
27
 
28
+ /**
29
+ * Packages that may be bundled inside a foundation but require single-instance
30
+ * access from a host environment (currently: unipress). When a foundation
31
+ * declares any of these as a dependency, we re-export the named symbols
32
+ * from the generated entry so the host can reach the foundation's bundled
33
+ * copy instead of importing its own — avoiding the dual-instance trap
34
+ * (each side gets its own React.createContext, registrations land in a
35
+ * context the other side can't see).
36
+ *
37
+ * Detection is by `dependencies` / `peerDependencies` declaration. Vite
38
+ * would fail to resolve an undeclared bare import anyway, so "declared"
39
+ * and "imported" stay aligned in practice. A foundation that declares one
40
+ * of these and never actually imports it pays the cost of bundling it (the
41
+ * re-export keeps the symbols alive) — minor and easily fixed by removing
42
+ * the unused dep.
43
+ *
44
+ * The export list is a public-API contract: removing a symbol here breaks
45
+ * hosts compiled against older built foundations that re-exported it. Add
46
+ * conservatively.
47
+ */
48
+ const HOST_SHAREABLE_PACKAGES = {
49
+ '@uniweb/press': ['compileSubtree', 'compileDocument']
50
+ }
51
+
52
+ /**
53
+ * Detect which HOST_SHAREABLE_PACKAGES the foundation declares as a dep.
54
+ * Reads the foundation's own package.json (one level above srcDir).
55
+ * Returns [] if package.json is missing or unreadable.
56
+ */
57
+ async function detectHostShareableImports(srcDir) {
58
+ const packages = Object.keys(HOST_SHAREABLE_PACKAGES)
59
+ if (packages.length === 0) return []
60
+
61
+ const pkgPath = join(dirname(srcDir), 'package.json')
62
+ if (!existsSync(pkgPath)) return []
63
+
64
+ let pkg
65
+ try {
66
+ pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
67
+ } catch {
68
+ return []
69
+ }
70
+
71
+ const declared = { ...pkg.dependencies, ...pkg.peerDependencies }
72
+ return packages.filter(p => p in declared)
73
+ }
74
+
28
75
  /**
29
76
  * Detect foundation config file (for props, vars, etc.)
30
77
  *
@@ -79,6 +126,7 @@ function generateEntrySource(components, options = {}) {
79
126
  meta = {},
80
127
  layouts = {},
81
128
  layoutMeta = {},
129
+ hostShareableImports = [],
82
130
  } = options
83
131
 
84
132
  const componentNames = Object.keys(components).sort()
@@ -148,6 +196,18 @@ function generateEntrySource(components, options = {}) {
148
196
  // Default export — non-component data (naturally unforgeable key)
149
197
  lines.push('')
150
198
  lines.push('export default { meta, capabilities, layoutMeta }')
199
+
200
+ // Re-export host-shareable packages the foundation actually imports.
201
+ // Lets unipress reach the foundation's bundled copy instead of importing
202
+ // its own (which would create a dual-instance React-context trap).
203
+ if (hostShareableImports.length > 0) {
204
+ lines.push('')
205
+ for (const pkg of hostShareableImports) {
206
+ const symbols = HOST_SHAREABLE_PACKAGES[pkg]
207
+ lines.push(`export { ${symbols.join(', ')} } from '${pkg}'`)
208
+ }
209
+ }
210
+
151
211
  lines.push('')
152
212
 
153
213
  return lines.join('\n')
@@ -239,6 +299,9 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
239
299
  // Extract per-layout runtime metadata from meta.js files
240
300
  const layoutMeta = extractAllLayoutRuntimeSchemas(layouts)
241
301
 
302
+ // Detect which host-shareable packages the foundation imports
303
+ const hostShareableImports = await detectHostShareableImports(srcDir)
304
+
242
305
  // Generate source
243
306
  const source = generateEntrySource(components, {
244
307
  cssPath,
@@ -246,6 +309,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
246
309
  meta,
247
310
  layouts,
248
311
  layoutMeta,
312
+ hostShareableImports,
249
313
  })
250
314
 
251
315
  // Write to file (skip if content unchanged to avoid unnecessary watcher triggers)
@@ -272,6 +336,9 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
272
336
  if (foundationExports) {
273
337
  console.log(` - Foundation exports found: ${foundationExports.path}`)
274
338
  }
339
+ if (hostShareableImports.length > 0) {
340
+ console.log(` - Host-shareable re-exports: ${hostShareableImports.join(', ')}`)
341
+ }
275
342
 
276
343
  return {
277
344
  outputPath: output,
@@ -280,6 +347,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
280
347
  foundationExports,
281
348
  meta,
282
349
  layoutMeta,
350
+ hostShareableImports,
283
351
  }
284
352
  }
285
353
 
@@ -20,6 +20,7 @@
20
20
  const DEFAULT_EXTERNALS = [
21
21
  'react',
22
22
  'react-dom',
23
+ 'react-dom/server',
23
24
  'react/jsx-runtime',
24
25
  'react/jsx-dev-runtime',
25
26
  '@uniweb/core',
package/src/prerender.js CHANGED
@@ -434,6 +434,29 @@ export async function prerenderSite(siteDir, options = {}) {
434
434
  }
435
435
  const defaultSiteContent = JSON.parse(await readFile(contentPath, 'utf8'))
436
436
 
437
+ // Link-mode detection: if site.yml's foundation is a registry scoped ref
438
+ // or a URL, the foundation lives on the hosting edge, not on disk. Static
439
+ // prerender (which writes dist/<route>/index.html) has no local JS to
440
+ // execute, so we skip cleanly here. This is the right call for CLI deploy
441
+ // (where the Worker SSRs from R2 at serve time) and for any site whose
442
+ // foundation is deliberately remote. Sites that still need prerender +
443
+ // registry foundation would need a fetch-and-execute path, tracked as
444
+ // future work.
445
+ const fndRef = defaultSiteContent?.config?.foundation
446
+ const isLinkModeFoundation = (
447
+ (typeof fndRef === 'string' && (
448
+ /^@[a-z0-9_-]+\/[a-z0-9_-]+@.+$/.test(fndRef) ||
449
+ fndRef.startsWith('http://') ||
450
+ fndRef.startsWith('https://')
451
+ )) ||
452
+ (fndRef && typeof fndRef === 'object' && fndRef.url)
453
+ )
454
+ if (isLinkModeFoundation) {
455
+ onProgress(`Link-mode foundation (${typeof fndRef === 'string' ? fndRef : fndRef.url || fndRef.name}) — skipping prerender.`)
456
+ onProgress('(HTML will be rendered by the serving worker / runtime.)')
457
+ return { pages: 0, files: [] }
458
+ }
459
+
437
460
  // Discover all locale content files
438
461
  const localeConfigs = await discoverLocaleContents(distDir, defaultSiteContent)
439
462
  if (localeConfigs.length > 1) {
@@ -27,6 +27,8 @@
27
27
  * for the visual editor.
28
28
  */
29
29
 
30
+ import { isRichSchema } from '@uniweb/core'
31
+
30
32
  /**
31
33
  * Parse data string into structured object
32
34
  * 'events' -> { type: 'events', limit: null }
@@ -119,7 +121,10 @@ function extractSchemaFields(schemaFields) {
119
121
 
120
122
  /**
121
123
  * Check if a schema value is in the full @uniweb/schemas format
122
- * Full format has: { name, version?, description?, fields: {...} }
124
+ * Full format has: { name, version?, description?, fields: { fieldName: fieldDef, ... } }
125
+ *
126
+ * The distinguishing feature is that `fields` is a *keyed object*, not an array.
127
+ * (A rich form schema also has `fields`, but as an array.)
123
128
  *
124
129
  * @param {Object} schema - Schema value to check
125
130
  * @returns {boolean}
@@ -129,10 +134,52 @@ function isFullSchemaFormat(schema) {
129
134
  schema &&
130
135
  typeof schema === 'object' &&
131
136
  typeof schema.fields === 'object' &&
132
- schema.fields !== null
137
+ schema.fields !== null &&
138
+ !Array.isArray(schema.fields)
133
139
  )
134
140
  }
135
141
 
142
+ /**
143
+ * Pass a rich form schema through with minimal normalization.
144
+ *
145
+ * Rich schemas are passed to the editor (for FormBlock UI rendering) and to
146
+ * the runtime (for default application). We keep all authored metadata so the
147
+ * editor has what it needs; we do not strip editor-only fields here because
148
+ * the same schema feeds both audiences.
149
+ *
150
+ * Normalizations:
151
+ * - `type: 'string'` → `type: 'text'` (legacy alias; warn in dev)
152
+ *
153
+ * @param {Object} schema - Rich schema as authored
154
+ * @returns {Object} - Normalized rich schema
155
+ */
156
+ function normalizeRichSchema(schema) {
157
+ return normalizeRichSchemaValue(schema)
158
+ }
159
+
160
+ function normalizeRichSchemaValue(value) {
161
+ if (Array.isArray(value)) {
162
+ return value.map(normalizeRichSchemaValue)
163
+ }
164
+ if (!value || typeof value !== 'object') return value
165
+ const out = {}
166
+ for (const [key, v] of Object.entries(value)) {
167
+ if (key === 'type' && v === 'string') {
168
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
169
+ console.warn(
170
+ "[uniweb] form schema field type 'string' is a legacy alias; use 'text' instead."
171
+ )
172
+ }
173
+ out[key] = 'text'
174
+ } else if (v && typeof v === 'object') {
175
+ out[key] = normalizeRichSchemaValue(v)
176
+ } else {
177
+ out[key] = v
178
+ }
179
+ }
180
+ return out
181
+ }
182
+
136
183
  /**
137
184
  * Extract lean schemas from meta.js schemas object
138
185
  * Strips editor-only fields while preserving structure
@@ -151,6 +198,13 @@ function extractSchemas(schemas) {
151
198
 
152
199
  const lean = {}
153
200
  for (const [schemaName, schemaValue] of Object.entries(schemas)) {
201
+ // Rich form schemas: pass through (with normalization). They drive both
202
+ // the FormBlock editor UI and the runtime default application.
203
+ if (isRichSchema(schemaValue)) {
204
+ lean[schemaName] = normalizeRichSchema(schemaValue)
205
+ continue
206
+ }
207
+
154
208
  // Handle full schema format (from @uniweb/schemas or npm packages)
155
209
  // Extract just the fields, discard name/version/description metadata
156
210
  const schemaFields = isFullSchemaFormat(schemaValue)
@@ -166,6 +220,7 @@ function extractSchemas(schemas) {
166
220
  return Object.keys(lean).length > 0 ? lean : null
167
221
  }
168
222
 
223
+
169
224
  /**
170
225
  * Extract param defaults from params object
171
226
  *
@@ -322,6 +322,68 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
322
322
  return { assets, hasExplicitPoster, hasExplicitPreview }
323
323
  }
324
324
 
325
+ /**
326
+ * Walk the top-level config object (site.yml / document.yml) for asset
327
+ * references and resolve each into a manifest entry. Catches things like
328
+ * book.covers.front, banner images, logos in metadata blocks — anything
329
+ * declared in the config that points at a local file by path.
330
+ *
331
+ * Result is keyed by the original source string (the value as it appears
332
+ * in the config). Foundations look up `website.assets[src]` at compile
333
+ * time and resolve `entry.resolved` to a filesystem path or `entry.url`
334
+ * to a URL, depending on the runtime context. This keeps foundations
335
+ * environment-agnostic — they don't need to know whether the compile
336
+ * pipeline runs in Node (unipress) or in the browser (editor).
337
+ *
338
+ * `walkDataAssets` already filters via `isLocalAssetPath` to skip
339
+ * non-asset strings (titles, descriptions, etc.).
340
+ *
341
+ * @param {Object} siteConfig - Parsed top-level config
342
+ * @param {string} siteRoot - Site/document root directory
343
+ * @returns {Object} Asset manifest keyed by original src string
344
+ */
345
+ export function collectConfigAssets(siteConfig, siteRoot) {
346
+ const assets = {}
347
+ if (!siteConfig || typeof siteConfig !== 'object') return assets
348
+
349
+ // Anchor for relative-path resolution — `dirname(anchor)` must equal siteRoot
350
+ // so `assets/front.png` resolves to `<siteRoot>/assets/front.png`.
351
+ const anchor = `${siteRoot}/_config_anchor`
352
+
353
+ // Walk siteConfig with a more permissive filter than `isLocalAssetPath`:
354
+ // config asset paths often appear without a `./` prefix (e.g.
355
+ // `book.covers.front: assets/front.png`), which is the natural spelling
356
+ // for authors. Accept any string with a media extension that isn't an
357
+ // external URL.
358
+ const visit = (data) => {
359
+ if (typeof data === 'string') {
360
+ if (isExternalUrl(data)) return
361
+ if (!(isImagePath(data) || isVideoPath(data) || isPdfPath(data))) return
362
+ const result = resolveAssetPath(data, anchor, siteRoot)
363
+ if (!result.external && result.resolved) {
364
+ assets[data] = {
365
+ original: data,
366
+ resolved: result.resolved,
367
+ isImage: result.isImage,
368
+ isVideo: result.isVideo,
369
+ isPdf: result.isPdf
370
+ }
371
+ }
372
+ return
373
+ }
374
+ if (Array.isArray(data)) {
375
+ data.forEach(visit)
376
+ return
377
+ }
378
+ if (data && typeof data === 'object') {
379
+ Object.values(data).forEach(visit)
380
+ }
381
+ }
382
+
383
+ visit(siteConfig)
384
+ return assets
385
+ }
386
+
325
387
  /**
326
388
  * Merge multiple asset collection results
327
389
  *
@@ -88,6 +88,24 @@ function detectFoundationType(foundation, siteRoot) {
88
88
  }
89
89
  }
90
90
 
91
+ // Registry scoped ref: "@namespace/name@version". By definition link-mode —
92
+ // the foundation lives on the hosting edge (R2) and is loaded at runtime.
93
+ // Surfacing this as `type: 'url'` makes Vite skip the local-foundation
94
+ // bundling path and use the noop virtual module (no need for VITE_FOUNDATION_MODE=runtime
95
+ // env var or a local foundation folder). Base URL defaults to the production
96
+ // worker but is overridable via UNIWEB_REGISTRY_URL for self-hosted / staging
97
+ // environments.
98
+ const scopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
99
+ if (scopedMatch) {
100
+ const [, ns, fn, ver] = scopedMatch
101
+ const base = process.env.UNIWEB_REGISTRY_URL || 'https://site-router.uniweb-edge.workers.dev'
102
+ return {
103
+ type: 'url',
104
+ url: `${base}/foundations/${ns}/${fn}/${ver}/foundation.js`,
105
+ cssUrl: `${base}/foundations/${ns}/${fn}/${ver}/assets/foundation.css`
106
+ }
107
+ }
108
+
91
109
  // Check if it's a local workspace sibling (directory name matches package name)
92
110
  const localPath = resolve(siteRoot, '..', name)
93
111
  if (existsSync(localPath)) {
@@ -456,7 +474,7 @@ export async function defineSiteConfig(options = {}) {
456
474
  // Deduplicate React packages to prevent dual-instance issues
457
475
  // Foundation externalizes React; when site bundles it, CJS and ESM
458
476
  // copies can coexist without this, causing "useRef of null" errors
459
- dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
477
+ dedupe: ['react', 'react-dom', 'react-dom/server', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
460
478
  alias: {
461
479
  ...alias,
462
480
  ...resolveOverrides?.alias
@@ -491,7 +509,7 @@ export async function defineSiteConfig(options = {}) {
491
509
  },
492
510
 
493
511
  optimizeDeps: {
494
- include: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
512
+ include: ['react', 'react-dom', 'react-dom/client', 'react-dom/server', 'react-router-dom'],
495
513
  exclude: ['#foundation']
496
514
  },
497
515
 
@@ -27,7 +27,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
27
27
  import { join, parse, resolve, sep } from 'node:path'
28
28
  import { existsSync, statSync, realpathSync } from 'node:fs'
29
29
  import yaml from 'js-yaml'
30
- import { collectSectionAssets, mergeAssetCollections } from './assets.js'
30
+ import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
32
  import { parseFetchConfig, singularize } from './data-fetcher.js'
33
33
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
@@ -256,6 +256,33 @@ function isIgnoredFolder(name) {
256
256
  return name.startsWith('_')
257
257
  }
258
258
 
259
+ /**
260
+ * Workspace-root content profiles, dispatched on the top-level config filename.
261
+ *
262
+ * - `site.yml` → site profile: content under `pages/`, page mode (multiple
263
+ * sections per page) by default, ordering field is `pages:`. The historical
264
+ * default; used for websites.
265
+ * - `document.yml` → document profile: content under `content/`, folder mode
266
+ * (each .md file is its own page/chapter) by default, ordering field is
267
+ * `content:` (with `pages:` accepted as a fallback alias). Used by document
268
+ * tools like unipress where the natural noun is "content" / "chapter."
269
+ *
270
+ * Per-folder folder.yml/page.yml overrides keep working in both profiles —
271
+ * the profile sets workspace-root defaults; per-folder configs take precedence.
272
+ * An explicit `paths: { pages: ... }` in either config also overrides the
273
+ * default content directory.
274
+ *
275
+ * Internal mode values match readFolderConfig: 'pages' (folder mode), 'sections' (page mode).
276
+ */
277
+ const CONTENT_PROFILES = {
278
+ 'site.yml': { contentDir: 'pages', defaultMode: 'sections', orderField: 'pages' },
279
+ 'document.yml': { contentDir: 'content', defaultMode: 'pages', orderField: 'content' }
280
+ }
281
+
282
+ function getContentProfile(configFile) {
283
+ return CONTENT_PROFILES[configFile] || CONTENT_PROFILES['site.yml']
284
+ }
285
+
259
286
  /**
260
287
  * Read folder configuration, determining content mode from config file presence.
261
288
  *
@@ -1926,18 +1953,34 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
1926
1953
  * @param {string} sitePath - Path to site directory
1927
1954
  * @param {Object} options - Collection options
1928
1955
  * @param {string} options.foundationPath - Path to foundation directory (for theme vars)
1956
+ * @param {string} [options.configFile='site.yml'] - Name of the top-level config file inside sitePath. Defaults to 'site.yml'. Document tools (unipress) pass 'document.yml'.
1929
1957
  * @returns {Promise<Object>} Site content object with assets manifest
1930
1958
  */
1931
1959
  export async function collectSiteContent(sitePath, options = {}) {
1932
- const { foundationPath } = options
1960
+ const { foundationPath, configFile = 'site.yml' } = options
1933
1961
 
1934
1962
  // Read site config and raw theme config
1935
- const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
1936
-
1937
- // Resolve content paths from site.yml paths: group, defaulting to standard locations
1938
- const pagesPath = siteConfig.paths?.pages
1939
- ? resolve(sitePath, siteConfig.paths.pages)
1940
- : join(sitePath, 'pages')
1963
+ const siteConfig = await readYamlFile(join(sitePath, configFile))
1964
+
1965
+ // Profile selects workspace-root defaults: site.yml pages/ + page mode +
1966
+ // pages: ordering; document.yml → content/ + folder mode + content: ordering.
1967
+ const profile = getContentProfile(configFile)
1968
+
1969
+ // Resolve content paths from <config>.paths: group, defaulting per profile.
1970
+ // Backward compatibility: when the document profile's `content/` is missing
1971
+ // but a `pages/` directory exists, fall back to `pages/` so existing unipress
1972
+ // projects (and any document.yml-using setup that predates the profile)
1973
+ // keep working without edits.
1974
+ let pagesPath
1975
+ if (siteConfig.paths?.pages) {
1976
+ pagesPath = resolve(sitePath, siteConfig.paths.pages)
1977
+ } else {
1978
+ const profileDefault = join(sitePath, profile.contentDir)
1979
+ const legacyFallback = join(sitePath, 'pages')
1980
+ pagesPath = (profile.contentDir !== 'pages' && !existsSync(profileDefault) && existsSync(legacyFallback))
1981
+ ? legacyFallback
1982
+ : profileDefault
1983
+ }
1941
1984
 
1942
1985
  const mounts = resolveMounts(siteConfig.paths, sitePath, pagesPath)
1943
1986
 
@@ -1968,14 +2011,21 @@ export async function collectSiteContent(sitePath, options = {}) {
1968
2011
  }
1969
2012
  }
1970
2013
 
1971
- // Extract page ordering config from site.yml
2014
+ // Extract page ordering config from the top-level config.
2015
+ // Document profile reads the `content:` field (with `pages:` as a back-compat
2016
+ // alias); site profile reads `pages:`. Either way the internal field name is
2017
+ // `pages` so collectPagesRecursive doesn't need to care about profile.
1972
2018
  const siteOrderConfig = {
1973
- pages: siteConfig.pages,
2019
+ pages: profile.orderField === 'pages'
2020
+ ? siteConfig.pages
2021
+ : (siteConfig[profile.orderField] ?? siteConfig.pages),
1974
2022
  index: siteConfig.index
1975
2023
  }
1976
2024
 
1977
- // Determine root content mode from folder.yml/page.yml presence in pages directory
1978
- const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
2025
+ // Root content mode default comes from the profile (folder mode for
2026
+ // documents, page mode for sites). A folder.yml/page.yml in the pages root
2027
+ // overrides the profile default per readFolderConfig.
2028
+ const { mode: rootContentMode } = await readFolderConfig(pagesPath, profile.defaultMode)
1979
2029
 
1980
2030
  // Collect layout areas from layout/ directory (including named layout subdirectories)
1981
2031
  const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
@@ -1985,9 +2035,18 @@ export async function collectSiteContent(sitePath, options = {}) {
1985
2035
  : siteConfig.layout?.name || null
1986
2036
 
1987
2037
  // Recursively collect all pages
1988
- const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
2038
+ let { pages, assetCollection, iconCollection, notFound, versionedScopes } =
1989
2039
  await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
1990
2040
 
2041
+ // Merge top-level config assets (e.g. document.yml's book.covers.front,
2042
+ // banner images, logos) into the manifest. The compile pipeline reads
2043
+ // website.assets[<original src>] to resolve these to filesystem paths
2044
+ // (Node) or URLs (browser) without doing its own I/O.
2045
+ const configAssets = collectConfigAssets(siteConfig, sitePath)
2046
+ if (Object.keys(configAssets).length > 0) {
2047
+ assetCollection = mergeAssetCollections(assetCollection, { assets: configAssets })
2048
+ }
2049
+
1991
2050
  // Deduplicate: at the root level, homepage promotion can create a route
1992
2051
  // collision between the promoted page and a content-less container.
1993
2052
  // At deeper levels, 1:1 mapping means collisions shouldn't happen — warn.
@@ -221,6 +221,16 @@ export function foundationBuildPlugin(options = {}) {
221
221
  // Build self-contained SSR bundle for edge rendering (Dynamic Workers)
222
222
  await buildSSRBundle(outDir)
223
223
  },
224
+
225
+ async closeBundle() {
226
+ // esbuild spawns a long-lived service child process on first build() and
227
+ // keeps it running. Its stop() is the documented teardown for hosts that
228
+ // need to exit cleanly. Best-effort — never fail the build over this.
229
+ try {
230
+ const { stop } = await import('esbuild')
231
+ await stop?.()
232
+ } catch {}
233
+ },
224
234
  }
225
235
  }
226
236