@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 +8 -8
- package/src/dev/plugin.js +3 -3
- package/src/generate-entry.js +15 -28
- package/src/i18n/data-models.js +77 -0
- package/src/i18n/data-strings.js +27 -0
- package/src/i18n/extract.js +15 -11
- package/src/i18n/index.js +9 -4
- package/src/i18n/merge.js +39 -22
- package/src/i18n/model-fields.js +97 -0
- package/src/i18n/records.js +3 -59
- package/src/resolve-data-schema.js +66 -13
- package/src/runtime-schema.js +54 -218
- package/src/site/plugin.js +8 -1
- package/src/uwx/data-schema.js +4 -2
- package/src/uwx/index.js +2 -0
- package/src/uwx/locale-sync.js +17 -11
- package/src/uwx/site-project.js +7 -4
- package/src/uwx/site.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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.
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
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.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
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
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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') ||
|
package/src/generate-entry.js
CHANGED
|
@@ -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
|
-
* - `
|
|
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 {
|
|
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 (
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
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
|
-
//
|
|
523
|
-
// The entry
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
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}`
|
package/src/i18n/data-strings.js
CHANGED
|
@@ -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
|
+
}
|
package/src/i18n/extract.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { resolveDefaultLocale } from '@uniweb/core'
|
|
9
9
|
import { computeHash, stripInlineTags } from './hash.js'
|
|
10
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|