@uniweb/build 0.53.0 → 0.54.1

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.53.0",
3
+ "version": "0.54.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,14 +58,14 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.5",
61
- "@uniweb/schemas": "^0.3.3",
62
- "@uniweb/theming": "^0.1.15",
63
61
  "@uniweb/content-writer": "^0.3.4",
64
62
  "@uniweb/projections": "^0.7.3",
65
- "@uniweb/semantic-parser": "^1.4.1"
63
+ "@uniweb/semantic-parser": "^1.4.1",
64
+ "@uniweb/schemas": "^0.3.3",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.26.5"
68
+ "@uniweb/runtime": "^0.26.6"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/runtime": "^0.26.5",
78
- "@uniweb/core": "^0.29.4"
77
+ "@uniweb/core": "^0.29.5",
78
+ "@uniweb/runtime": "^0.26.6"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/prerender.js CHANGED
@@ -1095,9 +1095,10 @@ export async function prerenderSite(siteDir, options = {}) {
1095
1095
  // ⭐ `renderer.render` IS the sequence this loop used to spell out —
1096
1096
  // renderPage → classify → injectPageContent. It was moved into
1097
1097
  // `@uniweb/runtime/ssr` because an SSR isolate assembles the same three steps
1098
- // per request, and two hand-written copies of one sequence is how the two
1099
- // lanes drift. This lane passes the Page it already holds; the isolate passes
1100
- // a route. See `runtime/src/page-renderer.js` for what it leaves to the host.
1098
+ // for the page a visit starts on, and two hand-written copies of one sequence
1099
+ // is how the two lanes drift. This lane passes the Page it already holds; the
1100
+ // isolate passes a route. See `runtime/src/page-renderer.js` for what it leaves
1101
+ // to the host.
1101
1102
  const result = renderer.render(page)
1102
1103
 
1103
1104
  if (result.outcome === 'failed') {
@@ -1714,14 +1714,18 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1714
1714
  }
1715
1715
 
1716
1716
  /**
1717
- * Determine the index page name from ordering config
1717
+ * The homepage ONE config declares: the first named `pages:` entry, else `index:`.
1718
+ * `undefined` when it declares none; `null` when its first `pages:` entry names
1719
+ * nothing usable, which has always meant "no homepage" rather than "fall through".
1718
1720
  *
1719
- * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent
1720
- * @param {Array} availableFolders - Array of { name, order } for folders at this level
1721
- * @returns {string|null} The folder name that should be the index, or null
1721
+ * The within-file rule, in one place — a level's config and each of the root's two
1722
+ * configs (`rootOrderConfig`) are read by it alike.
1723
+ *
1724
+ * @param {Object} [config] - `{ pages, index }` as authored
1725
+ * @returns {string|null|undefined}
1722
1726
  */
1723
- function determineIndexPage(orderConfig, availableFolders) {
1724
- const { pages: pagesArray, index: indexName } = orderConfig || {}
1727
+ function declaredHomepage(config) {
1728
+ const { pages: pagesArray, index: indexName } = config || {}
1725
1729
 
1726
1730
  // 1. Explicit pages array - first non-'...' item is index
1727
1731
  if (Array.isArray(pagesArray) && pagesArray.length > 0) {
@@ -1733,9 +1737,69 @@ function determineIndexPage(orderConfig, availableFolders) {
1733
1737
  }
1734
1738
 
1735
1739
  // 2. Explicit index property
1736
- if (indexName) {
1737
- return indexName
1740
+ return indexName || undefined
1741
+ }
1742
+
1743
+ /**
1744
+ * The homepage a level's order config names. The root's was already decided across
1745
+ * its two configs, and arrives as `homepage` (`rootOrderConfig`); any other level
1746
+ * that promotes one — a folder promoted to `/`, the latest version of a scope at the
1747
+ * root — names it in its own config.
1748
+ */
1749
+ function homepageOf(orderConfig) {
1750
+ return orderConfig && 'homepage' in orderConfig ? orderConfig.homepage : declaredHomepage(orderConfig)
1751
+ }
1752
+
1753
+ /**
1754
+ * The site's TOP LEVEL — its order and its homepage — from the two configs that can
1755
+ * declare it: the site config (`site.yml`, or a document profile's own file) and the
1756
+ * pages directory's own config (`pages/folder.yml`, or `pages/page.yml`).
1757
+ *
1758
+ * ⭐ The pages directory's config is read as a MOUNTED directory's is: the content's
1759
+ * own declaration, under the site's. Every other directory's config orders its
1760
+ * children, and a mounted one's does too — but the root's was read for its mode
1761
+ * alone until 2026-09-19, so a content repository that orders itself kept its order
1762
+ * mounted below the root and lost it as the whole pages directory: alphabetical,
1763
+ * and in folder mode with no homepage at all.
1764
+ *
1765
+ * ⛔ THE HOMEPAGE IS DECIDED PER CONFIG, NOT PER KEY. Within one file the first
1766
+ * `pages:` entry beats `index:`, as it always has; across the two, whichever form the
1767
+ * site config uses beats the pages directory's. Merged key by key, a `pages:` list
1768
+ * from the pages directory would override a `site.yml` `index:`. The order is the
1769
+ * site config's `pages:`, else the pages directory's.
1770
+ *
1771
+ * ⚖️ `pages:` and `index:` ONLY. The root has no container page, so the keys that
1772
+ * describe one (`title`, `seo`, …) mean nothing here — a repository carries them for
1773
+ * when it is mounted below the root. And `layout:` stays the site config's alone: a
1774
+ * site's layout travels on the sync lane as that file's own value, verbatim, so a
1775
+ * second source would render on a static build and nowhere else.
1776
+ *
1777
+ * @param {{ pages?: Array, index?: string }} [site] - the site config's own order keys
1778
+ * @param {Object} [root] - the pages directory's config, as `readFolderConfig` read it
1779
+ * @returns {{ pages: Array|undefined, index: string|undefined, homepage: string|null|undefined }}
1780
+ */
1781
+ export function rootOrderConfig(site = {}, root = {}) {
1782
+ const listed = (value) => Array.isArray(value) && value.length > 0
1783
+ const fromSite = declaredHomepage(site)
1784
+ const homepage = fromSite !== undefined ? fromSite : declaredHomepage(root)
1785
+ return {
1786
+ pages: listed(site?.pages) ? site.pages : listed(root?.pages) ? root.pages : undefined,
1787
+ index: homepage || undefined,
1788
+ homepage,
1738
1789
  }
1790
+ }
1791
+
1792
+ /**
1793
+ * Determine the index page name from ordering config
1794
+ *
1795
+ * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent
1796
+ * @param {Array} availableFolders - Array of { name, order } for folders at this level
1797
+ * @returns {string|null} The folder name that should be the index, or null
1798
+ */
1799
+ function determineIndexPage(orderConfig, availableFolders) {
1800
+ // 1–2. The first named `pages:` entry, else `index:` (`declaredHomepage`).
1801
+ const declared = homepageOf(orderConfig)
1802
+ if (declared !== undefined) return declared
1739
1803
 
1740
1804
  // 3. Fallback: lowest order value, or first alphabetically
1741
1805
  // IMPORTANT: Dynamic route folders (e.g., [slug]) can never be index pages
@@ -1953,11 +2017,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1953
2017
  // pages: controls order only and every folder keeps its natural route.
1954
2018
  let indexName = null
1955
2019
  if (parentRoute === '/') {
1956
- if (pagesParsedFM && pagesParsedFM.before.length > 0) {
1957
- indexName = extractItemName(pagesParsedFM.before[0])
1958
- } else {
1959
- indexName = orderConfig?.index || null
1960
- }
2020
+ indexName = homepageOf(orderConfig) ?? null
1961
2021
  }
1962
2022
 
1963
2023
  // Add md-file-pages
@@ -2505,21 +2565,22 @@ export async function collectSiteContent(sitePath, options = {}) {
2505
2565
  }
2506
2566
  }
2507
2567
 
2568
+ // Root content mode default comes from the profile (folder mode for
2569
+ // documents, page mode for sites). A folder.yml/page.yml in the pages root
2570
+ // overrides the profile default per readFolderConfig.
2571
+ const { mode: rootContentMode, config: rootFolderConfig } = await readFolderConfig(pagesPath, profile.defaultMode)
2572
+
2508
2573
  // Extract page ordering config from the top-level config.
2509
2574
  // Document profile reads the `content:` field (with `pages:` as a back-compat
2510
2575
  // alias); site profile reads `pages:`. Either way the internal field name is
2511
2576
  // `pages` so collectPagesRecursive doesn't need to care about profile.
2512
- const siteOrderConfig = {
2577
+ // ⭐ Layered over the pages directory's own `pages:` / `index:` — see `rootOrderConfig`.
2578
+ const siteOrderConfig = rootOrderConfig({
2513
2579
  pages: profile.orderField === 'pages'
2514
2580
  ? siteConfig.pages
2515
2581
  : (siteConfig[profile.orderField] ?? siteConfig.pages),
2516
2582
  index: siteConfig.index
2517
- }
2518
-
2519
- // Root content mode default comes from the profile (folder mode for
2520
- // documents, page mode for sites). A folder.yml/page.yml in the pages root
2521
- // overrides the profile default per readFolderConfig.
2522
- const { mode: rootContentMode } = await readFolderConfig(pagesPath, profile.defaultMode)
2583
+ }, rootFolderConfig)
2523
2584
 
2524
2585
  // Collect layout areas from the layout/ directory, read from the site alone
2525
2586
  const { layouts, assetCollection: layoutAssets, iconCollection: layoutIcons } =
@@ -64,8 +64,9 @@ const ownContent = (record, ...childKeys) => {
64
64
  * the projector would write it to.
65
65
  *
66
66
  * Paths use the conventional `pages/` and `layout/` roots. A site that relocates
67
- * them via `info.paths` still gets stable, unique keys — only the displayed prefix
68
- * would differ from disk, which is a labelling nicety, not a correctness issue.
67
+ * them via `site.yml::paths` (`settings.paths` on the wire) still gets stable, unique
68
+ * keys — only the displayed prefix would differ from disk, which is a labelling
69
+ * nicety, not a correctness issue.
69
70
  *
70
71
  * @returns {Map<string, object>} path → the unit's own record
71
72
  */
@@ -0,0 +1,32 @@
1
+ // Where a site's pages and layout live — ONE definition for the push, which reads
2
+ // the tree from here (`site.js`), and the pull, which writes it back here
3
+ // (`site-project.js`). A leaf module so the pull does not import the push.
4
+
5
+ import { join, resolve } from 'node:path'
6
+
7
+ /**
8
+ * `site.yml::paths.pages` / `paths.layout`, else `pages/` and `layout/` — resolved
9
+ * as the build resolves them (`site/content-collector.js`).
10
+ *
11
+ * ⛔ The pull read this from the pulled document's `info.paths` until 2026-09-19.
12
+ * `paths` moved to the `settings` Section on 2026-09-09 and that reader did not
13
+ * move with it, so it read as absent: every pull wrote pages to `pages/` and layout
14
+ * to `layout/` whatever the site declared, while the build and the next push kept
15
+ * reading the declared directory — untouched by the pull, and recorded by it as
16
+ * current.
17
+ *
18
+ * ⭐ Reading `site.yml` rather than the document is the fix, not a detour: it is the
19
+ * file both readers use, and the pull writes the document's `settings.paths` into it
20
+ * before it places a single page.
21
+ *
22
+ * @param {string} siteRoot
23
+ * @param {object|null} siteYml - the parsed `site.yml`
24
+ * @returns {{ pagesDir: string, layoutDir: string }}
25
+ */
26
+ export function siteContentDirs(siteRoot, siteYml) {
27
+ const paths = siteYml?.paths
28
+ return {
29
+ pagesDir: paths?.pages ? resolve(siteRoot, paths.pages) : join(siteRoot, 'pages'),
30
+ layoutDir: paths?.layout ? resolve(siteRoot, paths.layout) : join(siteRoot, 'layout'),
31
+ }
32
+ }
@@ -46,6 +46,7 @@ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTrans
46
46
  import { buildFreeformPath } from '../i18n/freeform.js'
47
47
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
48
48
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
49
+ import { siteContentDirs } from './site-dirs.js'
49
50
 
50
51
  // The pull-side identity index: a per-clone, GITIGNORED `uuid → relative path`
51
52
  // map under `.uniweb/`, the home for the backend's per-item identity so that
@@ -607,24 +608,35 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
607
608
  return y
608
609
  }
609
610
 
610
- // Delete `<name>.md` files in `pageDir` whose stableId isn't in `keep`.
611
+ // ⛔ A PRUNE MAY ONLY DELETE WHAT A PUSH COULD HAVE SENT. An orphan is a file the
612
+ // incoming document no longer has — but a push reads the tree with the build's own
613
+ // rules (`isMarkdownFile`, `isIgnoredFolder`), so a `_draft.md`, a `README.md`, a
614
+ // `_partials/` folder or a `.git/` directory never reaches the backend and can never
615
+ // come back in a pulled document. Judged by "not in the incoming set" alone, every
616
+ // one of them was an orphan and a default pull DELETED it — the `.git/` of a pages
617
+ // directory that is its own clone included. The same two rules decide both sides
618
+ // now, which is also what the layout prune below already did.
619
+
620
+ // Delete the section files in `pageDir` whose stableId isn't in `keep`.
611
621
  function pruneOrphanSectionFiles(pageDir, keep, report) {
612
622
  if (!existsSync(pageDir)) return
613
623
  for (const entry of readdirSync(pageDir)) {
614
- if (extname(entry).toLowerCase() !== '.md') continue
624
+ if (!isMarkdownFile(entry)) continue
615
625
  if (keep.has(basename(entry, extname(entry)))) continue
616
626
  const p = join(pageDir, entry)
627
+ if (!statSync(p).isFile()) continue
617
628
  unlinkSync(p)
618
629
  report.deleted.push(p)
619
630
  }
620
631
  }
621
632
 
622
- // Delete subdirectories of `pagesDir` whose name isn't an incoming page dir.
633
+ // Delete the page directories in `pagesDir` whose name isn't an incoming page dir.
623
634
  function pruneOrphanPageDirs(pagesDir, keepDirs, report) {
624
635
  if (!existsSync(pagesDir)) return
625
636
  for (const entry of readdirSync(pagesDir)) {
637
+ if (isIgnoredFolder(entry) || keepDirs.has(entry)) continue
626
638
  const p = join(pagesDir, entry)
627
- if (!statSync(p).isDirectory() || keepDirs.has(entry)) continue
639
+ if (!statSync(p).isDirectory()) continue
628
640
  rmSync(p, { recursive: true, force: true })
629
641
  report.deleted.push(p)
630
642
  }
@@ -870,11 +882,11 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
870
882
  // localized scalars during the page walk.
871
883
  const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale }
872
884
 
873
- const paths = document?.info?.paths || {}
874
- const pagesDir = paths.pages ? join(siteRoot, paths.pages) : join(siteRoot, 'pages')
885
+ // Where the tree goes is what `site.yml` says NOW — `siteInfoToConfig` above has
886
+ // written the document's `settings.paths` into it — because that is the file the
887
+ // next push and the build read. See `siteContentDirs`.
888
+ const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, readAuthoredYaml(join(siteRoot, 'site.yml')))
875
889
  projectPages(document?.pages, pagesDir, sourceLocale, report, prune, ctx)
876
-
877
- const layoutBaseDir = paths.layout ? join(siteRoot, paths.layout) : join(siteRoot, 'layout')
878
890
  projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
879
891
 
880
892
  report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
package/src/uwx/site.js CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  fetchFromQueryShorthand,
58
58
  assertRouteFolder,
59
59
  mountEntriesOf,
60
+ rootOrderConfig,
60
61
  } from '../site/content-collector.js'
61
62
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
62
63
  import { readLayoutFolder } from '../site/layout-folder.js'
@@ -69,6 +70,7 @@ import { loadFreeformTranslation } from '../i18n/freeform.js'
69
70
  import { upsertYamlScalar } from './yaml-upsert.js'
70
71
  import { resolveQueriesConfig } from './queries-config.js'
71
72
  import { resolveSelfScope } from './self-scope.js'
73
+ import { siteContentDirs } from './site-dirs.js'
72
74
 
73
75
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
74
76
 
@@ -545,8 +547,7 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
545
547
  }
546
548
 
547
549
  // Top-level sections: every NON-`@` markdown file, in order. `@`-prefixed files
548
- // are children, pulled in via their parent's `nest:` above (an orphaned `@`
549
- // file with no parent is simply omitted — it stays out of the document).
550
+ // are children, pulled in via their parent's `nest:` above.
550
551
  const seen = new Set()
551
552
  const sections = []
552
553
  for (const file of mdFiles) {
@@ -555,13 +556,33 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
555
556
  sections.push(await buildSection(file, seen))
556
557
  }
557
558
 
559
+ // An `@` file no `nest:` claimed is an ORPHAN, and the build keeps it — appended
560
+ // as a top-level section, with a warning, "to avoid silent data loss" (`processPage`,
561
+ // `site/content-collector.js`). Mirrored exactly: at the end, in file order, with no
562
+ // children of its own. ⛔ This lane omitted it until 2026-09-19, and a pull of the
563
+ // result then DELETED the file — on disk and absent from the document, it read as
564
+ // something removed upstream.
565
+ for (const file of mdFiles) {
566
+ if (!isChildSection(file) || seen.has(file)) continue
567
+ seen.add(file)
568
+ const stableDefault = parseNumericPrefix(stripAtPrefix(parse(file).name)).name
569
+ const { section } = await processMarkdownFile(join(pageDir, file), String(seen.size), siteRoot, stableDefault)
570
+ section.subsections = []
571
+ sections.push(section)
572
+ }
573
+
558
574
  return sections.map((s, i) => sectionToRecord(s, i))
559
575
  }
