@uniweb/build 0.80.2 → 0.80.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.80.2",
3
+ "version": "0.80.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,10 +57,10 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
+ "@uniweb/projections": "^0.7.15",
60
61
  "@uniweb/content-reader": "^1.2.8",
61
62
  "@uniweb/content-writer": "^0.3.4",
62
63
  "@uniweb/schemas": "^0.13.0",
63
- "@uniweb/projections": "^0.7.15",
64
64
  "@uniweb/semantic-parser": "^1.5.1",
65
65
  "@uniweb/theming": "^0.2.0"
66
66
  },
@@ -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.29.4",
78
- "@uniweb/core": "^0.38.0"
77
+ "@uniweb/core": "^0.38.0",
78
+ "@uniweb/runtime": "^0.29.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -30,8 +30,7 @@
30
30
  * @module @uniweb/build/site/layout-folder
31
31
  */
32
32
 
33
- import { readdir } from 'node:fs/promises'
34
- import { existsSync } from 'node:fs'
33
+ import { existsSync, readdirSync } from 'node:fs'
35
34
  import { dirname, join, parse, relative } from 'node:path'
36
35
  import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
37
36
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
@@ -93,7 +92,19 @@ export function _resetLayoutFolderWarnings() {
93
92
  * section order; `source` is the area's path, relative, for messages.
94
93
  * @throws when the folder declares something no lane can read the same way
95
94
  */
96
- export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning = (m) => console.warn(m) } = {}) {
95
+ export async function readLayoutFolder(layoutDir, options = {}) {
96
+ return readLayoutFolderSync(layoutDir, options)
97
+ }
98
+
99
+ /**
100
+ * `readLayoutFolder`, synchronously — the same reader, for a caller that cannot await: the pull,
101
+ * which writes each area's sections back where this finds them (`uwx/site-project.js`).
102
+ *
103
+ * @param {string} layoutDir
104
+ * @param {Object} [options] - as `readLayoutFolder`'s
105
+ * @returns {Array<{ layout: string, area: string, form: 'file'|'folder', dir: string, files: string[], source: string }>}
106
+ */
107
+ export function readLayoutFolderSync(layoutDir, { siteRoot = null, onWarning = (m) => console.warn(m) } = {}) {
97
108
  if (!layoutDir || !existsSync(layoutDir)) return []
98
109
 
99
110
  const base = siteRoot || dirname(layoutDir)
@@ -140,8 +151,8 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
140
151
  }
141
152
 
142
153
  /** The areas inside one layout's folder (the root folder is the default layout's). */
143
- const readAreas = async (layoutName, dir, { isRoot }) => {
144
- const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
154
+ const readAreas = (layoutName, dir, { isRoot }) => {
155
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
145
156
 
146
157
  const files = []
147
158
  const folders = []
@@ -168,14 +179,14 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
168
179
  const areaDir = join(dir, folder)
169
180
  const area = parseNumericPrefix(folder).name
170
181
  const sectionFiles = []
171
- for (const entry of await readdir(areaDir, { withFileTypes: true })) {
182
+ for (const entry of readdirSync(areaDir, { withFileTypes: true })) {
172
183
  if (entry.isFile()) {
173
184
  refusePageConfig(areaDir, entry.name)
174
185
  if (!isMarkdownFile(entry.name)) continue
175
186
  refuseChild(areaDir, entry.name)
176
187
  sectionFiles.push(entry.name)
177
188
  } else if (entry.isDirectory() && !isIgnoredFolder(entry.name)) {
178
- const nested = (await readdir(join(areaDir, entry.name))).filter(isMarkdownFile)
189
+ const nested = readdirSync(join(areaDir, entry.name)).filter(isMarkdownFile)
179
190
  if (nested.length > 0) {
180
191
  fail(
181
192
  join(areaDir, entry.name),
@@ -197,7 +208,7 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
197
208
  return []
198
209
  }
199
210
 
200
- const layoutFolders = await readAreas(DEFAULT_LAYOUT, layoutDir, { isRoot: true })
211
+ const layoutFolders = readAreas(DEFAULT_LAYOUT, layoutDir, { isRoot: true })
201
212
 
202
213
  // Each folder directly under layout/ is a named layout; `default` (in any case, with
203
214
  // or without a trailing `Layout`) is the default layout written as a folder.
@@ -229,7 +240,7 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
229
240
  )
230
241
  }
231
242
  }
232
- await readAreas(layoutName, join(layoutDir, folder), { isRoot: false })
243
+ readAreas(layoutName, join(layoutDir, folder), { isRoot: false })
233
244
  }
234
245
 
235
246
  return areas
package/src/uwx/index.js CHANGED
@@ -91,6 +91,8 @@ export {
91
91
  // The file the author keeps a section in (`1-hero.md` holds `hero`) — what a section
92
92
  // unit's path names on disk, for a push recording what it delivered.
93
93
  existingSectionFile,
94
+ // …and the file a layout unit names on disk (`default/footer.md` holds the `footer.md` place).
95
+ layoutUnitFile,
94
96
  } from './site-project.js'
95
97
  export {
96
98
  createTranslationCollector,
@@ -27,6 +27,7 @@ import { visitDataBlockStrings } from '../i18n/data-strings.js'
27
27
  import { resolveDocForLocale, translatedInline, linkLabels } from '../i18n/merge.js'
28
28
  import { computeSourceHash } from '../i18n/freeform-manifest.js'
29
29
  import { sameMarkdownDocument, canonicalJson } from './same-content.js'
30
+ import { parseFrontmatter } from '../utils/frontmatter.js'
30
31
 
31
32
  const FREEFORM_MANIFEST = '.manifest.json'
32
33
 
@@ -389,6 +390,15 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
389
390
  return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
390
391
  }
391
392
 
393
+ // Whether a free-form file says `target`, read as a push reads it — its body, as stored.
394
+ function sameStoredFreeform(text, filePath, target) {
395
+ try {
396
+ return sameMarkdownDocument(parseFrontmatter(text, filePath).body, target)
397
+ } catch {
398
+ return false
399
+ }
400
+ }
401
+
392
402
  // ⭐ A LINK'S LABEL, NOT THE LINK, when only its words were translated. The merge reads a label onto
393
403
  // the source's own link (`merge.js::translatedInline`), so it keeps following that link wherever the
394
404
  // source moves it, where `[Donar Ahora](/contact)` would keep `/contact`. Several links are a label a
@@ -564,7 +574,9 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
564
574
  }
565
575
  // ⭐ A file whose markdown already says this is left as the author wrote it — the writer's
566
576
  // markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
567
- if (current !== text && !(current !== null && sameMarkdownDocument(current.trim(), target))) {
577
+ // Compared as a push reads it (`i18n/freeform.js::loadFreeformTranslation`). ⛔ Until 2026-10-08 the
578
+ // file was trimmed first, so one whose last line ended in a space was rewritten by every pull.
579
+ if (current !== text && !(current !== null && sameStoredFreeform(current, filePath, target))) {
568
580
  mkdirSync(dirname(filePath), { recursive: true })
569
581
  writeFileSync(filePath, text)
570
582
  report.written.push(filePath)
@@ -204,7 +204,10 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
204
204
  // only frontmatter — a parametric page's `type:` and `fetch:` — was re-serialized, and lost its
205
205
  // comments, on a pull that changed nothing. So is no document at all (a params-only update).
206
206
  const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
207
- const keepsBody = !content || sameMarkdownDocument(authoredBody, content)
207
+ // Compared as a push reads it: the body as stored (`content-collector.js::processMarkdownFile`).
208
+ // ⛔ Until 2026-10-08 the trimmed copy was compared, so a body whose last line ended in a space never
209
+ // matched what a push had sent from it, and every pull rewrote it — trailing blank lines and all.
210
+ const keepsBody = !content || sameMarkdownDocument(existingBody || '', content)
208
211
  if (existing && keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
209
212
  const body = keepsBody ? authoredBody : proseMirrorToMarkdown(content)
210
213
  return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
@@ -52,7 +52,8 @@ import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
52
52
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
53
53
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
54
54
  import { siteContentDirs } from './site-dirs.js'
55
- import { layoutAreaRoute } from '../site/layout-folder.js'
55
+ import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
56
+ import { layoutNameKey } from '@uniweb/core/layout-name'
56
57
  import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
57
58
  import { parseFrontmatter } from '../utils/frontmatter.js'
58
59
 
@@ -503,6 +504,7 @@ export function sectionFileBase(record) {
503
504
  export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext }) {
504
505
  const written = []
505
506
  const files = [] // the top level's file names, in the document's order
507
+ const childFiles = [] // every nested section's file name
506
508
  let nested = false
507
509
  const buildEntries = (records, top) => {
508
510
  const entries = []
@@ -539,6 +541,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
539
541
  context: pageContext ? translationContext({ stableId }, pageContext.route) : null,
540
542
  })
541
543
  written.push(filePath)
544
+ if (!top) childFiles.push(basename(filePath))
542
545
  const children = Array.isArray(record.$children) ? record.$children : []
543
546
  if (children.length > 0) nested = true
544
547
  // Listed by the name the build finds the file by — which is not the id for a file found by its `id:`.
@@ -552,8 +555,12 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
552
555
  ctx?.sectionFiles?.set(pageDir, new Set(written.map((f) => basename(f, extname(f)))))
553
556
  // ⭐ Whether the files give the document's order by themselves, as the build reads a page with no
554
557
  // `sections:` list — by name, numeric prefixes first — so the page needs no list to say it.
555
- const filesGiveOrder = !nested && files.every((f) => !f.startsWith('@')) && [...files].sort(compareFilenames).join('\n') === files.join('\n')
556
- return { sections, written, filesGiveOrder }
558
+ const topInOrder = files.every((f) => !f.startsWith('@')) && [...files].sort(compareFilenames).join('\n') === files.join('\n')
559
+ const filesGiveOrder = !nested && topInOrder
560
+ // …and, for a nested page, whether they give its top level, every child being an `@` file — which
561
+ // is all a `nest:` needs beside them (`pageRecordToYml`).
562
+ const filesGiveTopOrder = nested && topInOrder && childFiles.every((f) => f.startsWith('@'))
563
+ return { sections, written, filesGiveOrder, filesGiveTopOrder }
557
564
  }
558
565
 
559
566
  // The name the build reads a section file's section by: its file name without the `@` child mark and
@@ -585,8 +592,12 @@ function listedNames(list) {
585
592
  export function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
586
593
  if (!existsSync(pageDir)) return null
587
594
  const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
595
+ // A child is looked for as the build looks for a `nest:` child (`findSectionFile`): `name.md`, then
596
+ // `@name.md`, then a numbered one. ⛔ Until 2026-10-08 a child went to the first file of its name in
597
+ // directory order, so `@booking-form.md` came back into a top-level `1-booking-form.md` beside it.
588
598
  const found =
589
599
  entries.find((e) => basename(e, '.md') === fileBase) ||
600
+ (child && entries.find((e) => e === `@${fileBase}.md`)) ||
590
601
  entries.find((e) => fileSectionName(e) === fileBase) ||
591
602
  entries.find((e) => {
592
603
  try {
@@ -608,7 +619,7 @@ export function existingSectionFile(pageDir, fileBase, stableId, { child = false
608
619
  const PAGE_YML_MANAGED_KEYS = new Set([
609
620
  'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
610
621
  'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
611
- 'query', 'fetch', 'data', 'sections',
622
+ 'query', 'fetch', 'data', 'sections', 'nest',
612
623
  ])
613
624
 
614
625
  // Keys a pull WRITES and never removes — kept out of the managed set on purpose. ⭐ `slug`, a page's
@@ -623,7 +634,7 @@ const PAGE_YML_KEPT_KEYS = new Set(['slug'])
623
634
  // entry names the directory; its other locales are the page's `slug:` map.
624
635
  // Identity (the backend uuid) is NOT written here — it lives in the gitignored
625
636
  // `.uniweb/` index so authored files stay clean.
626
- function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, { isRoot = false, slug = null, filesGiveOrder = false } = {}) {
637
+ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, { isRoot = false, slug = null, filesGiveOrder = false, filesGiveTopOrder = false } = {}) {
627
638
  const y = {}
628
639
  if (record.stable_id !== undefined) y.id = record.stable_id
629
640
  // The page's localized URL segments, when the site keeps them here (`pageSlugToYml`).
@@ -671,22 +682,53 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
671
682
  // their nesting, and anything new is discovered and appended as it would be in a
672
683
  // page that was never pulled.
673
684
  // ⭐ The author's own list is kept when it gives the same order — its `...` stays where they put it.
674
- // A page with no list gets none where its files' names give the order (`filesGiveOrder`).
685
+ // A page with no list gets none where its files' names give the order (`filesGiveOrder`) — nor where
686
+ // they give the top level and the author's `nest:` the nesting, the form the docs lead with.
675
687
  if (sectionsArray && sectionsArray.length > 0) {
676
688
  const authored = existing?.sections
677
689
  if (Array.isArray(authored)) {
678
690
  // Compared by the names the files are found by, so a list naming `1-hero` keeps naming it.
679
691
  const same = JSON.stringify(listedNames(authored.filter((e) => e !== '...'))) === JSON.stringify(sectionsArray)
680
692
  y.sections = same ? authored : [...sectionsArray, '...']
693
+ } else if (filesGiveTopOrder && nestSays(existing?.nest, sectionsArray)) {
694
+ // Nothing to list: the files and `nest:` say it, kept below.
681
695
  } else if (!filesGiveOrder) {
682
696
  y.sections = [...sectionsArray, '...']
683
697
  } else if (authored !== undefined) {
684
698
  y.sections = authored // `'*'`, which says what no list says
685
699
  }
686
700
  }
701
+ // ⭐ `nest:` stays as the author wrote it — unless this pull writes a list that nests: `nest:` wins
702
+ // over a list's nesting (`content-collector.js::processNesting`), so beside a new one it would put
703
+ // back the nesting the list carries. ⛔ Until 2026-10-08 a pull added a list to every page that
704
+ // nested with `nest:`, beside it, saying the same nesting twice — and a nesting changed in the app
705
+ // came back in the list, where the stale `nest:` overrode it.
706
+ const listed = y.sections !== undefined && y.sections !== existing?.sections && nestsIn(y.sections)
707
+ if (existing?.nest !== undefined && !listed) y.nest = existing.nest
687
708
  return y
688
709
  }
689
710
 
711
+ // Whether a `sections:` list nests: an entry that is a parent with its children.
712
+ function nestsIn(list) {
713
+ return Array.isArray(list) && list.some((entry) => isPlainRecord(entry))
714
+ }
715
+
716
+ // Whether a page's `nest:` gives exactly the nesting of the sections the pull wrote — each parent the
717
+ // list nests, its children in order, one level down, as the build attaches them to top-level sections.
718
+ function nestSays(nest, list) {
719
+ if (!isPlainRecord(nest)) return false
720
+ const parents = list.filter((entry) => isPlainRecord(entry))
721
+ if (parents.length !== Object.keys(nest).length) return false
722
+ return parents.every((entry) => {
723
+ const [parent, children] = Object.entries(entry)[0]
724
+ return (
725
+ Array.isArray(nest[parent]) &&
726
+ children.every((child) => typeof child === 'string') &&
727
+ JSON.stringify(nest[parent].map(String)) === JSON.stringify(children)
728
+ )
729
+ })
730
+ }
731
+
690
732
  // ⛔ A PRUNE MAY ONLY DELETE WHAT A PUSH COULD HAVE SENT. An orphan is a file the
691
733
  // incoming document no longer has — but a push reads the tree with the build's own
692
734
  // rules (`isMarkdownFile`, `isIgnoredFolder`), so a `_draft.md`, a `README.md`, a
@@ -787,10 +829,12 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
787
829
 
788
830
  let sectionsArray = []
789
831
  let filesGiveOrder = false
832
+ let filesGiveTopOrder = false
790
833
  if (record.mode === 'page' && Array.isArray(record.page_sections)) {
791
834
  const r = pageSectionsToFiles({ pageDir, pageSections: record.page_sections, ctx, pageContext })
792
835
  sectionsArray = r.sections
793
836
  filesGiveOrder = r.filesGiveOrder
837
+ filesGiveTopOrder = r.filesGiveTopOrder
794
838
  report.sections.push(...r.written)
795
839
  }
796
840
 
@@ -800,7 +844,15 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
800
844
  // owns only PAGE_YML_MANAGED_KEYS.
801
845
  const existing = readAuthoredYaml(ymlPath)
802
846
  const pageSlug = pageSlugToYml(record, canon, existing, sourceLocale, ctx)
803
- writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, existing, { isRoot: !routePrefix, slug: pageSlug, filesGiveOrder }), PAGE_YML_MANAGED_KEYS)
847
+ const yml = pageRecordToYml(record, sectionsArray, sourceLocale, existing, { isRoot: !routePrefix, slug: pageSlug, filesGiveOrder, filesGiveTopOrder })
848
+ // ⭐ A `page.yml` with nothing to say is not created: the build reads an empty one as none
849
+ // (`content-collector.js::readFolderConfig`). ⛔ Until 2026-10-08 a pull wrote `{}` into every page
850
+ // whose folder had none. An empty `folder.yml` is written still — its presence is folder mode.
851
+ if (ymlName === 'page.yml' && Object.keys(yml).length === 0 && !existsSync(ymlPath)) {
852
+ writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
853
+ continue
854
+ }
855
+ writeMergedYaml(ymlPath, yml, PAGE_YML_MANAGED_KEYS)
804
856
  report.pages.push(ymlPath)
805
857
 
806
858
  writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
@@ -1003,6 +1055,82 @@ export function layoutSectionPaths(layoutSections) {
1003
1055
  return out
1004
1056
  }
1005
1057
 
1058
+ // The key an area is held under on both sides: its layout's name as the runtime compares it.
1059
+ const layoutAreaKey = (layout, area) => `${layoutNameKey(layout || DEFAULT_LAYOUT)}/${area}`
1060
+
1061
+ // The areas the author's layout folder holds, by key — read by the build's own reader, so found
1062
+ // wherever the build finds them. None for a folder the build refuses.
1063
+ function authoredLayoutAreas(layoutBaseDir) {
1064
+ let areas = []
1065
+ try {
1066
+ areas = readLayoutFolderSync(layoutBaseDir, { onWarning: () => {} })
1067
+ } catch {
1068
+ areas = []
1069
+ }
1070
+ return new Map(areas.map((area) => [layoutAreaKey(area.layout, area.area), area]))
1071
+ }
1072
+
1073
+ // The layout and area a placement names, and the section's id where it names one.
1074
+ function placementOf(relPath) {
1075
+ const parts = relPath.split('/')
1076
+ const name = (file) => basename(file, extname(file))
1077
+ if (parts.length === 1) return { layout: DEFAULT_LAYOUT, area: name(parts[0]), id: null }
1078
+ if (parts.length === 2) return { layout: parts[0], area: name(parts[1]), id: null }
1079
+ return { layout: parts[0], area: parts[1], id: fileSectionName(parts[2]) }
1080
+ }
1081
+
1082
+ /**
1083
+ * ⭐ THE FILES THE AUTHOR KEEPS A LAYOUT'S SECTIONS IN — the area found by the build's own reader
1084
+ * (`readLayoutFolderSync`): one file, at the root or in its layout's folder (`default/footer.md`), or a
1085
+ * folder of section files numbered as the author numbers them. Each placed section → its file: the
1086
+ * author's where the area holds exactly the incoming sections, by name and in order; else the place
1087
+ * `layoutSectionPaths` gives it, as for a clone.
1088
+ * ⛔ Until 2026-10-08 every section went to that place, so a pull into the copy that pushed moved
1089
+ * `layout/default/footer.md` to `layout/footer.md` and renumbered `0-alert.md` and `1-header.md` as
1090
+ * `1-alert.md` and `2-header.md` — beside the old files, or, pruning, in place of them.
1091
+ */
1092
+ function authoredLayoutFiles(layoutBaseDir, placed) {
1093
+ const areas = authoredLayoutAreas(layoutBaseDir)
1094
+ const byArea = new Map()
1095
+ for (const entry of placed) {
1096
+ const { layout, area } = placementOf(entry.relPath)
1097
+ const key = layoutAreaKey(layout, area)
1098
+ if (!byArea.has(key)) byArea.set(key, [])
1099
+ byArea.get(key).push(entry)
1100
+ }
1101
+ const fileOf = new Map()
1102
+ for (const [key, entries] of byArea) {
1103
+ const area = areas.get(key)
1104
+ let files = null
1105
+ if (area?.form === 'file' && entries.length === 1) files = [area.files[0]]
1106
+ else if (area?.form === 'folder' && area.files.length === entries.length) {
1107
+ const named = area.files.every((file, i) => fileSectionName(file) === recordStableId(entries[i].record))
1108
+ if (named) files = area.files
1109
+ }
1110
+ entries.forEach((entry, i) => fileOf.set(entry, files ? join(area.dir, files[i]) : join(layoutBaseDir, entry.relPath)))
1111
+ }
1112
+ return fileOf
1113
+ }
1114
+
1115
+ /**
1116
+ * The file a layout unit — a `layoutSectionPaths` place, relative to the layout folder — is on disk:
1117
+ * that place, else the author's file for the same section (`authoredLayoutFiles`). For a reader that
1118
+ * maps a unit to the file a pull wrote it to (`uniweb`'s `unitFilesOf`).
1119
+ *
1120
+ * @param {string} layoutBaseDir - the site's layout folder
1121
+ * @param {string} relPath - the unit's place, as `layoutSectionPaths` gives it
1122
+ * @returns {string} an absolute path
1123
+ */
1124
+ export function layoutUnitFile(layoutBaseDir, relPath) {
1125
+ const placed = join(layoutBaseDir, relPath)
1126
+ if (existsSync(placed)) return placed
1127
+ const { layout, area, id } = placementOf(relPath)
1128
+ const found = authoredLayoutAreas(layoutBaseDir).get(layoutAreaKey(layout, area))
1129
+ if (!found) return placed
1130
+ const file = found.form === 'file' || found.files.length === 1 ? found.files[0] : found.files.find((f) => fileSectionName(f) === id)
1131
+ return file ? join(found.dir, file) : placed
1132
+ }
1133
+
1006
1134
  // Delete orphan layout section files not in `keep` — at every depth the layout
1007
1135
  // folder reader reads (`<area>.md`, `<layout>/<area>.md`, `<layout>/<area>/*.md`) —
1008
1136
  // and remove an area or layout folder left empty. Only files the reader treats as
@@ -1045,8 +1173,11 @@ function pruneOrphanLayout(layoutBaseDir, keep, report) {
1045
1173
  // layout and area folders. Pruning is guarded against an empty incoming set.
1046
1174
  function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1047
1175
  const written = []
1048
- for (const { record, relPath } of layoutSectionPaths(layoutSections)) {
1049
- const filePath = join(layoutBaseDir, relPath)
1176
+ const placed = layoutSectionPaths(layoutSections)
1177
+ const fileOf = authoredLayoutFiles(layoutBaseDir, placed)
1178
+ for (const entry of placed) {
1179
+ const { record } = entry
1180
+ const filePath = fileOf.get(entry)
1050
1181
  // A relocation into a new area folder needs the folder first.
1051
1182
  mkdirSync(dirname(filePath), { recursive: true })
1052
1183
  placeByUuid(ctx, record.$uuid, filePath)
package/src/uwx/site.js CHANGED
@@ -513,10 +513,16 @@ function sectionToRecord(section, index) {
513
513
  return rec
514
514
  }
515
515
 
516
- // Resolve a section's logical name (`hero`, `card-a`) to its file in `mdFiles`,
517
- // matching the normal build's conventions: bare or `@`-prefixed, with or without
518
- // a numeric `N-` prefix. The stable name is the filename minus `@` and `N-`.
516
+ // Resolve a section's logical name (`hero`, `card-a`) to its file in `mdFiles`, in the
517
+ // normal build's order (`content-collector.js::findSectionFile`): `name.md`, then
518
+ // `@name.md`, then the first file whose name is `name` once its `@` and `N-` prefixes
519
+ // are off. ⛔ Until 2026-10-08 this took the first match in file order, so a child
520
+ // `@booking-form.md` that `nest:` names beside a top-level `1-booking-form.md` resolved to
521
+ // the top-level file — already placed, so skipped — and the child was pushed as an
522
+ // orphan at the end of the page, where the build nests it.
519
523
  function findSectionFileName(mdFiles, sectionName) {
524
+ if (mdFiles.includes(`${sectionName}.md`)) return `${sectionName}.md`
525
+ if (mdFiles.includes(`@${sectionName}.md`)) return `@${sectionName}.md`
520
526
  for (const file of mdFiles) {
521
527
  const bare = stripAtPrefix(parse(file).name)
522
528
  if (parseNumericPrefix(bare).name === sectionName) return file