@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/src/uwx/site.js CHANGED
@@ -56,8 +56,7 @@ import {
56
56
  isIgnoredFolder,
57
57
  parseNumericPrefix,
58
58
  compareFilenames,
59
- parseWildcardArray,
60
- applyWildcardOrder,
59
+ orderFolders,
61
60
  processMarkdownFile,
62
61
  declaredFetch,
63
62
  checkDeclaration,
@@ -65,9 +64,11 @@ import {
65
64
  assertRouteFolder,
66
65
  mountEntriesOf,
67
66
  rootOrderConfig,
67
+ composeLocalizedRoute,
68
+ isValidSlugSegment,
68
69
  } from '../site/content-collector.js'
69
70
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
70
- import { readLayoutFolder } from '../site/layout-folder.js'
71
+ import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
71
72
  import { normalizeHideIn } from '../site/nav-visibility.js'
72
73
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
73
74
  import { emitEntitySyncPackage } from './entity-document.js'
@@ -75,6 +76,8 @@ import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeCon
75
76
  import { unwrapLocalized } from './backfill.js'
76
77
  import { loadFreeformTranslation } from '../i18n/freeform.js'
77
78
  import { resolveLocaleList } from '../i18n/locales.js'
79
+ import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
80
+ import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
78
81
  import { updateBackendState, readBackendState } from './sync-store.js'
79
82
  import { upsertYamlScalar } from './yaml-upsert.js'
80
83
  import { resolveQueriesConfig } from './queries-config.js'
@@ -142,6 +145,89 @@ function stripCredentials(block, label) {
142
145
  // processMarkdownFile only destructures type/component/preset/input/props/
143
146
  // fetch/data/id out of frontmatter, so `background:` and `theme:` stay
144
147
  // inside section.params — lift them into the entity type's dedicated fields.
148
+ // The `type` a push sends for a section whose file names none — the Model requires one. A pull does
149
+ // not write it back into a file that names none (`sectionRecordToFile`): absent, the foundation's
150
+ // `defaultSection` renders it, which is not always `Content`.
151
+ export const FILLED_SECTION_TYPE = 'Content'
152
+
153
+ const AUTHORED_SLUG = Symbol('authoredSlug')
154
+ const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
155
+
156
+ /**
157
+ * Post-pass: a page's localized URL segments ride in its localized `slug`, beside the source one —
158
+ * the slot the site-content Model declares for them. From the form the site keeps them in, read as
159
+ * the build reads it (`buildRouteTranslations`): the pages' own `slug: { <locale>: <segment> }` when
160
+ * any page declares one, else `site.yml`'s `i18n.routeTranslations`, a whole route per locale, taken
161
+ * apart into each page's segment. ⛔ Until 2026-09-26 only the source slug was sent, so a clone lost
162
+ * every localized URL — measured on the `international` template, whose `/acerca-de` came back as
163
+ * `/about`.
164
+ *
165
+ * A route whose localized form is not its parent's plus one segment cannot be said per page, and is
166
+ * named rather than dropped in silence. Mutates the records in place.
167
+ */
168
+ function localizeRouteSlugs(pages, siteYml, sourceLocale) {
169
+ const languages = Array.isArray(siteYml?.languages) ? new Set(siteYml.languages) : null
170
+ const carried = (locale, segment) =>
171
+ locale !== sourceLocale && (!languages || languages.has(locale)) && isValidSlugSegment(segment)
172
+ // Each page with the route the build gives it (`buildRouteOf`).
173
+ const visit = (records, parentRoute, fn) => {
174
+ for (const record of records || []) {
175
+ const route = buildRouteOf(record, parentRoute, sourceLocale)
176
+ fn(record, route, parentRoute)
177
+ visit(record.$children, route, fn)
178
+ }
179
+ }
180
+ let ownForm = false
181
+ visit(pages, '/', (record) => { if (record[AUTHORED_SLUG]) ownForm = true })
182
+ if (ownForm) {
183
+ visit(pages, '/', (record) => {
184
+ for (const [locale, segment] of Object.entries(record[AUTHORED_SLUG] || {})) {
185
+ if (carried(locale, segment)) record.slug[locale] = segment
186
+ }
187
+ })
188
+ return
189
+ }
190
+ const map = siteYml?.i18n?.routeTranslations
191
+ if (!isPlainRecord(map)) return
192
+ const segments = new Map() // canonical route → { locale: segment }, as the build composes them
193
+ visit(pages, '/', (record, route, parentRoute) => {
194
+ if (record.is_dynamic || record.is_index) return
195
+ for (const [locale, table] of Object.entries(map)) {
196
+ const display = isPlainRecord(table) ? table[route] : undefined
197
+ if (typeof display !== 'string') continue
198
+ const parts = display.split('/').filter(Boolean)
199
+ const segment = parts.pop()
200
+ if (`/${parts.join('/')}` !== composeLocalizedRoute(parentRoute, locale, segments)) {
201
+ console.warn(
202
+ `uwx/site: \`i18n.routeTranslations\` gives ${route} the ${locale} URL ${display}, which is not ` +
203
+ `its parent's plus one segment — it is not sent. Give the page \`slug: { ${locale}: … }\` instead.`
204
+ )
205
+ continue
206
+ }
207
+ if (!carried(locale, segment)) continue
208
+ record.slug[locale] = segment
209
+ segments.set(route, { ...(segments.get(route) || {}), [locale]: segment })
210
+ }
211
+ })
212
+ }
213
+
214
+ /**
215
+ * The route the build gives a page record: an index page takes its parent's, a parametric one is
216
+ * `:<param>` (`[...path]` is `:path*`), any other its slug under its parent. One rule, exported for the
217
+ * pull (`site-project.js`), which must address a page as the push does.
218
+ *
219
+ * @param {object} record - a page record (`slug`, `is_index`, `is_dynamic`)
220
+ * @param {string} parentRoute - its parent's route, `/` at the top
221
+ * @param {string} sourceLocale
222
+ * @returns {string}
223
+ */
224
+ export function buildRouteOf(record, parentRoute, sourceLocale) {
225
+ if (record?.is_index) return parentRoute
226
+ const slug = unwrapLocalized(record?.slug, sourceLocale)
227
+ const segment = record?.is_dynamic ? (slug === CATCH_ALL_MARKER ? ':path*' : `:${slug}`) : slug
228
+ return parentRoute === '/' ? `/${segment}` : `${parentRoute}/${segment}`
229
+ }
230
+
145
231
  // Post-pass: wrap every section's `content` (page sections + their `$children`,
146
232
  // recursing into child pages, plus layout sections) into per-locale form. Mutates
147
233
  // the records in place. Only called for multi-locale sites. Each target locale is
@@ -154,9 +240,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
154
240
 
155
241
  const localizeSection = async (record, page) => {
156
242
  if (!record.content) return
157
- let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations)
243
+ const stableId = record.stable_id || record.$id
244
+ // Resolved in the section's context, as the build resolves it (`translationContext`): a
245
+ // context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
246
+ // resolved with none, so an override never reached a backend.
247
+ let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
158
248
  if (page) {
159
- const section = { stableId: record.stable_id || record.$id }
249
+ const section = { stableId }
160
250
  for (const locale of targetLocales) {
161
251
  // loadFreeformTranslation returns { content, frontmatter, … } — the doc is `.content`.
162
252
  const body = (await loadFreeformTranslation(section, page, locale, freeformBase))?.content
@@ -176,19 +266,38 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
176
266
  if (Array.isArray(s.$children)) await visitSections(s.$children, page)
177
267
  }
178
268
  }
179
- const visitPages = async (pgs, routePrefix) => {
269
+ // A page is addressed by the route the build gives it (`buildRouteOf`): a free-form translation is
270
+ // looked up by it, and a translation's context names it. ⛔ Until 2026-09-26 this was the slug path,
271
+ // so a homepage was `home` where the build reads `/`, and its sections' free-form files were never sent.
272
+ const visitPages = async (pgs, parentRoute) => {
180
273
  for (const p of pgs || []) {
181
- // `slug` is a localized `{lang:value}` map; the free-form route is the
182
- // canonical (source-locale) path, so unwrap before building it.
183
- const slug = unwrapLocalized(p.slug, sourceLocale)
184
- const route = routePrefix ? `${routePrefix}/${slug}` : slug
274
+ const route = buildRouteOf(p, parentRoute, sourceLocale)
185
275
  const page = { route, id: p.stable_id }
276
+ localizePageMeta(p, route, sourceLocale, translations)
186
277
  if (Array.isArray(p.page_sections)) await visitSections(p.page_sections, page)
187
278
  if (Array.isArray(p.$children)) await visitPages(p.$children, route)
188
279
  }
189
280
  }
190
- await visitPages(pages, '')
191
- await visitSections(layoutSections, null) // layout sections have no free-form home
281
+ await visitPages(pages, '/')
282
+ // A layout area's section has its free-form home at the area's route, as the build reads it
283
+ // (`layoutAreaRoute`). ⛔ This said "layout sections have no free-form home" until 2026-09-26.
284
+ for (const s of layoutSections || []) await localizeSection(s, { route: layoutAreaRoute(s.layout_name, s.area || s.stable_id) })
285
+ }
286
+
287
+ // ⭐ A page's title, label, description and keywords are resolved in the page's context, as the
288
+ // build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
289
+ // page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
290
+ // override never reached a backend — nor, until the same day, an entry's default.
291
+ function localizePageMeta(page, route, sourceLocale, translations) {
292
+ const context = { page: route, section: '_meta' }
293
+ const again = (value) =>
294
+ value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
295
+ ? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
296
+ : value
297
+ for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
298
+ if (page[key] !== undefined) page[key] = again(page[key])
299
+ }
300
+ if (Array.isArray(page.keywords)) page.keywords = page.keywords.map(again)
192
301
  }
193
302
 
194
303
  function mapSectionData(section) {
@@ -198,7 +307,7 @@ function mapSectionData(section) {
198
307
  delete params.background
199
308
  delete params.theme
200
309
 
201
- const data = { type: section.type || 'Content' } // entity type requires `type`
310
+ const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
202
311
  setIf(data, 'stable_id', section.stableId ?? undefined)
203
312
  setIf(data, 'preset', section.preset ?? undefined)
204
313
  setIf(data, 'input', section.input ?? undefined)
@@ -212,13 +321,13 @@ function mapSectionData(section) {
212
321
  }
213
322
 
214
323
  function buildPageData(config, ctx) {
215
- const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
324
+ const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where, pageFields } =
216
325
  ctx
217
326
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
218
- // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
219
- // site emits one entry (`{ en: "home" }`); per-locale slug overrides for
220
- // multi-locale localized routes (from i18n.routeTranslations) are follow-on
221
- // producer work. `mode` is the plain delivery mode.
327
+ // site-content Model declares it localized; greenlit 2026-06-13). The source
328
+ // entry is the folder name; the other locales' segments are added by
329
+ // `localizeRouteSlugs`, once the whole tree is known. `mode` is the plain
330
+ // delivery mode.
222
331
  const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
223
332
  setIf(data, 'stable_id', config.id)
224
333
  setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
@@ -249,7 +358,13 @@ function buildPageData(config, ctx) {
249
358
  setIf(data, 'redirect', config.redirect)
250
359
  setIf(data, 'rewrite', config.rewrite)
251
360
  setIf(data, 'layout', config.layout)
252
- setIf(data, 'seo', config.seo)
361
+ // ⭐ Its Open Graph title and description per language, where the deployment's page takes them
362
+ // (`uwx/open-graph.js`) — out of `seo`, which travels as one object. ⛔ Until 2026-09-26 they rode
363
+ // inside `seo` only, in the source language, though the build translates them.
364
+ const og = splitOpenGraph(config.seo, pageFields, sourceLocale, translations)
365
+ setIf(data, 'og_title', og.og_title)
366
+ setIf(data, 'og_description', og.og_description)
367
+ setIf(data, 'seo', og.seo)
253
368
  // ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
254
369
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
255
370
  // carried one dataset for a page that asked for several.
@@ -351,21 +466,14 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
351
466
  order: typeof config.order === 'number' ? config.order : undefined,
352
467
  })
353
468
  }
354
- // Mirror content-collector's pageFolders sort: explicit order, then
355
- // numeric-prefix filename order.
356
- folders.sort(
357
- (a, b) =>
358
- (a.order ?? Infinity) - (b.order ?? Infinity) ||
359
- compareFilenames(a.dirName, b.dirName)
360
- )
361
- // Then the parent's `pages:` wildcard, exactly as a normal build applies it.
362
- if (Array.isArray(parentConfig?.pages)) {
363
- const parsed = parseWildcardArray(parentConfig.pages)
364
- if (parsed && parsed.mode !== 'all') {
365
- return applyWildcardOrder(folders, parsed)
366
- }
367
- }
368
- return folders
469
+ // Explicit order, then numeric-prefix filename order, then the parent's `pages:` wildcard —
470
+ // one function, which the pull reproduces a level's order with too (`site-project.js`).
471
+ // ⚠️ It does NOT match the build's own page-folder sort in one case, and the note that said
472
+ // "mirror content-collector's pageFolders sort" here read as if it did: the collector compares
473
+ // a level's folder names whole, as strings, and matches `pages:` against them whole, where
474
+ // this strips a numeric prefix (`2-about` is `about`, and `10-` sorts after `2-`). Only a level
475
+ // with numbered page folders can tell them apart, and which rule is right for them is open.
476
+ return orderFolders(folders, parentConfig?.pages)
369
477
  }
