@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 +9 -9
- package/src/generate-entry.js +34 -40
- package/src/i18n/audit.js +18 -20
- 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 +167 -27
- package/src/i18n/model-fields.js +97 -0
- package/src/i18n/records.js +3 -59
- package/src/runtime-schema.js +84 -0
- package/src/schema.js +51 -5
- package/src/site/config.js +4 -0
- package/src/site/plugin.js +8 -1
- package/src/site/preview-directory-index.js +67 -0
- package/src/uwx/index.js +2 -0
- package/src/uwx/locale-sync.js +17 -11
- package/src/uwx/registry-package.js +17 -5
- 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.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/
|
|
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/
|
|
65
|
-
"@uniweb/
|
|
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.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/runtime": "^0.28.10",
|
|
78
|
+
"@uniweb/core": "^0.36.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/generate-entry.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Exports:
|
|
7
7
|
* - `components` - Object map of component name -> React component
|
|
8
|
-
* - `capabilities` -
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
164
|
-
//
|
|
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(
|
|
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
|
|
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(
|
|
194
|
-
|
|
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
|
-
//
|
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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 &&
|
|
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
|
|
117
|
+
* Does the merge drop the inline markup this translation's source element carries?
|
|
115
118
|
*
|
|
116
|
-
* Asks the
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
122
|
-
if (!markdownToProseMirror) return
|
|
124
|
+
function dropsSourceMarkup(markup, value) {
|
|
125
|
+
if (!markdownToProseMirror) return false
|
|
123
126
|
try {
|
|
124
|
-
|
|
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
|
|
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}`
|
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
|
|