@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 +7 -7
- 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/site/plugin.js +8 -1
- 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.75.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
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}`
|
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
|
|
|
@@ -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
|
+
}
|
package/src/i18n/records.js
CHANGED
|
@@ -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
|
|
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.
|
package/src/site/plugin.js
CHANGED
|
@@ -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,
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -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 {
|
|
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
|
-
// (`
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|