@uniweb/build 0.64.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.64.0",
3
+ "version": "0.64.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
+ "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/content-writer": "^0.3.4",
60
62
  "@uniweb/projections": "^0.7.4",
61
63
  "@uniweb/semantic-parser": "^1.4.1",
62
- "@uniweb/schemas": "^0.7.0",
63
- "@uniweb/theming": "^0.1.15",
64
- "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/content-reader": "^1.2.5"
64
+ "@uniweb/schemas": "^0.7.1",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.5"
68
+ "@uniweb/runtime": "^0.27.6"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.30.0",
78
- "@uniweb/runtime": "^0.27.5"
77
+ "@uniweb/core": "^0.30.1",
78
+ "@uniweb/runtime": "^0.27.6"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -30,6 +30,9 @@ try {
30
30
  export function extractTranslatableContent(siteContent) {
31
31
  const units = {}
32
32
 
33
+ // The site's own Open Graph title and description (`site.yml` `seo:`), in the site's context.
34
+ extractFromSiteMeta(siteContent.config, units)
35
+
33
36
  for (const page of siteContent.pages || []) {
34
37
  const pageRoute = page.route || '/'
35
38
 
@@ -82,6 +85,22 @@ export function extractTranslatableContent(siteContent) {
82
85
  * @param {string} pageRoute - Page route
83
86
  * @param {Object} units - Units accumulator
84
87
  */
88
+ /**
89
+ * The context of the site's own metadata — its Open Graph title and description (`site.yml` `seo:`),
90
+ * which a push sends per language as `settings.og_title` / `og_description` (`uwx/open-graph.js`).
91
+ * A page's is `<route>:_meta`; the site's is this one.
92
+ */
93
+ export const SITE_META_CONTEXT = Object.freeze({ page: '/', section: '_site' })
94
+
95
+ function extractFromSiteMeta(config, units) {
96
+ const seo = config?.seo
97
+ if (!seo || typeof seo !== 'object') return
98
+ if (typeof seo.ogTitle === 'string' && seo.ogTitle) addUnit(units, seo.ogTitle, 'site.seo.ogTitle', SITE_META_CONTEXT)
99
+ if (typeof seo.ogDescription === 'string' && seo.ogDescription) {
100
+ addUnit(units, seo.ogDescription, 'site.seo.ogDescription', SITE_META_CONTEXT)
101
+ }
102
+ }
103
+
85
104
  function extractFromPageMeta(page, pageRoute, units) {
86
105
  // Use special section identifier for page-level metadata
87
106
  const context = { page: pageRoute, section: '_meta' }
package/src/i18n/merge.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
- import { elementText, blockElements, translationContext } from './extract.js'
17
+ import { elementText, blockElements, translationContext, SITE_META_CONTEXT } from './extract.js'
18
18
  import { visitDataStrings } from './data-strings.js'
19
19
 
20
20
  // Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
@@ -71,6 +71,7 @@ export function mergeTranslations(siteContent, translations, options = {}) {
71
71
  function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
72
72
  // Deep clone to avoid mutating original
73
73
  const translated = JSON.parse(JSON.stringify(siteContent))
74
+ translateSiteMeta(translated.config, translations, fallbackToSource)
74
75
 
75
76
  for (const page of translated.pages || []) {
76
77
  const pageRoute = page.route || '/'
@@ -120,6 +121,7 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
120
121
 
121
122
  // Deep clone to avoid mutating original
122
123
  const translated = JSON.parse(JSON.stringify(siteContent))
124
+ translateSiteMeta(translated.config, translations, fallbackToSource)
123
125
 
124
126
  for (const page of translated.pages || []) {
125
127
  const pageRoute = page.route || '/'
@@ -222,6 +224,18 @@ function toPlainText(value) {
222
224
  * Every field here is a PLAIN-TEXT slot — `<title>`, a nav label, a meta
223
225
  * description — so each value is flattened. See `toPlainText`.
224
226
  */
227
+ // The site's own Open Graph title and description (`config.seo`), in the site's context — as a page's
228
+ // are in its own (`translatePageMeta`).
229
+ function translateSiteMeta(config, translations, fallbackToSource) {
230
+ const seo = config?.seo
231
+ if (!seo || typeof seo !== 'object') return
232
+ for (const key of ['ogTitle', 'ogDescription']) {
233
+ if (typeof seo[key] === 'string' && seo[key]) {
234
+ seo[key] = lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource)
235
+ }
236
+ }
237
+ }
238
+
225
239
  function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
226
240
  const context = { page: pageRoute, section: '_meta' }
227
241
  const translate = (value) =>
@@ -23,7 +23,7 @@ import { computeHash } from './hash.js'
23
23
  import { loadFreeformRecord } from './freeform.js'
24
24
  import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
25
25
  import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
26
- import { toDataSchemaDeclaration, isProseMirrorField } from '../uwx/data-schema.js'
26
+ import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
27
27
  import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
28
28
  import { resolveDocForLocale } from './merge.js'
29
29
  import { extractUnitsFromDoc } from './extract.js'
@@ -224,6 +224,10 @@ function eachModelField(record, model, visit) {
224
224
  function eachSectionValue(value, section, path, visit) {
225
225
  if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
226
226
  if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
227
+ // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
228
+ else if (isOpenMapSection(section) && isPlainObject(value)) {
229
+ for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
230
+ }
227
231
  }
228
232
 
229
233
  function eachSectionField(holder, fields, path, visit) {
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { isRichSchema } from '@uniweb/core'
28
28
  import { deliveredFields } from '@uniweb/schemas/conform'
29
+ import { enumValues } from '@uniweb/schemas/format'
29
30
 
30
31
  /**
31
32
  * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
@@ -70,7 +71,8 @@ function extractSchemaField(field) {
70
71
  // inert at runtime but carried through.
71
72
  if (field.type) lean.type = field.type
72
73
  if (field.default !== undefined) lean.default = field.default
73
- if (field.enum !== undefined) lean.enum = field.enum
74
+ // The values only: an entry's label (`{ value, label }`) is an editor's, and the runtime compares.
75
+ if (field.enum !== undefined) lean.enum = enumValues(field.enum)
74
76
  if (field.options) lean.options = field.options
75
77
 
76
78
  // Nested object → recurse into its field map.
@@ -31,7 +31,8 @@
31
31
  import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
33
  import { recordLayout } from '@uniweb/schemas/conform'
34
- import { detectFoundationType } from './foundation-ref.js'
34
+ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
35
+ import { readRegisteredFoundation } from './registered-foundation.js'
35
36
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
36
37
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
37
38
  import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
@@ -457,6 +458,12 @@ export function foundationDataSchemas(siteRoot, siteYml) {
457
458
  */
458
459
  export function foundationSchemaJson(siteRoot, siteYml) {
459
460
  if (!siteYml?.foundation) return null
461
+ // ⭐ A foundation named by catalog ref — every clone's — is read from the registered version the
462
+ // project keeps (`registered-foundation.js`): its section types, with their `data:`, and no data
463
+ // schemas, which travel as Models. Null until a push or a pull has read it.
464
+ if (parseCatalogRef(siteYml.foundation)) {
465
+ return readRegisteredFoundation(siteRoot, siteYml.foundation)?.schema || null
466
+ }
460
467
  let info
461
468
  try {
462
469
  info = detectFoundationType(siteYml.foundation, siteRoot)
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A REGISTERED FOUNDATION, AS A PROJECT THAT NAMES IT BY CATALOG REF KEEPS IT.
3
+ *
4
+ * A site whose foundation is a catalog ref (`@acme/fnd@1.2.0`) — every clone — has no build of it
5
+ * to read from. What the project's own tools need from it is its section types' `data:`, which is
6
+ * what types a query named for a data key (`uwx/data-key-types.js`). The CLI reads the registered
7
+ * version from the backend the site is on and keeps the reply here, under `.uniweb/`; the build,
8
+ * the push and the pull read it back through `foundationSchemaJson`.
9
+ *
10
+ * ⭐ A registered version never changes, so the copy never goes stale, and losing it costs a round
11
+ * trip, never a wrong answer: the next push or pull reads it again. That is what makes `.uniweb/`
12
+ * (gitignored, per copy) the right home for it.
13
+ *
14
+ * The reply is kept whole, as the backend answered it — its `schema` is the foundation schema a
15
+ * build would emit, less its data schemas, which travel as Models.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { parseCatalogRef } from './foundation-ref.js'
20
+
21
+ /** Where a project keeps a registered foundation's reply — null for anything but a catalog ref. */
22
+ export function registeredFoundationPath(siteRoot, ref) {
23
+ const parsed = parseCatalogRef(ref)
24
+ if (!parsed) return null
25
+ return join(siteRoot, '.uniweb', 'foundations', parsed.scope, `${parsed.name}@${parsed.version}.json`)
26
+ }
27
+
28
+ /** The kept reply for a catalog ref, or null when the project keeps none. */
29
+ export function readRegisteredFoundation(siteRoot, ref) {
30
+ const path = registeredFoundationPath(siteRoot, ref)
31
+ if (!path || !existsSync(path)) return null
32
+ try {
33
+ const reply = JSON.parse(readFileSync(path, 'utf8'))
34
+ return reply && typeof reply === 'object' ? reply : null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** Keep a registered foundation's reply. Returns whether it was written. */
41
+ export function writeRegisteredFoundation(siteRoot, ref, reply) {
42
+ const path = registeredFoundationPath(siteRoot, ref)
43
+ if (!path || !reply || typeof reply !== 'object') return false
44
+ mkdirSync(dirname(path), { recursive: true })
45
+ writeFileSync(path, JSON.stringify(reply, null, 2) + '\n')
46
+ return true
47
+ }
@@ -32,7 +32,7 @@ import yaml from 'js-yaml'
32
32
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
33
33
  import { proseMirrorToMarkdown } from '@uniweb/content-writer'
34
34
  import { parseFrontmatter } from './entity-source.js'
35
- import { isProseMirrorField } from './data-schema.js'
35
+ import { isProseMirrorField, isOpenMapSection, openMapFromRows } from './data-schema.js'
36
36
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
37
37
  import { unwrapLocalizedContent } from './locale-sync.js'
38
38
  import { parseBibtex, exportBibtex } from '@citestyle/bibtex'
@@ -307,7 +307,7 @@ function decodeRecord(fields, value, dec, sectionName) {
307
307
  const raw = value[key]
308
308
  if (raw === undefined) continue
309
309
  if (field?.type === 'section') {
310
- out[key] = field.multiple === true ? decodeList(field, raw, dec) : decodeRecord(field.fields, raw, dec, null)
310
+ out[key] = field.multiple === true ? decodeSectionList(field, raw, dec) : decodeRecord(field.fields, raw, dec, null)
311
311
  continue
312
312
  }
313
313
  const isBody = Boolean(dec.bodyAt) && sectionName === dec.bodyAt.section && key === dec.bodyAt.key
@@ -318,6 +318,14 @@ function decodeRecord(fields, value, dec, sectionName) {
318
318
  return out
319
319
  }
320
320
 
321
+ // A `many` field's records — an OPEN MAP's written back as the author's map, keyed by each row's
322
+ // `name` (`isOpenMapSection`), which is what the push reads a map from. Rows that cannot be a map
323
+ // stay a list, which the push reads too.
324
+ function decodeSectionList(def, value, dec) {
325
+ const list = decodeList(def, value, dec)
326
+ return (isOpenMapSection(def) && openMapFromRows(list)) || list
327
+ }
328
+
321
329
  // The records of a `many` section; a self-nesting one's `$children` nest back under
322
330
  // `children:`, the reserved key the push reads them from.
323
331
  function decodeList(def, value, dec) {
@@ -332,8 +340,11 @@ function decodeList(def, value, dec) {
332
340
  }
333
341
 
334
342
  // A leaf's authored value. A localized SCALAR's target locales are captured into the
335
- // collector (the content body's are captured by `unwrapLocalizedContent`, with its
336
- // free-form path); a list of localized values is unwrapped element by element.
343
+ // collector — a markdown body's too, keyed by its whole text as the records extractor keys it
344
+ // (a ProseMirror body's are captured by `unwrapLocalizedContent`, with its free-form path); a
345
+ // list of localized values is unwrapped element by element. ⛔ Until 2026-09-26 a body was
346
+ // taken to be ProseMirror, so a markdown one's translations were captured nowhere and a
347
+ // clone rendered it in the source language.
337
348
  function decodeLeaf(raw, field, dec, isBody) {
338
349
  if (field?.type === 'entity_ref') return decodeReference(raw, field, dec)
339
350
  if (isProseMirrorField(field)) {
@@ -344,10 +355,10 @@ function decodeLeaf(raw, field, dec, isBody) {
344
355
  }
345
356
  if (!field?.localized) return raw
346
357
  if (Array.isArray(raw)) {
347
- if (!isBody) for (const item of raw) dec.collector?.add(item, dec.context)
358
+ for (const item of raw) dec.collector?.add(item, dec.context)
348
359
  return unwrapLocalizedList(raw, dec.sourceLocale)
349
360
  }
350
- if (!isBody) dec.collector?.add(raw, dec.context)
361
+ dec.collector?.add(raw, dec.context)
351
362
  return unwrapLocalized(raw, dec.sourceLocale)
352
363
  }
353
364
 
@@ -37,6 +37,40 @@ const CONTENT_TEXT_FORMATS = new Set(['markdown', 'html'])
37
37
  // declares its own `name` is an error instead, which names the problem precisely.
38
38
  const OPEN_MAP_KEY = 'name'
39
39
 
40
+ /**
41
+ * ⭐ AN OPEN MAP'S LOWERED SECTION. `values:` lowers to rows (`lowerField`): a `multiple` section
42
+ * whose required string `name` — the map's key — is made each row's identity by a `unique_field`
43
+ * rule. A record FILE holds it as the author wrote it, a map from key to value; the WIRE holds the
44
+ * rows. This is the one test, read off the declaration alone, because a pull into a clone has
45
+ * nothing else: the push turns a file's map into rows by it, and the pull the rows back into a map.
46
+ */
47
+ export function isOpenMapSection(def) {
48
+ if (def?.multiple !== true || def.self_nesting === true) return false
49
+ const key = def.fields?.[OPEN_MAP_KEY]
50
+ if (key?.type !== 'string' || key.required !== true || key.localized === true) return false
51
+ return (def.constraints || []).some((c) => c?.kind === 'unique_field' && c.field === OPEN_MAP_KEY && c.scope === 'section')
52
+ }
53
+
54
+ /** A file's open map as the rows it lowers to, in the map's key order: each key is its row's `name`. */
55
+ export function openMapEntries(map) {
56
+ return Object.entries(map).map(([key, value]) => ({ key, value, row: { ...value, [OPEN_MAP_KEY]: key } }))
57
+ }
58
+
59
+ /**
60
+ * Rows back as the map a file holds, or null when they cannot be one — a row with no key, or a key
61
+ * two rows share — so a caller keeps them a list, which a push reads too, and nothing is lost.
62
+ */
63
+ export function openMapFromRows(rows) {
64
+ const map = {}
65
+ for (const row of rows || []) {
66
+ const key = row?.[OPEN_MAP_KEY]
67
+ if (typeof key !== 'string' || key === '' || Object.hasOwn(map, key)) return null
68
+ const { [OPEN_MAP_KEY]: _key, ...value } = row
69
+ map[key] = value
70
+ }
71
+ return map
72
+ }
73
+
40
74
  /**
41
75
  * A `text` field marked as rich content (`format: markdown` or `html`): the
42
76
  * file-based body target. Round-trips as the raw source string — what the retired
@@ -74,6 +74,12 @@
74
74
  "layout": [
75
75
  "site.yml::layout"
76
76
  ],
77
+ "og_description": [
78
+ "site.yml::seo"
79
+ ],
80
+ "og_title": [
81
+ "site.yml::seo"
82
+ ],
77
83
  "paths": [
78
84
  "site.yml::paths"
79
85
  ],
package/src/uwx/index.js CHANGED
@@ -179,3 +179,12 @@ export {
179
179
  // is the foundation-less variant (only data-schema entities) for a schemas-only package.
180
180
  export { buildRegistryPackage, buildSchemaOnlyPackage } from './registry-package.js'
181
181
  export { toDataSchemaDeclaration } from './data-schema.js'
182
+
183
+ // A registered foundation a project names by catalog ref — a clone's — kept under `.uniweb/` as the
184
+ // backend answered for it, so the build, the push and the pull can read its section types.
185
+ export {
186
+ registeredFoundationPath,
187
+ readRegisteredFoundation,
188
+ writeRegisteredFoundation,
189
+ } from '../site/registered-foundation.js'
190
+ export { parseCatalogRef } from '../site/foundation-ref.js'
@@ -0,0 +1,69 @@
1
+ // ⭐ A PAGE'S OR A SITE'S OPEN GRAPH TITLE AND DESCRIPTION, PER LANGUAGE — `og_title` and
2
+ // `og_description` on the wire, `seo.ogTitle` / `seo.ogDescription` in a file.
3
+ //
4
+ // A file keeps them inside `seo`, whose other keys are crawler and sitemap directives a deployment
5
+ // stores as one object. Where the deployment's page — or `settings` — Section declares the two keys
6
+ // (`GET /dev/config` → `siteContent.pageFields` / `settingsFields`), a push sends them per language
7
+ // through those keys and the rest of `seo` as it is; elsewhere they ride inside `seo` as authored,
8
+ // in the source language only. A pull puts them back inside `seo`.
9
+ import { localizeScalar } from './locale-sync.js'
10
+ import { unwrapLocalized } from './backfill.js'
11
+
12
+ // wire key → the key inside a file's `seo`
13
+ const OPEN_GRAPH_KEYS = [
14
+ ['og_title', 'ogTitle'],
15
+ ['og_description', 'ogDescription'],
16
+ ]
17
+
18
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
19
+
20
+ /** Whether a Section's declared keys take the two. */
21
+ export function takesOpenGraph(fields) {
22
+ return Array.isArray(fields) && OPEN_GRAPH_KEYS.every(([wire]) => fields.includes(wire))
23
+ }
24
+
25
+ /**
26
+ * A file's `seo` as a push sends it: `{ og_title?, og_description?, seo? }`. The two are taken out
27
+ * of `seo` and localized only where the Section takes them; `seo` is left out when nothing else is
28
+ * in it. A value that is not a string stays where it is.
29
+ */
30
+ export function splitOpenGraph(seo, fields, sourceLocale, translations, context = null) {
31
+ if (!takesOpenGraph(fields) || !isRecord(seo)) return { seo }
32
+ const rest = { ...seo }
33
+ const out = {}
34
+ for (const [wire, key] of OPEN_GRAPH_KEYS) {
35
+ if (typeof seo[key] !== 'string') continue
36
+ out[wire] = localizeScalar(seo[key], sourceLocale, translations, context)
37
+ delete rest[key]
38
+ }
39
+ out.seo = Object.keys(rest).length ? rest : undefined
40
+ return out
41
+ }
42
+
43
+ /** One of the two, as a push sends it — or undefined where the Section does not take them. */
44
+ export function openGraphValue(seo, wire, fields, sourceLocale, translations, context = null) {
45
+ return splitOpenGraph(seo, fields, sourceLocale, translations, context)[wire]
46
+ }
47
+
48
+ /** `seo` as a push sends it beside the two — without them where the Section takes them. */
49
+ export function seoBesideOpenGraph(seo, fields) {
50
+ return splitOpenGraph(seo, fields, null, null).seo
51
+ }
52
+
53
+ /**
54
+ * A document's `og_title` / `og_description` folded back into a file's `seo` — the source
55
+ * language's value — with the other languages handed to `collector` in `context`. Returns `seo`
56
+ * unchanged when the holder carries neither, and undefined for an empty result.
57
+ */
58
+ export function foldOpenGraph(holder, seo, sourceLocale, collector = null, context = null) {
59
+ if (!OPEN_GRAPH_KEYS.some(([wire]) => holder?.[wire] !== undefined)) return seo
60
+ const out = isRecord(seo) ? { ...seo } : {}
61
+ for (const [wire, key] of OPEN_GRAPH_KEYS) {
62
+ const value = holder?.[wire]
63
+ if (value === undefined) continue
64
+ collector?.add(value, context)
65
+ const source = unwrapLocalized(value, sourceLocale)
66
+ if (typeof source === 'string' && source !== '') out[key] = source
67
+ }
68
+ return Object.keys(out).length ? out : undefined
69
+ }
@@ -201,10 +201,13 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
201
201
  // is not re-serialized: the writer's markdown differs from an author's in blank lines and line breaks,
202
202
  // and a pull that changed nothing reformatted every section it touched (measured 2026-09-26 on the
203
203
  // `marketing` template). A file with nothing to change is not written at all.
204
+ // ⛔ An EMPTY body is one too: until 2026-09-26 it never compared as unchanged, so a section holding
205
+ // only frontmatter — a parametric page's `type:` and `fetch:` — was re-serialized, and lost its
206
+ // comments, on a pull that changed nothing. So is no document at all (a params-only update).
204
207
  const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
205
- const keepsBody = Boolean(content && authoredBody) && sameMarkdownDocument(authoredBody, content)
206
- if (keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
207
- const body = content && !keepsBody ? proseMirrorToMarkdown(content) : authoredBody
208
+ const keepsBody = !content || sameMarkdownDocument(authoredBody, content)
209
+ if (existing && keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
210
+ const body = keepsBody ? authoredBody : proseMirrorToMarkdown(content)
208
211
  return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
209
212
  }
210
213
 
@@ -255,8 +255,12 @@ function siteFoundationDeclarations(siteRoot) {
255
255
  return { own: null, keyTypes: null }
256
256
  }
257
257
  const schema = siteYml ? foundationSchemaJson(siteRoot, siteYml) : null
258
+ // ⛔ Own names only from a schema that carries its data schemas. The registered version a clone
259
+ // keeps carries none (they travel as Models), and read as an empty set it said "owns nothing", so a
260
+ // clone placed a foundation's own `@std/exhibit` in `records/std/exhibit/`; unknown (null), the
261
+ // standard-schema rule decides (`unresolveSelfScope`), as it did before the kept version existed.
258
262
  return schema
259
- ? { own: ownSchemaNames(schema.dataSchemas || {}), keyTypes: dataKeyTypes(schema) }
263
+ ? { own: ownSchemaNames(schema.dataSchemas), keyTypes: dataKeyTypes(schema) }
260
264
  : { own: null, keyTypes: null }
261
265
  }
262
266
 
@@ -318,7 +322,9 @@ const DECL_WIRE_CONSUMED = new Set([
318
322
  'deferred',
319
323
  'detail_url',
320
324
  'detail',
321
- 'queryable'
325
+ 'queryable',
326
+ // Says the query names no schema of its own — how the file is written, never a key in it.
327
+ 'typed_by_data_key',
322
328
  ])
323
329
 
324
330
  // A standard schema (`@std/<name>`), normalized as the build resolves one — known with no foundation
@@ -348,7 +354,7 @@ function isDerivedDeferred(d, dataSchemas, pulledModel = null) {
348
354
  return d.deferred.every((f) => a.has(f))
349
355
  }
350
356
 
351
- function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null) {
357
+ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, keyTypes = null, authored = null, models = null, markedOnly = false) {
352
358
  // ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
353
359
  // `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
354
360
  // (`site.js::queriesNested`), and both checks below are keyed by the author's
@@ -385,8 +391,15 @@ function declToFileShape(wire, dataSchemas = null, scope = null, own = null, key
385
391
  // ⛔ UNLESS THE AUTHOR WROTE IT: a `schema:` the local file already states for this query is
386
392
  // written back as it is. Until 2026-09-25 a pull into a working copy dropped international's
387
393
  // `articles: { schema: '@std/article' }`, because a section types `articles` as `@std/article`.
388
- const keyDefault = name && !own?.has(name) ? keyTypes?.get(name) : null
394
+ // ⭐ A CLONE GOES BY THE MARK. Where the key's type comes only from the registered foundation a
395
+ // project keeps (`markedOnly`: its foundation is a catalog ref), a query is written terse only when
396
+ // the push marked it `typed_by_data_key` — the author wrote no schema — or when the author's own
397
+ // file declares it with none. Unmarked, the schema was the author's, and it is written.
389
398
  const written = name ? authored?.get(name) : undefined
399
+ const typedHere = !markedOnly || wire.typed_by_data_key === true || written === null
400
+ const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
401
+ // In the author's form: the registered version names the type qualified (`@acme/member`).
402
+ const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
390
403
  if (d.schema && (d.schema === written || (d.schema !== defaultSchema(name) && d.schema !== keyDefault))) {
391
404
  decl.schema = d.schema
392
405
  }
@@ -463,17 +476,41 @@ function authoredQuerySchemas(siteRoot, siteYml) {
463
476
  } catch {
464
477
  file = null
465
478
  }
479
+ // name → the schema the author's file states for it, or null for a query it declares with none.
480
+ // ⛔ The string shorthand (`team: '@/member'`) is a schema too: until 2026-09-26 it read as none,
481
+ // so a pull could write the author's `team: '@/member'` back as `team: {}`.
466
482
  const out = new Map()
467
483
  for (const decls of [siteYml?.queries, file]) {
468
484
  if (!decls || typeof decls !== 'object') continue
469
485
  for (const [name, decl] of Object.entries(decls)) {
470
- if (typeof decl?.schema === 'string') out.set(name, decl.schema)
471
- else if (out.has(name)) out.delete(name)
486
+ const schema = typeof decl === 'string' ? decl : typeof decl?.schema === 'string' ? decl.schema : null
487
+ out.set(name, schema)
472
488
  }
473
489
  }
474
490
  return out
475
491
  }
476
492
 
493
+ // ⭐ WHERE A NEW RECORD OF A KEY'S TYPE GOES: the folder of a query the site declares with no schema
494
+ // of its own, whose name is a data key typed as that Model — `records/team/` for `team:` typed
495
+ // `@acme/member` — which is where a push reads it from (`uwx/data-key-types.js`). model → folder.
496
+ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
497
+ const out = new Map()
498
+ if (!keyTypes?.size) return out
499
+ let siteYml = null
500
+ try {
501
+ siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || null
502
+ } catch {
503
+ siteYml = null
504
+ }
505
+ for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
506
+ if (schema !== null || own?.has(name)) continue
507
+ const type = keyTypes.get(name)
508
+ const model = type ? resolveSelfScope(type, scope) : null
509
+ if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
510
+ }
511
+ return out
512
+ }
513
+
477
514
  export function declarationsToQueriesYml({ document, siteRoot, scope, models = null, ...rest }) {
478
515
  refuseOrgOption(rest, 'declarationsToQueriesYml')
479
516
  const decls = Array.isArray(document?.queries) ? document.queries : []
@@ -500,10 +537,12 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, models = n
500
537
  const own = ownSchemaNames(dataSchemas)
501
538
  const keyTypes = siteYml ? dataKeyTypes(foundationSchemaJson(siteRoot, siteYml)) : null
502
539
  const authored = authoredQuerySchemas(siteRoot, siteYml)
540
+ // The key types a catalog-ref foundation has come from the registered version the project keeps.
541
+ const markedOnly = Boolean(parseCatalogRef(siteYml?.foundation))
503
542
 
504
543
  const queries = {}
505
544
  for (const d of decls) {
506
- const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models)
545
+ const { name, decl } = declToFileShape(d, dataSchemas, selfScope, own, keyTypes, authored, models, markedOnly)
507
546
  if (!name) continue
508
547
  queries[name] = decl
509
548
  }
@@ -682,6 +721,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
682
721
  // The file holding a record already, by its own id: the derived folder first, then any
683
722
  // other that reads as the same Model (`recordDirsReadingAs`).
684
723
  const readingAs = (model) => recordDirsReadingAs(recordsRoot, model, selfScope, { own, keyTypes })
724
+ const keyFolders = typedQueryFolders(siteRoot, recordsRoot, selfScope, own, keyTypes)
685
725
  const findRecordFile = (poolDir, model, ownId) => {
686
726
  const dirs = [poolDir, ...readingAs(model).filter((dir) => dir !== poolDir)]
687
727
  // Single-record files first, as before; then an entry of a list file.
@@ -735,8 +775,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
735
775
  isNew = false
736
776
  } else {
737
777
  // A new record goes where the project already keeps records of its Model — its derived
738
- // folder, else another that reads as the Model (`records/team/` for `@std/member`).
739
- const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || poolDir
778
+ // folder, else another that reads as the Model (`records/team/` for `@std/member`), else the
779
+ // folder of a query typed by its data key as the Model (`typedQueryFolders`) — a clone's.
780
+ const home = existsSync(poolDir) ? poolDir : readingAs(schema)[0] || keyFolders.get(schema) || poolDir
740
781
  format = defaultFormat(home, declaration)
741
782
  filePath = join(home, where.slug + EXT_FOR_FORMAT[format])
742
783
  isNew = true
@@ -44,6 +44,7 @@ import { readFileSync, existsSync } from 'node:fs'
44
44
  import yaml from 'js-yaml'
45
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
46
46
  import { detectFoundationType } from '../site/foundation-ref.js'
47
+ import { foundationSchemaJson } from '../site/queries-config.js'
47
48
  import { join, resolve } from 'node:path'
48
49
 
49
50
  import { resolveQueriesConfig } from './queries-config.js'
@@ -55,7 +56,7 @@ import {
55
56
  poolPathReadings,
56
57
  poolDirsForSchema,
57
58
  } from '../site/entity-pool.js'
58
- import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
59
+ import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isOpenMapSection, openMapEntries } from './data-schema.js'
59
60
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
60
61
  import { emitEntitySyncPackage } from './entity-document.js'
61
62
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
@@ -268,6 +269,25 @@ export function recordsToEntities({
268
269
  `"${bodyTarget.section}.${bodyTarget.key}"`
269
270
  )
270
271
  }
272
+ // ⛔ A SITE'S FOLDER PLACES ONLY A RECORD SOMETHING CAN REFERENCE. A folder entry is a
273
+ // reference, and a Model with no brief is not linkable (`toDataSchemaDeclaration`), so a
274
+ // backend refuses to place its records — measured 2026-09-25 as a `409 … linkable: false`,
275
+ // and by design (backend, 2026-09-26). Refused here, once for the schema, before anything
276
+ // is sent — and saying why, which the backend's refusal names only by its design doc.
277
+ if (declaration.linkable === false) {
278
+ const slugs = (records || []).map((r) => r?.slug).filter(Boolean)
279
+ if (slugs.length) {
280
+ const named = slugs.slice(0, 3).join(', ') + (slugs.length > 3 ? ` and ${slugs.length - 3} more` : '')
281
+ refusals.push(
282
+ `${label}: ${declaration.name} has no brief, so a site's folder cannot place its records ` +
283
+ `(${named}) — a folder entry is a reference, and only a record of a schema with a brief ` +
284
+ 'can be one. Give the schema a brief (`brief: true` on one of its sections), or keep this ' +
285
+ 'data out of records/.'
286
+ )
287
+ }
288
+ return { entities, warnings, refusals }
289
+ }
290
+
271
291
  for (const record of records || []) {
272
292
  const slug = record.slug
273
293
  if (!slug) {
@@ -429,6 +449,20 @@ function readBySection(layout, record, enc) {
429
449
  // are its child sections), a list of records for a `many` one.
430
450
  function encodeSection(def, value, enc, path) {
431
451
  if (def.multiple === true) {
452
+ // ⭐ AN OPEN MAP is the author's map in a file and rows on the wire (`isOpenMapSection`): each
453
+ // key becomes its row's `name`. ⛔ Until 2026-09-26 the map was refused as "a list of records",
454
+ // so no record holding one could be pushed. Rows written as a list are read as they are.
455
+ if (isOpenMapSection(def) && isPlainObject(value)) {
456
+ const rows = []
457
+ for (const { key, value: entry, row } of openMapEntries(value)) {
458
+ if (!isPlainObject(entry)) {
459
+ enc.shape.push(`"${path}.${key}" is one entry of a map — write it as a map of its fields.`)
460
+ continue
461
+ }
462
+ rows.push(encodeItem(def, row, enc, `${path}.${key}`))
463
+ }
464
+ return rows
465
+ }
432
466
  if (!Array.isArray(value)) {
433
467
  enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
434
468
  return undefined
@@ -757,6 +791,17 @@ function resolveFoundationDir(siteRoot, opts) {
757
791
  }
758
792
  }
759
793
 
794
+ // The site's foundation's schema as a project keeps it for a catalog ref (`foundationSchemaJson`,
795
+ // `site/registered-foundation.js`) — its section types, which is all this is read for.
796
+ function registeredFoundationSchema(siteRoot) {
797
+ try {
798
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
799
+ return foundationSchemaJson(siteRoot, siteYml)
800
+ } catch {
801
+ return null
802
+ }
803
+ }
804
+
760
805
  // Load the local foundation's built schema.json (the source of locally-defined
761
806
  // Model declarations), or null when there's no local foundation. `required` (set
762
807
  // when no remote resolver is available) turns "missing" into a helpful error
@@ -988,7 +1033,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
988
1033
  // holds records of that type. `keyTyped` records each such name for the `queries` Section,
989
1034
  // which must name the same data schema (`sync-package.js` hands it on). ⛔ Never for a schema
990
1035
  // a query asked for explicitly: that one is the author's, and fails loudly.
991
- const keyTypes = dataKeyTypes(localSchema)
1036
+ // A clone has no local foundation: its section types come from the registered version it keeps.
1037
+ const keyTypes = dataKeyTypes(localSchema || registeredFoundationSchema(siteRoot))
992
1038
  const keyTyped = new Map()
993
1039
  const typedFor = async (schema) => {
994
1040
  const key = keyOfDefaultRef(schema)
@@ -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'
@@ -43,7 +44,7 @@ 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'
45
46
  import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
46
- import { translationContext } from '../i18n/extract.js'
47
+ import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
47
48
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
48
49
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, localesDir } from './locale-sync.js'
49
50
  import { resolveLocaleList } from '../i18n/locales.js'
@@ -282,6 +283,11 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
282
283
  for (const [settingsKey, ymlKey] of Object.entries(SETTINGS_TO_SITE_YML)) {
283
284
  if (settingsSection[settingsKey] !== undefined) siteChanges[ymlKey] = settingsSection[settingsKey]
284
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
285
291
 
286
292
  // `settings.keywords` is a LOCALIZED list (it renders into `<meta name="keywords">`)
287
293
  // → unwrap to the source locale; the target locales go to the collector.
@@ -696,7 +702,10 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
696
702
  if (record.redirect !== undefined) y.redirect = record.redirect
697
703
  if (record.rewrite !== undefined) y.rewrite = record.rewrite
698
704
  if (record.layout !== undefined) y.layout = record.layout
699
- if (record.seo !== undefined) y.seo = record.seo
705
+ // Its Open Graph title and description, per language on the wire, back inside `seo`
706
+ // (`uwx/open-graph.js`); their other languages are collected with the page's metadata.
707
+ const seo = foldOpenGraph(record, record.seo, sourceLocale)
708
+ if (seo !== undefined) y.seo = seo
700
709
  // Invert the build's resolution rather than copy it — see fetch-shapes.js. Under
701
710
  // the key the file already uses (`existing`), else `query:` for names alone.
702
711
  if (record.fetch !== undefined) {
@@ -816,6 +825,8 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
816
825
  ctx.collector?.add(record.title, meta)
817
826
  ctx.collector?.add(record.label, meta)
818
827
  ctx.collector?.add(record.description, meta)
828
+ ctx.collector?.add(record.og_title, meta)
829
+ ctx.collector?.add(record.og_description, meta)
819
830
  // keywords is a localized ARRAY — capture each element's target locales.
820
831
  if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta))
821
832
  else ctx.collector?.add(record.keywords, meta)
package/src/uwx/site.js CHANGED
@@ -76,7 +76,8 @@ import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeCon
76
76
  import { unwrapLocalized } from './backfill.js'
77
77
  import { loadFreeformTranslation } from '../i18n/freeform.js'
78
78
  import { resolveLocaleList } from '../i18n/locales.js'
79
- import { translationContext } from '../i18n/extract.js'
79
+ import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
80
+ import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
80
81
  import { updateBackendState, readBackendState } from './sync-store.js'
81
82
  import { upsertYamlScalar } from './yaml-upsert.js'
82
83
  import { resolveQueriesConfig } from './queries-config.js'
@@ -293,7 +294,9 @@ function localizePageMeta(page, route, sourceLocale, translations) {
293
294
  value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
294
295
  ? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
295
296
  : value
296
- for (const key of ['title', 'label', 'description']) if (page[key] !== undefined) page[key] = again(page[key])
297
+ for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
298
+ if (page[key] !== undefined) page[key] = again(page[key])
299
+ }
297
300
  if (Array.isArray(page.keywords)) page.keywords = page.keywords.map(again)
298
301
  }
299
302
 
@@ -318,7 +321,7 @@ function mapSectionData(section) {
318
321
  }
319
322
 
320
323
  function buildPageData(config, ctx) {
321
- const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
324
+ const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where, pageFields } =
322
325
  ctx
323
326
  // The page `slug` is the localized route source — a `{lang: slug}` map (the
324
327
  // site-content Model declares it localized; greenlit 2026-06-13). The source
@@ -355,7 +358,13 @@ function buildPageData(config, ctx) {
355
358
  setIf(data, 'redirect', config.redirect)
356
359
  setIf(data, 'rewrite', config.rewrite)
357
360
  setIf(data, 'layout', config.layout)
358
- 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)
359
368
  // ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
360
369
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
361
370
  // carried one dataset for a page that asked for several.
@@ -729,7 +738,7 @@ const REPUBLISH_CLAUSE =
729
738
  // Recursively build the `pages` tree: each record carries its fields, its inline
730
739
  // `page_sections` (page mode only), and its child pages under `$children`.
731
740
  async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
732
- const { siteRoot, siteIndex, sourceLocale, translations } = ctx
741
+ const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
733
742
  const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
734
743
  const out = []
735
744
  // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
@@ -763,6 +772,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
763
772
  siteIndex,
764
773
  sourceLocale,
765
774
  translations,
775
+ pageFields,
766
776
  })
767
777
  // `$id` is the stableId when authored (rename-stable), else the slug (the
768
778
  // natural handle — spec default). The path is NEVER the identity.
@@ -980,7 +990,9 @@ const DECL_EMITTED_ABOVE = new Set([
980
990
  'limit',
981
991
  'excerpt',
982
992
  'deferred',
983
- 'queryable'
993
+ 'queryable',
994
+ // Derived by the rule above, never taken from a file.
995
+ 'typed_by_data_key',
984
996
  ])
985
997
 
986
998
  const DECL_NOT_ON_WIRE = new Set([
@@ -1022,7 +1034,7 @@ function externalSource(d) {
1022
1034
  return source
1023
1035
  }
1024
1036
 
1025
- function queriesNested(declarations, uuids = null, scope = null, keyTyped = null) {
1037
+ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null, queryFields = null) {
1026
1038
  const out = []
1027
1039
  for (const [name, d] of Object.entries(declarations)) {
1028
1040
  refuseUnder(d.where, `queries.${name}`)
@@ -1041,8 +1053,13 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1041
1053
  // empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
1042
1054
  // A query whose name-defaulted schema stands for the type of the data key of its name
1043
1055
  // names that type — the data schema its records are sent as (`data-key-types.js`).
1044
- 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
1045
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
1046
1063
  setIf(data, 'sort', d.sort)
1047
1064
  // Legacy `filter:` is not synced — it is translated to `where` upstream
1048
1065
  // (the canonical predicate). No legacy fields on the wire.
@@ -1238,7 +1255,7 @@ function secretsNested(provisioned) {
1238
1255
  // ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
1239
1256
  // branches is the other half, and `producer-list-drift.test.js` fails if a key
1240
1257
  // emitted here has neither.
1241
- function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null } = {}) {
1258
+ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages = null, settingsFields = null } = {}) {
1242
1259
  const settings = {}
1243
1260
 
1244
1261
  // Site-wide values an author declares once and references from page content as
@@ -1287,7 +1304,12 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1287
1304
  // (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
1288
1305
  // ever passed the card test because `image` was inside it; the card's picture is
1289
1306
  // `info.preview` now.
1290
- 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))
1291
1313
  // ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
1292
1314
  // (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
1293
1315
  // convenience, not because it is a different kind of thing. Localized, so it
@@ -1569,7 +1591,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1569
1591
  // (uwx-format.md → info.url.)
1570
1592
 
1571
1593
  // `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
1572
- 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 }
1573
1596
  // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1574
1597
  // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1575
1598
  // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
@@ -1650,7 +1673,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1650
1673
  // Emitted only when the file declares something — see `settingsNested`.
1651
1674
  // The languages the site's translation files make, for a site that declares none (`settingsNested`).
1652
1675
  const languages = targetLocales.length > 0 ? [sourceLocale, ...targetLocales] : null
1653
- const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages })
1676
+ const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations, languages, settingsFields: opts.settingsFields })
1654
1677
  if (settings) doc.settings = settings
1655
1678
  doc.pages = pages
1656
1679
  doc.layout_sections = layoutSections
@@ -1663,7 +1686,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1663
1686
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1664
1687
  // consumer sees, leave the identifier alone.
1665
1688
  const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1666
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped)
1689
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope, opts.keyTyped, opts.queryFields)
1667
1690
  // Emitted ONLY when the file declares the key — see the header above
1668
1691
  // `serviceRecords`: on a replaced Section, absent and empty are different
1669
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)) {