@uniweb/build 0.75.0 → 0.75.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.75.0",
3
+ "version": "0.75.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,12 +57,12 @@
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.8",
61
- "@uniweb/content-writer": "^0.3.4",
62
60
  "@uniweb/projections": "^0.7.13",
61
+ "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/semantic-parser": "^1.5.1",
63
+ "@uniweb/content-reader": "^1.2.8",
63
64
  "@uniweb/schemas": "^0.10.2",
64
- "@uniweb/theming": "^0.2.0",
65
- "@uniweb/semantic-parser": "^1.5.1"
65
+ "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.28.10"
@@ -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.36.0",
78
- "@uniweb/runtime": "^0.28.10"
77
+ "@uniweb/runtime": "^0.28.10",
78
+ "@uniweb/core": "^0.36.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * ⭐ WHAT A SECTION TYPE SAYS ITS DATA BLOCKS ARE. A tagged data block (```yaml:logos) feeds the
3
+ * data key of its tag, and the section's type declares that key in its `meta.js` `data:` — a data
4
+ * schema ref (`'@/member'`), an inline field map (`{ name: 'string', style: { type: 'string', enum:
5
+ * [...] } }`), or nothing said (`{}`). The foundation's `main.js` `data:` reaches every section, and
6
+ * a key both declare is the section type's (`@uniweb/core/data-keys::declaredKeys`).
7
+ *
8
+ * This is the model of each declared shape, lowered by the push's own rule (`records.js::modelOf`),
9
+ * so the fields a block translates are the ones its model marks `localized`
10
+ * (`data-strings.js::visitDataBlockStrings`). Every form of a declaration is read by the one
11
+ * normalizer (`@uniweb/schemas/foundation::normalizeData`); a ref resolves as a query's records
12
+ * resolve theirs (`resolveRecordSchemas`). A key with no shape, or a ref that does not resolve, has
13
+ * no model, and its blocks keep the heuristic.
14
+ */
15
+ import { normalizeData } from '@uniweb/schemas/foundation'
16
+ import { foundationSections, resolveRecordSchemas } from '../site/queries-config.js'
17
+ import { modelOf } from './records.js'
18
+
19
+ /** The lookup when there is no foundation to ask: every block keeps the heuristic. */
20
+ export const NO_DATA_MODELS = () => null
21
+
22
+ /**
23
+ * The lookup for a site: `(sectionType, tag) => model | null`.
24
+ *
25
+ * @param {string} siteRoot
26
+ * @param {object} [siteYml] - an already-read site.yml
27
+ * @returns {Promise<(type: string, tag: string) => object|null>}
28
+ */
29
+ export async function dataBlockModels(siteRoot, siteYml = null) {
30
+ let sections
31
+ try {
32
+ sections = await foundationSections(siteRoot, siteYml)
33
+ } catch {
34
+ return NO_DATA_MODELS
35
+ }
36
+ if (!sections || typeof sections !== 'object') return NO_DATA_MODELS
37
+
38
+ // Each declared key, by who declares it: a section type by name, the foundation as ''.
39
+ const declared = new Map()
40
+ const declare = (owner, data) => {
41
+ const normalized = normalizeData(data, { strict: false })
42
+ for (const [key, decl] of Object.entries(normalized || {})) declared.set(at(owner, key), decl)
43
+ }
44
+ for (const [name, entry] of Object.entries(sections)) {
45
+ if (!entry || typeof entry !== 'object' || name === 'dataSchemas' || name === '_layouts') continue
46
+ if (name === '_self') declare('', entry.data)
47
+ else if (!name.startsWith('_')) declare(name, entry.data)
48
+ }
49
+ if (!declared.size) return NO_DATA_MODELS
50
+
51
+ const refs = [...declared.values()].filter((d) => d.kind === 'schema').map((d) => d.schema)
52
+ let resolved = {}
53
+ if (refs.length) {
54
+ try {
55
+ resolved = (await resolveRecordSchemas(siteRoot, refs, { siteYml })).schemas || {}
56
+ } catch {
57
+ resolved = {}
58
+ }
59
+ }
60
+
61
+ const models = new Map()
62
+ for (const [where, decl] of declared) {
63
+ if (decl.kind === 'schema') models.set(where, modelOf(resolved[decl.schema] || null, decl.schema))
64
+ else if (decl.kind === 'fields') {
65
+ const { kind: _kind, problems: _problems, ...schema } = decl
66
+ models.set(where, modelOf(schema, '@/inline'))
67
+ } else models.set(where, null)
68
+ }
69
+
70
+ return (type, tag) => {
71
+ if (!tag) return null
72
+ if (type && models.has(at(type, tag))) return models.get(at(type, tag)) || null
73
+ return models.get(at('', tag)) || null
74
+ }
75
+ }
76
+
77
+ const at = (owner, key) => `${owner}\u0000${key}`
@@ -19,6 +19,8 @@
19
19
  * missed field shows up as untranslated content nobody notices.
