@uniweb/build 0.63.4 → 0.64.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.
@@ -42,12 +42,19 @@ import yaml from 'js-yaml'
42
42
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
43
43
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
44
44
  import { declarationsToQueriesYml } from './records-project.js'
45
+ import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
46
+ import { translationContext } from '../i18n/extract.js'
45
47
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
46
- import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
47
- import { buildFreeformPath } from '../i18n/freeform.js'
48
+ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, localesDir } from './locale-sync.js'
49
+ import { resolveLocaleList } from '../i18n/locales.js'
50
+ import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
48
51
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
49
52
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
50
53
  import { siteContentDirs } from './site-dirs.js'
54
+ import { layoutAreaRoute } from '../site/layout-folder.js'
55
+ import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
56
+ import { parseFrontmatter } from '../utils/frontmatter.js'
57
+ import { upsertYamlScalar } from './yaml-upsert.js'
51
58
 
52
59
  // The pull-side identity index: a per-clone, GITIGNORED `uuid → relative path`
53
60
  // map under `.uniweb/`, the home for the backend's per-item identity so that
@@ -226,7 +233,7 @@ function readAuthoredYaml(filePath) {
226
233
  * @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
227
234
  * write status ('updated' | 'unchanged')
228
235
  */
229
- export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
236
+ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false, deferLanguages = false }) {
230
237
  const info = document?.info || {}
231
238
  const settingsSection = document?.settings || {}
232
239
 
@@ -332,7 +339,18 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
332
339
  }
333
340
  }
334
341
 
342
+ // ⭐ `languages` for a site whose `site.yml` declares none is judged once the pull has written the
343
+ // translation files (`deferLanguages`, `siteContentDocumentToProject`): a push sends the list those
344
+ // files make (`uwx/site.js::settingsNested`), and writing it back would turn "every translation
345
+ // file" into a fixed list.
346
+ let pendingLanguages
347
+ if (deferLanguages && siteChanges.languages !== undefined && !('languages' in (readAuthoredYaml(join(siteRoot, 'site.yml')) || {}))) {
348
+ pendingLanguages = siteChanges.languages
349
+ delete siteChanges.languages
350
+ }
351
+
335
352
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
353
+ if (pendingLanguages !== undefined) result.pendingLanguages = pendingLanguages
336
354
 
337
355
  // theme (whole object) → theme.yml.
338
356
  if (settingsSection.theme && typeof settingsSection.theme === 'object') {
@@ -416,14 +434,14 @@ function reinlineInsets(content, insets) {
416
434
  * structural maps on a localized `content` field are captured into it
417
435
  * @returns {'updated'|'unchanged'}
418
436
  */
419
- export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath }) {
437
+ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null }) {
420
438
  const { type, stable_id, preset, input, params, content, insets, fetch, background, theme_override } = record || {}
421
439
 
422
440
  // A localized `content` field unwraps to the source-locale doc for the body; its
423
441
  // target-locale structural maps are captured into the locales/ collector, and any
424
442
  // free-form target body is captured with `freeformRelPath` for writing under
425
443
  // locales/freeform/. A bare doc (source-only / pre-localization) passes through.
426
- const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath)
444
+ const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context)
427
445
 
428
446
  const frontmatter = {}
429
447
  if (type !== undefined) frontmatter.type = type
@@ -439,10 +457,13 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
439
457
  const { key, value } = authorableDeclaration(fetch)
440
458
  frontmatter[key] = value
441
459
  }
442
- if (stable_id !== undefined) frontmatter.id = stable_id
460
+ // `id:` only where the file's name does not already give it (`writeId`, `pageSectionsToFiles`).
461
+ if (stable_id !== undefined && writeId) frontmatter.id = stable_id
443
462
 
444
463
  const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
445
- return writeSectionFile({ filePath, content: body, params: frontmatter })
464
+ // ⛔ Not `type: Content` into a file that names no type: it is what the push sent for it, and
465
+ // absent, the foundation's `defaultSection` renders it. Until 2026-09-26 a pull wrote it back.
466
+ return writeSectionFile({ filePath, content: body, params: frontmatter, implied: { type: FILLED_SECTION_TYPE } })
446
467
  }
447
468
 
448
469
  // ---------------------------------------------------------------------------
