@uniweb/build 0.74.4 → 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.74.4",
3
+ "version": "0.75.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-reader": "^1.2.8",
61
- "@uniweb/projections": "^0.7.11",
62
- "@uniweb/theming": "^0.2.0",
60
+ "@uniweb/projections": "^0.7.13",
63
61
  "@uniweb/content-writer": "^0.3.4",
64
- "@uniweb/schemas": "^0.10.1",
65
- "@uniweb/semantic-parser": "^1.5.1"
62
+ "@uniweb/semantic-parser": "^1.5.1",
63
+ "@uniweb/content-reader": "^1.2.8",
64
+ "@uniweb/schemas": "^0.10.2",
65
+ "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.28.7"
68
+ "@uniweb/runtime": "^0.28.10"
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.35.2",
78
- "@uniweb/runtime": "^0.28.7"
77
+ "@uniweb/runtime": "^0.28.10",
78
+ "@uniweb/core": "^0.36.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Exports:
7
7
  * - `components` - Object map of component name -> React component
8
- * - `capabilities` - Custom Layout and props from src/main.js (if present)
8
+ * - `capabilities` - what the runtime reads of src/main.js (`extractFoundationRuntime`): the
9
+ * code it calls, referenced from the module, and its data, lean — plus the discovered layouts
9
10
  * - `meta` - Per-component runtime metadata extracted from meta.js files
10
11
  *
11
12
  * The `meta` export contains only properties needed at runtime:
@@ -26,10 +27,11 @@ import { join, dirname } from 'node:path'
26
27
  import {
27
28
  discoverComponents,
28
29
  discoverLayoutsInPath,
30
+ loadFoundationConfig,
29
31
  DEFAULT_SECTION_PATHS,
30
32
  LAYOUTS_PATH
31
33
  } from './schema.js'
