@uniweb/build 1.7.5 → 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,11 +57,11 @@
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
59
  "@uniweb/content-writer": "~1.3.0",
60
+ "@uniweb/schemas": "~1.6.5",
60
61
  "@uniweb/content-reader": "~1.5.4",
61
- "@uniweb/semantic-parser": "~1.12.2",
62
62
  "@uniweb/projections": "~1.1.6",
63
- "@uniweb/theming": "~1.2.1",
64
- "@uniweb/schemas": "~1.6.5"
63
+ "@uniweb/semantic-parser": "~1.12.2",
64
+ "@uniweb/theming": "~1.2.1"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.1.15"
@@ -540,18 +540,32 @@ function getContentProfile(configFile) {
540
540
  */
541
541
  async function readFolderConfig(dirPath, inheritedMode) {
542
542
  const folderYml = await readYamlFile(join(dirPath, 'folder.yml'))
543
- if (Object.keys(folderYml).length > 0) {
544
- return { config: folderYml, mode: 'pages', source: 'folder.yml' }
545
- }
546
- const pageYml = await readYamlFile(join(dirPath, 'page.yml'))
547
- if (Object.keys(pageYml).length > 0) {
548
- return { config: pageYml, mode: 'sections', source: 'page.yml' }
549
- }
550
- // Check for empty folder.yml (presence signals folder mode even if empty)
551
- if (existsSync(join(dirPath, 'folder.yml'))) {
552
- return { config: {}, mode: 'pages', source: 'folder.yml' }
553
- }
554
- return { config: {}, mode: inheritedMode, source: 'inherited' }
543
+ const pageYml = Object.keys(folderYml).length > 0 ? {} : await readYamlFile(join(dirPath, 'page.yml'))
544
+ const { mode, source } = folderContentMode(folderYml, pageYml, existsSync(join(dirPath, 'folder.yml')), inheritedMode)
545
+ return { config: source === 'folder.yml' ? folderYml : source === 'page.yml' ? pageYml : {}, mode, source }
546
+ }
547
+
548
+ /**
549
+ * A folder's content mode from its two config files — `readFolderConfig`'s rule, as a pure
550
+ * function, so a synchronous reader applies the same one: the pull, which must know which pages
551
+ * a folder's `.md` files are (`uwx/git-authored.js`).
552
+ *
553
+ * - a `folder.yml` with keys → folder mode
554
+ * - else a `page.yml` with keys → page mode
555
+ * - else an empty `folder.yml` → folder mode (its presence is the declaration)
556
+ * - else the parent's mode
557
+ *
558
+ * @param {object|null} folderYml - the parsed `folder.yml`, or empty
559
+ * @param {object|null} pageYml - the parsed `page.yml`, or empty
560
+ * @param {boolean} hasFolderYml - whether a `folder.yml` exists at all
561
+ * @param {string} inheritedMode - 'sections' or 'pages'
562
+ * @returns {{ mode: string, source: string }}
563
+ */
564
+ export function folderContentMode(folderYml, pageYml, hasFolderYml, inheritedMode) {
565
+ if (folderYml && Object.keys(folderYml).length > 0) return { mode: 'pages', source: 'folder.yml' }
566
+ if (pageYml && Object.keys(pageYml).length > 0) return { mode: 'sections', source: 'page.yml' }
567
+ if (hasFolderYml) return { mode: 'pages', source: 'folder.yml' }
568
+ return { mode: inheritedMode, source: 'inherited' }
555
569
  }
556
570
 
557
571
  /**
@@ -717,6 +731,63 @@ function resolveMounts(pathsConfig, sitePath, pagesPath, { strict = false } = {}
717
731
  return resolved.size > 0 ? resolved : null
718
732
  }
719
733
 
734
+ /**
735
+ * How a route read from a mount, a local folder of the same name, or both, is shaped: the two
736
+ * configs layered, the mode it is read in, and the mode its children are walked in. ONE rule, for
737
+ * the build's walk (`collectPagesRecursive`) and the push's (`uwx/site.js::orderedSubfolders`), so
738
+ * a mounted route is the same page tree on both lanes.
739
+ *
740
+ * The configs LAYER rather than one replacing the other: the mounted repo declares what its content
741
+ * is (ordering, title, how it nests), the local folder how this site presents it (layout, SEO, menu
742
+ * visibility). Mode is not a config key — it comes from which config file exists — so it is not
743
+ * spread: an explicit local declaration wins (a `page.yml` stub with its own markdown is a real
744
+ * landing page above mounted children); otherwise the mount decides, because it owns the content.
745
+ * A folder-mode route's children are walked in folder mode; a page-mode route's in the mount's own
746
+ * mode when they come from one — it is the mount's tree — else in the route's.
747
+ *
748
+ * @param {string} name - the route segment
749
+ * @param {{ config: object, mode: string, source: string }|null} local - the local folder's
750
+ * `readFolderConfig`, or null
751
+ * @param {{ config: object, mode: string, source: string }|null} mounted - the mount target's
752
+ * `readFolderConfig`, or null
753
+ * @param {string} contentMode - the mode of the level the route is on
754
+ * @returns {{ dirConfig: object, dirMode: string, childMode: string }}
755
+ */
756
+ export function layerMountedFolder(name, local, mounted, contentMode) {
757
+ const dirConfig = mounted
758
+ ? { title: mounted.config.title || name, ...mounted.config, ...local?.config }
759
+ : local.config
760
+ const dirMode = local && local.source !== 'inherited'
761
+ ? local.mode
762
+ : mounted?.mode ?? local?.mode ?? contentMode
763
+ const childMode = dirMode === 'pages' ? 'pages' : mounted?.mode ?? dirMode
764
+ return { dirConfig, dirMode, childMode }
765
+ }
766
+
767
+ /**
768
+ * A folder-mode folder's `.md` pages, in the order the build lists them: by filename (numeric
769
+ * prefix first), then by the folder's own `pages:`. Each name is the page's slug — the filename
770
+ * without its numeric prefix. Shared by the build's walk and the push's, so the sync lane sends the
771
+ * pages the build makes, in the build's order.
772
+ *
773
+ * @param {string[]} entries - the folder's directory listing
774
+ * @param {{ pages?: Array }} [orderConfig] - the folder's own config
775
+ * @returns {{ files: Array<{ file: string, name: string }>, strict: Set<string>|null }} the pages
776
+ * in order, and the names a strict `pages:` lists (null when it is not strict)
777
+ */
778
+ export function folderModePageFiles(entries, orderConfig) {
779
+ const items = entries.filter(isMarkdownFile).sort(compareFilenames).map((file) => {
780
+ const { name } = parse(file)
781
+ return { file, name: parseNumericPrefix(name).name || name }
782
+ })
783
+ const parsed = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
784
+ if (!parsed || parsed.mode === 'all') return { files: items, strict: null }
785
+ return {
786
+ files: applyWildcardOrder(items, parsed),
787
+ strict: parsed.mode === 'strict' ? new Set(parsed.before.map(extractItemName).filter(Boolean)) : null
788
+ }
789
+ }
790
+
720
791
  /**
721
792
  * Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
722
793
  * Supports:
@@ -1885,21 +1956,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1885
1956
  const mountPath = mounts?.get(name) ?? null
1886
1957
  const localPath = localDirs.has(name) ? join(dirPath, name) : null
1887
1958
 
1888
- // The content's declaration about itself, then the site's about presenting it.
1889
1959
  const mounted = mountPath ? await readFolderConfig(mountPath, 'pages') : null
1890
1960
  const local = localPath ? await readFolderConfig(localPath, contentMode) : null
1891
-
1892
- const dirConfig = mounted
1893
- ? { title: mounted.config.title || name, ...mounted.config, ...local?.config }
1894
- : local.config
1895
-
1896
- // Mode is not a config key — it comes from which config file exists, so it
1897
- // cannot be spread. An explicit local declaration wins (a `page.yml` stub
1898
- // with its own markdown is a real landing page above mounted children);
1899
- // otherwise the mount decides, because it owns the content being read.
1900
- const dirMode = local && local.source !== 'inherited'
1901
- ? local.mode
1902
- : mounted?.mode ?? local?.mode ?? contentMode
1961
+ const { dirConfig, dirMode, childMode } = layerMountedFolder(name, local, mounted, contentMode)
1903
1962
 
1904
1963
  const folderLayout = normalizeLayoutConfig(dirConfig.layout)
1905
1964
 
@@ -1909,11 +1968,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1909
1968
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined,
1910
1969
  dirConfig,
1911
1970
  dirMode,
1912
- // The mode the mounted subtree is walked in belongs to the mount: it is
1913
- // the mount's tree. Without this a `page.yml` stub silently imposed page
1914
- // mode on a mounted folder of pages, and only every mounted subfolder
1915
- // carrying its own folder.yml kept that from collapsing them into one page.
1916
- mountedContentMode: mounted?.mode ?? null,
1971
+ childMode,
1917
1972
  childOrderConfig: {
1918
1973
  pages: dirConfig.pages,
1919
1974
  index: dirConfig.index
@@ -1998,32 +2053,13 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1998
2053
 
1999
2054
  // --- Pages mode: .md files are child pages ---
2000
2055
  if (contentMode === 'pages') {
2001
- // Collect and process .md files as individual pages
2002
- const mdFiles = entries.filter(isMarkdownFile).sort(compareFilenames)
2003
- const mdPageItems = []
2004
-
2005
- for (const file of mdFiles) {
2006
- const { name } = parse(file)
2007
- const { name: stableName } = parseNumericPrefix(name)
2056
+ // Each .md file is a page, in the order `folderModePageFiles` gives — the one the push sends
2057
+ // them in (`uwx/site.js`): by filename, then by this folder's `pages:`.
2058
+ const { files: mdFiles, strict: strictPageNamesFM } = folderModePageFiles(entries, orderConfig)
2059
+ const orderedMdPages = []
2060
+ for (const { file, name } of mdFiles) {
2008
2061
  const result = await processFileAsPage(join(dirPath, file), file, siteRoot, parentRoute)
2009
- if (result) {
2010
- mdPageItems.push({ name: stableName || name, result })
2011
- }
2012
- }
2013
-
2014
- // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
2015
- let orderedMdPages
2016
- let strictPageNamesFM = null
2017
-
2018
- const pagesParsedFM = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
2019
-
2020
- if (pagesParsedFM && pagesParsedFM.mode !== 'all') {
2021
- orderedMdPages = applyWildcardOrder(mdPageItems, pagesParsedFM)
2022
- if (pagesParsedFM.mode === 'strict') {
2023
- strictPageNamesFM = new Set(pagesParsedFM.before.map(extractItemName).filter(Boolean))
2024
- }
2025
- } else {
2026
- orderedMdPages = mdPageItems
2062
+ if (result) orderedMdPages.push({ name, result })
2027
2063
  }
2028
2064
 
2029
2065
  // In folder mode, determine index page (homepage only).
@@ -2064,7 +2100,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2064
2100
 
2065
2101
  // Process subdirectories
2066
2102
  for (const folder of orderedFolders) {
2067
- const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
2103
+ const { name: entry, path: entryPath, dirConfig, dirMode, childMode, childOrderConfig, childLayout } = folder
2068
2104
  assertRouteFolder(entry, parentRoute)
2069
2105
  const isIndex = entry === indexName
2070
2106
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
@@ -2102,7 +2138,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2102
2138
  const childDirPath = mounts?.get(entry) || entryPath
2103
2139
  const childParentRoute = isIndex ? parentRoute : page.route
2104
2140
  const childFetch = page.fetch || parentFetch
2105
- const childContentMode = mountedContentMode ?? 'sections'
2141
+ const childContentMode = childMode
2106
2142
  const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
2107
2143
  pages.push(...subResult.pages)
2108
2144
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
@@ -2160,7 +2196,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2160
2196
  // Recurse in folder mode — pass container's own fetch config (or fall back to parent's)
2161
2197
  const childDirPath = mounts?.get(entry) || entryPath
2162
2198
  const containerFetch = containerPage.fetch || parentFetch
2163
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
2199
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, childMode, null, effectiveLayout)
2164
2200
  pages.push(...subResult.pages)
2165
2201
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
2166
2202
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -2202,7 +2238,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2202
2238
 
2203
2239
  // Second pass: process each page folder
2204
2240
  for (const folder of orderedFolders) {
2205
- const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
2241
+ const { name: entry, path: entryPath, dirConfig, dirMode, childMode, childOrderConfig, childLayout } = folder
2206
2242
  assertRouteFolder(entry, parentRoute)
2207
2243
  const isIndex = entry === indexPageName
2208
2244
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
@@ -2262,7 +2298,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2262
2298
  // container above passes it.
2263
2299
  const childDirPath = mounts?.get(entry) || entryPath
2264
2300
  const containerFetch = containerPage.fetch || parentFetch
2265
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
2301
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, childMode, null, effectiveLayout)
2266
2302
  pages.push(...subResult.pages)
2267
2303
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
2268
2304
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -2304,7 +2340,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2304
2340
  ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
2305
2341
  : page.route
2306
2342
  const childFetch = page.fetch || parentFetch
2307
- const childContentMode = mountedContentMode ?? dirMode
2343
+ const childContentMode = childMode
2308
2344
  const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
2309
2345
  pages.push(...subResult.pages)
2310
2346
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
@@ -3099,6 +3135,8 @@ export {
3099
3135
  parseNumericPrefix,
3100
3136
  compareFilenames,
3101
3137
  processMarkdownFile,
3138
+ processFileAsPage,
3139
+ resolveMounts,
3102
3140
  findSectionFile,
3103
3141
  processNesting,
3104
3142
  }
@@ -0,0 +1,85 @@
1
+ // The pages a pull leaves alone: those authored in files a pull cannot write back.
2
+ //
3
+ // ⭐ Two kinds, decided 2026-10-23 [Diego: the pull leaves them alone, since the docs are written in
4
+ // git]. A push sends both whole, read by the build's own rules (`site.js`), so the backend holds
5
+ // the site as the build makes it. A pull writes neither:
6
+ //
7
+ // · every page under a `paths: { pages/<segment>: <dir> }` mount. Its files are another
8
+ // repository's, outside the site; the local folder of that name holds only how this site
9
+ // presents them, and a pull writing the mounted pages into it would grow a second copy there.
10
+ // · every `.md` page of a folder-mode folder. The file IS the page, and a pull writes a page as a
11
+ // folder (`page.yml` + its sections), which would turn the author's folder of pages into a
12
+ // folder of page folders.
13
+ //
14
+ // ⇒ An edit made to one of these pages in the app is not brought back, and the next push replaces
15
+ // it with the file. Pruning skips them too: a pull never deletes what it does not write.
16
+ //
17
+ // A leaf module, read synchronously as the pull projects (`site-project.js`), through the build's
18
+ // mode rule (`folderContentMode`) and its folder-mode page list (`folderModePageFiles`), so the set
19
+ // is the one the push walks.
20
+
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import yaml from 'js-yaml'
24
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
25
+ import { isIgnoredFolder } from '../utils/content-files.js'
26
+ import { folderContentMode, folderModePageFiles, mountEntriesOf, parseNumericPrefix } from '../site/content-collector.js'
27
+
28
+ const DYNAMIC_RE = /^\[(.+)\]$/
29
+
30
+ // The collector's own reading of a config file (`parseYaml`): absent or unreadable is empty.
31
+ function readYaml(filePath) {
32
+ try {
33
+ return yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
34
+ } catch {
35
+ return {}
36
+ }
37
+ }
38
+
39
+ // A folder's mode, as `readFolderConfig` reads it.
40
+ function modeOf(dir, inheritedMode) {
41
+ const folderYml = readYaml(join(dir, 'folder.yml'))
42
+ const pageYml = Object.keys(folderYml).length > 0 ? {} : readYaml(join(dir, 'page.yml'))
43
+ return folderContentMode(folderYml, pageYml, existsSync(join(dir, 'folder.yml')), inheritedMode).mode
44
+ }
45
+
46
+ /**
47
+ * The pages of a site a pull leaves alone, by route: each a slug path with no leading slash
48
+ * (`docs/start/install`), as the pull composes a page's route from the document.
49
+ *
50
+ * @param {object|null} siteYml - the parsed `site.yml`
51
+ * @param {string} pagesDir - the site's pages directory (`siteContentDirs`)
52
+ * @returns {{ mounted: Set<string>, files: Set<string>, has: (route: string) => boolean }}
53
+ * `mounted` — the mounted segments, each leaving its whole route alone; `files` — the routes of
54
+ * folder-mode `.md` pages; `has` — whether a route is either
55
+ */
56
+ export function gitAuthoredPages(siteYml, pagesDir) {
57
+ const mounted = new Set(mountEntriesOf(siteYml?.paths).map(([segment]) => segment))
58
+ const files = new Set()
59
+ const walk = (dir, prefix, inheritedMode) => {
60
+ let entries
61
+ try {
62
+ entries = readdirSync(dir, { withFileTypes: true })
63
+ } catch {
64
+ return
65
+ }
66
+ const mode = modeOf(dir, inheritedMode)
67
+ if (mode === 'pages') {
68
+ for (const { name } of folderModePageFiles(entries.map((e) => e.name)).files) {
69
+ files.add(prefix + name)
70
+ }
71
+ }
72
+ for (const e of entries) {
73
+ if (!e.isDirectory() || isIgnoredFolder(e.name)) continue
74
+ if (!prefix && mounted.has(e.name)) continue // the whole route is left alone
75
+ const dyn = e.name.match(DYNAMIC_RE)
76
+ walk(join(dir, e.name), `${prefix}${dyn ? dyn[1] : parseNumericPrefix(e.name).name}/`, mode)
77
+ }
78
+ }
79
+ if (existsSync(pagesDir)) walk(pagesDir, '', 'sections')
80
+ return {
81
+ mounted,
82
+ files,
83
+ has: (route) => mounted.has(String(route).split('/')[0]) || files.has(route),
84
+ }
85
+ }
@@ -54,6 +54,7 @@ import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
54
54
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
55
55
  import { documentSourceLocale } from './source-locale.js'
56
56
  import { siteContentDirs } from './site-dirs.js'
57
+ import { gitAuthoredPages } from './git-authored.js'
57
58
  import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
58
59
  import { layoutNameKey } from '@uniweb/core/layout-name'
59
60
  import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
@@ -693,8 +694,13 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
693
694
  if (sectionsArray && sectionsArray.length > 0) {
694
695
  const authored = existing?.sections
695
696
  if (Array.isArray(authored)) {
696
- // Compared by the names the files are found by, so a list naming `1-hero` keeps naming it.
697
- const same = JSON.stringify(listedNames(authored.filter((e) => e !== '...'))) === JSON.stringify(sectionsArray)
697
+ // Compared by the names the files are found by, so a list naming `1-hero` keeps naming it —
698
+ // and with the author's `nest:` applied, as the build reads the two together: a list beside a
699
+ // `nest:` that gives the incoming nesting is kept, its `nest:` with it. ⛔ Until 2026-10-23 the
700
+ // list was compared alone, so a page that lists its sections and nests with `nest:` had both
701
+ // rewritten into one inclusive list on every pull.
702
+ const said = withNest(listedNames(authored.filter((e) => e !== '...')), existing?.nest)
703
+ const same = JSON.stringify(said) === JSON.stringify(sectionsArray)
698
704
  y.sections = same ? authored : [...sectionsArray, '...']
699
705
  } else if (filesGiveTopOrder && nestSays(existing?.nest, sectionsArray)) {
700
706
  // Nothing to list: the files and `nest:` say it, kept below.
@@ -714,6 +720,17 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
714
720
  return y
715
721
  }
716
722
 
723
+ // A `sections:` list's entries with a page's `nest:` applied, as the build applies it after the list
724
+ // (`content-collector.js::processNesting`): a top-level parent `nest:` names takes the children it
725
+ // lists, in place of any the list gives it.
726
+ function withNest(list, nest) {
727
+ if (!isPlainRecord(nest)) return list
728
+ return list.map((entry) => {
729
+ const name = typeof entry === 'string' ? entry : isPlainRecord(entry) ? Object.keys(entry)[0] : null
730
+ return name && Array.isArray(nest[name]) ? { [name]: nest[name].map((c) => fileSectionName(String(c))) } : entry
731
+ })
732
+ }
733
+
717
734
  // Whether a `sections:` list nests: an entry that is a parent with its children.
718
735
  function nestsIn(list) {
719
736
  return Array.isArray(list) && list.some((entry) => isPlainRecord(entry))
@@ -805,8 +822,17 @@ export function pageDirName(record, sourceLocale) {
805
822
  // accumulates the slug-path route (for the free-form translation path; matches the
806
823
  // producer's slugPath, normalizeRouteForPath strips any leading slash on both).
807
824
  function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix = '', list = null, canonParent = '/') {
825
+ let leftAlone = false
808
826
  for (const record of pages || []) {
809
827
  const slug = unwrapLocalized(record.slug, sourceLocale) // localized {lang:value} → canonical
828
+ const route = routePrefix ? `${routePrefix}/${slug}` : slug
829
+ // ⭐ A PAGE AUTHORED IN A FILE THE PULL CANNOT WRITE BACK IS LEFT ALONE — a mount's, or a
830
+ // folder-mode `.md` page (`git-authored.js`). Its children too: a mount's are the mount's.
831
+ if (ctx.leave?.has(route)) {
832
+ report.leftAlone?.push(...subtreeRoutes(record, route, sourceLocale))
833
+ leftAlone = true
834
+ continue
835
+ }
810
836
  // The route the build gives the page (`buildRouteOf`, the push's rule): an index page takes its
811
837
  // parent's, a parametric one is `:<param>`.
812
838
  const canon = buildRouteOf(record, canonParent, sourceLocale)
@@ -829,7 +855,6 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
829
855
  if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta, text))
830
856
  else ctx.collector?.add(record.keywords, meta, text)
831
857
 
832
- const route = routePrefix ? `${routePrefix}/${slug}` : slug
833
858
  // Addressed as the push addresses it: its free-form translations and its translations' contexts
834
859
  // are keyed by the build's route. ⛔ Until 2026-09-26 this was the slug path — `home` for a
835
860
  // homepage the build reads at `/`.
@@ -865,7 +890,18 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
865
890
 
866
891
  writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
867
892
  }
868
- reconcileLevelOrder({ pages, pagesDir, sourceLocale, list, homepage: routePrefix ? null : ctx.homepage })
893
+ // A level holding a page left alone keeps the order its files give: reconciling it would write an
894
+ // `order:` into a page folder that does not exist, or rewrite a `pages:` list the author keeps.
895
+ if (!leftAlone) reconcileLevelOrder({ pages, pagesDir, sourceLocale, list, homepage: routePrefix ? null : ctx.homepage })
896
+ }
897
+
898
+ // A page's route and each of its descendants', as `writePagesTree` composes them.
899
+ function subtreeRoutes(record, route, sourceLocale) {
900
+ const out = [route]
901
+ for (const child of record.$children || []) {
902
+ out.push(...subtreeRoutes(child, `${route}/${unwrapLocalized(child.slug, sourceLocale)}`, sourceLocale))
903
+ }
904
+ return out
869
905
  }
870
906
 
871
907
  // ⭐ A PAGE'S LOCALIZED URL SEGMENTS — the other locales of its `slug` (`localizeRouteSlugs`, the push) —
@@ -1010,19 +1046,27 @@ function collectSectionFileBases(pageSections) {
1010
1046
 
1011
1047
  // Pass 2 — prune orphan section files (per page dir) and orphan page dirs (per
1012
1048
  // level), AFTER every relocation in pass 1. Guarded against wiping an empty level.
1013
- function prunePagesTree(pages, pagesDir, sourceLocale, report, ctx = null) {
1049
+ function prunePagesTree(pages, pagesDir, sourceLocale, report, ctx = null, routePrefix = '') {
1014
1050
  const incomingDirs = new Set()
1015
1051
  for (const record of pages || []) {
1016
1052
  incomingDirs.add(pageDirName(record, sourceLocale))
1053
+ const slug = unwrapLocalized(record.slug, sourceLocale)
1054
+ const route = routePrefix ? `${routePrefix}/${slug}` : slug
1055
+ // Left alone by pass 1, so nothing of it is pruned either (`git-authored.js`).
1056
+ if (ctx?.leave?.has(route)) continue
1017
1057
  const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
1018
1058
  if (record.mode === 'page') {
1019
1059
  // The files pass 1 wrote into — the author's own names among them — else the names it would give.
1020
1060
  const keep = ctx?.sectionFiles?.get(pageDir) || collectSectionFileBases(record.page_sections)
1021
1061
  if (keep.size > 0) pruneOrphanSectionFiles(pageDir, keep, report)
1022
1062
  }
1023
- prunePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx)
1063
+ prunePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
1024
1064
  }
1025
- if (incomingDirs.size > 0) pruneOrphanPageDirs(pagesDir, incomingDirs, report)
1065
+ if (incomingDirs.size === 0) return
1066
+ // A mounted route's local folder is never an orphan, whatever the document holds: what it
1067
+ // presents is another repository's (`git-authored.js`).
1068
+ if (!routePrefix) for (const segment of ctx?.leave?.mounted ?? []) incomingDirs.add(segment)
1069
+ pruneOrphanPageDirs(pagesDir, incomingDirs, report)
1026
1070
  }
1027
1071
 
1028
1072
  /**
@@ -1291,7 +1335,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1291
1335
  // Before anything is written: a pull that would leave content out of the files stops here.
1292
1336
  refuseContentFilesCannotHold(document, sourceLocale)
1293
1337
 
1294
- const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
1338
+ const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [], leftAlone: [] }
1295
1339
 
1296
1340
  // Collects target-locale translations of localized scalars as they're projected;
1297
1341
  // flushed to locales/{locale}.json at the end (the manifest stays derivable).
@@ -1327,7 +1371,11 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1327
1371
  // Where the tree goes is what `site.yml` says NOW — `siteInfoToConfig` above has
1328
1372
  // written the document's `settings.paths` into it — because that is the file the
1329
1373
  // next push and the build read. See `siteContentDirs`.
1330
- const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, readAuthoredYaml(join(siteRoot, 'site.yml')))
1374
+ const siteYml = readAuthoredYaml(join(siteRoot, 'site.yml'))
1375
+ const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, siteYml)
1376
+ // ⭐ The pages authored in files a pull cannot write back — a mount's, a folder-mode folder's
1377
+ // `.md` pages — are neither written nor pruned (`git-authored.js`).
1378
+ ctx.leave = gitAuthoredPages(siteYml, pagesDir)
1331
1379
  // The root page the backend marks as the homepage, by the directory it is written to.
1332
1380
  const home = (document?.pages || []).find((p) => p?.is_index)
1333
1381
  ctx.homepage = home ? pageDirName(home, sourceLocale) : null
package/src/uwx/site.js CHANGED
@@ -40,9 +40,10 @@
40
40
  // they ride under their parent section's `$children` (page_sections is
41
41
  // self_nesting), via the same `processNesting` the normal build uses.
42
42
  //
43
- // v0 scope (stated, not silent): folder-mode `.md`-as-pages (document/blog-list
44
- // profile), `paths:` mounts, versioned scopes, and media/asset bytes
45
- // (favicon/assets — carried out of band).
43
+ // v0 scope (stated, not silent): versioned scopes, and media/asset bytes
44
+ // (favicon/assets — carried out of band). Folder-mode `.md`-as-pages and
45
+ // `paths: { pages/<segment> }` mounts are carried since 2026-10-23, read by the
46
+ // build's own rules; a pull leaves those pages alone (`git-authored.js`).
46
47
 
47
48
  import { readdir, readFile } from 'node:fs/promises'
48
49
  import { dataBlockModels } from '../i18n/data-models.js'
@@ -59,11 +60,14 @@ import {
59
60
  compareFilenames,
60
61
  orderFolders,
61
62
  processMarkdownFile,
63
+ processFileAsPage,
62
64
  declaredFetch,
63
65
  checkDeclaration,
64
66
  fetchFromQueryShorthand,
65
67
  assertRouteFolder,
66
- mountEntriesOf,
68
+ resolveMounts,
69
+ layerMountedFolder,
70
+ folderModePageFiles,
67
71
  rootOrderConfig,
68
72
  composeLocalizedRoute,
69
73
  isValidSlugSegment,
@@ -427,24 +431,37 @@ function buildPageData(config, ctx) {
427
431
  return data
428
432
  }
429
433
 
430
- async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
434
+ // A level's child folders, each with the config and mode the build reads it with. At the root,
435
+ // `mounts` adds every `paths: { pages/<segment>: … }` route, shaped by the build's own rule
436
+ // (`layerMountedFolder`): the mount's config under the local folder's, the mode the mount gives
437
+ // unless the local folder declares one, and the children read from the mount. ⛔ Until 2026-10-23
438
+ // this lane read no mount, so a mounted route was pushed as an empty container (a local stub) or
439
+ // not at all (none) — measured 2026-09-18 on a documentation site: 49 pages built, 2 pushed.
440
+ async function orderedSubfolders(dirPath, inheritedMode, parentConfig, mounts = null) {
431
441
  const entries = await readdir(dirPath, { withFileTypes: true })
432
- const folders = []
442
+ const local = new Map()
433
443
  for (const e of entries) {
434
- if (!e.isDirectory() || isIgnoredFolder(e.name)) continue
435
- const path = join(dirPath, e.name)
436
- const { config, mode, source } = await readFolderConfig(
437
- path,
438
- inheritedMode
439
- )
444
+ if (e.isDirectory() && !isIgnoredFolder(e.name)) local.set(e.name, join(dirPath, e.name))
445
+ }
446
+ const folders = []
447
+ for (const dirName of new Set([...local.keys(), ...(mounts?.keys() ?? [])])) {
448
+ const localPath = local.get(dirName) ?? null
449
+ const mountPath = mounts?.get(dirName) ?? null
450
+ const localConfig = localPath ? await readFolderConfig(localPath, inheritedMode) : null
451
+ const mountedConfig = mountPath ? await readFolderConfig(mountPath, 'pages') : null
452
+ const { dirConfig, dirMode, childMode } = layerMountedFolder(dirName, localConfig, mountedConfig, inheritedMode)
440
453
  folders.push({
441
- dirName: e.name,
442
- name: parseNumericPrefix(e.name).name,
443
- path,
444
- config,
445
- internalMode: mode,
446
- source,
447
- order: typeof config.order === 'number' ? config.order : undefined,
454
+ dirName,
455
+ name: parseNumericPrefix(dirName).name,
456
+ // Where the page's own sections are — the local folder, as the build reads them — and
457
+ // where its child pages are: the mount's tree, when it has one.
458
+ path: localPath ?? mountPath,
459
+ childrenPath: mountPath ?? localPath,
460
+ config: dirConfig,
461
+ internalMode: dirMode,
462
+ childMode,
463
+ source: (localConfig ?? mountedConfig).source,
464
+ order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined,
448
465
  })
449
466
  }
450
467
  // Explicit order, then numeric-prefix filename order, then the parent's `pages:` wildcard —
@@ -487,9 +504,10 @@ const CATCH_ALL_MARKER = '...path'
487
504
  // `multi` section is read as new (inserted, stored counterpart deleted). See
488
505
  // the IDENTITY note in the file header and `site-diff.js`.
489
506
  //
490
- // v0 deferrals: folder-mode `.md`-as-pages, `paths:` mounts, versioned scopes, and
491
- // media/asset bytes (favicon/assets, carried out of band). `@`-prefix `nest:`
492
- // hierarchy is NOT deferred — it is reconstructed here, same as a normal build.
507
+ // v0 deferrals: versioned scopes, and media/asset bytes (favicon/assets, carried
508
+ // out of band). `@`-prefix `nest:` hierarchy, folder-mode `.md`-as-pages and
509
+ // `paths: { pages/<segment> }` mounts are NOT deferred — each is read here by the
510
+ // build's own rule (folder-mode pages and mounts since 2026-10-23).
493
511
  // ===========================================================================
494
512
 
495
513
  const SITE_MODEL_NAME = '@uniweb/site-content'
@@ -573,7 +591,7 @@ function isExplicitWithRest(sectionsConfig) {
573
591
  // a section name (string) or `{ name: [children…] }`, resolved to its file by
574
592
  // name (bare / `@` / numeric-prefix tolerant) and recursed. Order and nesting
575
593
  // come from the array, not the directory.
576
- async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, { appendRest = false } = {}) {
594
+ async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, { appendRest = false, nest = null } = {}) {
577
595
  const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
578
596
  const seen = new Set()
579
597
 
@@ -615,15 +633,37 @@ async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, {
615
633
  sections.push(section)
616
634
  }
617
635
  }
636
+
637
+ // ⭐ `nest:` OVER THE LIST, as the build applies it after one (`processNesting`,
638
+ // `site/content-collector.js`): each top-level parent it names gets the children it lists, in
639
+ // that order, replacing any the list nested — "`nest:` wins" (`docs/reference/page-configuration.md`).
640
+ // ⛔ Until 2026-10-23 an explicit list ignored `nest:` here, so a page listing its sections and
641
+ // nesting a figure under each pushed none of the figures, which the build renders — and a pull
642
+ // then deleted their `@` files, absent from the document.
643
+ if (nest && typeof nest === 'object') {
644
+ for (const [parentName, childNames] of Object.entries(nest)) {
645
+ if (!Array.isArray(childNames)) continue
646
+ const parent = sections.find((s) => s.stableId === parentName)
647
+ if (!parent) continue
648
+ parent.subsections = []
649
+ for (const childName of childNames) {
650
+ const file = findSectionFileName(mdFiles, childName)
651
+ if (!file) continue
652
+ const { section } = await processMarkdownFile(join(pageDir, file), String(parent.subsections.length + 1), siteRoot, childName)
653
+ section.subsections = []
654
+ parent.subsections.push(section)
655
+ }
656
+ }
657
+ }
618
658
  return sections.map((s, i) => sectionToRecord(s, i))
619
659
  }
620
660
 
621
661
  async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
622
662
  if (isFullyExplicitSections(pageConfig?.sections)) {
623
- return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections)
663
+ return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { nest: pageConfig.nest })
624
664
  }
625
665
  if (isExplicitWithRest(pageConfig?.sections)) {
626
- return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { appendRest: true })
666
+ return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { appendRest: true, nest: pageConfig.nest })
627
667
  }
628
668
  const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
629
669
  const nest = pageConfig?.nest && typeof pageConfig.nest === 'object' ? pageConfig.nest : {}
@@ -678,56 +718,45 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
678
718
  return sections.map((s, i) => sectionToRecord(s, i))
679
719
  }
680
720
 
681
- // A folder-mode folder's `.md` files are pages to the build and nothing to this
682
- // lane. Name every one of them, so the shortfall is visible at push time rather
683
- // than by reading the backend's store or opening the published site.
684
- //
685
- // `source` is `readFolderConfig`'s: a folder with no config of its own is in folder
686
- // mode because a `folder.yml` above it put it there, and the message says so —
687
- // "it is a folder-mode folder (`folder.yml`)" over a folder that has none would send
688
- // the author looking for a file that does not exist.
689
- async function warnFolderModePagesSkipped(dirPath, siteRoot, source = 'folder.yml') {
690
- let files
691
- try {
692
- files = (await readdir(dirPath)).filter(isMarkdownFile)
693
- } catch {
694
- return
721
+ // A folder-mode folder's `.md` files, as the pages the build makes of them (`processFileAsPage`):
722
+ // each a page whose one section is the file, titled by its first heading, a draft when the file
723
+ // says `hidden: true`, slugged by the filename without its numeric prefix — and in the build's
724
+ // order (`folderModePageFiles`). ⛔ Until 2026-10-23 this lane sent none of them and warned instead;
725
+ // a documentation site — folder-mode pages in a mounted repo — published 2 of its 49 pages.
726
+ async function folderModePageRecords(ctx, dirPath, orderConfig, isRoot) {
727
+ const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
728
+ const { files } = folderModePageFiles(await readdir(dirPath), orderConfig)
729
+ const records = []
730
+ for (const { file, name } of files) {
731
+ const filePath = join(dirPath, file)
732
+ const { page } = await processFileAsPage(filePath, file, siteRoot, '/')
733
+ const config = { title: page.title }
734
+ if (page.hidden) config.hidden = true
735
+ const data = buildPageData(config, {
736
+ where: relative(siteRoot, filePath),
737
+ slug: name,
738
+ mode: 'page',
739
+ isDynamic: false,
740
+ isRoot,
741
+ siteIndex,
742
+ sourceLocale,
743
+ translations,
744
+ pageFields,
745
+ })
746
+ const record = withIdentity(data.stable_id || name, data)
747
+ record.page_sections = page.sections.map((s, i) => sectionToRecord(s, i))
748
+ records.push(record)
695
749
  }
696
- if (files.length === 0) return
697
- const where = relative(siteRoot, dirPath)
698
- const why = source === 'inherited'
699
- ? 'it is a folder-mode folder (it has no `folder.yml` or `page.yml` of its own, so it inherits folder mode from the `folder.yml` above it)'
700
- : 'it is a folder-mode folder (`folder.yml`)'
701
- console.warn(
702
- `uwx/site: ${files.length} page(s) in \`${where}\` will NOT be pushed — ` +
703
- `${why}, and pushing \`.md\`-as-pages ` +
704
- `is not implemented on the sync lane. A build renders them; the backend ` +
705
- `will not have them.\n` +
706
- files.map((f) => ` - ${where}/${f}`).join('\n') +
707
- `\n To publish them, make each one a page-mode directory ` +
708
- `(\`<name>/page.yml\` + its section files). To ship them as they are, ` +
709
- `use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.` +
710
- REPUBLISH_CLAUSE
711
- )
750
+ return records
712
751
  }
713
752
 
714
- // A push sends the page tree as ONE entity (see site-diff.js), so a republish does
715
- // not merge: the shorter document becomes the whole tree. Measured by the backend
716
- // lane 2026-09-18 against a current `uniwebd` — 49 stored pages replaced by 2, the
717
- // publish reporting success. Both skip warnings carry this, because "will not be
718
- // pushed" reads as "you publish short" when the real cost on an already-published
719
- // site is that live pages are REMOVED.
720
- const REPUBLISH_CLAUSE =
721
- `\n ⚠ If this site has been published before, a push REPLACES its whole page ` +
722
- `tree — pages already live that are missing here are REMOVED, and the publish ` +
723
- `still reports success.`
724
-
725
753
  // Recursively build the `pages` tree: each record carries its fields, its inline
726
- // `page_sections` (page mode only), and its child pages under `$children`.
727
- async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
754
+ // `page_sections` (page mode only), and its child pages under `$children`. A folder-mode level's
755
+ // own `.md` pages come first, then its folders, as the build lists them.
756
+ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot, mounts = null) {
728
757
  const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
729
- const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
730
- const out = []
758
+ const out = inheritedMode === 'pages' ? await folderModePageRecords(ctx, dirPath, parentConfig, isRoot) : []
759
+ const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig, mounts)
731
760
  // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
732
761
  // `[path]` would name a route variable — refused here as the collector refuses
733
762
  // them, so a site that cannot build cannot sync either (ruled 2026-09-11).
@@ -775,26 +804,13 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
775
804
  record.slug = { ...record.slug, [sourceLocale]: f.config.slug }
776
805
  }
777
806
 
807
+ // A folder-mode folder has no sections: its `.md` files are pages, sent among its children.
778
808
  if (mode === 'page') {
779
809
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
780
810
  if (sections.length > 0) record.page_sections = sections
781
- } else {
782
- // ⛔ FOLDER MODE: the build makes each `.md` file here a PAGE; this lane
783
- // makes it nothing. It is a v0 mapper gap (bidirectional-sync.md §"Other
784
- // v0 mapper gaps"), and until it closes the ONLY thing standing between an
785
- // author and a site that publishes a fraction of itself is this warning.
786
- //
787
- // ⭐ It has to be here, not in `status` or `push`. Those compare the built
788
- // document against what was last sent, so with the pages already absent
789
- // from the document both correctly report "Synced" / "Nothing to push".
790
- // The loss happens upstream of every diff, which is why it was invisible:
791
- // measured 2026-09-18, a 49-page documentation site published 2 pages with
792
- // every CLI instrument reporting success [measured:backend, channel with
793
- // backend, and reproduced here against the same site].
794
- await warnFolderModePagesSkipped(f.path, siteRoot, f.source)
795
811
  }
796
812
 
797
- const children = await walkPagesNested(ctx, f.path, slugPath, f.internalMode, f.config, false)
813
+ const children = await walkPagesNested(ctx, f.childrenPath, slugPath, f.childMode, f.config, false)
798
814
  if (children.length > 0) record.$children = children
799
815
 
800
816
  out.push(record)
@@ -1557,50 +1573,25 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1557
1573
  // `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
1558
1574
  // …and the keys this deployment's page takes (`siteContent.pageFields`), for its Open Graph texts.
1559
1575
  const ctx = { siteRoot, sourceLocale, translations, pageFields: opts.pageFields }
1560
- // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1561
- // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1562
- // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
1563
- // lane reads `paths.pages` alone. A sub-mounted folder therefore syncs as an
1564
- // EMPTY container: its directory on disk holds only the `folder.yml`, so it
1565
- // emits with no children and nothing anywhere says so. Measured 2026-09-18 on
1566
- // a site whose entire corpus was one sub-mount: 49 pages built, 2 pushed.
1567
- // Deferred by design for now (bidirectional-sync.md); warned about here so it
1568
- // is not also silent.
1569
- //
1570
1576
  // The same two directories the pull writes back to — see `siteContentDirs`.
1571
1577
  const { pagesDir: pagesPath, layoutDir } = siteContentDirs(siteRoot, siteYml)
1572
- for (const [segment] of mountEntriesOf(siteYml.paths)) {
1573
- // Only a LOCAL stub (`pages/<segment>/`, the folder that gives a mounted route its
1574
- // layout) is walked, so the route arrives as an empty container; with no stub,
1575
- // nothing here stands for the route and it is not sent at all. ⚠️ The message
1576
- // said "empty container" in both cases until 2026-09-19.
1577
- const fate = existsSync(join(pagesPath, segment))
1578
- ? 'will be pushed as an empty container'
1579
- : 'will not be pushed at all'
1580
- console.warn(
1581
- `uwx/site: \`paths: { pages/${segment}: … }\` is NOT read on the sync lane — ` +
1582
- `\`${segment}\` ${fate} and none of the mounted ` +
1583
- `pages will reach the backend. A build mounts it normally. Inline the ` +
1584
- `directory under \`pages/${segment}/\` to push it, or ship the site with ` +
1585
- `\`uniweb export\` / \`uniweb deploy --host <adapter>\`.` +
1586
- REPUBLISH_CLAUSE
1587
- )
1588
- }
1589
1578
  let pages = []
1590
1579
  if (existsSync(pagesPath)) {
1591
- // The root has a mode too, read as the build reads it (`rootContentMode`):
1592
- // `pages/folder.yml` puts the whole site in folder mode, and every folder with no
1593
- // config of its own inherits it. ⛔ This walk started in page mode unconditionally
1594
- // until 2026-09-19, so such a site's top-level `.md` pages were dropped without a
1595
- // word and its top-level folders were pushed as pages of sections.
1596
- const { mode: rootMode, source: rootSource, config: rootConfig } = await readFolderConfig(pagesPath, 'sections')
1597
- if (rootMode === 'pages') await warnFolderModePagesSkipped(pagesPath, siteRoot, rootSource)
1580
+ // ⭐ SUB-MOUNTS, AS THE BUILD READS THEM — `paths: { pages/<segment>: <dir> }`, through its own
1581
+ // `resolveMounts`, and STRICT as a production build is: a mount with nothing in it (a git
1582
+ // submodule never fetched) stops the push. A push replaces the site's whole page tree, so a
1583
+ // mount read as empty would remove every page it holds from a published site.
1584
+ const mounts = resolveMounts(siteYml.paths, siteRoot, pagesPath, { strict: true })
1585
+ // The root has a mode too, read as the build reads it (`rootContentMode`): `pages/folder.yml`
1586
+ // puts the whole site in folder mode, its `.md` files pages and every folder with no config of
1587
+ // its own in folder mode too.
1588
+ const { mode: rootMode, config: rootConfig } = await readFolderConfig(pagesPath, 'sections')
1598
1589
  // The top level's order and homepage, decided by the build's own function: the
1599
1590
  // site's `pages:` / `index:` over the pages directory's own. ⛔ The homepage read
1600
1591
  // `index:` alone until 2026-09-19, so a site naming it by its first `pages:` entry
1601
1592
  // — as the build does — pushed no homepage at all.
1602
1593
  const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1603
- pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1594
+ pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true, mounts)
1604
1595
  localizeRouteSlugs(pages, siteYml, sourceLocale)
1605
1596
  }
1606
1597