@uniweb/build 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * Runtime Schema Extractor
3
3
  *
4
- * Extracts lean runtime-relevant metadata from full meta.js files.
5
- * The runtime schema is optimized for size and contains only what's
6
- * needed at render time:
4
+ * Extracts lean runtime-relevant metadata from full meta.js files — what a published page
5
+ * reads to render, and nothing else:
7
6
  *
8
7
  * - background: 'self' when component handles its own background
9
8
  * - data: { <key>: <schema ref> | null } — every `content.data` key the component
10
9
  * declares, in order, with its schema ref (null for an inline shape)
11
- * - schemas: { <key>: <lean fields> } — field defaults for the declared keys that have any
10
+ * - vars: { <name>: { default? } } — the CSS variables the component declares
12
11
  * - defaults: param default values
13
12
  * - context: static capabilities for cross-block coordination
14
13
  * - initialState: initial values for mutable block state
@@ -20,14 +19,20 @@
20
19
  * delivery was default-on — every key that reached a section — and `data:` was a hint for
21
20
  * defaults and the editor; `data: false` was the opt-out, emitted as `inheritData: false`.
22
21
  *
22
+ * ⛔ NO FIELD DEFAULTS — ruled 2026-10-05 [Diego]. Each declared key also carried `schemas`
23
+ * until then — its schema's field defaults, with `enum` and the nesting that led to them — and
24
+ * the runtime filled a missing field from its `default` and replaced a value its `enum`
25
+ * rejected. A missing field is the record's own fact, and what it renders as is the
26
+ * component's choice; a copy frozen into the bundle drifts from the schema it was taken from;
27
+ * and checking a value is `uniweb validate`'s and the push's job, not the page's. A form in
28
+ * `data:` is the editor's, for authoring the block: none of it reaches the runtime.
29
+ * ⛔ Nor `inset` (an editor's flag — an inset is found by name) or a layout's `areas` (a page's
30
+ * areas are its site's `layout/` folder): no runtime reader read either, until 2026-10-05.
31
+ *
23
32
  * Full metadata (titles, descriptions, hints, etc.) stays in schema.json
24
33
  * for the visual editor.
25
34
  */
26
35
 
27
- import { isRichSchema } from '@uniweb/core'
28
- import { dataRefOf } from '@uniweb/core/data-keys'
29
- import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
30
- import { enumValues } from '@uniweb/schemas/format'
31
36
  import { lowerData } from '@uniweb/schemas/content'
32
37
 
33
38
  /**
@@ -42,188 +47,6 @@ function dataRef(value) {
42
47
  return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
43
48
  }
44
49
 
45
- /**
46
- * Extract lean schema field for runtime
47
- * Strips editor-only fields (label, hint, description)
48
- * Keeps runtime fields (type, default, enum, options, fields, items)
49
- *
50
- * Vocabulary is the data-schema format:
51
- * an `object` field nests via `fields:` (a field map); an `array` field nests
52
- * via `items:` (a single element field). This matches what
53
- * resolve-data-schema.js normalizes named refs to, so named-ref and inline
54
- * `data:` schemas share one shape.
55
- *
56
- * @param {string|Object} field - Schema field definition
57
- * @returns {string|Object} - Lean field definition
58
- */
59
- function extractSchemaField(field) {
60
- // Shorthand: a bare type string ('string', 'decimal', …).
61
- if (typeof field === 'string') {
62
- return field
63
- }
64
-
65
- if (!field || typeof field !== 'object') {
66
- return field
67
- }
68
-
69
- const lean = {}
70
-
71
- // Keep runtime-relevant fields: the default, plus the inline picklist
72
- // (`enum`) used for value validation. `options` is a curated-ref string —
73
- // inert at runtime but carried through.
74
- if (field.type) lean.type = field.type
75
- if (field.default !== undefined) lean.default = field.default
76
- // The values only: an entry's label (`{ value, label }`) is an editor's, and the runtime compares.
77
- if (field.enum !== undefined) lean.enum = enumValues(field.enum)
78
- if (field.options) lean.options = field.options
79
-
80
- // Nested object → recurse into its field map.
81
- if (field.type === 'object' && field.fields && typeof field.fields === 'object') {
82
- lean.fields = extractSchemaFields(field.fields)
83
- }
84
-
85
- // Array → recurse into its single element field (which may itself be an
86
- // object carrying nested `fields`).
87
- if (field.type === 'array' && field.items !== undefined) {
88
- lean.items = extractSchemaField(field.items)
89
- }
90
-
91
- // If the only thing left is `type`, collapse to the bare type string.
92
- const keys = Object.keys(lean)
93
- if (keys.length === 1 && keys[0] === 'type') {
94
- return lean.type
95
- }
96
-
97
- return keys.length > 0 ? lean : null
98
- }
99
-
100
- /**
101
- * Extract lean schema fields for an entire schema object
102
- *
103
- * @param {Object} schemaFields - Map of fieldName -> field definition
104
- * @returns {Object} - Map of fieldName -> lean field definition
105
- */
106
- function extractSchemaFields(schemaFields) {
107
- if (!schemaFields || typeof schemaFields !== 'object') {
108
- return {}
109
- }
110
-
111
- const lean = {}
112
- for (const [name, field] of Object.entries(schemaFields)) {
113
- const leanField = extractSchemaField(field)
114
- if (leanField !== null) {
115
- lean[name] = leanField
116
- }
117
- }
118
- return lean
119
- }
120
-
121
- /**
122
- * Check if a schema value is in the full @uniweb/schemas format
123
- * Full format has: { name, version?, description?, fields: { fieldName: fieldDef, ... } }
124
- *
125
- * The distinguishing feature is that `fields` is a *keyed object*, not an array.
126
- * (A rich form schema also has `fields`, but as an array.)
127
- *
128
- * @param {Object} schema - Schema value to check
129
- * @returns {boolean}
130
- */
131
- function isFullSchemaFormat(schema) {
132
- return (
133
- schema &&
134
- typeof schema === 'object' &&
135
- typeof schema.fields === 'object' &&
136
- schema.fields !== null &&
137
- !Array.isArray(schema.fields)
138
- )
139
- }
140
-
141
- /**
142
- * Pass a rich form schema through with minimal normalization.
143
- *
144
- * Rich schemas are passed to the editor (for FormBlock UI rendering) and to
145
- * the runtime (for default application). We keep all authored metadata so the
146
- * editor has what it needs; we do not strip editor-only fields here because
147
- * the same schema feeds both audiences.
148
- *
149
- * Normalizations:
150
- *
151
- * @param {Object} schema - Rich schema as authored
152
- * @returns {Object} - Normalized rich schema
153
- */
154
- function normalizeRichSchema(schema) {
155
- return normalizeRichSchemaValue(schema)
156
- }
157
-
158
- function normalizeRichSchemaValue(value) {
159
- if (Array.isArray(value)) {
160
- return value.map(normalizeRichSchemaValue)
161
- }
162
- if (!value || typeof value !== 'object') return value
163
- const out = {}
164
- for (const [key, v] of Object.entries(value)) {
165
- if (v && typeof v === 'object') {
166
- out[key] = normalizeRichSchemaValue(v)
167
- } else {
168
- out[key] = v
169
- }
170
- }
171
- return out
172
- }
173
-
174
- /**
175
- * Lean a single `data:` entry value into the runtime field structure that
176
- * prepare-props.applySchemas applies. A `data:` value is one of:
177
- * - a named ref (`'@/member'`, or `{ schema: '@/member' }`) → resolved on
178
- * disk by the build's data-schema resolver; its fields are lean-extracted.
179
- * - an inline rich-form schema (`{ fields: [...] }`) → passed through
180
- * normalized (drives the FormBlock editor UI + default application).
181
- * - an inline full-format schema (`{ name, version, fields: {...} }`) or a
182
- * bare field map (`{ field: {...} }`) → lean-extracted.
183
- *
184
- * Source-agnostic: the same `content.data` key may be filled by a fetched
185
- * collection, a tagged code block, or an editor form — the schema (and its
186
- * defaults) is identical, so there is one declaration surface. Returns the
187
- * lean structure, or null when there's nothing to apply.
188
- *
189
- * @param {string|Object} value - The `data:` entry value
190
- * @param {Object} dataSchemaMap - Resolved schemas keyed by ref
191
- * @returns {Object|null}
192
- */
193
- function leanDataSchema(value, dataSchemaMap) {
194
- // Named ref → the resolved schema's fields. `'@std/article/*'` asks for whole records of it.
195
- const { ref, whole } = dataRefOf(value)
196
- if (ref) {
197
- const resolved = dataSchemaMap[ref]
198
- // ⭐ THE FIELDS OF WHAT THE COMPONENT RECEIVES (ruled 2026-09-27 [Diego]): a brief's fields at
199
- // the top (`briefFieldMap`), or, for `'@x/y/*'`, the whole record as stored — one field per
200
- // section, the brief's included (`wholeFieldMap`) — so a default lands where the record carries
201
- // its field: `@std/article`'s `status` inside `body`. A section is filled only when the record
202
- // holds it (`applySchemaToObject` recurses into what is there).
203
- //
204
- // ⛔ It was the merged `deliveredFields` until 2026-09-27 — the brief's fields beside the other
205
- // sections' names, one map for two shapes; `flatRecordFields`, the retired flat form, until
206
- // 2026-09-24; and before 2026-09-03 `resolved.fields`, which a sections-form schema does not
207
- // have. `dataSchemaMap` holds each schema normalized in its authored form (`fields:` or
208
- // `sections:`), and only these readers say what one record of it looks like.
209
- const fields = whole ? wholeFieldMap(resolved) : briefFieldMap(resolved)
210
- if (!fields) return null
211
- const lean = extractSchemaFields(fields)
212
- return Object.keys(lean).length > 0 ? lean : null
213
- }
214
-
215
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
216
-
217
- // Inline rich-form schema (fields: array) — drives FormBlock + defaults.
218
- if (isRichSchema(value)) return normalizeRichSchema(value)
219
-
220
- // Inline full-format schema or a bare field map.
221
- const fields = isFullSchemaFormat(value) ? value.fields : value
222
- const lean = extractSchemaFields(fields)
223
- return Object.keys(lean).length > 0 ? lean : null
224
- }
225
-
226
-
227
50
  /**
228
51
  * Extract param defaults from params object
229
52
  *
@@ -246,27 +69,45 @@ function extractParamDefaults(params) {
246
69
  return Object.keys(defaults).length > 0 ? defaults : null
247
70
  }
248
71
 
72
+ /**
73
+ * A section type's CSS variables as the runtime reads them: every declared name, with its
74
+ * `default` when it has one. `Block.mergeComponentVars` (`@uniweb/core`) emits each default
75
+ * on the section and takes a section's `vars:` value only for a name declared here, so a
76
+ * var with no default still needs its name. A label, a type, options and a description are
77
+ * the editor's. A shorthand value (`'card-gap': '1.5rem'`) is written as its `default`.
78
+ *
79
+ * ⛔ Not emitted until 2026-10-05, while core read it since 2026-03-02: a section type's
80
+ * `vars:` reached no page, and neither did a section's `vars:` for them.
81
+ *
82
+ * @param {Object} vars - the `vars:` of a meta.js
83
+ * @returns {Object|null} `{ <name>: { default? } }`, or null when none is declared
84
+ */
85
+ function leanComponentVars(vars) {
86
+ if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return null
87
+ const out = {}
88
+ for (const [name, config] of Object.entries(vars)) {
89
+ if (config !== null && typeof config === 'object' && !Array.isArray(config)) {
90
+ out[name] = config.default !== undefined ? { default: config.default } : {}
91
+ } else {
92
+ out[name] = config === null || config === undefined ? {} : { default: config }
93
+ }
94
+ }
95
+ return Object.keys(out).length > 0 ? out : null
96
+ }
97
+
249
98
  /**
250
99
  * Extract lean runtime schema from a full meta.js object
251
100
  *
252
101
  * @param {Object} fullMeta - The full meta.js default export
253
- * @param {Object} [dataSchemaMap] - Resolved data schemas keyed by ref (from
254
- * resolve-data-schema.js), used to lean-extract field defaults
255
- * for each `data:` binding.
256
102
  * @returns {Object|null} - Lean runtime schema or null if empty
257
103
  */
258
- export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
104
+ export function extractRuntimeSchema(fullMeta) {
259
105
  if (!fullMeta || typeof fullMeta !== 'object') {
260
106
  return null
261
107
  }
262
108
 
263
109
  const runtime = {}
264
110
 
265
- // Inset flag: signals this component is available for inline @ references
266
- if (fullMeta.inset) {
267
- runtime.inset = true
268
- }
269
-
270
111
  // Background opt-out: 'self' means the component renders its own background
271
112
  // layer (solid colors, insets, effects), so the runtime skips its Background.
272
113
  if (fullMeta.background) {
@@ -277,12 +118,12 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
277
118
  // data: it maps each `content.data` key to its schema. A value is a named ref
278
119
  // (`'@/member'`), an inline field map, or an inline rich-form (`{ fields: [...] }`,
279
120
  // an editor form). Source-agnostic — the data may arrive by fetch, tagged code
280
- // block, or editor form; the schema and its defaults are identical.
121
+ // block, or editor form, and it reaches the component as it arrived.
281
122
  //
282
- // ⭐ Every key reaches the runtime, a key with no fields included — `data` lists them
283
- // with their refs, because the keys ARE what the section receives, and a key's ref is
284
- // what a fetch of another name fills it by. `schemas` carries field defaults for the
285
- // keys that have fields. `data: false` declares nothing, as no `data:` does.
123
+ // ⭐ Every key reaches the runtime — `data` lists them with their refs, because the keys
124
+ // ARE what the section receives, and a key's ref is what a fetch of another name fills
125
+ // it by. Its shape stays in schema.json, for the editor. `data: false` declares nothing,
126
+ // as no `data:` does.
286
127
  //
287
128
  // ⭐ A concept block's key is written as its fence is — `'md:faq': 'Questions and
288
129
  // answers'` — and lowered to the key a component reads, `faq`, with no schema: the value
@@ -308,11 +149,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
308
149
  }
309
150
  runtime.data = runtime.data || {}
310
151
  runtime.data[key] = dataRef(value)
311
- const lean = leanDataSchema(value, dataSchemaMap)
312
- if (lean) {
313
- runtime.schemas = runtime.schemas || {}
314
- runtime.schemas[key] = lean
315
- }
316
152
  }
317
153
  } else if (data !== undefined) {
318
154
  throw new Error(
@@ -321,6 +157,11 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
321
157
  )
322
158
  }
323
159
 
160
+ const vars = leanComponentVars(fullMeta.vars)
161
+ if (vars) {
162
+ runtime.vars = vars
163
+ }
164
+
324
165
  const paramsObj = fullMeta.params
325
166
  const defaults = extractParamDefaults(paramsObj)
326
167
  if (defaults) {
@@ -339,9 +180,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
339
180
  runtime.initialState = fullMeta.initialState
340
181
  }
341
182
 
342
- // (Top-level `schemas:` is gone — inline field maps and rich-forms are now
343
- // just `data:` entries with an inline value. See leanDataSchema.)
344
-
345
183
  return Object.keys(runtime).length > 0 ? runtime : null
346
184
  }