@@ -528,7 +549,9 @@ export function sectionFileBase(record) {
528
549
  */
529
550
  export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext }) {
530
551
  const written = []
531
- const buildEntries = (records) => {
552
+ const files = [] // the top level's file names, in the document's order
553
+ let nested = false
554
+ const buildEntries = (records, top) => {
532
555
  const entries = []
533
556
  for (const record of records || []) {
534
557
  // Filesystem-safe filename (= stableId when already safe; derived from `$uuid`
@@ -538,7 +561,9 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
538
561
  const fileBase = sectionFileBase(record)
539
562
  if (!fileBase) continue // no stable id AND no `$uuid` → nothing to name it by
540
563
  const stableId = recordStableId(record) || fileBase
541
- const filePath = join(pageDir, `${fileBase}.md`)
564
+ // The file the author keeps it in, else a new `<fileBase>.md` (`existingSectionFile`).
565
+ const filePath = existingSectionFile(pageDir, fileBase, stableId, { child: !top }) || join(pageDir, `${fileBase}.md`)
566
+ if (top) files.push(basename(filePath))
542
567
  // If this uuid's section moved (an app-side stableId rename), relocate its
543
568
  // `.md` in place before writing; then record its current path in the index.
544
569
  placeByUuid(ctx, record.$uuid, filePath)
@@ -547,14 +572,76 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
547
572
  const freeformRelPath = pageContext
548
573
  ? buildFreeformPath({ stableId }, pageContext)
549
574
  : null
550
- sectionRecordToFile({ filePath, record, sourceLocale: ctx?.sourceLocale, collector: ctx?.collector, freeformRelPath })
575
+ // Every path the renderer would find it at, so one the author keeps is written back in place.
576
+ const freeformCandidates = pageContext ? freeformPathsFor({ stableId }, pageContext) : null
577
+ sectionRecordToFile({
578
+ filePath,
579
+ record,
580
+ sourceLocale: ctx?.sourceLocale,
581
+ collector: ctx?.collector,
582
+ freeformRelPath,
583
+ freeformCandidates,
584
+ writeId: fileSectionName(filePath) !== stableId,
585
+ context: pageContext ? translationContext({ stableId }, pageContext.route) : null,
586
+ })
551
587
  written.push(filePath)
552
588
  const children = Array.isArray(record.$children) ? record.$children : []
553
- entries.push(children.length > 0 ? { [fileBase]: buildEntries(children) } : fileBase)
589
+ if (children.length > 0) nested = true
590
+ // Listed by the name the build finds the file by — which is not the id for a file found by its `id:`.
591
+ const listed = fileSectionName(filePath)
592
+ entries.push(children.length > 0 ? { [listed]: buildEntries(children, false) } : listed)
554
593
  }
555
594
  return entries
556
595
  }
557
- return { sections: buildEntries(pageSections), written }
596
+ const sections = buildEntries(pageSections, true)
597
+ // Kept for the prune, which must not take a file this pull wrote into for an orphan.
598
+ ctx?.sectionFiles?.set(pageDir, new Set(written.map((f) => basename(f, extname(f)))))
599
+ // ⭐ Whether the files give the document's order by themselves, as the build reads a page with no
600
+ // `sections:` list — by name, numeric prefixes first — so the page needs no list to say it.
601
+ const filesGiveOrder = !nested && files.every((f) => !f.startsWith('@')) && [...files].sort(compareFilenames).join('\n') === files.join('\n')
602
+ return { sections, written, filesGiveOrder }
603
+ }
604
+
605
+ // The name the build reads a section file's section by: its file name without the `@` child mark and
606
+ // the numeric ordering prefix (`2-story.md` holds `story`) — unless its frontmatter names another.
607
+ function fileSectionName(filePath) {
608
+ const base = stripAtPrefix(basename(filePath, extname(filePath)))
609
+ return parseNumericPrefix(base).name || base
610
+ }
611
+
612
+ // A `sections:` list with each entry as that name — `1-hero` and `hero` find the same file.
613
+ function listedNames(list) {
614
+ return list.map((entry) =>
615
+ typeof entry === 'string'
616
+ ? fileSectionName(entry)
617
+ : entry && typeof entry === 'object'
618
+ ? Object.fromEntries(Object.entries(entry).map(([k, v]) => [fileSectionName(k), Array.isArray(v) ? listedNames(v) : v]))
619
+ : entry
620
+ )
621
+ }
622
+
623
+ // ⭐ THE FILE THE AUTHOR KEEPS A SECTION IN — found by the name the build reads it by (`1-hero.md` holds
624
+ // `hero`), else by the `id:` in its frontmatter. ⛔ Until 2026-09-26 a pull wrote every section as
625
+ // `<id>.md` and pruned the author's `1-hero.md` as an orphan: a pull into the copy that pushed renamed
626
+ // every numbered section file, wrote its id into it, and added a `sections:` list to recover the order
627
+ // the numbers had given (measured on the `international` template).
628
+ //
629
+ // ⛔ A top-level section never takes an `@` file: that is a child's name, and the build renders an
630
+ // `@` file no `nest:` claims only as an orphan appended at the end — listed by name, it is not rendered.
631
+ function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
632
+ if (!existsSync(pageDir)) return null
633
+ const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
634
+ const found =
635
+ entries.find((e) => basename(e, '.md') === fileBase) ||
636
+ entries.find((e) => fileSectionName(e) === fileBase) ||
637
+ entries.find((e) => {
638
+ try {
639
+ return parseFrontmatter(readFileSync(join(pageDir, e), 'utf8'), e).frontmatter?.id === stableId
640
+ } catch {
641
+ return false // unreadable — not this one
642
+ }
643
+ })
644
+ return found ? join(pageDir, found) : null
558
645
  }