32
- import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
34
+ import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas, extractFoundationRuntime } from './runtime-schema.js'
33
35
  import { collectSchemaRefs, buildDataSchemaMap, SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
34
36
 
35
37
  /**
@@ -88,31 +90,12 @@ async function detectHostShareableImports(srcDir) {
88
90
  }
89
91
 
90
92
  /**
91
- * Detect foundation config file (for props, vars, etc.)
92
- *
93
- * Looks for (in priority order): main.js, main.jsx
94
- *
95
- * The foundation's main authored module.
96
- *
97
- * The file should export:
98
- * - props (optional) - Foundation-wide props
99
- * - vars (optional) - CSS custom properties (also read by schema builder)
100
- * - defaultLayout (optional) - Default layout name
101
- *
102
- * Note: Layout components are now discovered from layouts/ at the source root.
93
+ * The foundation's `main.js` — its declarations, read by `loadFoundationConfig` as the schema
94
+ * reads them. `main.js` is the only name: ⛔ until 2026-10-01 this also took a `main.jsx`, which
95
+ * nothing else read, so the editor saw none of its declarations while the runtime saw all of them.
103
96
  */
104
97
  function detectFoundationExports(srcDir) {
105
- const candidates = [
106
- { path: 'main.js', ext: 'js' },
107
- { path: 'main.jsx', ext: 'jsx' },
108
- ]
109
-
110
- for (const { path, ext } of candidates) {
111
- if (existsSync(join(srcDir, path))) {
112
- return { path: `./${path}`, ext }
113
- }
114
- }
115
- return null
98
+ return existsSync(join(srcDir, 'main.js')) ? { path: './main.js', ext: 'js' } : null
116
99
  }
117
100
 
118
101
  /**
@@ -140,6 +123,7 @@ function generateEntrySource(components, options = {}) {
140
123
  const {
141
124
  cssPath = null,
142
125
  foundationExports = null,
126
+ foundationRuntime = { code: [], data: {} },
143
127
  meta = {},
144
128
  layouts = {},
145
129
  layoutMeta = {},
@@ -160,10 +144,15 @@ function generateEntrySource(components, options = {}) {
160
144
  lines.push(`import '${cssPath}'`)
161
145
  }
162
146
 
163
- // Foundation capabilities import (for props, vars, etc.)
164
- // Note: Layout/layouts no longer merged from main.js — layouts come from src/layouts/ discovery
147
+ // The foundation's `main.js` — imported for the code the runtime calls, and only referenced for
148
+ // it, so the bundler drops the rest of its default export (measured 2026-10-01). With no such
149
+ // code, imported for its side effects alone. Its data is written below, lean.
165
150
  if (foundationExports) {
166
- lines.push(`import * as _foundationModule from '${foundationExports.path}'`)
151
+ lines.push(
152
+ foundationRuntime.code.length > 0
153
+ ? `import * as _foundationModule from '${foundationExports.path}'`
154
+ : `import '${foundationExports.path}'`
155
+ )
167
156
  }
168
157
 
169
158
  // Component imports
@@ -185,21 +174,19 @@ function generateEntrySource(components, options = {}) {
185
174
  lines.push(`export { ${componentNames.join(', ')} }`)
186
175
  }
187
176
 
188
- // Foundation capabilities (props, vars, etc. + discovered layouts)
177
+ // Foundation capabilities — what the runtime reads of `main.js`, and nothing else of it
178
+ // (`extractFoundationRuntime`, ruled 2026-10-01 [Diego]): the code it calls, referenced from the
179
+ // module, and its data as a lean literal — `vars` (each var's `default`, `type`, `applyTo`, as
180
+ // `loadFoundationConfig` reads them, named export first), `data` (each key's schema ref), and the
181
+ // defaults that pick a layout, a section type, transitions and scroll. Plus the discovered layouts.
182
+ // ⛔ Until 2026-10-01 the whole default export was spread in, so a foundation's name, description
183
+ // and its vars' descriptions shipped in every published bundle.
189
184
  lines.push('')
190
185
  if (foundationExports || layoutNames.length > 0) {
191
186
  const capParts = []
192
187
  if (foundationExports) {
193
- capParts.push('..._foundationModule.default')
194
- // `vars` is a NAMED export of main.js, so the spread above misses it —
195
- // schema.js reads `module.vars || module.default?.vars` and this must
196
- // agree with it (named wins), or the runtime and the editor would see
197
- // different foundation vars. Without this line the declared theme vars
198
- // exist ONLY in dist/meta/schema.json, which is a build-time artifact:
199
- // any lane that generates theme CSS outside the build (the runtime's
200
- // ensureThemeCss, a cloud shell assembler) would silently produce a
201
- // theme missing every foundation-declared var.
202
- capParts.push('vars: _foundationModule.vars || _foundationModule.default?.vars')
188
+ for (const key of foundationRuntime.code) capParts.push(`${key}: _foundationModule.default?.${key}`)
189
+ for (const [key, value] of Object.entries(foundationRuntime.data)) capParts.push(`${key}: ${JSON.stringify(value)}`)
203
190
  }
204
191
  if (layoutNames.length > 0) {
205
192
  capParts.push(`layouts: { ${layoutNames.join(', ')} }`)
@@ -316,8 +303,13 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
316
303
  // Check for CSS file
317
304
  const cssPath = detectCssFile(srcDir)
318
305
 
319
- // Check for foundation exports (props, vars, etc.)
306
+ // The foundation's `main.js`, and what the runtime reads of it — read as the schema reads it
307
+ // (`loadFoundationConfig`: the named `vars` first, JSX imports transpiled), so the editor and the
308
+ // runtime see one declaration.
309
+ // Asked even with no main.js: it refuses a `foundation.js` or `main.jsx` left in its place.
320
310
  const foundationExports = detectFoundationExports(srcDir)
311
+ const foundationConfig = await loadFoundationConfig(srcDir)
312
+ const foundationRuntime = foundationExports ? extractFoundationRuntime(foundationConfig) : { code: [], data: {} }
321
313
 
322
314
  // Resolve the data schemas referenced by section bindings, then extract
323
315
  // per-component runtime metadata (which lean-extracts field defaults from
@@ -347,6 +339,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
347
339
  const source = generateEntrySource(components, {
348
340
  cssPath,
349
341
  foundationExports,
342
+ foundationRuntime,
350
343
  meta,
351
344
  layouts,
352
345
  layoutMeta,
@@ -386,6 +379,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
386
379
  componentNames,
387
380
  layoutNames,
388
381
  foundationExports,
382
+ foundationRuntime,
389
383
  meta,
390
384
  layoutMeta,
391
385
  hostShareableImports,
package/src/i18n/audit.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFile, writeFile } from 'fs/promises'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
+ import { translationKeepsMarkup } from './merge.js'
13
14
 
14
15
  // Inline markdown → ProseMirror, so the markup check asks the merge's own question
15
16
  // ("would this value produce marks?") instead of pattern-matching markdown syntax.
@@ -67,16 +68,18 @@ export async function auditLocale(localesPath, locale) {
67
68
  const translation = getTranslationText(translations[hash])
68
69
  valid.push({ hash, source, translation })
69
70
 
70
- // ⭐ The source element carries inline markdown; the translation does not.
71
- // `merge` builds the translated element from the VALUE alone — it never
72
- // re-applies marks from the source — so this entry will render as flat prose
73
- // and its links will be gone. It still counts as translated, which is why
74
- // coverage cannot see it: the whole class scores 100%.
71
+ // ⭐ The source element carries inline markdown, and what the merge makes of
72
+ // the translation does not — so this entry will render as flat prose and its
73
+ // links will be gone. It still counts as translated, which is why coverage
74
+ // cannot see it: the whole class scores 100%. The merge's own answer
75
+ // (`translationKeepsMarkup`): a plain label for a button keeps its link there,
76
+ // so it is no loss. ⛔ Until 2026-10-01 any plain value counted, when the merge
77
+ // rebuilt every element from the value alone.
75
78
  // ⛔ REPLACES a `<N>`-tag check that could no longer fire. Those tags were the
76
79
  // pre-2026-06-24 keying scheme (`d12d594`); extraction has not emitted one
77
80
  // since, so the check was dead while looking like a live guard — and it was
78
81
  // the only thing in the pipeline that even gestured at mark fidelity.
79
- if (unit.markup && translation.length > 0 && !carriesInlineMarkup(translation)) {
82
+ if (unit.markup && translation.length > 0 && dropsSourceMarkup(unit.markup, translation)) {
80
83
  losesMarkup.push({ hash, source, markup: unit.markup, translation })
81
84
  }
82
85
  } else {
@@ -111,24 +114,19 @@ export async function auditLocale(localesPath, locale) {
111
114
  }
112
115
 
113
116
  /**
114
- * Does this translation value produce any inline marks when the merge parses it?
117
+ * Does the merge drop the inline markup this translation's source element carries?
115
118
  *
116
- * Asks the parser rather than a regex: the merge resolves a value through
117
- * `markdownToProseMirror`, so the only honest test of "will this keep its link"
118
- * is to run the same conversion. With no converter available the check declines
119
- * to fire — a missing dependency must not invent findings.
119
+ * Asks the merge rather than a regex (`translationKeepsMarkup`), since what the
120
+ * merge makes of a value is the only honest answer to "will this keep its link".
121
+ * With no converter available the check declines to fire — a missing dependency
122
+ * must not invent findings.
120
123
  */
121
- function carriesInlineMarkup(value) {
122
- if (!markdownToProseMirror) return true
124
+ function dropsSourceMarkup(markup, value) {
125
+ if (!markdownToProseMirror) return false
123
126
  try {
124
- const doc = markdownToProseMirror(value)
125
- const walk = (nodes) =>
126
- (nodes || []).some(
127
- (n) => (n?.marks && n.marks.length > 0) || (n?.type && n.type !== 'text' && n.type !== 'paragraph') || walk(n?.content)
128
- )
129
- return walk(doc?.content)
127
+ return !translationKeepsMarkup(markup, value)
130
128
  } catch {
131
- return true
129
+ return false
132
130
  }
133
131
  }
134
132
 
@@ -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