@uniweb/build 0.47.2 → 0.48.0

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.
@@ -24,16 +24,18 @@
24
24
  import { readFile, readdir, stat } from 'node:fs/promises'
25
25
  import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
26
26
  import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
27
+ import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
28
+ import { readLayoutFolder, DEFAULT_LAYOUT } from './layout-folder.js'
27
29
  import { join, parse, relative, resolve, sep } from 'node:path'
28
30
  import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
29
31
  import yaml from 'js-yaml'
30
32
  import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
31
33
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
34
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
- import { parseFetchConfig } from './data-fetcher.js'
35
+ import { parseFetchConfig, toFetchList } from './data-fetcher.js'
34
36
  import { resolveExtensionUrls } from './extension-urls.js'
35
37
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
36
- import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
38
+ import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery } from '@uniweb/core'
37
39
  import { parseFrontmatter } from '../utils/frontmatter.js'
38
40
 
39
41
  // Try to import content-reader, fall back to simplified parser
@@ -245,6 +247,9 @@ export function declaredFetch(config, where) {
245
247
  return config?.fetch ?? fetchFromQueryShorthand(config?.query)
246
248
  }
247
249
 
250
+ /** `parseFetchConfig`'s arguments for a level: its declaration, the file it sits in, and the level. */
251
+ const fetchIn = (config, where, level) => [declaredFetch(config, where), where, { level }]
252
+
248
253
  /**
249
254
  * The refusals `declaredFetch` makes, alone — for a reader that must keep the
250
255
  * desugaring inline (the sync push's `settings.fetch`, whose sources
@@ -300,6 +305,51 @@ const isQueryNames = (value) =>
300
305
  const formatQueryNames = (value) =>
301
306
  isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
302
307
 
308
+ /**
309
+ * ⚠️ A `current:` NOTHING WILL READ — said once per binding. `current:` applies on a
310
+ * section's binding under the key its page's URL narrows (the route query,
311
+ * `pageRouteQuery`); anywhere else the runtime ignores it, and the author would see
312
+ * the section deliver its list as though the line were not there.
313
+ *
314
+ * @param {Array<Object>} pages - collected pages, parents linked
315
+ * @param {Object} layouts - collected layout areas (never on a parametric route)
316
+ * @param {Object|Array|null} siteFetch - the parsed site fetch
317
+ */
318
+ function warnUnreadCurrent(pages, layouts, siteFetch) {
319
+ const byRoute = new Map(pages.map((p) => [p.route, p]))
320
+ const access = {
321
+ routeOf: (p) => p.route,
322
+ parentOf: (p) => (p.parent ? byRoute.get(p.parent) ?? null : null),
323
+ fetchOf: (p) => p.fetch,
324
+ sectionsOf: (p) => p.sections,
325
+ site: siteFetch,
326
+ }
327
+ const check = (sections, where, routeKeyOf) => {
328
+ for (const section of sections || []) {
329
+ for (const one of toFetchList(section.fetch)) {
330
+ if (one?.current === undefined) continue
331
+ const routeKey = routeKeyOf()
332
+ if (one.as === routeKey) continue
333
+ console.warn(
334
+ `[uniweb] ${where}: \`current: ${one.current}\` on content.data.${one.as} is ignored — it applies on a ` +
335
+ `section of a parametric page, under the key its URL narrows` +
336
+ (routeKey ? ` (here \`${routeKey}\`).` : '; this page has none.')
337
+ )
338
+ }
339
+ check(section.subsections, where, routeKeyOf)
340
+ }
341
+ }
342
+ for (const page of pages) {
343
+ let key
344
+ check(page.sections, page.route, () => (key === undefined ? (key = pageRouteQuery(page, access)?.key ?? null) : key))
345
+ }
346
+ for (const [name, areas] of Object.entries(layouts || {})) {
347
+ for (const [area, areaPage] of Object.entries(areas || {})) {
348
+ check(areaPage?.sections, `layout ${name}/${area}`, () => null)
349
+ }
350
+ }
351
+ }
352
+
303
353
  /**
304
354
  * Build version metadata from detected versions and page.yml config
305
355
  * @param {Array<Object>} detectedVersions - Detected version infos
@@ -442,20 +492,6 @@ function extractInsets(doc) {
442
492
  return insets
443
493
  }
444
494
 
445
- /**
446
- * Check if a file is a markdown file that should be processed.
447
- * Excludes:
448
- * - Files not ending in .md
449
- * - Files starting with _ (drafts/private)
450
- * - README.md (repo documentation, not site content)
451
- */
452
- function isMarkdownFile(filename) {
453
- if (!filename.endsWith('.md')) return false
454
- if (filename.startsWith('_')) return false
455
- if (filename.toLowerCase() === 'readme.md') return false
456
- return true
457
- }
458
-
459
495
  /**
460
496
  * Check if a filename uses the @ prefix (child section convention).
461
497
  * @-prefixed files are excluded from auto-discovered top-level sections —
@@ -473,22 +509,6 @@ function stripAtPrefix(filename) {
473
509
  return filename.replace(/^@+/, '')
474
510
  }
475
511
 
476
- /**
477
- * Check if a folder should be ignored.
478
- *
479
- * Excludes folders starting with `_` (drafts/private) and with `.` (hidden).
480
- *
481
- * Hidden folders matter more than they look. A site's own `pages/` never holds
482
- * one, but a mount target routinely does: point `paths:` at a directory that is
483
- * also a git working tree — a sibling clone, which the docs suggest — and `.git`
484
- * is a directory sitting right next to the content. Walked as content it
485
- * contributed hundreds of routes. (A submodule hides this: there, `.git` is a
486
- * file, so only the plain-clone case ever showed it.)
487
- */
488
- function isIgnoredFolder(name) {
489
- return name.startsWith('_') || name.startsWith('.')
490
- }
491
-
492
512
  /**
493
513
  * Workspace-root content profiles, dispatched on the top-level config filename.
494
514
  *
@@ -1054,7 +1074,7 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1054
1074
  input,
1055
1075
  params: { ...params, ...props },
1056
1076
  content: proseMirrorContent,
1057
- fetch: parseFetchConfig(resolvedFetch),
1077
+ fetch: parseFetchConfig(resolvedFetch, relative(siteRoot, filePath), { level: 'section' }),
1058
1078
  ...(insets.length > 0 ? { insets } : {}),
1059
1079
  subsections: []
1060
1080
  }
@@ -1666,7 +1686,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1666
1686
  },
1667
1687
 
1668
1688
  // Data fetching — `fetch:`, or the `query:` shorthand (`declaredFetch`)
1669
- fetch: parseFetchConfig(declaredFetch(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')))),
1689
+ fetch: parseFetchConfig(...fetchIn(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')), 'page')),
1670
1690
 
1671
1691
  hasContent: hierarchicalSections.length > 0,
1672
1692
  sections: hierarchicalSections
@@ -2038,7 +2058,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2038
2058
  // ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
2039
2059
  // alone until 2026-09-11, so a container's `folder.yml` shorthand reached
2040
2060
  // a backend on `push` and was dropped from a static build.
2041
- fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
2061
+ fetch: parseFetchConfig(...fetchIn(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')), 'page')) || null,
2042
2062
  hasContent: false,
2043
2063
  sections: [],
2044
2064
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2135,7 +2155,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2135
2155
  // a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
2136
2156
  // build while `push` carried it, so its pages had the folder's data on a
2137
2157
  // hosted site and none on an exported one.
2138
- fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
2158
+ fetch: parseFetchConfig(...fetchIn(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')), 'page')) || null,
2139
2159
  hasContent: false,
2140
2160
  sections: [],
2141
2161
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
@@ -2222,12 +2242,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2222
2242
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
2223
2243
  }
2224
2244
 
2225
- /**
2226
- * Load foundation variables from schema.json
2227
- *
2228
- * @param {string} foundationPath - Path to foundation directory
2229
- * @returns {Promise<Object>} Foundation variables or empty object
2230
- */
2231
2245
  /**
2232
2246
  * Load foundation schema data needed by the content collector.
2233
2247
  *
@@ -2239,11 +2253,16 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2239
2253
  * the build never calls it), so it answers "something could resolve this",
2240
2254
  * never "Loom will".
2241
2255
  *
2256
+ * ⛔ **No layout names.** This returned the foundation's layouts until 2026-09-13,
2257
+ * for `collectLayouts` to decide whether a folder under `layout/` was a named
2258
+ * layout. A site's meaning does not depend on a foundation the build may not have
2259
+ * — see `./layout-folder.js`, which reads that folder from the site alone.
2260
+ *
2242
2261
  * @param {string} foundationPath - Path to foundation directory
2243
- * @returns {Promise<{ vars: Object, layoutNames: Set<string>, hasContentHandler: boolean }>}
2262
+ * @returns {Promise<{ vars: Object, hasContentHandler: boolean }>}
2244
2263
  */