20
20
  */
21
21
 
22
+ import { visitModelStrings } from './model-fields.js'
23
+
22
24
  /** Types that are never translatable regardless of schema. */
23
25
  export const NON_TRANSLATABLE_TYPES = new Set([
24
26
  'number', 'boolean', 'date', 'datetime', 'url', 'email', 'image'
@@ -112,3 +114,28 @@ export function visitDataStrings(data, visit, path = '', depth = 0) {
112
114
  // numbers and booleans are never prose
113
115
  }
114
116
  }
117
+
118
+ /**
119
+ * ⭐ A TAGGED DATA BLOCK'S STRINGS, BY ITS DECLARED SHAPE WHEN IT HAS ONE. A section type that
120
+ * declares the shape of the key a block is tagged with — `data: { logos: { name: 'string', style:
121
+ * { type: 'string', enum: [...] } } }`, or a data schema ref — says which of its fields are text,
122
+ * and that is what is walked: the fields its model marks `localized`, the push's own rule
123
+ * (`model-fields.js`). An enum, a URL, a `translatable: false` field is never offered, whatever its
124
+ * name. A block whose key declares no shape (`{}`, or no declaration) keeps the heuristic above.
125
+ *
126
+ * ⛔ Until 2026-10-02 every block was walked by the heuristic, so a field the heuristic does not know
127
+ * — a `style: display-black` naming one of a component's looks — was offered for translation, and
128
+ * a translated one quietly fell back to the component's default look.
129
+ *
130
+ * One rule for the four places that walk a block — extraction, the build's merge, the push and the
131
+ * pull — so a string extracted is a string translated, and a translation pushed is one read back.
132
+ *
133
+ * @param {object|Array} data - the block's value: a list of items, or one
134
+ * @param {(value: string, path: string) => string|void} visit
135
+ * @param {object|null} [model] - the block's lowered model (`data-models.js`), or null
136
+ */
137
+ export function visitDataBlockStrings(data, visit, model = null) {
138
+ if (!model) return visitDataStrings(data, visit)
139
+ if (Array.isArray(data)) data.forEach((item, i) => visitModelStrings(item, model, visit, `[${i}]`))
140
+ else visitModelStrings(data, model, visit)
141
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { resolveDefaultLocale } from '@uniweb/core'
9
9
  import { computeHash, stripInlineTags } from './hash.js'
10
- import { visitDataStrings } from './data-strings.js'
10
+ import { visitDataBlockStrings } from './data-strings.js'
11
11
 
12
12
  // ProseMirror inline fragment → inline markdown, for the TRANSLATOR-FACING half of
13
13
  // a unit. Same lazy-import-with-fallback pattern as merge.js, so the synchronous
@@ -25,9 +25,13 @@ try {
25
25
  /**
26
26
  * Extract all translatable units from site content
27
27
  * @param {Object} siteContent - Parsed site-content.json
28
+ * @param {Object} [options]
29
+ * @param {(type: string, tag: string) => Object|null} [options.dataModel] - what each section type
30
+ * declares its data blocks are (`data-models.js::dataBlockModels`); without it, every block is
31
+ * walked by the heuristic
28
32
  * @returns {Object} Manifest with translation units
29
33
  */
30
- export function extractTranslatableContent(siteContent) {
34
+ export function extractTranslatableContent(siteContent, { dataModel = null } = {}) {
31
35
  const units = {}
32
36
 
33
37
  // The site's own Open Graph title and description (`site.yml` `seo:`), in the site's context.
@@ -41,7 +45,7 @@ export function extractTranslatableContent(siteContent) {
41
45
 
42
46
  // Extract section content
43
47
  for (const section of page.sections || []) {
44
- extractFromSection(section, pageRoute, units)
48
+ extractFromSection(section, pageRoute, units, dataModel)
45
49
  }
46
50
  }
47
51
 
@@ -51,7 +55,7 @@ export function extractTranslatableContent(siteContent) {
51
55
  const pageRoute = notFoundPage.route || '/404'
52
56
  extractFromPageMeta(notFoundPage, pageRoute, units)
53
57
  for (const section of notFoundPage.sections || []) {
54
- extractFromSection(section, pageRoute, units)
58
+ extractFromSection(section, pageRoute, units, dataModel)
55
59
  }
56
60
  }
57
61
 
@@ -64,7 +68,7 @@ export function extractTranslatableContent(siteContent) {
64
68
  if (layoutPage?.sections) {
65
69
  const pageRoute = layoutPage.route || `/layout/${layoutName === 'default' ? '' : layoutName + '/'}${areaKey}`
66
70
  for (const section of layoutPage.sections) {
67
- extractFromSection(section, pageRoute, units)
71
+ extractFromSection(section, pageRoute, units, dataModel)
68
72
  }
69
73
  }
70
74
  }
@@ -169,7 +173,7 @@ export function translationContext(section, pageRoute) {
169
173
  * @param {string} pageRoute - Parent page route
170
174
  * @param {Object} units - Units accumulator
171
175
  */
172
- function extractFromSection(section, pageRoute, units) {
176
+ function extractFromSection(section, pageRoute, units, dataModel = null) {
173
177
  const context = translationContext(section, pageRoute)
174
178
 
175
179
  // Extract from parsed semantic content if available
@@ -179,12 +183,12 @@ function extractFromSection(section, pageRoute, units) {
179
183
 
180
184
  if (section.content?.type === 'doc') {
181
185
  extractFromProseMirrorDoc(section.content, context, units)
182
- extractFromDataBlocks(section.content, context, units)
186
+ extractFromDataBlocks(section.content, context, units, (tag) => dataModel?.(section.type, tag) ?? null)
183
187
  }
184
188
 
185
189
  // Recursively process subsections
186
190
  for (const subsection of section.subsections || []) {
187
- extractFromSection(subsection, pageRoute, units)
191
+ extractFromSection(subsection, pageRoute, units, dataModel)
188
192
  }
189
193
  }
190
194
 
@@ -214,14 +218,14 @@ function extractFromSection(section, pageRoute, units) {
214
218
  * as the same per-string entries this lane writes — the block-element keying
215
219
  * contract is untouched.
216
220
  */
217
- function extractFromDataBlocks(doc, context, units) {
221
+ function extractFromDataBlocks(doc, context, units, modelFor = () => null) {
218
222
  for (const node of dataBlockNodes(doc)) {
219
223
  const { tag, data } = node.attrs || {}
220
224
  if (!tag || !data || typeof data !== 'object') continue
221
225
 
222
- visitDataStrings(data, (value, fieldPath) => {
226
+ visitDataBlockStrings(data, (value, fieldPath) => {
223
227
  addUnit(units, value, `data.${tag}.${fieldPath}`, context)
224
- })
228
+ }, modelFor(tag))
225
229
  }
226
230
  }
227
231
 
package/src/i18n/index.js CHANGED
@@ -14,6 +14,7 @@ import { join, dirname } from 'node:path'
14
14
  import { computeHash, normalizeText } from './hash.js'
15
15
  import { availableLocales, resolveLocaleList } from './locales.js'
16
16
  import { extractTranslatableContent } from './extract.js'
17
+ import { dataBlockModels } from './data-models.js'
17
18
  import { syncManifests, formatSyncReport } from './sync.js'
18
19
  import { mergeTranslations, generateAllLocales } from './merge.js'
19
20
  import { auditLocale, cleanLocale, formatAuditReport } from './audit.js'
@@ -160,8 +161,8 @@ export async function extractManifest(siteRoot, siteContent, options = {}) {
160
161
  dryRun = false
161
162
  } = options
162
163
 
163
- // Extract translatable content
164
- const manifest = extractTranslatableContent(siteContent)
164
+ // Extract translatable content — a data block by what its section type declares it is
165
+ const manifest = extractTranslatableContent(siteContent, { dataModel: await dataBlockModels(siteRoot) })
165
166
 
166
167
  // Ensure locales directory exists
167
168
  const localesPath = join(siteRoot, localesDir)
@@ -372,6 +373,8 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
372
373
  const defaultLocale = resolveDefaultLocale(siteContent.config)
373
374
 
374
375
  const outputs = {}
376
+ // What each section type declares its data blocks are, so a block translates what extraction offered.
377
+ const dataModel = await dataBlockModels(siteRoot)
375
378
 
376
379
  // ⭐ The DEFAULT locale's payload needs the same stamp. It is not written in
377
380
  // this loop — it is the source this function reads — but a visitor landing on
@@ -420,7 +423,8 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
420
423
  fallbackToSource,
421
424
  locale,
422
425
  localesDir: localesPath,
423
- freeformEnabled: true
426
+ freeformEnabled: true,
427
+ dataModel
424
428
  })
425
429
 
426
430
  // Check for stale/orphaned free-form translations and warn
@@ -428,7 +432,8 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
428
432
  } else {
429
433
  // Use sync merge (original behavior)
430
434
  translated = mergeTranslations(siteContent, translations, {
431
- fallbackToSource
435
+ fallbackToSource,
436
+ dataModel
432
437
  })
433
438
  }
434
439
 
package/src/i18n/merge.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
17
  import { elementText, blockElements, translationContext, SITE_META_CONTEXT } from './extract.js'
18
- import { visitDataStrings } from './data-strings.js'
18
+ import { visitDataBlockStrings } from './data-strings.js'
19
19
 
20
20
  // Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
21
21
  // translation VALUE (which carries marks/links/icons as inline markdown). Same
@@ -42,6 +42,9 @@ try {
42
42
  * @param {string} [options.locale] - Locale code for free-form lookups
43
43
  * @param {string} [options.localesDir] - Path to locales directory
44
44
  * @param {boolean} [options.freeformEnabled=false] - Enable free-form translation lookup
45
+ * @param {(type: string, tag: string) => Object|null} [options.dataModel] - what each section type
46
+ * declares its data blocks are (`data-models.js::dataBlockModels`), so a block translates the
47
+ * fields extraction offered; without it, every block is walked by the heuristic
45
48
  * @returns {Object|Promise<Object>} Translated site content (async if freeformEnabled)
46
49
  */
47
50
  export function mergeTranslations(siteContent, translations, options = {}) {
@@ -49,7 +52,8 @@ export function mergeTranslations(siteContent, translations, options = {}) {
49
52
  fallbackToSource = true,
50
53
  locale = null,
51
54
  localesDir = null,
52
- freeformEnabled = false
55
+ freeformEnabled = false,
56
+ dataModel = null
53
57
  } = options
54
58
 
55
59
  // If free-form is enabled, use async version
@@ -57,18 +61,19 @@ export function mergeTranslations(siteContent, translations, options = {}) {
57
61
  return mergeTranslationsAsync(siteContent, translations, {
58
62
  fallbackToSource,
59
63
  locale,
60
- localesDir
64
+ localesDir,
65
+ dataModel
61
66
  })
62
67
  }
63
68
 
64
69
  // Sync version (original behavior)
65
- return mergeTranslationsSync(siteContent, translations, fallbackToSource)
70
+ return mergeTranslationsSync(siteContent, translations, fallbackToSource, dataModel)
66
71
  }
67
72
 
68
73
  /**
69
74
  * Synchronous merge (original behavior, no free-form)
70
75
  */
71
- function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
76
+ function mergeTranslationsSync(siteContent, translations, fallbackToSource, dataModel = null) {
72
77
  // Deep clone to avoid mutating original
73
78
  const translated = JSON.parse(JSON.stringify(siteContent))
74
79
  translateSiteMeta(translated.config, translations, fallbackToSource)
@@ -81,7 +86,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
81
86
 
82
87
  // Translate section content
83
88
  for (const section of page.sections || []) {
84
- translateSectionSync(section, pageRoute, translations, fallbackToSource)
89
+ translateSectionSync(section, pageRoute, translations, fallbackToSource, dataModel)
85
90
  }
86
91
  }
87
92
 
@@ -90,7 +95,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
90
95
  const pageRoute = translated.notFound.route || '/404'
91
96
  translatePageMeta(translated.notFound, pageRoute, translations, fallbackToSource)
92
97
  for (const section of translated.notFound.sections || []) {
93
- translateSectionSync(section, pageRoute, translations, fallbackToSource)
98
+ translateSectionSync(section, pageRoute, translations, fallbackToSource, dataModel)
94
99
  }
95
100
  }
96
101
 
@@ -103,7 +108,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
103
108
  if (layoutPage?.sections) {
104
109
  const pageRoute = layoutPage.route || `/layout/${layoutName === 'default' ? '' : layoutName + '/'}${areaKey}`
105
110
  for (const section of layoutPage.sections) {
106
- translateSectionSync(section, pageRoute, translations, fallbackToSource)
111
+ translateSectionSync(section, pageRoute, translations, fallbackToSource, dataModel)
107
112
  }
108
113
  }
109
114
  }
@@ -117,7 +122,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
117
122
  * Asynchronous merge with free-form support
118
123
  */
119
124
  async function mergeTranslationsAsync(siteContent, translations, options) {
120
- const { fallbackToSource, locale, localesDir } = options
125
+ const { fallbackToSource, locale, localesDir, dataModel } = options
121
126
 
122
127
  // Deep clone to avoid mutating original
123
128
  const translated = JSON.parse(JSON.stringify(siteContent))
@@ -134,7 +139,8 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
134
139
  await translateSectionAsync(section, page, translations, {
135
140
  fallbackToSource,
136
141
  locale,
137
- localesDir
142
+ localesDir,
143
+ dataModel
138
144
  })
139
145
  }
140
146
  }
@@ -147,7 +153,8 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
147
153
  await translateSectionAsync(section, translated.notFound, translations, {
148
154
  fallbackToSource,
149
155
  locale,
150
- localesDir
156
+ localesDir,
157
+ dataModel
151
158
  })
152
159
  }
153
160
  }
@@ -164,7 +171,8 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
164
171
  await translateSectionAsync(section, layoutPage, translations, {
165
172
  fallbackToSource,
166
173
  locale,
167
- localesDir
174
+ localesDir,
175
+ dataModel
168
176
  })
169
177
  }
170
178
  }
@@ -281,17 +289,17 @@ function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
281
289
  /**
282
290
  * Translate a section's content (synchronous, hash-based only)
283
291
  */
284
- function translateSectionSync(section, pageRoute, translations, fallbackToSource) {
292
+ function translateSectionSync(section, pageRoute, translations, fallbackToSource, dataModel = null) {
285
293
  const context = translationContext(section, pageRoute)
286
294
 
287
295
  if (section.content?.type === 'doc') {
288
296
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
289
- translateDataBlocks(section.content, context, translations, fallbackToSource)
297
+ translateDataBlocks(section.content, context, translations, fallbackToSource, (tag) => dataModel?.(section.type, tag) ?? null)
290
298
  }
291
299
 
292
300
  // Recursively translate subsections
293
301
  for (const subsection of section.subsections || []) {
294
- translateSectionSync(subsection, pageRoute, translations, fallbackToSource)
302
+ translateSectionSync(subsection, pageRoute, translations, fallbackToSource, dataModel)
295
303
  }
296
304
  }
297
305
 
@@ -303,7 +311,7 @@ function translateSectionSync(section, pageRoute, translations, fallbackToSource
303
311
  * 2. Fall back to hash-based translation (element-by-element)
304
312
  */
305
313
  async function translateSectionAsync(section, page, translations, options) {
306
- const { fallbackToSource, locale, localesDir } = options
314
+ const { fallbackToSource, locale, localesDir, dataModel } = options
307
315
  const pageRoute = page.route || '/'
308
316
  const context = translationContext(section, pageRoute)
309
317
 
@@ -318,7 +326,7 @@ async function translateSectionAsync(section, page, translations, options) {
318
326
  // Fall back to hash-based translation
319
327
  if (section.content?.type === 'doc') {
320
328
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
321
- translateDataBlocks(section.content, context, translations, fallbackToSource)
329
+ translateDataBlocks(section.content, context, translations, fallbackToSource, (tag) => dataModel?.(section.type, tag) ?? null)
322
330
  }
323
331
  }
324
332
 
@@ -496,9 +504,15 @@ function withMarks(node, marks) {
496
504
  * silently; it now does (`uwx/locale-sync.js::deriveStructuralMap`), so the two
497
505
  * halves travel together.
498
506
  *
507
+ * ⭐ A block whose key its section type declares the shape of translates the fields that shape says
508
+ * are text, and only those (`data-strings.js::visitDataBlockStrings`) — the fields extraction
509
+ * offered, so a translation of an enum's value that happens to share its words with prose elsewhere
510
+ * is never applied to it.
511
+ *
512
+ * @param {(tag: string) => Object|null} [modelFor] - the model of a block's key, by its tag
499
513
  * @returns {boolean} whether any value was translated
500
514
  */
501
- function translateDataBlocks(doc, context, translations, fallbackToSource) {
515
+ function translateDataBlocks(doc, context, translations, fallbackToSource, modelFor = () => null) {
502
516
  let changed = false
503
517
  const walk = (nodes) => {
504
518
  for (const node of nodes || []) {
@@ -506,11 +520,11 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
506
520
  if (node.type === 'dataBlock') {
507
521
  const data = node.attrs?.data
508
522
  if (data && typeof data === 'object') {
509
- visitDataStrings(data, (value) => {
523
+ visitDataBlockStrings(data, (value) => {
510
524
  const translated = lookupTranslation(value, context, translations, fallbackToSource)
511
525
  if (translated !== value) changed = true
512
526
  return translated
513
- })
527
+ }, modelFor(node.attrs?.tag))
514
528
  }
515
529
  } else if (Array.isArray(node.content)) {
516
530
  walk(node.content)
@@ -529,12 +543,15 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
529
543
  * (so the caller can omit an untranslated locale — it falls back to the source
530
544
  * locale). Lets the sync producer emit a self-contained per-locale DOC instead of a
531
545
  * source-keyed map (which a consumer would otherwise resolve against the source).
546
+ *
547
+ * `modelFor` is the section's data-block models by tag — `(tag) => dataModel(section.type, tag)` —
548
+ * so a push translates a block as the build does; without it, blocks are walked by the heuristic.
532
549
  */
533
- export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }) {
550
+ export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }, modelFor = () => null) {
534
551
  if (!sourceDoc || sourceDoc.type !== 'doc' || !table) return null
535
552
  const doc = JSON.parse(JSON.stringify(sourceDoc))
536
553
  const elements = translateProseMirrorDoc(doc, context, table, true)
537
- const data = translateDataBlocks(doc, context, table, true)
554
+ const data = translateDataBlocks(doc, context, table, true, modelFor)
538
555
  return elements || data ? doc : null
539
556
  }
540
557
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ⭐ A RECORD'S FIELDS, WALKED BY ITS MODEL — the shape a push sends it in, lowered by the push's
3
+ * own rule (`uwx/data-schema.js::toDataSchemaDeclaration`), which marks `localized` exactly the
4
+ * fields that travel per locale: text — never an enum, a value format (a URL, an email), a
5
+ * `translatable: false` field, a number, a date, a file or a reference.
6
+ *
7
+ * One walk for two things a model describes: a record (`records.js`), and a tagged data block whose
8
+ * key a section type declares the shape of (`data-strings.js::visitDataBlockStrings`).
9
+ */
10
+ import { isOpenMapSection } from '../uwx/data-schema.js'
11
+
12
+ export function isPlainObject(value) {
13
+ return !!value && typeof value === 'object' && !Array.isArray(value)
14
+ }
15
+
16
+ /**
17
+ * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
18
+ * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
19
+ * parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
20
+ * record under), and a top-level `slug` is how a record's reader hands the file's name
21
+ * on. Until 2026-09-14 they became translation units, and a translation whose source
22
+ * happened to equal one rewrote it, so a record's page stopped matching its URL.
23
+ * ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
24
+ * `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
25
+ *
26
+ * @param {string|number} key - a field name (an array index is never one)
27
+ * @param {boolean} topLevel - whether the key sits at the record's own level
28
+ * @returns {boolean}
29
+ */
30
+ export function isRecordSystemField(key, topLevel) {
31
+ if (typeof key !== 'string') return false
32
+ return key.startsWith('$') || (topLevel && key === 'slug')
33
+ }
34
+
35
+ /**
36
+ * Each field of a model that a record holds a value for, in the shape the record is DELIVERED in
37
+ * (`toDeliveredRecord`): the brief's fields at the top — every field, for a model of one single
38
+ * section — and each other section under its name, a list of them for a `multiple` one, a nested
39
+ * section the same way. Calls `visit(holder, name, field, path)` for each.
40
+ */
41
+ export function eachModelField(record, model, visit) {
42
+ const sections = Object.entries(model?.sections || {})
43
+ const flat = sections.length === 1 && !sections[0][1]?.multiple
44
+ for (const [name, section] of sections) {
45
+ if (flat || section?.brief) eachSectionField(record, section?.fields, '', visit)
46
+ else eachSectionValue(record?.[name], section, name, visit)
47
+ }
48
+ }
49
+
50
+ function eachSectionValue(value, section, path, visit) {
51
+ if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
52
+ if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
53
+ // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
54
+ else if (isOpenMapSection(section) && isPlainObject(value)) {
55
+ for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
56
+ }
57
+ }
58
+
59
+ export function eachSectionField(holder, fields, path, visit) {
60
+ if (!isPlainObject(holder)) return
61
+ for (const [name, field] of Object.entries(fields || {})) {
62
+ if (holder[name] == null || isRecordSystemField(name, !path)) continue
63
+ const at = path ? `${path}.${name}` : name
64
+ if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
65
+ else visit(holder, name, field || {}, at)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Each string a record's model marks `localized` — a text field's value, or each string of a list
71
+ * of them — handed to `visit(value, path)`; a different string returned replaces it, in place, as
72
+ * `data-strings.js::visitDataStrings` does. A rich field is one string here: a data block holds its
73
+ * markdown as written.
74
+ *
75
+ * @param {object} record
76
+ * @param {object} model - a lowered data schema (`toDataSchemaDeclaration`)
77
+ * @param {(value: string, path: string) => string|void} visit
78
+ * @param {string} [prefix] - prepended to each path (`[0]` for a list's first item)
79
+ */
80
+ export function visitModelStrings(record, model, visit, prefix = '') {
81
+ const pathOf = (path) => (prefix ? (path.startsWith('[') ? `${prefix}${path}` : `${prefix}.${path}`) : path)
82
+ eachModelField(record, model, (holder, name, field, path) => {
83
+ if (!field.localized) return
84
+ const value = holder[name]
85
+ if (typeof value === 'string') {
86
+ if (!value.trim()) return
87
+ const next = visit(value, pathOf(path))
88
+ if (typeof next === 'string' && next !== value) holder[name] = next
89
+ } else if (Array.isArray(value)) {
90
+ value.forEach((item, i) => {
91
+ if (typeof item !== 'string' || !item.trim()) return
92
+ const next = visit(item, pathOf(`${path}[${i}]`))
93
+ if (typeof next === 'string' && next !== item) value[i] = next
94
+ })
95
+ }
96
+ })
97
+ }
@@ -23,7 +23,8 @@ 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, isOpenMapSection } from '../uwx/data-schema.js'
26
+ import { toDataSchemaDeclaration, isProseMirrorField } from '../uwx/data-schema.js'
27
+ import { eachModelField, eachSectionField, isRecordSystemField, isPlainObject } from './model-fields.js'
27
28
  import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored, storedFromMerged } from '@uniweb/schemas/conform'
28
29
  import { resolveDocForLocale } from './merge.js'
29
30
  import { extractUnitsFromDoc } from './extract.js'
@@ -186,7 +187,7 @@ function isFieldTranslatable(fieldDef) {
186
187
  * @param {string} ref - the ref it was resolved from
187
188
  * @returns {Object|null} the lowered declaration, or null when there is none
188
189
  */
189
- function modelOf(schema, ref) {
190
+ export function modelOf(schema, ref) {
190
191
  if (!schema) return null
191
192
  try {
192
193
  return toDataSchemaDeclaration(schema, { name: ref || '@/record', resolveName: (r) => r })
@@ -208,40 +209,6 @@ function referencedModels(model) {
208
209
  return out
209
210
  }
210
211
 
211
- /**
212
- * Each field of a model that a record holds a value for, in the shape the record is DELIVERED in
213
- * (`toDeliveredRecord`): the brief's fields at the top — every field, for a model of one single
214
- * section — and each other section under its name, a list of them for a `multiple` one, a nested
215
- * section the same way. Calls `visit(holder, name, field, path)` for each.
216
- */
217
- function eachModelField(record, model, visit) {
218
- const sections = Object.entries(model?.sections || {})
219
- const flat = sections.length === 1 && !sections[0][1]?.multiple
220
- for (const [name, section] of sections) {
221
- if (flat || section?.brief) eachSectionField(record, section?.fields, '', visit)
222
- else eachSectionValue(record?.[name], section, name, visit)
223
- }
224
- }
225
-
226
- function eachSectionValue(value, section, path, visit) {
227
- if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
228
- if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
229
- // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
230
- else if (isOpenMapSection(section) && isPlainObject(value)) {
231
- for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
232
- }
233
- }
234
-
235
- function eachSectionField(holder, fields, path, visit) {
236
- if (!isPlainObject(holder)) return
237
- for (const [name, field] of Object.entries(fields || {})) {
238
- if (holder[name] == null || isRecordSystemField(name, !path)) continue
239
- const at = path ? `${path}.${name}` : name
240
- if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
241
- else visit(holder, name, field || {}, at)
242
- }
243
- }
244
-
245
212
  /** The fields of a model's brief — what a reference to one of its records is delivered as. */
246
213
  function briefFields(model) {
247
214
  const sections = Object.values(model?.sections || {})
@@ -360,25 +327,6 @@ function derivesExcerpt(record, whole, dataSchema, config) {
360
327
  return derivedExcerptOf(whole ?? record, dataSchema, config) === record.excerpt
361
328
  }
362
329
 
363
- /**
364
- * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
365
- * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
366
- * parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
367
- * record under), and a top-level `slug` is how a record's reader hands the file's name
368
- * on. Until 2026-09-14 they became translation units, and a translation whose source
369
- * happened to equal one rewrote it, so a record's page stopped matching its URL.
370
- * ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
371
- * `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
372
- *
373
- * @param {string|number} key - a field name (an array index is never one)
374
- * @param {boolean} topLevel - whether the key sits at the record's own level
375
- * @returns {boolean}
376
- */
377
- function isRecordSystemField(key, topLevel) {
378
- if (typeof key !== 'string') return false
379
- return key.startsWith('$') || (topLevel && key === 'slug')
380
- }
381
-
382
330
  /**
383
331
  * A record's handle — `$name`, which the build sets from the record's file (`slug:` or
384
332
  * its name). ⭐ Not `slug` alone: a record of a schema whose brief declares a `slug`
@@ -1208,10 +1156,6 @@ function applyFreeform(record, freeform, dataSchema, schemaRef) {
1208
1156
  return out
1209
1157
  }
1210
1158
 
1211
- function isPlainObject(value) {
1212
- return !!value && typeof value === 'object' && !Array.isArray(value)
1213
- }
1214
-
1215
1159
  /**
1216
1160
  * Apply translations to a collection item (sync, hash-based only) — by the model it is pushed as
1217
1161
  * when it has one (`translateByModel`), else a companion schema's fields, else the heuristic.
@@ -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
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) {