@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.
- package/package.json +9 -9
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +22 -17
- package/src/i18n/records.js +389 -161
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/query-processor.js +13 -2
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/records-project.js +108 -21
- package/src/uwx/records.js +19 -16
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +322 -38
- package/src/uwx/site.js +146 -35
package/src/uwx/site-project.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
575
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
ctx.collector?.add(record.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|