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