@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.
- package/package.json +7 -7
- package/src/i18n/extract.js +43 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +36 -17
- package/src/i18n/records.js +393 -161
- package/src/runtime-schema.js +3 -1
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/queries-config.js +8 -1
- package/src/site/query-processor.js +13 -2
- package/src/site/registered-foundation.js +47 -0
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +20 -9
- package/src/uwx/data-schema.js +34 -0
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/open-graph.js +69 -0
- package/src/uwx/project-writer.js +109 -31
- package/src/uwx/records-project.js +156 -28
- package/src/uwx/records.js +67 -18
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +334 -39
- package/src/uwx/site.js +178 -44
- package/src/uwx/sync-package.js +24 -1
- package/src/validate-data.js +15 -5
package/src/uwx/site-project.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
575
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
ctx.collector?.add(record.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|