559
646
 
560
647
  // Every key pageRecordToYml can emit — the keys the projector OWNS in a
@@ -570,14 +657,23 @@ const PAGE_YML_MANAGED_KEYS = new Set([
570
657
  'query', 'fetch', 'data', 'sections',
571
658
  ])
572
659
 
660
+ // Keys a pull WRITES and never removes — kept out of the managed set on purpose. ⭐ `slug`, a page's
661
+ // localized URL segments: a document pushed by a CLI from before 2026-09-26 carries none, and reading
662
+ // that as "none" would delete the author's (`pageSlugToYml`). The cost is that one removed in the
663
+ // backend stays in the file.
664
+ const PAGE_YML_KEPT_KEYS = new Set(['slug'])
665
+
573
666
  // Inverse of site.js buildPageData → the `page.yml` / `folder.yml` object.
574
- // `slug`/`mode`/`is_dynamic`/`param_name` are NOT keys here — they shape the
575
- // directory (name, page.yml vs folder.yml, `[param]/`), not the config body.
667
+ // `mode`/`is_dynamic`/`param_name` are NOT keys here — they shape the directory
668
+ // (name, page.yml vs folder.yml, `[param]/`), not the config body. `slug`'s source
669
+ // entry names the directory; its other locales are the page's `slug:` map.
576
670
  // Identity (the backend uuid) is NOT written here — it lives in the gitignored
577
671
  // `.uniweb/` index so authored files stay clean.
578
- function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
672
+ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, { isRoot = false, slug = null, filesGiveOrder = false } = {}) {
579
673
  const y = {}
580
674
  if (record.stable_id !== undefined) y.id = record.stable_id
675
+ // The page's localized URL segments, when the site keeps them here (`pageSlugToYml`).
676
+ if (slug) y.slug = slug
581
677
  const title = unwrapLocalized(record.title, sourceLocale)
582
678
  if (title !== undefined) y.title = title
583
679
  const description = unwrapLocalized(record.description, sourceLocale)
@@ -585,7 +681,9 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
585
681
  const label = unwrapLocalized(record.label, sourceLocale)
586
682
  if (label !== undefined) y.label = label
587
683
  if (record.keywords !== undefined) y.keywords = unwrapLocalizedList(record.keywords, sourceLocale)
588
- if (record.is_index) y.index = true
684
+ // A ROOT page's homepage mark is the site's to carry (`site.yml`, below); on its own page.yml only
685
+ // where the author wrote it. ⛔ Until 2026-09-26 every pull added `index: true` to it, beside the site's.
686
+ if (record.is_index && (!isRoot || existing?.index === true)) y.index = true
589
687
  if (record.hidden !== undefined) y.hidden = record.hidden
590
688
  if (record.hide_in !== undefined) y.hideIn = record.hide_in
591
689
  if (record.knowledge !== undefined) y.knowledge = record.knowledge
@@ -615,7 +713,20 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
615
713
  // The trailing `...` makes it inclusive: listed sections keep their order and
616
714
  // their nesting, and anything new is discovered and appended as it would be in a
617
715
  // page that was never pulled.
618
- if (sectionsArray && sectionsArray.length > 0) y.sections = [...sectionsArray, '...']
716
+ // ⭐ The author's own list is kept when it gives the same order — its `...` stays where they put it.
717
+ // A page with no list gets none where its files' names give the order (`filesGiveOrder`).
718
+ if (sectionsArray && sectionsArray.length > 0) {
719
+ const authored = existing?.sections
720
+ if (Array.isArray(authored)) {
721
+ // Compared by the names the files are found by, so a list naming `1-hero` keeps naming it.
722
+ const same = JSON.stringify(listedNames(authored.filter((e) => e !== '...'))) === JSON.stringify(sectionsArray)
723
+ y.sections = same ? authored : [...sectionsArray, '...']
724
+ } else if (!filesGiveOrder) {
725
+ y.sections = [...sectionsArray, '...']
726
+ } else if (authored !== undefined) {
727
+ y.sections = authored // `'*'`, which says what no list says
728
+ }
729
+ }
619
730
  return y
620
731
  }