370
478
 
371
479
  const DYNAMIC_RE = /^\[(.+)\]$/
@@ -630,7 +738,7 @@ const REPUBLISH_CLAUSE =
630
738
  // Recursively build the `pages` tree: each record carries its fields, its inline
631
739
  // `page_sections` (page mode only), and its child pages under `$children`.
632
740
  async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
633
- const { siteRoot, siteIndex, sourceLocale, translations } = ctx
741
+ const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
634
742
  const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
635
743
  const out = []
636
744
  // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
@@ -664,11 +772,15 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
664
772
  siteIndex,
665
773
  sourceLocale,
666
774
  translations,
775
+ pageFields,
667
776
  })
668
777
  // `$id` is the stableId when authored (rename-stable), else the slug (the
669
778
  // natural handle — spec default). The path is NEVER the identity.
670
779
  const id = data.stable_id || slug
671
780
  const record = withIdentity(id, data)
781
+ // A page's own localized URL segments, for `localizeRouteSlugs` — under a symbol, so they
782
+ // are never serialized as a field of the record.
783
+ if (!dyn && isPlainRecord(f.config?.slug)) record[AUTHORED_SLUG] = f.config.slug
672
784
 
673
785
  if (mode === 'page') {
674
786
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
@@ -878,7 +990,9 @@ const DECL_EMITTED_ABOVE = new Set([
878
990
  'limit',
879
991
  'excerpt',
880
992
  'deferred',
881
- 'queryable'
993
+ 'queryable',
994
+ // Derived by the rule above, never taken from a file.
995
+ 'typed_by_data_key',
882
996
  ])
883
997
 
884
998
  const DECL_NOT_ON_WIRE = new Set([
@@ -920,7 +1034,7 @@ function externalSource(d) {
920
1034
  return source
921
1035
  }
922
1036
 
923
- function queriesNested(declarations, uuids = null, scope = null, keyTyped = null) {
1037
+ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null) {
924
1038
  const out = []
925
1039
  for (const [name, d] of Object.entries(declarations)) {
926
1040
  refuseUnder(d.where, `queries.${name}`)
@@ -939,8 +1053,13 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
939
1053
  // empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
940
1054
  // A query whose name-defaulted schema stands for the type of the data key of its name
941
1055
  // names that type — the data schema its records are sent as (`data-key-types.js`).
942
- const schema = (!d.schemaExplicit && keyTyped?.get(d.schema)) || d.schema
1056
+ const typed = (!d.schemaExplicit && keyTyped?.get(d.schema)) || null
1057
+ const schema = typed || d.schema
943
1058
  setIf(data, 'schema', resolveSelfScope(schema, scope))
1059
+ // ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
1060
+ // one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
1061
+ // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
1062
+ if (typed && Array.isArray(queryFields) && queryFields.includes('typed_by_data_key')) data.typed_by_data_key = true
944
1063
  setIf(data, 'sort', d.sort)
945
1064
  // Legacy `filter:` is not synced — it is translated to `where` upstream
946
1065
  // (the canonical predicate). No legacy fields on the wire.
@@ -1136,7 +1255,7 @@ function secretsNested(provisioned) {
1136
1255
  // ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
1137
1256
  // branches is the other half, and `producer-list-drift.test.js` fails if a key
1138
1257
  // emitted here has neither.
1139
- function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations } = {}) {
1258
+ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null, settingsFields = null } = {}) {
1140
1259
  const settings = {}
1141
1260
 
1142
1261
  // Site-wide values an author declares once and references from page content as
@@ -1170,7 +1289,13 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1170
1289
  // ⇒ `settings` is where they live. `publish_languages` rides VERBATIM, dangling
1171
1290
  // codes included: sync carries the full working set and only *publish* filters,
1172
1291
  // which preserves a locale's publish intent across a remove + re-add in `languages:`.
1173
- setIf(settings, 'languages', siteYml.languages)
1292
+ // ⭐ A site that declares no `languages:` has the languages its translation files make — the build's
1293
+ // rule (`i18n/locales.js`) — and sends that list (`languages`, below), as it sends a derived
1294
+ // `deferred:`: a host may serve a site in the languages it declares and no others, and the site's
1295
+ // localized URLs with them. The pull does not write it back where the files already say it
1296
+ // (`siteContentDocumentToProject`). ⛔ Until 2026-09-26 only a declared list was sent, so the
1297
+ // `international` template, which declares none, sent no language but its source.
1298
+ setIf(settings, 'languages', siteYml.languages ?? languages ?? undefined)
1174
1299
  setIf(settings, 'default_language', siteYml.defaultLanguage)
1175
1300
  setIf(settings, 'publish_languages', siteYml.publishLanguages)
1176
1301
 
@@ -1179,7 +1304,12 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1179
1304
  // (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
1180
1305
  // ever passed the card test because `image` was inside it; the card's picture is
1181
1306
  // `info.preview` now.
1182
- setIf(settings, 'seo', siteYml.seo)
1307
+ // The site's Open Graph title and description per language, where the deployment's `settings`
1308
+ // takes them (`uwx/open-graph.js`), in the site's context (`SITE_META_CONTEXT`).
1309
+ // (Each read inline from `siteYml.seo`, so the emit surface names its source.)
1310
+ setIf(settings, 'og_title', openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
1311
+ setIf(settings, 'og_description', openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
1312
+ setIf(settings, 'seo', seoBesideOpenGraph(siteYml.seo, settingsFields))
1183
1313
  // ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
1184
1314
  // (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
1185
1315
  // convenience, not because it is a different kind of thing. Localized, so it
@@ -1461,7 +1591,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1461
1591
  // (uwx-format.md → info.url.)
1462
1592
 
1463
1593
  // `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
1464
- const ctx = { siteRoot, sourceLocale, translations }
1594
+ // …and the keys this deployment's page takes (`siteContent.pageFields`), for its Open Graph texts.
1595
+ const ctx = { siteRoot, sourceLocale, translations, pageFields: opts.pageFields }
1465
1596
  // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1466
1597
  // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1467
1598
  // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
@@ -1506,6 +1637,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1506
1637
  // — as the build does — pushed no homepage at all.
1507
1638
  const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1508
1639
  pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1640
+ localizeRouteSlugs(pages, siteYml, sourceLocale)
1509
1641
  }
1510
1642
 
1511
1643
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
@@ -1539,7 +1671,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1539
1671
  doc.$schema = SITE_MODEL_NAME
1540
1672
  doc.info = info
1541
1673
  // Emitted only when the file declares something — see `settingsNested`.
1542
- const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations })
1674
+ // The languages the site's translation files make, for a site that declares none (`settingsNested`).
1675
+ const languages = targetLocales.length > 0 ? [sourceLocale, ...targetLocales] : null
1676
+ const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages, settingsFields: opts.settingsFields })
1543
1677
  if (settings) doc.settings = settings
1544
1678
  doc.pages = pages
1545
1679
  doc.layout_sections = layoutSections
@@ -1552,7 +1686,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1552
1686
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1553
1687
  // consumer sees, leave the identifier alone.
1554
1688
  const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1555
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped)
1689
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped, opts.queryFields)
1556
1690
  // Emitted ONLY when the file declares the key — see the header above
