@uniweb/build 0.15.5 → 0.15.7

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.
@@ -1164,11 +1164,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1164
1164
  }
1165
1165
  }
1166
1166
 
1167
- // Create items with .name property for applyWildcardOrder
1168
- const allItems = [...discoveredMap.keys()].map(name => ({ name }))
1169
- const ordered = applyWildcardOrder(allItems, sectionsParsed)
1170
-
1171
1167
  // Collect subsection configs from the original array (e.g., { features: [a, b] })
1168
+ // BEFORE ordering, because the children they name must not also be treated as
1169
+ // top-level sections.
1172
1170
  const subsectionConfigs = new Map()
1173
1171
  for (const item of [...sectionsParsed.before, ...sectionsParsed.after]) {
1174
1172
  if (typeof item === 'object' && item !== null) {
@@ -1179,6 +1177,26 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1179
1177
  }
1180
1178
  }
1181
1179
 
1180
+ // A child named in `nest`-style config is already placed, so it must not fall
1181
+ // into the auto-discovered "rest" as well. `@`-prefixed files are excluded from
1182
+ // discovery above, but a child does NOT have to be `@`-prefixed: `uniweb pull`
1183
+ // writes every section — nested or not — as a flat `<stableId>.md` in the page
1184
+ // dir. Without this, an inclusive list flattens exactly the nesting the list
1185
+ // exists to preserve, promoting each child to a sibling of its own parent.
1186
+ const claimedChildren = new Set()
1187
+ for (const children of subsectionConfigs.values()) {
1188
+ for (const child of children || []) {
1189
+ const name = typeof child === 'string' ? child : Object.keys(child || {})[0]
1190
+ if (name) claimedChildren.add(name)
1191
+ }
1192
+ }
1193
+
1194
+ // Create items with .name property for applyWildcardOrder
1195
+ const allItems = [...discoveredMap.keys()]
1196
+ .filter(name => !claimedChildren.has(name))
1197
+ .map(name => ({ name }))
1198
+ const ordered = applyWildcardOrder(allItems, sectionsParsed)
1199
+
1182
1200
  // Process sections in wildcard-expanded order
1183
1201
  const sections = []
1184
1202
  let sectionIndex = 1
@@ -64,7 +64,22 @@ export async function assembleDataBall(distDir, schemalessNames = []) {
64
64
  if (schemaless.has(collectionOf(relPath))) data[relPath] = value
65
65
  }
66
66
  const search = await readJsonTree(join(distDir, '_search'))
67
+
68
+ // Agent projections deliberately do NOT ride the ball.
69
+ //
70
+ // A backend that stores the site's content derives them itself at publish —
71
+ // one producer, so shipping ours would upload an artifact to the one host
72
+ // that does not need it, and invite two answers for one site. The projections
73
+ // this build emits into `dist/` are for hosts with no backend to derive them:
74
+ // static hosts and foreign backends. That is `@uniweb/projections`' scope and
75
+ // it is unchanged.
76
+ //
77
+ // (An opt-in `projections` bucket lived here while the delivery contract was
78
+ // open. It never shipped enabled, and the one-producer ruling closed the
79
+ // question — removed rather than left as a flag nobody may turn on.)
80
+
67
81
  if (Object.keys(data).length === 0 && Object.keys(search).length === 0) return null
82
+
68
83
  return { data, search }
69
84
  }
70
85
 
@@ -34,6 +34,15 @@ import { resolve, join } from 'node:path'
34
34
  import { watch, existsSync } from 'node:fs'
35
35
  import { readFile, readdir } from 'node:fs/promises'
36
36
  import { resolveDefaultLocale } from '@uniweb/core'
37
+ import {
38
+ renderSiteIndex,
39
+ renderPageMarkdown,
40
+ resolveAgentsConfig,
41
+ selectIndexablePages,
42
+ pageMarkdownFilename,
43
+ applyRouteTranslation,
44
+ INDEX_FILENAME
45
+ } from '@uniweb/projections'
37
46
  import { collectSiteContent } from './content-collector.js'
38
47
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
39
48
  import { processAdvancedAssets } from './advanced-processors.js'