621
732
 
@@ -666,8 +777,8 @@ function pruneOrphanPageDirs(pagesDir, keepDirs, report) {
666
777
  // incoming level never nukes an existing one (a malformed/partial payload can't
667
778
  // wipe it).
668
779
  function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
669
- writePagesTree(pages, pagesDir, sourceLocale, report, ctx)
670
- if (prune) prunePagesTree(pages, pagesDir, sourceLocale, report)
780
+ writePagesTree(pages, pagesDir, sourceLocale, report, ctx, '', rootOrderList(ctx.siteRoot, pagesDir))
781
+ if (prune) prunePagesTree(pages, pagesDir, sourceLocale, report, ctx)
671
782
  }
672
783
 
673
784
  // The directory name for a page record (slug, or `[param]/` for a dynamic page).
@@ -688,29 +799,39 @@ export function pageDirName(record, sourceLocale) {
688
799
  // section files, recursing into children. No deletes happen here. `routePrefix`
689
800
  // accumulates the slug-path route (for the free-form translation path; matches the
690
801
  // producer's slugPath, normalizeRouteForPath strips any leading slash on both).
691
- function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix = '') {
802
+ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix = '', list = null, canonParent = '/') {
692
803
  for (const record of pages || []) {
693
804
  const slug = unwrapLocalized(record.slug, sourceLocale) // localized {lang:value} → canonical
805
+ // The route the build gives the page (`buildRouteOf`, the push's rule): an index page takes its
806
+ // parent's, a parametric one is `:<param>`.
807
+ const canon = buildRouteOf(record, canonParent, sourceLocale)
694
808
  const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
695
809
  // Relocate the whole page dir if this uuid moved to a new slug, then record it.
696
810
  placeByUuid(ctx, record.$uuid, pageDir)
697
811
 
698
812
  // Capture target-locale translations of the page's localized scalars; the
699
- // source value goes inline into page.yml below (pageRecordToYml unwraps it).
700
- ctx.collector?.add(record.title)
701
- ctx.collector?.add(record.label)
702
- ctx.collector?.add(record.description)
813
+ // source value goes inline into page.yml below (pageRecordToYml unwraps it). In the page's
814
+ // context, as the build translates them (`<route>:_meta`), so an override for one page comes back.
815
+ const meta = { page: canon, section: '_meta' }
816
+ ctx.collector?.add(record.title, meta)
817
+ ctx.collector?.add(record.label, meta)
818
+ ctx.collector?.add(record.description, meta)
703
819
  // keywords is a localized ARRAY — capture each element's target locales.
704
- if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw))
705
- else ctx.collector?.add(record.keywords)
820
+ if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta))
821
+ else ctx.collector?.add(record.keywords, meta)
706
822
 
707
823
  const route = routePrefix ? `${routePrefix}/${slug}` : slug
708
- const pageContext = { route, id: record.stable_id }
824
+ // Addressed as the push addresses it: its free-form translations and its translations' contexts
825
+ // are keyed by the build's route. ⛔ Until 2026-09-26 this was the slug path — `home` for a
826
+ // homepage the build reads at `/`.
827
+ const pageContext = { route: canon, id: record.stable_id }
709
828
 