1557
1691
  // `serviceRecords`: on a replaced Section, absent and empty are different
1558
1692
  // requests and one of them is destructive.
@@ -359,6 +359,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
359
359
  // …and the same data schema, where a name stands for the type of its data key
360
360
  // (`@/team` → `@/member`, `data-key-types.js`): decided once, by the records.
361
361
  keyTyped: col.keyTyped,
362
+ // …and whether this deployment takes the key that says so (`siteContent.queryFields`) —
363
+ // and the page and `settings` keys that carry Open Graph texts per language.
364
+ ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
365
+ ...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
366
+ ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
362
367
  // Withhold the `$services`/`$secrets` Sections when the caller has
363
368
  // determined the file is not asking for anything new by them. Passed
364
369
  // through rather than decided here: the last-agreed state is project
@@ -517,6 +522,21 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
517
522
  }))
518
523
  const changedRecords = recordChanged.filter((r) => r.changed)
519
524
 
525
+ // ⛔ A RECORD THAT LOST ITS IDENTITY IS NOT UNCHANGED. Its hash was banked for this backend,
526
+ // so a push to it succeeded — and wrote the record's `$uuid` into its file — yet it goes with no
527
+ // `$uuid` now: the line is gone from the file. Skipped as unchanged, the folder still named it,
528
+ // by `$ref`, and the backend refused a reference to a record the package does not carry
529
+ // (measured 2026-09-26); sent, it would be a second record. A pull writes it back.
530
+ const refusals = [...(col.refusals || [])]
531
+ for (const r of recordChanged) {
532
+ if (r.changed || r.entity.document.$uuid) continue
533
+ refusals.push(
534
+ `${r.entity.id}: its \`$uuid\` is gone from its file, though it was pushed to this backend ` +
535
+ 'before — sent as it is, it would be a second record. Run `uniweb pull`, which writes it ' +
536
+ 'back, then push.'
537
+ )
538
+ }
539
+
520
540
  let records = null