560
576
 
561
577
  // A folder-mode folder's `.md` files are pages to the build and nothing to this
562
578
  // lane. Name every one of them, so the shortfall is visible at push time rather
563
579
  // than by reading the backend's store or opening the published site.
564
- async function warnFolderModePagesSkipped(dirPath, siteRoot) {
580
+ //
581
+ // `source` is `readFolderConfig`'s: a folder with no config of its own is in folder
582
+ // mode because a `folder.yml` above it put it there, and the message says so —
583
+ // "it is a folder-mode folder (`folder.yml`)" over a folder that has none would send
584
+ // the author looking for a file that does not exist.
585
+ async function warnFolderModePagesSkipped(dirPath, siteRoot, source = 'folder.yml') {
565
586
  let files
566
587
  try {
567
588
  files = (await readdir(dirPath)).filter(isMarkdownFile)
@@ -570,18 +591,33 @@ async function warnFolderModePagesSkipped(dirPath, siteRoot) {
570
591
  }
571
592
  if (files.length === 0) return
572
593
  const where = relative(siteRoot, dirPath)
594
+ const why = source === 'inherited'
595
+ ? '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)'
596
+ : 'it is a folder-mode folder (`folder.yml`)'
573
597
  console.warn(
574
598
  `uwx/site: ${files.length} page(s) in \`${where}\` will NOT be pushed — ` +
575
- `it is a folder-mode folder (\`folder.yml\`), and pushing \`.md\`-as-pages ` +
599
+ `${why}, and pushing \`.md\`-as-pages ` +
576
600
  `is not implemented on the sync lane. A build renders them; the backend ` +
577
601
  `will not have them.\n` +
578
602
  files.map((f) => ` - ${where}/${f}`).join('\n') +
579
603
  `\n To publish them, make each one a page-mode directory ` +
580
604
  `(\`<name>/page.yml\` + its section files). To ship them as they are, ` +
581
- `use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.`
605
+ `use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.` +
606
+ REPUBLISH_CLAUSE
582
607
  )
583
608
  }
584
609
 
610
+ // A push sends the page tree as ONE entity (see site-diff.js), so a republish does
611
+ // not merge: the shorter document becomes the whole tree. Measured by the backend
612
+ // lane 2026-09-18 against a current `uniwebd` — 49 stored pages replaced by 2, the
613
+ // publish reporting success. Both skip warnings carry this, because "will not be
614
+ // pushed" reads as "you publish short" when the real cost on an already-published
615
+ // site is that live pages are REMOVED.
616
+ const REPUBLISH_CLAUSE =
617
+ `\n ⚠ If this site has been published before, a push REPLACES its whole page ` +
618
+ `tree — pages already live that are missing here are REMOVED, and the publish ` +
619
+ `still reports success.`
620
+
585
621
  // Recursively build the `pages` tree: each record carries its fields, its inline
586
622
  // `page_sections` (page mode only), and its child pages under `$children`.
587
623
  async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
@@ -597,7 +633,16 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
597
633
  assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
598
634
  const dyn = f.dirName.match(DYNAMIC_RE)
599
635
  const slug = dyn ? dyn[1] : f.name
600
- const mode = f.source === 'folder.yml' ? 'folder' : 'page'
636
+ // ⭐ THE BUILD'S ANSWER, NOT A SECOND ONE. `readFolderConfig` has already resolved
637
+ // this folder's mode — a `folder.yml` here, a `page.yml` here, or neither and
638
+ // INHERITED from its parent — and that resolved mode is exactly what the build
639
+ // reads (`site/content-collector.js`). ⛔ This line tested the config file's NAME
640
+ // until 2026-09-19, so a folder with no config under a folder-mode parent — the
641
+ // documented cascade, one `folder.yml` at the top of a docs tree — was pushed as
642
+ // ONE page whose sections were its `.md` files while the build rendered each as a
643
+ // page of its own. Nothing warned, and a pull then wrote a `page.yml` into the
644
+ // folder, converting the author's own site to the wrong shape.
645
+ const mode = f.internalMode === 'pages' ? 'folder' : 'page'
601
646
  const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
602
647
 
603
648
  const data = buildPageData(f.config, {
@@ -632,7 +677,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
632
677
  // measured 2026-09-18, a 49-page documentation site published 2 pages with
633
678
  // every CLI instrument reporting success [measured:backend, channel with
634
679
  // backend, and reproduced here against the same site].
635
- await warnFolderModePagesSkipped(f.path, siteRoot)
680
+ await warnFolderModePagesSkipped(f.path, siteRoot, f.source)
636
681
  }
637
682
 
638
683
  const children = await walkPagesNested(ctx, f.path, slugPath, f.internalMode, f.config, false)
@@ -1384,7 +1429,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1384
1429
  // left in an old site.yml is inert — nothing reads it, so nothing sends it.
1385
1430
  // (uwx-format.md → info.url.)
1386
1431
 
1387
- const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1432
+ // `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
1433
+ const ctx = { siteRoot, sourceLocale, translations }
1388
1434
  // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1389
1435
  // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1390
1436
  // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
@@ -1394,25 +1440,43 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1394
1440
  // a site whose entire corpus was one sub-mount: 49 pages built, 2 pushed.
1395
1441
  // Deferred by design for now (bidirectional-sync.md); warned about here so it
1396
1442
  // is not also silent.
1443
+ //
1444
+ // The same two directories the pull writes back to — see `siteContentDirs`.
1445
+ const { pagesDir: pagesPath, layoutDir } = siteContentDirs(siteRoot, siteYml)
1397
1446
  for (const [segment] of mountEntriesOf(siteYml.paths)) {
1447
+ // Only a LOCAL stub (`pages/<segment>/`, the folder that gives a mounted route its
1448
+ // layout) is walked, so the route arrives as an empty container; with no stub,
1449
+ // nothing here stands for the route and it is not sent at all. ⚠️ The message
1450
+ // said "empty container" in both cases until 2026-09-19.
1451
+ const fate = existsSync(join(pagesPath, segment))
1452
+ ? 'will be pushed as an empty container'
1453
+ : 'will not be pushed at all'
1398
1454
  console.warn(
1399
1455
  `uwx/site: \`paths: { pages/${segment}: … }\` is NOT read on the sync lane — ` +
1400
- `\`${segment}\` will be pushed as an empty container and none of the mounted ` +
1456
+ `\`${segment}\` ${fate} and none of the mounted ` +
1401
1457
  `pages will reach the backend. A build mounts it normally. Inline the ` +
1402
1458
  `directory under \`pages/${segment}/\` to push it, or ship the site with ` +
1403
- `\`uniweb export\` / \`uniweb deploy --host <adapter>\`.`
1459
+ `\`uniweb export\` / \`uniweb deploy --host <adapter>\`.` +
1460
+ REPUBLISH_CLAUSE
1404
1461
  )
1405
1462
  }
1406
- const pagesPath = siteYml.paths?.pages
1407
- ? join(siteRoot, siteYml.paths.pages)
1408
- : join(siteRoot, 'pages')
1409
- const pages = existsSync(pagesPath)
1410
- ? await walkPagesNested(ctx, pagesPath, '', 'sections', siteYml, true)
1411
- : []
1412
-
1413
- const layoutDir = siteYml.paths?.layout
1414
- ? join(siteRoot, siteYml.paths.layout)
1415
- : join(siteRoot, 'layout')
1463
+ let pages = []
1464
+ if (existsSync(pagesPath)) {
1465
+ // The root has a mode too, read as the build reads it (`rootContentMode`):
1466
+ // `pages/folder.yml` puts the whole site in folder mode, and every folder with no
1467
+ // config of its own inherits it. ⛔ This walk started in page mode unconditionally
1468
+ // until 2026-09-19, so such a site's top-level `.md` pages were dropped without a
1469
+ // word and its top-level folders were pushed as pages of sections.
1470
+ const { mode: rootMode, source: rootSource, config: rootConfig } = await readFolderConfig(pagesPath, 'sections')
1471
+ if (rootMode === 'pages') await warnFolderModePagesSkipped(pagesPath, siteRoot, rootSource)
1472
+ // The top level's order and homepage, decided by the build's own function: the
1473
+ // site's `pages:` / `index:` over the pages directory's own. ⛔ The homepage read
1474
+ // `index:` alone until 2026-09-19, so a site naming it by its first `pages:` entry
1475
+ // — as the build does — pushed no homepage at all.
1476
+ const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1477
+ pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1478
+ }
1479
+
1416
1480
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
1417
1481
 
1418
1482
  // Wrap each section's content into its per-locale form (source doc + target
@@ -28,7 +28,8 @@ import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
30
  import { YAML_OPTIONS } from './utils/yaml-schema.js'
31
- import { queryNameFromUrl, siteReaches, declaredKeys, fillDeclaredKeys } from '@uniweb/core'
31
+ import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
32
+ import { parentRouteOf } from '@uniweb/core/route-match'
32
33
 
33
34
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
34
35
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -105,7 +106,26 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
105
106
  const deferred = []
106
107
 
107
108
  const byRoute = new Map((site.pages || []).map((p) => [p.route, p]))
109
+ // ⭐ The parent by the ONE parent rule, not the declared field alone: a payload may omit
110
+ // `parent`, and reading it raw gives a page no parent here while every other lane infers one —
111
+ // so the site's fetch reached pages it does not reach, and a parent's reached none.
112
+ const parentOf = (page) => {
113
+ if (!page?.route) return null
114
+ const route = parentRouteOf(page.route, { declared: page.parent ?? null, has: (r) => byRoute.has(r) })
115
+ return route ? byRoute.get(route) ?? null : null
116
+ }
117
+ const access = {
118
+ routeOf: (p) => p?.route,
119
+ parentOf,
120
+ fetchOf: (p) => p?.fetch ?? null,
121
+ sectionsOf: (p) => p?.sections,
122
+ site: config.fetch,
123
+ }
108
124
  for (const page of site.pages || []) {
125
+ const parent = parentOf(page)
126
+ // Its route query, when it is on a parametric route — a page nested inside one included,
127
+ // which is a level this check did not have.
128
+ const route = pageRouteQuery(page, access)
109
129
  walkSections(page.sections || [], (section) => {
110
130
  const type = section.type
111
131
  if (!type) return
@@ -115,10 +135,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
115
135
  // same function the entity store delivers by — so a `post: '@std/article'` key
116
136
  // filled by an `articles` fetch is checked against `@std/article`, and a fetch that
117
137
  // fills no declared key is not checked at all, since no component receives it. The
118
- // levels are the section's own fetch, its page's, its parent page's and — for a
119
- // top-level page — the site's (`siteReaches`).
138
+ // levels are the rule's, so this check sees what the render sees (`fetchLevels`,
139
+ // `@uniweb/core/page-data`) — ⛔ it wrote its own list until 2026-09-19, which read only
140
+ // the declared parent and had no route binding for a nested page.
120
141
  const declared = declaredKeys(bindings)
121
- const levels = [section.fetch, page.fetch, byRoute.get(page.parent)?.fetch, siteReaches(page.parent) ? config.fetch : null]
142
+ const levels = fetchLevels({ own: section.fetch, page: page.fetch, parent, route, site: config.fetch })
122
143
  const inputs = collectInputs(levels)
123
144
  const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
124
145
  const fills = fillDeclaredKeys(declared, levels, { queries: config.queries, held })