710
829
  let sectionsArray = []
830
+ let filesGiveOrder = false
711
831
  if (record.mode === 'page' && Array.isArray(record.page_sections)) {
712
832
  const r = pageSectionsToFiles({ pageDir, pageSections: record.page_sections, ctx, pageContext })
713
833
  sectionsArray = r.sections
834
+ filesGiveOrder = r.filesGiveOrder
714
835
  report.sections.push(...r.written)
715
836
  }
716
837
 
@@ -718,11 +839,124 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
718
839
  const ymlPath = join(pageDir, ymlName)
719
840
  // Merge (not full-dump) so author-added keys survive a pull; the projector
720
841
  // owns only PAGE_YML_MANAGED_KEYS.
721
- writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, readAuthoredYaml(ymlPath)), PAGE_YML_MANAGED_KEYS)
842
+ const existing = readAuthoredYaml(ymlPath)
843
+ const pageSlug = pageSlugToYml(record, canon, existing, sourceLocale, ctx)
844
+ writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, existing, { isRoot: !routePrefix, slug: pageSlug, filesGiveOrder }), PAGE_YML_MANAGED_KEYS)
722
845
  report.pages.push(ymlPath)
723
846
 
724
- writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
847
+ writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
848
+ }
849
+ reconcileLevelOrder({ pages, pagesDir, sourceLocale, list, homepage: routePrefix ? null : ctx.homepage })
850
+ }
851
+
852
+ // ⭐ A PAGE'S LOCALIZED URL SEGMENTS — the other locales of its `slug` (`localizeRouteSlugs`, the push) —
853
+ // go back where the site keeps them: the page's own `slug:` map, or `site.yml`'s `i18n.routeTranslations`
854
+ // (`writeRouteTranslations`). A copy that keeps neither — a clone — gets the page's `slug:`, the form the
855
+ // docs lead with. ⛔ Until 2026-09-26 they did not travel, and a clone lost every localized URL.
856
+ //
857
+ // ⚖️ A pull adds and changes these, and never removes one: a page pushed by a CLI from before carries
858
+ // none, and reading that as "none" would delete what the author wrote.
859
+ function pageSlugToYml(record, canon, existing, sourceLocale, ctx) {
860
+ if (record.is_dynamic) return null
861
+ const targets = localizedTargets(record.slug, sourceLocale)
862
+ const own = isPlainRecord(existing?.slug)
863
+ if (!own && ctx.routes?.siteMap && !record.is_index) {
864
+ if (Object.keys(targets).length) ctx.routes.slugs.set(canon, targets)
865
+ return null
725
866
  }
867
+ if (!Object.keys(targets).length) return null
868
+ const next = { ...(own ? existing.slug : {}), ...targets }
869
+ return own && stableJson(next) === stableJson(existing.slug) ? null : next
870
+ }
871
+
872
+ // The locales of a pulled localized `slug` other than the source one.
873
+ function localizedTargets(slug, sourceLocale) {
874
+ if (!isPlainRecord(slug)) return {}
875
+ return Object.fromEntries(
876
+ Object.entries(slug).filter(([l, v]) => l !== sourceLocale && l !== '@' && !l.startsWith('$') && typeof v === 'string' && v)
877
+ )
878
+ }
879
+
880
+ // `site.yml`'s `i18n.routeTranslations`, from the segments the pages carried — each route composed
881
+ // as the build composes it. Written only when that changes it, since the write re-dumps `site.yml`.
882
+ function writeRouteTranslations(siteRoot, routes) {
883
+ if (!routes?.siteMap || routes.slugs.size === 0) return
884
+ const authored = routes.siteMap
885
+ const next = JSON.parse(JSON.stringify(authored))
886
+ for (const [route, targets] of routes.slugs) {
887
+ for (const locale of Object.keys(targets)) {
888
+ if (!isPlainRecord(next[locale])) next[locale] = {}
889
+ next[locale][route] = composeLocalizedRoute(route, locale, routes.slugs)
890
+ }
891
+ }
892
+ if (stableJson(next) !== stableJson(authored)) writeSiteConfig(siteRoot, { i18n: { routeTranslations: next } })
893
+ }
894
+
895
+ const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
896
+ const stableJson = (value) =>
897
+ JSON.stringify(value, (_, v) => (isPlainRecord(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v))
898
+
899
+ // ⭐ A LEVEL'S ORDER IS THE BACKEND'S — the order the pulled document gives its pages, which is the
900
+ // order a push read them in, or the one the app set since. A level whose own config already gives it
901
+ // is left as it is; otherwise it is said the way the level says it: its `pages:` list, in that order,
902
+ // or else `order:` on each page. ⛔ Until 2026-09-26 a pull wrote neither: a clone's menus came back in
903
+ // filename order (measured on the `international` and `marketing` templates), and an order changed in
904
+ // the app was undone by the next push.
905
+ //
906
+ // ⭐ `order:` goes on the fewest pages that give the order. The build puts the numbered pages first and
907
+ // the rest after them by filename (`orderFolders`), so the pages at the END that are already in
908
+ // filename order, and carry no `order:`, need none — the way `international` numbers four pages and
909
+ // leaves `404` and `home` to follow. ⛔ The first version of this numbered every page of the level.
910
+ function reconcileLevelOrder({ pages, pagesDir, sourceLocale, list, homepage = null }) {
911
+ const level = (pages || []).map((r) => {
912
+ const dirName = pageDirName(r, sourceLocale)
913
+ return { dirName, yml: join(pagesDir, dirName, r.mode === 'folder' ? 'folder.yml' : 'page.yml') }
914
+ })
915
+ if (level.length < 2) return
916
+ const folders = level.map(({ dirName, yml }) => {
917
+ const order = readAuthoredYaml(yml)?.order
918
+ return { dirName, name: parseNumericPrefix(dirName).name, order: typeof order === 'number' ? order : undefined }
919
+ })
920
+ const ordered = orderFolders(folders, list?.pages).map((f) => f.dirName)
921
+ if (ordered.join('\n') === level.map((l) => l.dirName).join('\n')) return
922
+ const parsed = Array.isArray(list?.pages) ? parseWildcardArray(list.pages) : null
923
+ if (parsed && parsed.mode !== 'all') {
924
+ const names = folders.map((f) => f.name)
925
+ // A root list's first entry is the homepage; when the backend's order does not open with it, the
926
+ // list opens with `...`, and `index:` names it (`siteContentDocumentToProject`).
927
+ if (homepage && names[0] !== homepage) list.write(['...', ...names])
928
+ else list.write(parsed.mode === 'strict' ? names : [...names, '...'])
929
+ return
930
+ }
931
+ // The unnumbered tail: the longest run at the end in filename order, none of it numbered.
932
+ const inWire = level.map(({ dirName }) => folders.find((f) => f.dirName === dirName))
933
+ let tail = inWire.length
934
+ while (tail > 0) {
935
+ const candidate = inWire.slice(tail - 1)
936
+ if (candidate[0].order !== undefined) break
937
+ const byName = orderFolders(candidate, null).map((f) => f.dirName)
938
+ if (byName.join('\n') !== candidate.map((f) => f.dirName).join('\n')) break
939
+ tail--
940
+ }
941
+ level.slice(0, tail).forEach(({ yml }, i) => writeMergedYaml(yml, { order: i + 1 }, ['order']))
942
+ }
943
+
944
+ // The `pages:` list a level is ordered by, and how to rewrite it. The root's is the site config's,
945
+ // else the pages directory's own (`rootOrderConfig`, the build's rule); any other level's is its
946
+ // parent page's.
947
+ function rootOrderList(siteRoot, pagesDir) {
948
+ const site = readAuthoredYaml(join(siteRoot, 'site.yml')) || {}
949
+ const dirConfig = ['folder.yml', 'page.yml'].map((f) => join(pagesDir, f)).find((f) => existsSync(f)) || null
950
+ const { pages, homepage } = rootOrderConfig({ pages: site.pages, index: site.index }, (dirConfig && readAuthoredYaml(dirConfig)) || {})
951
+ const inSite = Array.isArray(site.pages) && site.pages.length > 0
952
+ return {
953
+ pages,
954
+ homepage,
955
+ write: (next) => (inSite || !dirConfig ? writeSiteConfig(siteRoot, { pages: next }) : writeMergedYaml(dirConfig, { pages: next }, ['pages'])),
956
+ }
957
+ }
958
+ function parentOrderList(ymlPath) {
959
+ return { pages: readAuthoredYaml(ymlPath)?.pages, write: (next) => writeMergedYaml(ymlPath, { pages: next }, ['pages']) }
726
960
  }
727
961
 
728
962
  // Every section FILE base in a page's section tree. Nested children are written as
@@ -743,16 +977,17 @@ function collectSectionFileBases(pageSections) {
743
977
 
744
978
  // Pass 2 — prune orphan section files (per page dir) and orphan page dirs (per
745
979
  // level), AFTER every relocation in pass 1. Guarded against wiping an empty level.
746
- function prunePagesTree(pages, pagesDir, sourceLocale, report) {
980
+ function prunePagesTree(pages, pagesDir, sourceLocale, report, ctx = null) {
747
981
  const incomingDirs = new Set()
748
982
  for (const record of pages || []) {
749
983
  incomingDirs.add(pageDirName(record, sourceLocale))
750
984
  const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
751
985
  if (record.mode === 'page') {
752
- const keep = collectSectionFileBases(record.page_sections)
986
+ // The files pass 1 wrote into — the author's own names among them — else the names it would give.
987
+ const keep = ctx?.sectionFiles?.get(pageDir) || collectSectionFileBases(record.page_sections)
753
988
  if (keep.size > 0) pruneOrphanSectionFiles(pageDir, keep, report)
754
989
  }
755
- prunePagesTree(record.$children || [], pageDir, sourceLocale, report)
990
+ prunePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx)
756
991
  }
757
992
  if (incomingDirs.size > 0) pruneOrphanPageDirs(pagesDir, incomingDirs, report)
758
993
  }
@@ -842,7 +1077,19 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
842
1077
  // A relocation into a new area folder needs the folder first.
843
1078
  mkdirSync(dirname(filePath), { recursive: true })
844
1079
  placeByUuid(ctx, record.$uuid, filePath)
845
- sectionRecordToFile({ filePath, record, sourceLocale: ctx?.sourceLocale, collector: ctx?.collector })
1080
+ // Its free-form translation lives at the area's route, as the build reads it (`layoutAreaRoute`).
1081
+ const stableId = recordStableId(record)
1082
+ const page = { route: layoutAreaRoute(record.layout_name, record.area || stableId) }
1083
+ sectionRecordToFile({
1084
+ filePath,
1085
+ record,
1086
+ sourceLocale: ctx?.sourceLocale,
1087
+ collector: ctx?.collector,
1088
+ writeId: fileSectionName(filePath) !== stableId,
1089
+ freeformRelPath: stableId ? buildFreeformPath({ stableId }, page) : null,
1090
+ freeformCandidates: stableId ? freeformPathsFor({ stableId }, page) : null,
1091
+ context: translationContext({ stableId }, page.route),
1092
+ })
846
1093
  report.layout.push(filePath)
847
1094
  written.push(filePath)
848
1095
  }
@@ -871,14 +1118,17 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
871
1118
  * @param {string|null} [params.scope] - the scope the push qualified a query's
872
1119
  * `@/x` schema with, so it is written back as `@/x` — see
873
1120
  * `declarationsToQueriesYml`, whose default it overrides.
1121
+ * @param {Object<string,object>|null} [params.models] - the Models the site's queries name, as the
1122
+ * pull read them from the backend, keyed by the ref on the wire — what a clone, with no
1123
+ * foundation on disk, judges a query's derived `deferred:` by.
874
1124
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
875
1125
  */
876
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
1126
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, models = null }) {
877
1127
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
878
1128
 
879
1129
  // Collects target-locale translations of localized scalars as they're projected;
880
1130
  // flushed to locales/{locale}.json at the end (the manifest stays derivable).
881
- const collector = createTranslationCollector(sourceLocale)
1131
+ const collector = createTranslationCollector(sourceLocale, { siteRoot })
882
1132
 
883
1133
  // Put the author's own asset paths back before anything is serialized. Stored
884
1134
  // content carries an id and a serve URL; only the committed map knows the ref
@@ -895,25 +1145,59 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
895
1145
  backend ? readBackendState(siteRoot, backend).assets || {} : {}
896
1146
  )