347
185
 
@@ -351,11 +189,11 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
351
189
  * @param {Object} componentsMeta - Map of componentName -> meta.js content
352
190
  * @returns {Object} - Map of componentName -> runtime schema (excludes null entries)
353
191
  */
354
- export function extractAllRuntimeSchemas(componentsMeta, dataSchemaMap = {}) {
192
+ export function extractAllRuntimeSchemas(componentsMeta) {
355
193
  const schemas = {}
356
194
 
357
195
  for (const [name, meta] of Object.entries(componentsMeta)) {
358
- const schema = extractRuntimeSchema(meta, dataSchemaMap)
196
+ const schema = extractRuntimeSchema(meta)
359
197
  if (schema) {
360
198
  schemas[name] = schema
361
199
  }
@@ -368,7 +206,6 @@ export function extractAllRuntimeSchemas(componentsMeta, dataSchemaMap = {}) {
368
206
  * Extract lean runtime schema for a layout from its full meta.js
369
207
  *
370
208
  * Layout runtime metadata:
371
- * - areas: Array of area names this layout supports
372
209
  * - transitions: View transition name overrides (the runtime auto-names areas;
373
210
  * this overrides per region, or `false` opts the layout out)
374
211
  * - defaults: Param default values
@@ -385,9 +222,8 @@ export function extractLayoutRuntimeSchema(fullMeta) {
385
222
 
386
223
  const runtime = {}
387
224
 
388
- if (fullMeta.areas && Array.isArray(fullMeta.areas)) {
389
- runtime.areas = fullMeta.areas
390
- }
225
+ // ⛔ No `areas`: the areas a page renders are its site's `layout/` folder
226
+ // (`Website.getLayoutAreas`), and a layout's list of them is for the editor.
391
227
 
392
228
  // `false` is a value, not an absence: it is the documented way a layout opts
393
229
  // out of per-area view transitions. The previous guard was
@@ -251,6 +251,7 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
251
251
  }
252
252
  import { generateSearchIndex, getSearchIndexFilename } from '@uniweb/projections'
253
253
  import { mergeTranslations } from '../i18n/merge.js'
254
+ import { dataBlockModels, NO_DATA_MODELS } from '../i18n/data-models.js'
254
255
  import { searchDeclaredOn } from './search-declared.js'
255
256
  import { mountDevApi } from '../dev/api-mount.js'
256
257
 
@@ -654,6 +655,9 @@ export function siteContentPlugin(options = {}) {
654
655
  }
655
656
 
656
657
  let siteContent = null
658
+ // What each section type declares its data blocks are — read once per collection of the site.
659
+ let dataModelPromise = null
660
+ const dataModels = () => (dataModelPromise ||= dataBlockModels(resolvedSitePath).catch(() => NO_DATA_MODELS))
657
661
  let resolvedSitePath = null
658
662
  let resolvedPublicDir = null
659
663
  let resolvedOutDir = null
@@ -791,7 +795,8 @@ export function siteContentPlugin(options = {}) {
791
795
  return mergeTranslations(siteContent, translations || {}, {
792
796
  locale,
793
797
  localesDir: join(resolvedSitePath, localesDir),
794
- freeformEnabled: hasFreeform
798
+ freeformEnabled: hasFreeform,
799
+ dataModel: await dataModels()
795
800
  })
796
801
  }
797
802
 
@@ -951,6 +956,7 @@ export function siteContentPlugin(options = {}) {
951
956
  // strict on a production build: a mount that contributes no pages is a
952
957
  // warning while you author and a broken deploy once you ship.
953
958
  siteContent = await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })
959
+ dataModelPromise = null
954
960
  headHtml = await loadHeadHtml()
955
961
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
956
962
 
@@ -1020,6 +1026,7 @@ export function siteContentPlugin(options = {}) {
1020
1026
  console.log('[site-content] Content changed, rebuilding...')
1021
1027
  try {
1022
1028
  siteContent = await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })
1029
+ dataModelPromise = null
1023
1030
  headHtml = await loadHeadHtml()
1024
1031
  if (materialize) {
1025
1032
  queriesConfig = siteContent.config?.queries
@@ -451,8 +451,10 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
451
451
  if (Array.isArray(field.enum)) out.enum = field.enum
452
452
  if (leafFormat) out.format = leafFormat
453
453
 
454
- // `default` is intentionally NOT emitted — it rides in the foundation-schema
455
- // blob (render / editor pre-fill), not the content type.
454
+ // No `default`: a named schema declares none (`resolve-data-schema.js` refuses one, since
455
+ // 2026-10-05). ⛔ This said it "rides in the foundation-schema blob (render / editor
456
+ // pre-fill)" until then — but that blob leaves `dataSchemas` out, so a named schema's
457
+ // default reached the runtime alone, and no editor.
456
458
  return out
457
459
  }
458
460
 
package/src/uwx/index.js CHANGED
@@ -78,6 +78,8 @@ export { matchStoredRecords } from './record-identity.js'
78
78
  // The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
79
79
  // up front and hands it to the synchronous projections above.
80
80
  export { siteSelfScope } from './self-scope.js'
81
+ // What each section type declares its data blocks are — a pull reads a translated block back by it.
82
+ export { dataBlockModels } from '../i18n/data-models.js'
81
83
  export {
82
84
  siteInfoToConfig,
83
85
  sectionRecordToFile,
@@ -23,7 +23,7 @@ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
23
23
  import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
24
24
  import { computeHash } from '../i18n/hash.js'
25
25
  import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
26
- import { visitDataStrings } from '../i18n/data-strings.js'
26
+ import { visitDataBlockStrings } from '../i18n/data-strings.js'
27
27
  import { resolveDocForLocale } from '../i18n/merge.js'
28
28
  import { computeSourceHash } from '../i18n/freeform-manifest.js'
29
29
  import { sameMarkdownDocument, canonicalJson } from './same-content.js'
@@ -167,8 +167,11 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
167
167
  * @param {string} sourceLocale
168
168
  * @param {string[]} [targetLocales]
169
169
  * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
170
+ * @param {object} [context] - the section's translation context
171
+ * @param {(tag: string) => object|null} [modelFor] - the section's data-block models by tag
172
+ * (`i18n/data-models.js`), so a block is translated as the build translates it
170
173
  */
171
- export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined) {
174
+ export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined, modelFor = () => null) {
172
175
  // Non-docs (null, an already-localized map) pass through untouched.
173
176
  if (!isProseMirrorDoc(doc)) return doc
174
177
 
@@ -178,7 +181,7 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
178
181
  const table = translations[locale]
179
182
  if (!table) continue
180
183
  // With the section's context, so a context-specific override resolves as the build resolves it.
181
- const resolved = resolveDocForLocale(doc, table, context)
184
+ const resolved = resolveDocForLocale(doc, table, context, modelFor)
182
185
  if (resolved) result[locale] = resolved
183
186
  }
184
187
  }
@@ -363,7 +366,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
363
366
  * per-locale link hrefs survive. Keyed by the source element's cleaned text, so
364
367
  * `computeHash(key)` matches the extractor's unit hash — closing the round trip.
365
368
  */
366
- function deriveStructuralMap(sourceDoc, targetDoc) {
369
+ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
367
370
  if (!isProseMirrorDoc(sourceDoc) || !isProseMirrorDoc(targetDoc)) return null
368
371
  const src = blockElements(sourceDoc)
369
372
  const tgt = blockElements(targetDoc)
@@ -383,15 +386,18 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
383
386
  // a mark we can't serialize → don't risk a lossy map; store as free-form
384
387
  return null
385
388
  }
386
- return addDataBlockStrings(map, sourceDoc, targetDoc)
389
+ return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
387
390
  }
388
391
 
389
392
  // ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
390
- // (`visitDataStrings`, the extractor's judgement), keyed by their source text as the build keys them.
393
+ // (`visitDataBlockStrings`: a block's declared shape, else the extractor's heuristic), keyed by their
394
+ // source text as the build keys them. ⭐ Walked by the same model the push translated with — the pairing
395
+ // is by position, so a declared text field the heuristic skips (a `status`) would otherwise read a
396
+ // faithful translation as another shape.
391
397
  // A target that is not the source with only those strings changed — another shape, another `href` —
392
398
  // cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
393
399
  // was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
394
- function addDataBlockStrings(map, sourceDoc, targetDoc) {
400
+ function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null) {
395
401
  const srcBlocks = dataBlockNodes(sourceDoc)
396
402
  const tgtBlocks = dataBlockNodes(targetDoc)
397
403
  if (srcBlocks.length !== tgtBlocks.length) return null
@@ -405,7 +411,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
405
411
  }
406
412
  const strings = (data) => {
407
413
  const out = []
408
- visitDataStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value))
414
+ visitDataBlockStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value), modelFor(s.tag))
409
415
  return out