521
541
  // ⭐ A RECORD THAT NAMES A RECORD THIS PUSH CREATES names it by `$ref`
522
542
  // (`records.js::encodeReference`), and the backend stores the uuid it mints for it. The
@@ -572,6 +592,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
572
592
  // banked is then, by construction, what was hashed. A future injection is covered
573
593
  // by adding one line here — not by finding every reader.
574
594
  const applied = {
595
+ ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
596
+ ...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
597
+ ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
575
598
  ...(assetRewrite ? { assetRewrite } : {}),
576
599
  ...(assetIds ? { assetIds } : {}),
577
600
  ...(injectInfo ? { injectInfo } : {}),
@@ -582,7 +605,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
582
605
  siteContent, records, siteContentUuid, hashes, warnings, skipped,
583
606
  // Records that name a record this push creates — see `namesNew` above.
584
607
  namesNew,
585
- refusals: col.refusals || [],
608
+ refusals,
586
609
  schemaless: col.schemaless, localAssets, applied,
587
610
  // { stamped, unknown } when identity was applied; null when the caller passed
588
611
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
@@ -206,10 +206,19 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
206
206
  // (pass 2). A list root was deferred here until then, though the query's records
207
207
  // are exactly that list.
208
208
 
209
+ // ⭐ WHAT THE QUERY'S RECORDS ARE, for a key typed by a schema whose root is a list: the
210
+ // list's ITEMS when the query selects other records (`@/link` records feeding `@/menu`), but
211
+ // ENTITIES of the list schema — each holding a whole list — when the query names that schema
212
+ // itself (`menus: { schema: '@/menu' }`). ⛔ Until 2026-09-26 both were checked as items, so
213
+ // every query over a list schema failed ("missing required field '[0].label'") and a push,
214
+ // which gates on this check, stopped.
215
+ const decl = typeof input.query === 'string' ? config.queries?.[input.query] : null
216
+ const querySchema = decl ? decl.schema ?? keyTypes.get(input.query) ?? `@/${input.query}` : undefined
217
+
209
218
  const pairKey = `${input.path} ${ref}`
210
219
  let entry = work.get(pairKey)
211
220
  if (!entry) {
212
- entry = { path: input.path, ref, schema, users: [] }
221
+ entry = { path: input.path, ref, schema, users: [], entities: querySchema === ref }
213
222
  work.set(pairKey, entry)
214
223
  }
215
224
  entry.users.push({ route: page.route, section: type, key })
@@ -234,10 +243,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
234
243
  schemasSeen.add(entry.ref)
235
244
  const items = Array.isArray(records) ? records : [records]
236
245
 
237
- // A schema whose ROOT IS A LIST describes the whole value the key receives, and the
238
- // query's records ARE that list — so they are checked once, as the list. Any other
239
- // schema describes one record, checked per record.
240
- if (rootListSection(entry.schema)) {
246
+ // A schema whose ROOT IS A LIST describes the whole value the key receives, and a query of
247
+ // other records delivers that list — so they are checked once, as the list. Any other
248
+ // schema describes one record, checked per record — and so are the entities of a list
249
+ // schema a query of that schema delivers, each holding its list (`validateItem`).
250
+ if (rootListSection(entry.schema) && !entry.entities) {
241
251
  recordCount += items.length
242
252
  const base = { file: entry.path, schema: entry.ref, users: entry.users }
243
253
  for (const finding of validateBound(entry.schema, items)) {