2245
2264
  export async function loadFoundationInfo(foundationPath) {
2246
- if (!foundationPath) return { vars: {}, layoutNames: new Set(), hasContentHandler: false }
2265
+ if (!foundationPath) return { vars: {}, hasContentHandler: false }
2247
2266
 
2248
2267
  // ⛔ **NOT `dist/meta/schema.json`.** That file is the EDITOR's artifact — the
2249
2268
  // rich per-section declaration a visual editor needs to render parameter forms
@@ -2256,26 +2275,11 @@ export async function loadFoundationInfo(foundationPath) {
2256
2275
  // Reading the editor's copy here was wrong twice over. It made a site build
2257
2276
  // depend on the foundation having been BUILT, and it read a derived artifact
2258
2277
  // when the source it derives from is right there — `generate-entry.js` calls
2259
- // these same two functions (`:197` for vars, `:287` for layouts) to produce the
2260
- // very `schema.json` this used to read back.
2261
- //
2262
- // ⭐ **And it made dev and build classify layouts differently.** The old
2263
- // fallback returned an EMPTY layout set whenever no built schema existed —
2264
- // "the normal state for `uniweb dev`" — and `collectLayouts` treats a
2265
- // `layout/<Name>/` directory as a named layout only when `<Name>` is in that
2266
- // set, otherwise as a folder-form area of the DEFAULT layout. So the same site
2267
- // rendered `layout/Wide/` as the Wide layout after a build and as a default
2268
- // area called "Wide" in dev. Measured 2026-09-02 before this change:
2269
- // built layouts ["default","Wide"], default areas ["footer","header"]
2270
- // dev layouts ["default"], default areas ["Wide","footer","header"]
2271
- // Reading from source removes the asymmetry: there is one answer, and it does
2272
- // not depend on whether `dist/` happens to exist.
2278
+ // the same function to produce the very `schema.json` this used to read back.
2273
2279
  const { resolveFoundationSrcPath } = await import('../utils/foundation-source-root.js')
2274
- const { loadFoundationConfig, discoverLayoutsInPath } = await import('../schema.js')
2280
+ const { loadFoundationConfig } = await import('../schema.js')
2275
2281
  const srcDir = resolveFoundationSrcPath(foundationPath)
2276
2282
 
2277
- // Two independent reads, so a failure in one does not cost the other. The
2278
- // previous single try/catch lost the layouts when only the config was broken.
2279
2283
  let vars = {}
2280
2284
  let hasContentHandler = false
2281
2285
  try {
@@ -2292,144 +2296,64 @@ export async function loadFoundationInfo(foundationPath) {
2292
2296
  )
2293
2297
  }
2294
2298
 
2295
- let layoutNames = new Set()
2296
- try {
2297
- layoutNames = new Set(Object.keys(await discoverLayoutsInPath(srcDir)))
2298
- } catch (err) {
2299
- console.warn(
2300
- `[content-collector] Could not discover the foundation's layouts in ${srcDir}: ${err.message}\n` +
2301
- `[content-collector] Continuing WITHOUT them — a site \`layout/<Name>/\` directory will be collected as an area of the default layout rather than as that named layout.`
2302
- )
2303
- }
2304
-
2305
- return { vars, layoutNames, hasContentHandler }
2299
+ return { vars, hasContentHandler }
2306
2300
  }
2307
2301
 
2308
2302
  /**
2309
- * Collect areas from a single directory (general named areas, not hardcoded).
2303
+ * Collect the site's layout areas: `{ <layout>: { <area>: { route, title, sections } } }`.
2304
+ *
2305
+ * ⭐ Read through `readLayoutFolder` (`./layout-folder.js`) — the ONE reader the
2306
+ * sync producer uses too. Depth decides what a folder under `layout/` is, never the
2307
+ * foundation. ⛔ Until 2026-09-13 a folder was a named layout here only when the
2308
+ * foundation declared a layout by that name, and an area of the default layout
2309
+ * otherwise, while sync called every folder a named layout: `layout/header/` was a
2310
+ * default header on a static build and a layout named `header` once synced
2311
+ * (measured).
2310
2312
  *
2311
- * Supports two forms per area:
2312
- * - Folder: dir/header/ (directory with .md files, like a page)
2313
- * - File shorthand: dir/header.md (single markdown file)
2314
- * Folder takes priority when both exist.
2313
+ * An area is a container of sections, not a page: no `page.yml` is read, so an area
2314
+ * carries no `fetch`, `seo` or section ordering of its own. ⭐ Its sections' assets
2315
+ * and icons join the site's manifests — ⛔ they were dropped until 2026-09-13, so a
2316
+ * logo referenced from a header section never entered the asset manifest (measured).
2315
2317
  *
2316
- * @param {string} dir - Directory to scan for area files
2318
+ * @param {string} layoutDir - Path to layout directory
2317
2319
  * @param {string} siteRoot - Path to site root
2318
- * @param {string} routePrefix - Route prefix for area pages (e.g., '/layout' or '/layout/marketing')
2319
- * @returns {Promise<Object>} Map of areaName -> page data
2320
+ * @returns {Promise<{ layouts: Object|null, assetCollection: Object, iconCollection: Object }>}
2320
2321
  */
2321
- async function collectAreasFromDir(dir, siteRoot, routePrefix = '/layout') {
2322
- const result = {}
2323
-
2324
- if (!existsSync(dir)) return result
2322
+ async function collectLayouts(layoutDir, siteRoot) {
2323
+ let assetCollection = { assets: {}, hasExplicitPoster: new Set(), hasExplicitPreview: new Set() }
2324
+ let iconCollection = { icons: new Set(), bySource: new Map() }
2325
2325
 
2326
- const entries = await readdir(dir, { withFileTypes: true })
2326
+ const areas = await readLayoutFolder(layoutDir, { siteRoot })
2327
+ if (areas.length === 0) return { layouts: null, assetCollection, iconCollection }
2327
2328
 
2328
- // Track which area names we've already processed (folder form takes priority)
2329
- const processed = new Set()
2330
-
2331
- // First pass: directories (folder form, higher priority)
2332
- for (const entry of entries) {
2333
- if (!entry.isDirectory()) continue
2334
- if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
2335
-
2336
- const areaName = entry.name
2337
- const entryPath = join(dir, areaName)
2338
- const pageResult = await processPage(entryPath, areaName, siteRoot, {
2339
- isIndex: false,
2340
- parentRoute: routePrefix
2341
- })
2342
- if (pageResult) {
2343
- result[areaName] = pageResult.page
2344
- processed.add(areaName)
2329
+ const layouts = {}
2330
+ for (const area of areas) {
2331
+ const sections = []
2332
+ for (const file of area.files) {
2333
+ const { name } = parse(file)
2334
+ const { prefix, name: stableName } = parseNumericPrefix(name)
2335
+ // A one-section area keeps the handles it has always had: positional id `1`,
2336
+ // and the area's name as its stable id — what sync sends as its `$id`. A
2337
+ // section of a multi-section area is named the way a page's section is.
2338
+ const id = area.form === 'file' ? '1' : (prefix || name)
2339
+ const stableId = area.form === 'file' ? area.area : (stableName || name)
2340
+ const result = await processMarkdownFile(join(area.dir, file), id, siteRoot, stableId)
2341
+ sections.push(result.section)
2342
+ assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
2343
+ iconCollection = mergeIconCollections(iconCollection, result.iconCollection)
2345
2344
  }
2346
- }
2347
-
2348
- // Second pass: markdown file shorthand (only if not already processed as folder)
2349
- for (const entry of entries) {
2350
- if (!entry.isFile()) continue
2351
- if (!entry.name.endsWith('.md')) continue
2352
- if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
2353
-
2354
- const areaName = entry.name.replace('.md', '')
2355
- if (processed.has(areaName)) continue
2356
-
2357
- const filePath = join(dir, entry.name)
2358
- const { section } = await processMarkdownFile(filePath, '1', siteRoot, areaName)
2359
- result[areaName] = {
2360
- route: `${routePrefix}/${areaName}`,
2361
- title: areaName.charAt(0).toUpperCase() + areaName.slice(1),
2345
+ const route = area.layout === DEFAULT_LAYOUT ? `/layout/${area.area}` : `/layout/${area.layout}/${area.area}`
2346
+ layouts[area.layout] ??= {}
2347
+ layouts[area.layout][area.area] = {
2348
+ route,
2349
+ title: area.area.charAt(0).toUpperCase() + area.area.slice(1),
2362
2350
  description: '',
2363
2351
  layout: {},
2364
- sections: [section]
2352
+ sections,
2365
2353
  }
2366
2354
  }
2367
2355
 
2368
- return result
2369
- }
2370
-
2371
- /**
2372
- * Collect layout areas from the layout/ directory, including named layout subdirectories.
2373
- *
2374
- * Root-level .md files and area directories form the "default" layout's areas.
2375
- * Subdirectories that match a foundation-declared layout name are named layouts,
2376
- * each with its own set of areas. All other subdirectories are folder-form areas
2377
- * for the default layout (multi-section areas).
2378
- *
2379
- * @param {string} layoutDir - Path to layout directory
2380
- * @param {string} siteRoot - Path to site root
2381
- * @param {Set<string>} layoutNames - Layout names declared in the foundation schema
2382
- * @returns {Promise<Object>} { layouts }
2383
- */
2384
- async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
2385
- if (!existsSync(layoutDir)) {
2386
- return { layouts: null }
2387
- }
2388
-
2389
- const entries = await readdir(layoutDir, { withFileTypes: true })
2390
-
2391
- // Identify named layout directories: only subdirectories whose name matches
2392
- // a layout component declared in the foundation. All others are folder-form
2393
- // areas for the default layout (e.g., layout/header/ with multiple sections).
2394
- const namedLayoutDirs = []
2395
-
2396
- for (const entry of entries) {
2397
- if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
2398
-
2399
- if (entry.isDirectory() && layoutNames.has(entry.name)) {
2400
- namedLayoutDirs.push(entry)
2401
- }
2402
- }
2403
-
2404
- // Collect default layout areas
2405
- const defaultAreas = await collectAreasFromDir(layoutDir, siteRoot, '/layout')
2406
- // Remove any named layout directories that got collected as areas
2407
- for (const dir of namedLayoutDirs) {
2408
- delete defaultAreas[dir.name]
2409
- }
2410
-
2411
- // Collect named layout areas
2412
- const namedLayouts = {}
2413
- for (const entry of namedLayoutDirs) {
2414
- const subdir = join(layoutDir, entry.name)
2415
- namedLayouts[entry.name] = await collectAreasFromDir(subdir, siteRoot, `/layout/${entry.name}`)
2416
- }
2417
-
2418
- const hasDefaultAreas = Object.keys(defaultAreas).length > 0
2419
- const hasNamedLayouts = Object.keys(namedLayouts).length > 0
2420
-
2421
- if (!hasDefaultAreas && !hasNamedLayouts) {
2422
- return { layouts: null }
2423
- }
2424
-
2425
- // Always use the layouts object format (general areas)
2426
- const layouts = {}
2427
- if (hasDefaultAreas) {
2428
- layouts.default = defaultAreas
2429
- }
2430
- Object.assign(layouts, namedLayouts)
2431
-
2432
- return { layouts }
2356
+ return { layouts, assetCollection, iconCollection }
2433
2357
  }
2434
2358
 
2435
2359
  /**
@@ -2519,9 +2443,8 @@ export async function collectSiteContent(sitePath, options = {}) {
2519
2443
  : join(sitePath, 'layout')
2520
2444
  const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
2521
2445
 
2522
- // Load foundation info (vars + layout names) and process theme
2523
- const { vars: foundationVars, layoutNames: layoutNames, hasContentHandler } =
2524
- await loadFoundationInfo(foundationPath)
2446
+ // Load foundation info (vars) and process theme
2447
+ const { vars: foundationVars, hasContentHandler } = await loadFoundationInfo(foundationPath)
2525
2448
 
2526
2449
  // ⭐ `placeholders:` IS DECLARED FOR A READER THAT MAY NOT EXIST, and that is
2527
2450
  // the one way this feature fails. Resolving `{…}` in page content is a
@@ -2581,8 +2504,9 @@ export async function collectSiteContent(sitePath, options = {}) {
2581
2504
  // overrides the profile default per readFolderConfig.
2582
2505
  const { mode: rootContentMode } = await readFolderConfig(pagesPath, profile.defaultMode)
2583
2506
 
2584
- // Collect layout areas from layout/ directory (including named layout subdirectories)
2585
- const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
2507
+ // Collect layout areas from the layout/ directory, read from the site alone
2508
+ const { layouts, assetCollection: layoutAssets, iconCollection: layoutIcons } =
2509
+ await collectLayouts(layoutPath, sitePath)
2586
2510
 
2587
2511
  // Site-level layout (from `site.yml::layout`) — the ROOT of the cascade, and
2588
2512
  // the whole object, not just its name.
@@ -2592,6 +2516,10 @@ export async function collectSiteContent(sitePath, options = {}) {
2592
2516
  let { pages, assetCollection, iconCollection, notFound, versionedScopes } =
2593
2517
  await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayout)
2594
2518
 
2519
+ // The layout sections' assets and icons, beside the pages'.
2520
+ assetCollection = mergeAssetCollections(assetCollection, layoutAssets)
2521
+ iconCollection = mergeIconCollections(iconCollection, layoutIcons)
2522
+
2595
2523
  // Merge top-level config assets (e.g. document.yml's book.covers.front,
2596
2524
  // banner images, logos) into the manifest. The compile pipeline reads
2597
2525
  // website.assets[<original src>] to resolve these to filesystem paths
@@ -2650,6 +2578,9 @@ export async function collectSiteContent(sitePath, options = {}) {
2650
2578
  page.parent = parentPage ? parentPage.route : null
2651
2579
  }
2652
2580
 
2581
+ const siteFetch = parseFetchConfig(...fetchIn(siteConfig, 'site.yml', 'site'))
2582
+ warnUnreadCurrent(pages, layouts, siteFetch)
2583
+
2653
2584
  // Page order is determined by per-level sorting during collection:
2654
2585
  // 1. Numeric 'order' property in page.yml (lower first, within each level)
2655
2586
  // 2. pages: array in parent config (wildcard-aware, overrides numeric order)
@@ -2786,7 +2717,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2786
2717
  // shorthand reached a backend on the sync lane and was silently ignored on
2787
2718
  // a static build — the works-on-one-lane shape. Every level reads it
2788
2719
  // through `declaredFetch` now.
2789
- fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
2720
+ fetch: siteFetch,
2790
2721
  fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2791
2722
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2792
2723
  // Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which