410
416
  }
411
417
  const from = strings(s.data)
@@ -418,7 +424,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
418
424
  pairs.set(from[j], to[j])
419
425
  }
420
426
  const rebuilt = JSON.parse(JSON.stringify(s.data))
421
- visitDataStrings(rebuilt, (value) => pairs.get(value) ?? value)
427
+ visitDataBlockStrings(rebuilt, (value) => pairs.get(value) ?? value, modelFor(s.tag))
422
428
  if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
423
429
  for (const [from, to] of pairs) map[from.trim()] = to
424
430
  }
@@ -436,7 +442,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
436
442
  * reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
437
443
  * doc (no locale wrap) is returned unchanged.
438
444
  */
439
- export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null) {
445
+ export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null, modelFor = () => null) {
440
446
  if (!isLocalizedContent(content)) return content
441
447
  const source = content[sourceLocale]
442
448
  for (const [locale, value] of Object.entries(content)) {
@@ -445,7 +451,7 @@ export function unwrapLocalizedContent(content, sourceLocale, collector, freefor
445
451
  if (isProseMirrorDoc(value)) {
446
452
  // A translation the site keeps as a free-form file stays one, even when it lines up.
447
453
  const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
448
- const map = kept ? null : deriveStructuralMap(source, value)
454
+ const map = kept ? null : deriveStructuralMap(source, value, modelFor)
449
455
  if (map) collector?.addStructuralMap?.(locale, map, context)
450
456
  else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
451
457
  } else {
@@ -403,7 +403,7 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
403
403
  * structural maps on a localized `content` field are captured into it
404
404
  * @returns {'updated'|'unchanged'}
405
405
  */
406
- export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null }) {
406
+ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null, dataModel = null }) {
407
407
  // ⛔ A stored `preset` or `input` is not written back: neither has an effect, and the build
408
408
  // would only warn about it (2026-09-27).
409
409
  // ⛔ No `background` / `theme_override` / `insets` / `fetch`: the site-content Model dropped all
@@ -416,7 +416,8 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
416
416
  // target-locale structural maps are captured into the locales/ collector, and any
417
417
  // free-form target body is captured with `freeformRelPath` for writing under
418
418
  // locales/freeform/. A bare doc (source-only / pre-localization) passes through.
419
- const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context)
419
+ // A data block is read back by what the section's type declares it is, as the push translated it.
420
+ const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context, (tag) => dataModel?.(type, tag) ?? null)
420
421
 
