@uniweb/build 0.10.3 → 0.11.3

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.3",
3
+ "version": "0.11.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,8 +57,8 @@
57
57
  "@uniweb/theming": "0.1.3"
58
58
  },
59
59
  "optionalDependencies": {
60
- "@uniweb/runtime": "0.8.3",
61
- "@uniweb/content-reader": "1.1.4",
60
+ "@uniweb/runtime": "0.8.5",
61
+ "@uniweb/content-reader": "1.1.6",
62
62
  "@uniweb/schemas": "0.2.1"
63
63
  },
64
64
  "peerDependencies": {
@@ -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.2"
71
+ "@uniweb/core": "0.7.4"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "vite": {
@@ -1,13 +1,32 @@
1
1
  /**
2
2
  * Clean Content Entry Point
3
3
  *
4
- * Exposes only collectSiteContent and its clean dependency chain
5
- * (Node builtins, js-yaml, @uniweb/theming). No Vite, sharp, or React.
6
- *
7
- * Used by Studio's sidecar (Bun-compiled binary) where those heavy
8
- * peer/native dependencies aren't available.
4
+ * Exposes content-collection helpers and their clean dependency chain
5
+ * (Node builtins, js-yaml, @uniweb/theming, @uniweb/core). No Vite,
6
+ * sharp, or React. Used by Studio's sidecar (Bun-compiled binary) and
7
+ * by @uniweb/unipress (CLI compile path) where those heavy peer/native
8
+ * dependencies aren't available.
9
9
  *
10
10
  * @module @uniweb/build/content
11
11
  */
12
12
 
13
13
  export { collectSiteContent } from '../site/content-collector.js'
14
+
15
+ // Collections + fetch resolution. Pure functions on the clean dep
16
+ // chain — re-exported here so headless callers (unipress, sidecars)
17
+ // can resolve `collections:` declarations without importing
18
+ // `@uniweb/build/site` (which pulls Vite via its plugin index).
19
+ export {
20
+ processCollections,
21
+ writeCollectionFiles,
22
+ getCollectionLastModified,
23
+ } from '../site/collection-processor.js'
24
+ export {
25
+ parseFetchConfig,
26
+ executeFetch,
27
+ applyFilter,
28
+ applySort,
29
+ applyPostProcessing,
30
+ mergeDataIntoContent,
31
+ singularize,
32
+ } from '../site/data-fetcher.js'
@@ -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) {
@@ -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'
@@ -181,13 +181,19 @@ async function readYamlFile(filePath) {
181
181
  /**
182
182
  * Extract inset references from a ProseMirror document.
183
183
  *
184
- * Walks top-level nodes for `inset_ref` (produced by content-reader
185
- * for `![alt](@ComponentName){params}` syntax). Each ref is removed from the
186
- * document and replaced with an `inset_placeholder` node carrying a
187
- * unique refId. The extracted refs are returned as an array.
184
+ * Walks the document recursively for `inset_ref` nodes (produced by
185
+ * content-reader for the `![alt](@ComponentName){params}` /
186
+ * `[text](@ComponentName){params}` / `[@key]{params}` forms). Each ref
187
+ * is removed and replaced in-place with an `inset_placeholder` node
188
+ * carrying a unique refId. The extracted refs are returned as an array.
189
+ *
190
+ * Inline insets (mid-paragraph) are kept as inline placeholders so the
191
+ * paragraph's text flow is preserved; block-level insets (own line)
192
+ * stay at the document root. Both share the same refId/getInset(refId)
193
+ * lookup machinery — only the position differs.
188
194
  *
189
195
  * @param {Object} doc - ProseMirror document (mutated in place)
190
- * @returns {Array} Array of { refId, type, params, description }
196
+ * @returns {Array} Array of { refId, type, params, description, embedKind }
191
197
  */
192
198
  function extractInsets(doc) {
193
199
  if (!doc?.content || !Array.isArray(doc.content)) return []
@@ -195,25 +201,34 @@ function extractInsets(doc) {
195
201
  const insets = []
196
202
  let refIndex = 0
197
203
 
198
- for (let i = 0; i < doc.content.length; i++) {
199
- const node = doc.content[i]
200
- if (node.type === 'inset_ref') {
201
- const { component, alt, ...params } = node.attrs || {}
202
- const refId = `inset_${refIndex++}`
203
- insets.push({
204
- refId,
205
- type: component,
206
- params: Object.keys(params).length > 0 ? params : {},
207
- title: alt || null,
208
- })
209
- // Replace in-place with placeholder
210
- doc.content[i] = {
211
- type: 'inset_placeholder',
212
- attrs: { refId },
204
+ function visit(nodes) {
205
+ if (!Array.isArray(nodes)) return
206
+ for (let i = 0; i < nodes.length; i++) {
207
+ const node = nodes[i]
208
+ if (!node) continue
209
+ if (node.type === 'inset_ref') {
210
+ const { component, alt, embedKind, ...params } = node.attrs || {}
211
+ const refId = `inset_${refIndex++}`
212
+ insets.push({
213
+ refId,
214
+ type: component,
215
+ embedKind: embedKind || 'visual',
216
+ params: Object.keys(params).length > 0 ? params : {},
217
+ title: alt || null,
218
+ })
219
+ nodes[i] = {
220
+ type: 'inset_placeholder',
221
+ attrs: { refId, embedKind: embedKind || 'visual' },
222
+ }
223
+ continue
224
+ }
225
+ if (Array.isArray(node.content)) {
226
+ visit(node.content)
213
227
  }
214
228
  }
215
229
  }
216
230
 
231
+ visit(doc.content)
217
232
  return insets
218
233
  }
219
234
 
@@ -256,6 +271,33 @@ function isIgnoredFolder(name) {
256
271
  return name.startsWith('_')
257
272
  }
258
273
 
274
+ /**
275
+ * Workspace-root content profiles, dispatched on the top-level config filename.
276
+ *
277
+ * - `site.yml` → site profile: content under `pages/`, page mode (multiple
278
+ * sections per page) by default, ordering field is `pages:`. The historical
279
+ * default; used for websites.
280
+ * - `document.yml` → document profile: content under `content/`, folder mode
281
+ * (each .md file is its own page/chapter) by default, ordering field is
282
+ * `content:` (with `pages:` accepted as a fallback alias). Used by document
283
+ * tools like unipress where the natural noun is "content" / "chapter."
284
+ *
285
+ * Per-folder folder.yml/page.yml overrides keep working in both profiles —
286
+ * the profile sets workspace-root defaults; per-folder configs take precedence.
287
+ * An explicit `paths: { pages: ... }` in either config also overrides the
288
+ * default content directory.
289
+ *
290
+ * Internal mode values match readFolderConfig: 'pages' (folder mode), 'sections' (page mode).
291
+ */
292
+ const CONTENT_PROFILES = {
293
+ 'site.yml': { contentDir: 'pages', defaultMode: 'sections', orderField: 'pages' },
294
+ 'document.yml': { contentDir: 'content', defaultMode: 'pages', orderField: 'content' }
295
+ }
296
+
297
+ function getContentProfile(configFile) {
298
+ return CONTENT_PROFILES[configFile] || CONTENT_PROFILES['site.yml']
299
+ }
300
+
259
301
  /**
260
302
  * Read folder configuration, determining content mode from config file presence.
261
303
  *
@@ -1926,18 +1968,34 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
1926
1968
  * @param {string} sitePath - Path to site directory
1927
1969
  * @param {Object} options - Collection options
1928
1970
  * @param {string} options.foundationPath - Path to foundation directory (for theme vars)
1971
+ * @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
1972
  * @returns {Promise<Object>} Site content object with assets manifest
1930
1973
  */
1931
1974
  export async function collectSiteContent(sitePath, options = {}) {
1932
- const { foundationPath } = options
1975
+ const { foundationPath, configFile = 'site.yml' } = options
1933
1976
 
1934
1977
  // 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')
1978
+ const siteConfig = await readYamlFile(join(sitePath, configFile))
1979
+
1980
+ // Profile selects workspace-root defaults: site.yml pages/ + page mode +
1981
+ // pages: ordering; document.yml → content/ + folder mode + content: ordering.
1982
+ const profile = getContentProfile(configFile)
1983
+
1984
+ // Resolve content paths from <config>.paths: group, defaulting per profile.
1985
+ // Backward compatibility: when the document profile's `content/` is missing
1986
+ // but a `pages/` directory exists, fall back to `pages/` so existing unipress
1987
+ // projects (and any document.yml-using setup that predates the profile)
1988
+ // keep working without edits.
1989
+ let pagesPath
1990
+ if (siteConfig.paths?.pages) {
1991
+ pagesPath = resolve(sitePath, siteConfig.paths.pages)
1992
+ } else {
1993
+ const profileDefault = join(sitePath, profile.contentDir)
1994
+ const legacyFallback = join(sitePath, 'pages')
1995
+ pagesPath = (profile.contentDir !== 'pages' && !existsSync(profileDefault) && existsSync(legacyFallback))
1996
+ ? legacyFallback
1997
+ : profileDefault
1998
+ }
1941
1999
 
1942
2000
  const mounts = resolveMounts(siteConfig.paths, sitePath, pagesPath)
1943
2001
 
@@ -1968,14 +2026,21 @@ export async function collectSiteContent(sitePath, options = {}) {
1968
2026
  }
1969
2027
  }
1970
2028
 
1971
- // Extract page ordering config from site.yml
2029
+ // Extract page ordering config from the top-level config.
2030
+ // Document profile reads the `content:` field (with `pages:` as a back-compat
2031
+ // alias); site profile reads `pages:`. Either way the internal field name is
2032
+ // `pages` so collectPagesRecursive doesn't need to care about profile.
1972
2033
  const siteOrderConfig = {
1973
- pages: siteConfig.pages,
2034
+ pages: profile.orderField === 'pages'
2035
+ ? siteConfig.pages
2036
+ : (siteConfig[profile.orderField] ?? siteConfig.pages),
1974
2037
  index: siteConfig.index
1975
2038
  }
1976
2039
 
1977
- // Determine root content mode from folder.yml/page.yml presence in pages directory
1978
- const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
2040
+ // Root content mode default comes from the profile (folder mode for
2041
+ // documents, page mode for sites). A folder.yml/page.yml in the pages root
2042
+ // overrides the profile default per readFolderConfig.
2043
+ const { mode: rootContentMode } = await readFolderConfig(pagesPath, profile.defaultMode)
1979
2044
 
1980
2045
  // Collect layout areas from layout/ directory (including named layout subdirectories)
1981
2046
  const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
@@ -1985,9 +2050,18 @@ export async function collectSiteContent(sitePath, options = {}) {
1985
2050
  : siteConfig.layout?.name || null
1986
2051
 
1987
2052
  // Recursively collect all pages
1988
- const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
2053
+ let { pages, assetCollection, iconCollection, notFound, versionedScopes } =
1989
2054
  await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
1990
2055
 
2056
+ // Merge top-level config assets (e.g. document.yml's book.covers.front,
2057
+ // banner images, logos) into the manifest. The compile pipeline reads
2058
+ // website.assets[<original src>] to resolve these to filesystem paths
2059
+ // (Node) or URLs (browser) without doing its own I/O.
2060
+ const configAssets = collectConfigAssets(siteConfig, sitePath)
2061
+ if (Object.keys(configAssets).length > 0) {
2062
+ assetCollection = mergeAssetCollections(assetCollection, { assets: configAssets })
2063
+ }
2064
+
1991
2065
  // Deduplicate: at the root level, homepage promotion can create a route
1992
2066
  // collision between the promoted page and a content-less container.
1993
2067
  // 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