@@ -187,23 +196,12 @@ async function processDevSectionFetches(sections, fetchOptions) {
187
196
  import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
188
197
  import { mergeTranslations } from '../i18n/merge.js'
189
198
 
190
- /**
191
- * Translate a canonical route for a given locale using route translations config
192
- * Supports exact and prefix matching (e.g., /blog /noticias also applies to /blog/post)
199
+ /*
200
+ * `applyRouteTranslation` now comes from `@uniweb/projections` (imported
201
+ * above). It used to be defined here; the agent index builds localized URLs
202
+ * from the same rule, and two copies would eventually disagree about where a
203
+ * localized page lives.
193
204
  */
194
- function applyRouteTranslation(route, locale, routeTranslations) {
195
- const localeMap = routeTranslations?.[locale]
196
- if (!localeMap) return route
197
- // Exact match
198
- if (localeMap[route]) return localeMap[route]
199
- // Prefix match
200
- for (const [canonical, translated] of Object.entries(localeMap)) {
201
- if (route.startsWith(canonical + '/')) {
202
- return translated + route.slice(canonical.length)
203
- }
204
- }
205
- return route
206
- }
207
205
 
208
206
  /**
209
207
  * Restrict authored `seo.locales` hreflang entries to locales the payload
@@ -577,6 +575,76 @@ export function siteContentPlugin(options = {}) {
577
575
  })
578
576
  }
579
577
 
578
+ /**
579
+ * Options shared by every projection call, so the dev middleware and the
580
+ * build emit cannot drift apart in how they build URLs.
581
+ */
582
+ function projectionOptions(content) {
583
+ const defaultLocale = resolveDefaultLocale(content.config)
584
+ return {
585
+ baseUrl: seoOptions.baseUrl,
586
+ basePath,
587
+ locale: content.config?.activeLocale || defaultLocale,
588
+ defaultLocale
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Emit `llms.txt` and the per-page `.md` projections.
594
+ *
595
+ * Called with the Rollup plugin context, so `this.emitFile` is available.
596
+ * Sits beside the sitemap and search-index emits because it is the same kind
597
+ * of thing: an artifact derived from the site's content alone.
598
+ *
599
+ * Unlike the SEO artifacts, this does NOT silently skip when `seo.baseUrl`
600
+ * is unset — it falls back to root-relative links and `uniweb doctor` warns.
601
+ * An agent that arrived via the index can still follow a relative link, and
602
+ * a silently-absent index is the failure mode the whole feature exists to
603
+ * prevent.
604
+ *
605
+ * @param {Object} content - Final site content for this locale
606
+ */
607
+ function emitProjections(content) {
608
+ const agents = resolveAgentsConfig(content?.config)
609
+ if (!agents.index && !agents.markdown) return
610
+ if (!content?.pages?.length) return
611
+
612
+ const options = projectionOptions(content)
613
+ const localeDir =
614
+ options.locale && options.defaultLocale && options.locale !== options.defaultLocale
615
+ ? `${options.locale}/`
616
+ : ''
617
+
618
+ if (agents.index) {
619
+ this.emitFile({
620
+ type: 'asset',
621
+ fileName: `${localeDir}${INDEX_FILENAME}`,
622
+ source: renderSiteIndex(content, { ...options, exclude: agents.exclude })
623
+ })
624
+ console.log(`[site-content] Generated ${localeDir}${INDEX_FILENAME}`)
625
+ }
626
+
627
+ if (agents.markdown) {
628
+ const pages = selectIndexablePages(content.pages, { exclude: agents.exclude })
629
+ let emitted = 0
630
+ for (const page of pages) {
631
+ const markdown = renderPageMarkdown(page)
632
+ if (!markdown) continue
633
+ const route =
634
+ options.locale !== options.defaultLocale
635
+ ? applyRouteTranslation(page.route, options.locale, content.config?.i18n?.routeTranslations)
636
+ : page.route
637
+ this.emitFile({
638
+ type: 'asset',
639
+ fileName: `${localeDir}${pageMarkdownFilename(route)}`,
640
+ source: markdown
641
+ })
642
+ emitted++
643
+ }
644
+ if (emitted) console.log(`[site-content] Generated ${emitted} page markdown projections`)
645
+ }
646
+ }
647
+
580
648
  return {
581
649
  name: 'uniweb:site-content',
582
650
 
@@ -928,6 +996,47 @@ export function siteContentPlugin(options = {}) {
928
996
  }
929
997
  }
930
998
 
999
+ // Serve the agent projections in dev mode.
1000
+ //
1001
+ // Without this the artifacts can only be inspected by deploying, and
1002
+ // `uniweb doctor`'s baseUrl warning describes something invisible
1003
+ // locally. Both routes reuse the build's generators, so what a
1004
+ // developer sees here is what ships.
1005
+ if (siteContent) {
1006
+ const agents = resolveAgentsConfig(siteContent.config)
1007
+ const url = req.url.split('?')[0]
1008
+
1009
+ const indexMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/${INDEX_FILENAME}$`))
1010
+ if (indexMatch && agents.index) {
1011
+ const localized = (indexMatch[1] ? await getTranslatedContent(indexMatch[1]) : null) || siteContent
1012
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
1013
+ res.end(
1014
+ renderSiteIndex(localized, {
1015
+ ...projectionOptions(localized),
1016
+ locale: indexMatch[1] || projectionOptions(localized).locale,
1017
+ exclude: agents.exclude
1018
+ })
1019
+ )
1020
+ return
1021
+ }
1022
+
1023
+ const markdownMatch = url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/(.+)\\.md$`))
1024
+ if (markdownMatch && agents.markdown) {
1025
+ const localized = (markdownMatch[1] ? await getTranslatedContent(markdownMatch[1]) : null) || siteContent
1026
+ const requested = markdownMatch[2] === 'index' ? '/' : `/${markdownMatch[2]}`
1027
+ const pages = selectIndexablePages(localized.pages, { exclude: agents.exclude })
1028
+ const page = pages.find(p => p.route === requested)
1029
+ if (page) {
1030
+ const markdown = renderPageMarkdown(page)
1031
+ if (markdown) {
1032
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
1033
+ res.end(markdown)
1034
+ return
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+
931
1040
  // Handle localized collection data (e.g., /fr/data/articles.json)
932
1041
  const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/data\\/(.+\\.json)$`))
933
1042
  if (localeDataMatch) {
@@ -1234,6 +1343,11 @@ export function siteContentPlugin(options = {}) {
1234
1343
 
1235
1344
  console.log(`[site-content] Generated ${searchFilename} (${searchIndex.count} entries)`)
1236
1345
  }
1346
+
1347
+ // Agent-readable projections: the index (discovery) and per-page
1348
+ // markdown (retrieval). Free and on by default; a site opts out under
1349
+ // `agents:` in site.yml.
1350
+ emitProjections.call(this, finalContent)
1237
1351
  },
1238
1352
 
1239
1353
  closeBundle() {
@@ -43,7 +43,6 @@ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTrans
43
43
  import { buildFreeformCollectionPath } from '../i18n/freeform.js'
44
44
 
45
45
  // Single-record source extensions we scan + place (BibTeX is multi-record → out).
46
- const SINGLE_RECORD_EXTS = ['.md', '.yml', '.yaml', '.json']
47
46
  const EXT_FOR_FORMAT = { md: '.md', yaml: '.yml', json: '.json' }
48
47
 
49
48
  function formatForExt(ext) {
@@ -68,7 +68,7 @@ export function emitEntitySyncPackage({
68
68
  for (const entity of entities) {
69
69
  const data = toJsonBuffer(entity.document)
70
70
  files.push({ name: entity.file, data })
71
- entries.push({
71
+ const entry = {
72
72
  kind: 'entity',
73
73
  // `$id` as the handle label — NOT the identity key on the sync lane (the
74
74
  // backend reads identity from the body) and never uuid-parsed for entities.
@@ -83,7 +83,39 @@ export function emitEntitySyncPackage({
83
83
  updated_at: null,
84
84
  file: entity.file,
85
85
  sha256: sha256Hex(data),
86
- })
86
+ }
87
+ // Optimistic-concurrency precondition (the push gate). When present, the
88
+ // backend compares it against the entity's stored version and refuses the
89
+ // WHOLE package atomically if it has moved (409 `reason: "stale_base"`),
90
+ // before any write. OMITTING it is the force path — the backend then falls
91
+ // back to the `collision` policy. Note `collision=force` does NOT override a
92
+ // present token: the token wins, so `--force` must DROP this, never flip
93
+ // `collision`. The value is the opaque RFC3339 `version` the backend stamped
94
+ // on the entity — read from the pull manifest's top-level `version` or from a
95
+ // prior push's `finalized[].version`, and never parsed or synthesized here.
96
+ //
97
+ // TOP-LEVEL on the entry, beside `sha256` — not nested under an `extra`
98
+ // object. The backend's Rust struct has an `extra` field but it is
99
+ // `#[serde(flatten)]`, so those keys serialize beside the others and `extra`
100
+ // never appears on the wire in either direction. Its pull manifests emit a
101
+ // top-level `version` for the same reason.
102
+ //
103
+ // The backend correlates this to an entity by the BODY's `$uuid`, not by
104
+ // `entries[].uuid` — which stays the `$id` handle above. Don't be tempted to
105
+ // write a real uuid into that field to "help": it would make the field mean
106
+ // two different things depending on sync state, and the backend has no use
107
+ // for it (it reads identity from the body everywhere else too).
108
+ if (entity.baseVersion) entry.base_version = entity.baseVersion
109
+ // Per-ITEM preconditions, keyed by each record's `$uuid`. The entity token
110
+ // gates the whole document, so a stale base on any one record refuses the
111
+ // entire push — which fires on the common case of two people editing different
112
+ // sections. These let the backend accept the records we may touch and refuse
113
+ // only the ones that genuinely moved. Same rules as the entity token: opaque,
114
+ // top-level on the entry (no `extra` wrapper), omitted = unconditional.
115
+ if (entity.itemBaseVersions && Object.keys(entity.itemBaseVersions).length) {
116
+ entry.item_base_versions = entity.itemBaseVersions
117
+ }
118
+ entries.push(entry)
87
119
  }
88
120
 
89
121
  const manifest = buildManifest({
package/src/uwx/index.js CHANGED
@@ -68,6 +68,15 @@ export {
68
68
  localeFilePath,
69
69
  } from './locale-sync.js'
70
70
  export { emitSyncPackages } from './sync-package.js'
71
+ export {
72
+ diffSiteUnits,
73
+ describeSiteDiff,
74
+ computeUnitHashes,
75
+ collectSiteUnits,
76
+ walkSiteUnits,
77
+ collectUnitUuids,
78
+ stampUnitUuids,
79
+ } from './site-diff.js'
71
80
  export {
72
81
  collectionRecordsToEntities,
73
82
  buildCollectionEntities,
@@ -0,0 +1,294 @@
1
+ // File-level diff of two `@uniweb/site-content` documents.
2
+ //
3
+ // The push staleness gate is ENTITY-grained: a site's whole page tree is one
4
+ // entity, so a refusal can only say "the document moved". That leaves the user
5
+ // with "pull and lose yours, or force and lose theirs" and no way to judge either.
6
+ // This turns the refusal into an account of which units diverged and which side
7
+ // moved them.
8
+ //
9
+ // THE UNIT IS THE FILE, NOT THE PAGE. A site's content is already split one
10
+ // section per file, so two people editing different sections — even of the SAME
11
+ // page — have not conflicted. Diffing at page granularity reports that as a
12
+ // conflict, and a false conflict is not a harmless imprecision: the only moves it
13
+ // leaves are pulling (loses their work) or forcing (loses the other side's), so it
14
+ // actively pushes people toward the destructive one. Three unit kinds, matching
15
+ // exactly what someone would open:
16
+ //
17
+ // site.yml site info (name, theme, foundation ref)
18
+ // pages/<route>/page.yml page metadata (title, slug, section order, …)
19
+ // pages/<route>/<section>.md one section — including nested `$children`,
20
+ // which the projector also writes flat here
21
+ // layout/<section>.md a layout section
22
+ //
23
+ // Units are DISJOINT: a page's hash excludes its sections, a section's excludes its
24
+ // children, so a change lands in exactly one unit and is never double-reported.
25
+ // Naming comes from the projector's own helpers (`pageDirName`,
26
+ // `safeStableIdFilename`) rather than a local copy, so a label always names a file
27
+ // that really exists.
28
+ //
29
+ // It needs TWO bases, and that is the non-obvious part.
30
+ //
31
+ // Our document and the backend's are not byte-comparable renderings of the same
32
+ // unit: the backend's copy carries fields we don't emit (`params`,
33
+ // `theme_override`, its own `$uuid`) and serializes in its own key order. So a hash
34
+ // taken on our side and one taken on theirs differ for a unit NEITHER side touched.
35
+ // A single base therefore validates exactly one comparison and silently corrupts
36
+ // the other — with a local-representation base every unit looks "changed
37
+ // upstream", which is worse than saying nothing.
38
+ //
39
+ // So each side is compared only against a base in its OWN representation:
40
+ // local vs localBase → did WE change it
41
+ // remote vs remoteBase → did THEY change it
42
+ // and local-vs-remote is never compared directly. Both bases are captured at the
43
+ // same two moments (a successful push, a pull) from sources already to hand: our
44
+ // emitted document, and the backend's `finalized[].document` / the pulled document.
45
+ //
46
+ // Either base may be missing; the affected side degrades to "unknown" and is
47
+ // reported as unattributed rather than guessed at.
48
+
49
+ import { entityContentHash } from './collections.js'
50
+ import { recordStableId, safeStableIdFilename, pageDirName } from './site-project.js'
51
+ import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
52
+
53
+ // A unit's own content, with the nested collections that are their own units
54
+ // removed — so editing a section never also marks its page (or its parent
55
+ // section) as changed.
56
+ const ownContent = (record, ...childKeys) => {
57
+ const copy = { ...record }
58
+ for (const k of childKeys) delete copy[k]
59
+ return copy
60
+ }
61
+
62
+ /**
63
+ * Every diffable unit of a site-content document, keyed by the repo-relative path
64
+ * the projector would write it to.
65
+ *
66
+ * Paths use the conventional `pages/` and `layout/` roots. A site that relocates
67
+ * them via `info.paths` still gets stable, unique keys — only the displayed prefix
68
+ * would differ from disk, which is a labelling nicety, not a correctness issue.
69
+ *
70
+ * @returns {Map<string, object>} path → the unit's own record
71
+ */
72
+ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
73
+ const units = new Map()
74
+ walkSiteUnits(doc, (path, record, kind) => {
75
+ units.set(
76
+ path,
77
+ kind === 'page' ? ownContent(record, 'page_sections', '$children')
78
+ : kind === 'info' ? ownContent(record)
79
+ : ownContent(record, '$children')
80
+ )
81
+ }, sourceLocale)
82
+ return units
83
+ }
84
+
85
+ /**
86
+ * Walk every unit of a site-content document, handing the callback the path and the
87
+ * LIVE record (not a copy — mutating it mutates the document).
88
+ *
89
+ * The single traversal behind hashing, uuid harvesting, and uuid stamping, so those
90
+ * three can never disagree about what a unit is or where it lives. A path that
91
+ * differs between them would silently mis-key a cache.
92
+ *
93
+ * @param {(path: string, record: object, kind: 'info'|'page'|'section') => void} cb
94
+ */
95
+ export function walkSiteUnits(doc, cb, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
96
+ const walkSections = (sections, dir) => {
97
+ for (const record of sections || []) {
98
+ const id = recordStableId(record)
99
+ // Anonymous and id-less: the projector cannot place it either, so there is
100
+ // no file to name and nothing to attribute.
101
+ if (id) cb(`${dir}/${safeStableIdFilename(id)}.md`, record, 'section')
102
+ walkSections(record.$children, dir)
103
+ }
104
+ }
105
+ const walkPages = (pages, parentDir) => {
106
+ for (const record of pages || []) {
107
+ const dirName = pageDirName(record, sourceLocale)
108
+ if (!dirName) continue
109
+ const dir = `${parentDir}/${dirName}`
110
+ cb(`${dir}/${record.mode === 'folder' ? 'folder.yml' : 'page.yml'}`, record, 'page')
111
+ if (record.mode !== 'folder') walkSections(record.page_sections, dir)
112
+ walkPages(record.$children, dir)
113
+ }
114
+ }
115
+ // `info` is an item too — it carries its own `$uuid` and its own per-item token,
116
+ // and it holds the site's name, theme and foundation ref. Omitting it left those
117
+ // ungated (an upstream theme change would not be caught) and invisible in the
118
+ // diff. It projects to several files (site.yml, theme.yml, head.html); `site.yml`
119
+ // is the label, since that is where its identity-bearing fields live.
120
+ if (doc?.info && typeof doc.info === 'object') cb('site.yml', doc.info, 'info')
121
+ walkPages(doc?.pages, 'pages')
122
+ walkSections(doc?.layout_sections, 'layout')
123
+ }
124
+
125
+ /** Per-unit content hashes: `{ <path>: <sha256> }`. */
126
+ export function computeUnitHashes(doc, sourceLocale) {
127
+ const out = {}
128
+ for (const [path, record] of collectSiteUnits(doc, sourceLocale)) out[path] = entityContentHash(record)
129
+ return out
130
+ }
131
+
132
+ /**
133
+ * Harvest per-item identity from a document the BACKEND produced (a pull, or a push
134
+ * response's `finalized[].document`): `{ <path>: <$uuid> }`.
135
+ *
136
+ * This is the map that has to be echoed back on the next push. Without it the
137
+ * backend cannot match our items — a uuid-less record in a `multi` section (which
138
+ * `pages`, `page_sections` and `layout_sections` all are) is read as new, so it is
139
+ * inserted and its stored counterpart is deleted. That silently replaces every
140
+ * page and section identity on every push, which in turn destroys the per-item
141
+ * handles the app holds for its own concurrency. Identity is not decoration here.
142
+ */
143
+ export function collectUnitUuids(doc, sourceLocale) {
144
+ const out = {}
145
+ walkSiteUnits(doc, (path, record) => {
146
+ if (typeof record?.$uuid === 'string' && record.$uuid) out[path] = record.$uuid
147
+ }, sourceLocale)
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * Stamp known `$uuid`s onto a document we are about to push, so the backend matches
153
+ * our items instead of re-minting them.
154
+ *
155
+ * A unit the map doesn't know is left alone: that is genuinely new content on its
156
+ * first push, and minting is the only coherent reading. The map's absence entirely
157
+ * is the dangerous case — see `collectUnitUuids` — and the caller is responsible for
158
+ * not pushing blind (the backend also refuses an all-blank `multi` section).
159
+ *
160
+ * @returns {{ stamped: number, unknown: number }}
161
+ */
162
+ export function stampUnitUuids(doc, pathToUuid = {}, sourceLocale) {
163
+ let stamped = 0
164
+ let unknown = 0
165
+ const seen = new Set()
166
+ const collisions = []
167
+ walkSiteUnits(doc, (path, record) => {
168
+ // Two records can resolve to ONE path when their stable ids collide — most
169
+ // easily `1-hero.md` and `hero.md` in the same page dir, since the numeric
170
+ // prefix is stripped. Stamping both with the same uuid produces a package the
171
+ // backend rejects outright ("a `$uuid` must be unique within the entity"), so
172
+ // only the first occurrence takes the identity; the rest push as new. The
173
+ // collision is reported because it is an authoring problem either way — the
174
+ // projector writes `<stableId>.md`, so a pull would collapse the two files
175
+ // into one.
176
+ if (seen.has(path)) {
177
+ collisions.push(path)
178
+ unknown++
179
+ return
180
+ }
181
+ seen.add(path)
182
+ const uuid = pathToUuid[path]
183
+ if (uuid) {
184
+ record.$uuid = uuid
185
+ stamped++
186
+ } else {
187
+ unknown++
188
+ }
189
+ }, sourceLocale)
190
+ return { stamped, unknown, collisions }
191
+ }
192
+
193
+ /**
194
+ * Compare the local and remote site-content documents unit by unit, attributing
195
+ * each side's changes against a base in that side's own representation.
196
+ *
197
+ * @param {object} localDoc - the document we were about to push
198
+ * @param {object} remoteDoc - the backend's current document
199
+ * @param {object} [bases]
200
+ * @param {Object<string,string>} [bases.local] - unit hashes of OUR document as of
201
+ * the last sync. Enables "did we change it".
202
+ * @param {Object<string,string>} [bases.remote] - unit hashes of THEIR document as
203
+ * of the last sync. Enables "did they change it". Never compare this against
204
+ * a locally-derived hash.
205
+ * @returns {{
206
+ * knowsLocal: boolean, knowsRemote: boolean,
207
+ * changedUpstream: string[], changedLocally: string[], changedBoth: string[],
208
+ * changedUnattributed: string[],
209
+ * addedUpstream: string[], addedLocally: string[], identical: string[],
210
+ * }} each list holding repo-relative paths, sorted.
211
+ */
212
+ export function diffSiteUnits(localDoc, remoteDoc, bases = {}) {
213
+ const localBase = bases.local || {}
214
+ const remoteBase = bases.remote || {}
215
+ const knowsLocal = Object.keys(localBase).length > 0
216
+ const knowsRemote = Object.keys(remoteBase).length > 0
217
+
218
+ const local = collectSiteUnits(localDoc)
219
+ const remote = collectSiteUnits(remoteDoc)
220
+
221
+ const out = {
222
+ knowsLocal, knowsRemote,
223
+ changedUpstream: [], changedLocally: [], changedBoth: [], changedUnattributed: [],
224
+ addedUpstream: [], addedLocally: [], identical: [],
225
+ }
226
+
227
+ for (const path of new Set([...local.keys(), ...remote.keys()])) {
228
+ const l = local.get(path)
229
+ const r = remote.get(path)
230
+ // Present on one side only. Set membership is representation-independent, so
231
+ // these are sound even with no bases at all — and `addedUpstream` is the
232
+ // dangerous one: forcing does not revert it, it deletes it.
233
+ if (l && !r) { out.addedLocally.push(path); continue }
234
+ if (r && !l) { out.addedUpstream.push(path); continue }
235
+
236
+ // Each side against its OWN base. A missing base entry is UNKNOWN, not unchanged.
237
+ const weChanged = knowsLocal && localBase[path] ? entityContentHash(l) !== localBase[path] : null
238
+ const theyChanged = knowsRemote && remoteBase[path] ? entityContentHash(r) !== remoteBase[path] : null
239
+
240
+ // An UNKNOWN side is only worth reporting when the other side doesn't already
241
+ // settle the question. If the remote is known to sit at its base, this unit is
242
+ // not contested no matter what we did to it — pushing our version is the whole
243
+ // point — so "differs, side unknown" would be both untrue and noise. That
244
+ // combination is the normal state right after a pull (which clears the local
245
+ // base), which is exactly when a refusal is most likely, so getting it wrong
246
+ // buried the one contested file under a list of perfectly fine ones.
247
+ if (weChanged === true && theyChanged === true) out.changedBoth.push(path)
248
+ else if (theyChanged === true) out.changedUpstream.push(path)
249
+ else if (weChanged === true) out.changedLocally.push(path)
250
+ else if (weChanged === null && theyChanged === null) out.changedUnattributed.push(path)
251
+ else out.identical.push(path)
252
+ }
253
+
254
+ for (const k of Object.keys(out)) if (Array.isArray(out[k])) out[k].sort()
255
+ return out
256
+ }
257
+
258
+ /**
259
+ * Render a diff as the lines a CLI shows after a staleness refusal, ordered by the
260
+ * cost of getting each one wrong. Returns `[]` when there is nothing to say.
261
+ */
262
+ export function describeSiteDiff(diff, { limit = 8 } = {}) {
263
+ const lines = []
264
+ // Never let a long list silently truncate into something that reads complete.
265
+ const list = (xs) =>
266
+ xs.length > limit ? `${xs.slice(0, limit).join(', ')} … and ${xs.length - limit} more` : xs.join(', ')
267
+
268
+ if (diff.addedUpstream.length) lines.push(`Added upstream — forcing DELETES these: ${list(diff.addedUpstream)}`)
269
+ if (diff.changedBoth.length) lines.push(`Changed on both sides: ${list(diff.changedBoth)}`)
270
+ if (diff.changedUpstream.length) lines.push(`Changed upstream — forcing discards these: ${list(diff.changedUpstream)}`)
271
+ if (diff.changedLocally.length) lines.push(`Changed by you — pulling discards these: ${list(diff.changedLocally)}`)
272
+ if (diff.changedUnattributed.length) lines.push(`Differs, side unknown: ${list(diff.changedUnattributed)}`)
273
+ if (diff.addedLocally.length) lines.push(`Added by you (not upstream yet): ${list(diff.addedLocally)}`)
274
+
275
+ // The good news is worth saying — but only when BOTH sides are actually known.
276
+ // Without the local base we cannot see our own edits, so "nothing was changed on
277
+ // both sides" would be a claim about evidence we don't have: the user could pull
278
+ // on the strength of it and lose the very edit they were trying to push. A
279
+ // reassurance that can't be backed is worse than no reassurance.
280
+ if (
281
+ lines.length && diff.knowsLocal && diff.knowsRemote &&
282
+ !diff.changedBoth.length && !diff.changedUnattributed.length
283
+ ) {
284
+ lines.push('No unit was changed on both sides — pulling should merge cleanly.')
285
+ }
286
+ // Say which half of the attribution is missing rather than under-reporting silently.
287
+ if (lines.length && !diff.knowsRemote) {
288
+ lines.push("No record of the backend's last state, so upstream edits to existing units are not listed.")
289
+ }
290
+ if (lines.length && !diff.knowsLocal) {
291
+ lines.push('No record of your last sync, so your own edits are not listed.')
292
+ }
293
+ return lines
294
+ }
@@ -100,6 +100,7 @@ const INFO_TO_SITE_YML = {
100
100
  fetcher: 'fetcher',
101
101
  build: 'build',
102
102
  search: 'search',
103
+ agents: 'agents',
103
104
  paths: 'paths',
104
105
  data: 'data',
105
106
  template: 'template',
@@ -259,7 +260,7 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
259
260
 
260
261
  // A section/page record's durable handle: the `stable_id` content field (which
261
262
  // survives the round trip), falling back to the `$id` transport handle.
262
- function recordStableId(record) {
263
+ export function recordStableId(record) {
263
264
  return record?.stable_id || record?.$id || null
264
265
  }
265
266
 
@@ -274,7 +275,7 @@ function recordStableId(record) {
274
275
  // collide on one filename (a collision would silently drop a section). The
275
276
  // `page.yml::sections:` leaf uses this same safe base so file resolution matches.
276
277
  const SAFE_STABLE_ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
277
- function safeStableIdFilename(stableId) {
278
+ export function safeStableIdFilename(stableId) {
278
279
  if (SAFE_STABLE_ID.test(stableId)) return stableId
279
280
  const base = stableId
280
281
  .replace(/[^A-Za-z0-9._-]+/g, '-')
@@ -361,7 +362,17 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
361
362
  if (record.layout !== undefined) y.layout = record.layout
362
363
  if (record.seo !== undefined) y.seo = record.seo
363
364
  if (record.fetch !== undefined) y.fetch = record.fetch
364
- if (sectionsArray && sectionsArray.length > 0) y.sections = sectionsArray
365
+ // `sections:` exists to preserve ORDER and NESTING, which the projected filenames
366
+ // can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
367
+ // decide MEMBERSHIP — and a bare list does: the collector reads a list without
368
+ // `...` as strict, "only listed sections processed". So a pulled page silently
369
+ // excluded any section added afterwards. You'd create the file, push, and be told
370
+ // "nothing to push", with nothing anywhere explaining why.
371
+ //
372
+ // The trailing `...` makes it inclusive: listed sections keep their order and
373
+ // their nesting, and anything new is discovered and appended as it would be in a
374
+ // page that was never pulled.
375
+ if (sectionsArray && sectionsArray.length > 0) y.sections = [...sectionsArray, '...']
365
376
  return y
366
377
  }
367
378
 
@@ -409,7 +420,7 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
409
420
  // `slug` is a localized `{lang: value}` map on the wire (a page route stays
410
421
  // localized); the directory uses the canonical SOURCE-locale slug. `param_name`
411
422
  // is already a plain string.
412
- function pageDirName(record, sourceLocale) {
423
+ export function pageDirName(record, sourceLocale) {
413
424
  const slug = unwrapLocalized(record.slug, sourceLocale)
414
425
  return record.is_dynamic ? `[${record.param_name || slug}]` : slug
415
426
  }