421
422
  const { hidden: paramHidden, fetch: paramFetch, ...paramsRest } = params && typeof params === 'object' ? params : {}
422
423
  const frontmatter = {}
@@ -556,6 +557,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
556
557
  record,
557
558
  sourceLocale: ctx?.sourceLocale,
558
559
  collector: ctx?.collector,
560
+ dataModel: ctx?.dataModel,
559
561
  freeformRelPath,
560
562
  freeformCandidates,
561
563
  writeId: fileSectionName(filePath) !== stableId,
@@ -1081,6 +1083,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1081
1083
  record,
1082
1084
  sourceLocale: ctx?.sourceLocale,
1083
1085
  collector: ctx?.collector,
1086
+ dataModel: ctx?.dataModel,
1084
1087
  writeId: fileSectionName(filePath) !== stableId,
1085
1088
  freeformRelPath: stableId ? buildFreeformPath({ stableId }, page) : null,
1086
1089
  freeformCandidates: stableId ? freeformPathsFor({ stableId }, page) : null,
@@ -1116,7 +1119,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1116
1119
  * `declarationsToQueriesYml`, whose default it overrides.
1117
1120
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
1118
1121
  */
1119
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
1122
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
1120
1123
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
1121
1124
 
1122
1125
  // Collects target-locale translations of localized scalars as they're projected;