897
1147
 
898
- report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
899
- report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
1148
+ report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation, deferLanguages: true })
1149
+ report.queries = declarationsToQueriesYml({ document, siteRoot, scope, models })
900
1150
 
901
1151
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
902
1152
  // rename detection, build a fresh one as we project, then persist it. Items not
903
1153
  // re-projected (deleted) drop out naturally. `collector` rides along to capture
904
1154
  // localized scalars during the page walk.
905
- const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale }
1155
+ const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map() }
1156
+ // Where the site keeps its localized URLs — `site.yml`'s map, when it has one (`pageSlugToYml`).
1157
+ const siteMap = readAuthoredYaml(join(siteRoot, 'site.yml'))?.i18n?.routeTranslations
1158
+ ctx.routes = { siteMap: isPlainRecord(siteMap) && Object.keys(siteMap).length ? siteMap : null, slugs: new Map() }
906
1159
 
907
1160
  // Where the tree goes is what `site.yml` says NOW — `siteInfoToConfig` above has
908
1161
  // written the document's `settings.paths` into it — because that is the file the
909
1162
  // next push and the build read. See `siteContentDirs`.
910
1163
  const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, readAuthoredYaml(join(siteRoot, 'site.yml')))
1164
+ // The root page the backend marks as the homepage, by the directory it is written to.
1165
+ const home = (document?.pages || []).find((p) => p?.is_index)
1166
+ ctx.homepage = home ? pageDirName(home, sourceLocale) : null
911
1167
  projectPages(document?.pages, pagesDir, sourceLocale, report, prune, ctx)
