@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.8",
61
61
  "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.7.13",
63
- "@uniweb/schemas": "^0.10.2",
64
- "@uniweb/theming": "^0.2.0",
65
- "@uniweb/semantic-parser": "^1.5.1"
62
+ "@uniweb/projections": "^0.7.14",
63
+ "@uniweb/semantic-parser": "^1.5.1",
64
+ "@uniweb/schemas": "^0.11.0",
65
+ "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.28.10"
68
+ "@uniweb/runtime": "^0.29.0"
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.36.0",
78
- "@uniweb/runtime": "^0.28.10"
77
+ "@uniweb/runtime": "^0.29.0",
78
+ "@uniweb/core": "^0.37.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/dev/plugin.js CHANGED
@@ -243,9 +243,9 @@ export function foundationDevPlugin(options = {}) {
243
243
  // Ignore generated files (build output triggers entry regeneration)
244
244
  if (!filename || filename.includes('_entry.generated')) return
245
245
 
246
- // Rebuild for source file changes, and for any file the entry is generated
247
- // from — a data schema under `schemas/` is `.yml` or `.json` as often as `.js`
248
- // (`shouldRegenerateForFile`)
246
+ // Rebuild for source file changes, and for the other structural files
247
+ // (`shouldRegenerateForFile`) — a data schema under `schemas/`, `.yml` or `.json`
248
+ // as often as `.js`, moves the rebuilt schema.json
249
249
  if (
250
250
  filename.endsWith('.js') ||
251
251
  filename.endsWith('.jsx') ||
@@ -12,7 +12,7 @@
12
12
  * The `meta` export contains only properties needed at runtime:
13
13
  * - `background` - 'self' opt-out when component handles its own background
14
14
  * - `data` - the `content.data` keys the component declares, with their schema refs
15
- * - `schemas` - field defaults for those keys
15
+ * - `vars` - the CSS variables the component declares, with their defaults
16
16
  * - `defaults` - Param default values
17
17
  * - `context` - Static capabilities for cross-block coordination
18
18
  * - `initialState` - Initial values for mutable block state
@@ -32,7 +32,7 @@ import {
32
32
  LAYOUTS_PATH
33
33
  } from './schema.js'
34
34
  import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas, extractFoundationRuntime } from './runtime-schema.js'
35
- import { collectSchemaRefs, buildDataSchemaMap, SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
35
+ import { SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
36
36
 
37
37
  /**
38
38
  * The files a foundation's data schemas are read from, relative to its source root:
@@ -196,12 +196,12 @@ function generateEntrySource(components, options = {}) {
196
196
  lines.push('const capabilities = null')
197
197
  }
198
198
 
199
- // Per-component runtime metadata (defaults, context, initialState, background, data)
199
+ // Per-component runtime metadata (defaults, context, initialState, background, data, vars)
200
200
  lines.push('')
201
201
  const metaJson = JSON.stringify(Object.keys(meta).length > 0 ? meta : {}, null, 2)
202
202
  lines.push(`const meta = ${metaJson}`)
203
203
 
204
- // Per-layout runtime metadata (areas, transitions, defaults)
204
+ // Per-layout runtime metadata (transitions, layers, scroll, defaults)
205
205
  lines.push('')
206
206
  const layoutMetaJson = JSON.stringify(Object.keys(layoutMeta).length > 0 ? layoutMeta : {}, null, 2)
207
207
  lines.push(`const layoutMeta = ${layoutMetaJson}`)
@@ -311,23 +311,12 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
311
311
  const foundationConfig = await loadFoundationConfig(srcDir)
312
312
  const foundationRuntime = foundationExports ? extractFoundationRuntime(foundationConfig) : { code: [], data: {} }
313
313
 
314
- // Resolve the data schemas referenced by section bindings, then extract
315
- // per-component runtime metadata (which lean-extracts field defaults from
316
- // the resolved schemas into meta.schemas[<key>]).
317
- // ⛔ Components only, unlike `schema.js`, which also passes the foundation's
318
- // `main.js` `data:`. Not an oversight: this map feeds `extractAllRuntimeSchemas`,
319
- // and `extractRuntimeSchema` reads `fullMeta.data` — per component. A
320
- // foundation-tier key reaches every section (`declaredKeys`) but has nowhere to
321
- // put its lean schema in a PER-COMPONENT runtime meta, so resolving its ref here
322
- // would produce an entry nothing reads.
323
- //
324
- // ⚠️ The consequence is real and still open: a foundation-tier `data:` entry
325
- // naming a schema gets no field defaults from `applySchemas` at runtime. Latent
326
- // today — every foundation-tier declaration in the templates is ref-less — and
327
- // fixing it means deciding where a foundation-wide schema lives in the runtime
328
- // meta, which is a design question, not a missing argument.
329
- const dataSchemaMap = await buildDataSchemaMap(collectSchemaRefs(components), { srcDir })
330
- const meta = extractAllRuntimeSchemas(components, dataSchemaMap)
314
+ // Per-component runtime metadata. ⭐ No data schema is resolved for it: the runtime reads a
315
+ // declared key's ref and nothing of its shape (2026-10-05), so the entry no longer depends on
316
+ // a schema file — `schema.js` resolves them, for schema.json, and a missing one stops the
317
+ // build there. ⛔ Until then the entry carried each key's field defaults, resolved here, and a
318
+ // foundation-tier `data:` key got none, having no per-component place to put them.
319
+ const meta = extractAllRuntimeSchemas(components)
331
320
 
332
321
  // Extract per-layout runtime metadata from meta.js files
333
322
  const layoutMeta = extractAllLayoutRuntimeSchemas(layouts)
@@ -519,13 +508,11 @@ export function shouldRegenerateForFile(file, srcDir) {
519
508
  }
520
509
  }
521
510
 
522
- // ⭐ The foundation's data schemas — `schemas/<name>.<ext>` and `schemas.config.js`.
523
- // The entry's `meta` carries each declared `data:` key with its schema's field
524
- // defaults, and a `@/name` ref whose file is missing stops entry generation. A
525
- // section receives only the keys its entry declares (2026-09-14), so an entry that
526
- // falls behind withholds data: a `data:` key declaring `@/member` before
527
- // `schemas/member.yml` exists fails the regeneration, and until 2026-09-14 writing
528
- // the file triggered no other — the dev server kept the entry without the key.
511
+ // The foundation's data schemas — `schemas/<name>.<ext>` and `schemas.config.js`.
512
+ // ⚠️ The entry holds nothing read from one since 2026-10-05 (no field defaults), so
513
+ // regenerating it changes nothing. The match stays for the dev rebuild of a
514
+ // runtime-linked foundation (`dev/plugin.js`), which also writes the editor's
515
+ // schema.json — whose `dataSchemas` a schema change does move.
529
516
  if (rel.startsWith(`${SCHEMAS_PATH}/`)) {
530
517
  const inner = rel.slice(SCHEMAS_PATH.length + 1)
531
518
  if (!inner.includes('/') && SCHEMA_EXTENSIONS.some(ext => inner.endsWith(ext))) {
@@ -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