@@ -1145,7 +1148,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1145
1148
  // rename detection, build a fresh one as we project, then persist it. Items not
1146
1149
  // re-projected (deleted) drop out naturally. `collector` rides along to capture
1147
1150
  // localized scalars during the page walk.
1148
- const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map() }
1151
+ const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map(), dataModel }
1149
1152
  // Where the site keeps its localized URLs — `site.yml`'s map, when it has one (`pageSlugToYml`).
1150
1153
  const siteMap = readAuthoredYaml(join(siteRoot, 'site.yml'))?.i18n?.routeTranslations
1151
1154
  ctx.routes = { siteMap: isPlainRecord(siteMap) && Object.keys(siteMap).length ? siteMap : null, slugs: new Map() }
package/src/uwx/site.js CHANGED
@@ -45,6 +45,7 @@
45
45
  // (favicon/assets — carried out of band).
46
46
 
47
47
  import { readdir, readFile } from 'node:fs/promises'
48
+ import { dataBlockModels } from '../i18n/data-models.js'
48
49
  import { existsSync } from 'node:fs'
49
50
  import { join, parse, relative } from 'node:path'
50
51
  import {
@@ -245,6 +246,8 @@ export function buildRouteOf(record, parentRoute, sourceLocale) {
245
246
  // hits the filesystem.
246
247
  async function localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot) {
247
248
  const freeformBase = localesDir(siteRoot)
249
+ // What each section type declares its data blocks are, so a block is pushed as the build translates it.
250
+ const dataModel = await dataBlockModels(siteRoot)
248
251
 
249
252
  const localizeSection = async (record, page) => {
250
253
  if (!record.content) return
@@ -252,7 +255,7 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
252
255
  // Resolved in the section's context, as the build resolves it (`translationContext`): a
253
256
  // context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
254
257
  // resolved with none, so an override never reached a backend.
255
- let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
258
+ let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route), (tag) => dataModel(record.type, tag))
256
259
  if (page) {
257
260
  const section = { stableId }
258
261
  for (const locale of targetLocales) {