1168
+ writeRouteTranslations(siteRoot, ctx.routes)
1169
+ // ⭐ THE HOMEPAGE IS THE ONE THE BACKEND MARKS, named where the site names it. ⛔ Until 2026-09-26 a
1170
+ // clone kept its scaffold's `index: home` whatever the site's homepage was — a page that may not
1171
+ // exist — and the homepage was marked on its own page.yml instead.
1172
+ if (ctx.homepage) {
1173
+ const root = rootOrderList(siteRoot, pagesDir)
1174
+ // A homepage marked `index: true` in its own config — where the push read it (`buildPageData`) —
1175
+ // is left marked there alone: a site.yml that names none is not given an `index:` it never had.
1176
+ const marksItself = readAuthoredYaml(join(pagesDir, ctx.homepage, home.mode === 'folder' ? 'folder.yml' : 'page.yml'))?.index === true
1177
+ if (root.homepage !== ctx.homepage && !(marksItself && root.homepage === undefined)) {
1178
+ const listed = Array.isArray(root.pages) ? parseWildcardArray(root.pages) : null
1179
+ // A list that names the homepage first beats `index:`, so it opens with `...` instead.
1180
+ if (listed && listed.before.length > 0) root.write(['...', ...root.pages.filter((e) => e !== '...')])
1181
+ upsertYamlScalar(join(siteRoot, 'site.yml'), 'index', ctx.homepage)
1182
+ }
1183
+ }
912
1184
  projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
913
1185
 
914
1186
  report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
915
1187
  // Target-locale FREE-FORM bodies → locales/freeform/{locale}/<relpath> + manifest.
916
1188
  report.freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
1189
+ // The site's languages, for a site.yml that declares none (`siteInfoToConfig`) — written only where
1190
+ // the translation files, now written, would not make the same list, or where `publishLanguages`
1191
+ // needs a declared list to draw from: the build refuses one without it.
1192
+ if (report.config?.pendingLanguages !== undefined) {
1193
+ const pulled = report.config.pendingLanguages
1194
+ delete report.config.pendingLanguages
1195
+ const site = readAuthoredYaml(join(siteRoot, 'site.yml')) || {}
1196
+ const source = site.defaultLanguage || sourceLocale
1197
+ const made = new Set([source, ...resolveLocaleList(undefined, localesDir(siteRoot))])
1198
+ const same = Array.isArray(pulled) && pulled.length === made.size && pulled.every((l) => made.has(l))
1199
+ if (!same || site.publishLanguages !== undefined) writeSiteConfig(siteRoot, { languages: pulled })
1200
+ }
917
1201
  writePullIndex(siteRoot, ctx.newIndex)
918
1202
  return report
919
1203
  }