@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/src/runtime-schema.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Runtime Schema Extractor
|
|
3
3
|
*
|
|
4
|
-
* Extracts lean runtime-relevant metadata from full meta.js files
|
|
5
|
-
*
|
|
6
|
-
* needed at render time:
|
|
4
|
+
* Extracts lean runtime-relevant metadata from full meta.js files — what a published page
|
|
5
|
+
* reads to render, and nothing else:
|
|
7
6
|
*
|
|
8
7
|
* - background: 'self' when component handles its own background
|
|
9
8
|
* - data: { <key>: <schema ref> | null } — every `content.data` key the component
|
|
10
9
|
* declares, in order, with its schema ref (null for an inline shape)
|
|
11
|
-
* -
|
|
10
|
+
* - vars: { <name>: { default? } } — the CSS variables the component declares
|
|
12
11
|
* - defaults: param default values
|
|
13
12
|
* - context: static capabilities for cross-block coordination
|
|
14
13
|
* - initialState: initial values for mutable block state
|
|
@@ -20,14 +19,20 @@
|
|
|
20
19
|
* delivery was default-on — every key that reached a section — and `data:` was a hint for
|
|
21
20
|
* defaults and the editor; `data: false` was the opt-out, emitted as `inheritData: false`.
|
|
22
21
|
*
|
|
22
|
+
* ⛔ NO FIELD DEFAULTS — ruled 2026-10-05 [Diego]. Each declared key also carried `schemas`
|
|
23
|
+
* until then — its schema's field defaults, with `enum` and the nesting that led to them — and
|
|
24
|
+
* the runtime filled a missing field from its `default` and replaced a value its `enum`
|
|
25
|
+
* rejected. A missing field is the record's own fact, and what it renders as is the
|
|
26
|
+
* component's choice; a copy frozen into the bundle drifts from the schema it was taken from;
|
|
27
|
+
* and checking a value is `uniweb validate`'s and the push's job, not the page's. A form in
|
|
28
|
+
* `data:` is the editor's, for authoring the block: none of it reaches the runtime.
|
|
29
|
+
* ⛔ Nor `inset` (an editor's flag — an inset is found by name) or a layout's `areas` (a page's
|
|
30
|
+
* areas are its site's `layout/` folder): no runtime reader read either, until 2026-10-05.
|
|
31
|
+
*
|
|
23
32
|
* Full metadata (titles, descriptions, hints, etc.) stays in schema.json
|
|
24
33
|
* for the visual editor.
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
|
-
import { isRichSchema } from '@uniweb/core'
|
|
28
|
-
import { dataRefOf } from '@uniweb/core/data-keys'
|
|
29
|
-
import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
|
|
30
|
-
import { enumValues } from '@uniweb/schemas/format'
|
|
31
36
|
import { lowerData } from '@uniweb/schemas/content'
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -42,188 +47,6 @@ function dataRef(value) {
|
|
|
42
47
|
return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
/**
|
|
46
|
-
* Extract lean schema field for runtime
|
|
47
|
-
* Strips editor-only fields (label, hint, description)
|
|
48
|
-
* Keeps runtime fields (type, default, enum, options, fields, items)
|
|
49
|
-
*
|
|
50
|
-
* Vocabulary is the data-schema format:
|
|
51
|
-
* an `object` field nests via `fields:` (a field map); an `array` field nests
|
|
52
|
-
* via `items:` (a single element field). This matches what
|
|
53
|
-
* resolve-data-schema.js normalizes named refs to, so named-ref and inline
|
|
54
|
-
* `data:` schemas share one shape.
|
|
55
|
-
*
|
|
56
|
-
* @param {string|Object} field - Schema field definition
|
|
57
|
-
* @returns {string|Object} - Lean field definition
|
|
58
|
-
*/
|
|
59
|
-
function extractSchemaField(field) {
|
|
60
|
-
// Shorthand: a bare type string ('string', 'decimal', …).
|
|
61
|
-
if (typeof field === 'string') {
|
|
62
|
-
return field
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (!field || typeof field !== 'object') {
|
|
66
|
-
return field
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const lean = {}
|
|
70
|
-
|
|
71
|
-
// Keep runtime-relevant fields: the default, plus the inline picklist
|
|
72
|
-
// (`enum`) used for value validation. `options` is a curated-ref string —
|
|
73
|
-
// inert at runtime but carried through.
|
|
74
|
-
if (field.type) lean.type = field.type
|
|
75
|
-
if (field.default !== undefined) lean.default = field.default
|
|
76
|
-
// The values only: an entry's label (`{ value, label }`) is an editor's, and the runtime compares.
|
|
77
|
-
if (field.enum !== undefined) lean.enum = enumValues(field.enum)
|
|
78
|
-
if (field.options) lean.options = field.options
|
|
79
|
-
|
|
80
|
-
// Nested object → recurse into its field map.
|
|
81
|
-
if (field.type === 'object' && field.fields && typeof field.fields === 'object') {
|
|
82
|
-
lean.fields = extractSchemaFields(field.fields)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Array → recurse into its single element field (which may itself be an
|
|
86
|
-
// object carrying nested `fields`).
|
|
87
|
-
if (field.type === 'array' && field.items !== undefined) {
|
|
88
|
-
lean.items = extractSchemaField(field.items)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// If the only thing left is `type`, collapse to the bare type string.
|
|
92
|
-
const keys = Object.keys(lean)
|
|
93
|
-
if (keys.length === 1 && keys[0] === 'type') {
|
|
94
|
-
return lean.type
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return keys.length > 0 ? lean : null
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Extract lean schema fields for an entire schema object
|
|
102
|
-
*
|
|
103
|
-
* @param {Object} schemaFields - Map of fieldName -> field definition
|
|
104
|
-
* @returns {Object} - Map of fieldName -> lean field definition
|
|
105
|
-
*/
|
|
106
|
-
function extractSchemaFields(schemaFields) {
|
|
107
|
-
if (!schemaFields || typeof schemaFields !== 'object') {
|
|
108
|
-
return {}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const lean = {}
|
|
112
|
-
for (const [name, field] of Object.entries(schemaFields)) {
|
|
113
|
-
const leanField = extractSchemaField(field)
|
|
114
|
-
if (leanField !== null) {
|
|
115
|
-
lean[name] = leanField
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return lean
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Check if a schema value is in the full @uniweb/schemas format
|
|
123
|
-
* Full format has: { name, version?, description?, fields: { fieldName: fieldDef, ... } }
|
|
124
|
-
*
|
|
125
|
-
* The distinguishing feature is that `fields` is a *keyed object*, not an array.
|
|
126
|
-
* (A rich form schema also has `fields`, but as an array.)
|
|
127
|
-
*
|
|
128
|
-
* @param {Object} schema - Schema value to check
|
|
129
|
-
* @returns {boolean}
|
|
130
|
-
*/
|
|
131
|
-
function isFullSchemaFormat(schema) {
|
|
132
|
-
return (
|
|
133
|
-
schema &&
|
|
134
|
-
typeof schema === 'object' &&
|
|
135
|
-
typeof schema.fields === 'object' &&
|
|
136
|
-
schema.fields !== null &&
|
|
137
|
-
!Array.isArray(schema.fields)
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Pass a rich form schema through with minimal normalization.
|
|
143
|
-
*
|
|
144
|
-
* Rich schemas are passed to the editor (for FormBlock UI rendering) and to
|
|
145
|
-
* the runtime (for default application). We keep all authored metadata so the
|
|
146
|
-
* editor has what it needs; we do not strip editor-only fields here because
|
|
147
|
-
* the same schema feeds both audiences.
|
|
148
|
-
*
|
|
149
|
-
* Normalizations:
|
|
150
|
-
*
|
|
151
|
-
* @param {Object} schema - Rich schema as authored
|
|
152
|
-
* @returns {Object} - Normalized rich schema
|
|
153
|
-
*/
|
|
154
|
-
function normalizeRichSchema(schema) {
|
|
155
|
-
return normalizeRichSchemaValue(schema)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function normalizeRichSchemaValue(value) {
|
|
159
|
-
if (Array.isArray(value)) {
|
|
160
|
-
return value.map(normalizeRichSchemaValue)
|
|
161
|
-
}
|
|
162
|
-
if (!value || typeof value !== 'object') return value
|
|
163
|
-
const out = {}
|
|
164
|
-
for (const [key, v] of Object.entries(value)) {
|
|
165
|
-
if (v && typeof v === 'object') {
|
|
166
|
-
out[key] = normalizeRichSchemaValue(v)
|
|
167
|
-
} else {
|
|
168
|
-
out[key] = v
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return out
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Lean a single `data:` entry value into the runtime field structure that
|
|
176
|
-
* prepare-props.applySchemas applies. A `data:` value is one of:
|
|
177
|
-
* - a named ref (`'@/member'`, or `{ schema: '@/member' }`) → resolved on
|
|
178
|
-
* disk by the build's data-schema resolver; its fields are lean-extracted.
|
|
179
|
-
* - an inline rich-form schema (`{ fields: [...] }`) → passed through
|
|
180
|
-
* normalized (drives the FormBlock editor UI + default application).
|
|
181
|
-
* - an inline full-format schema (`{ name, version, fields: {...} }`) or a
|
|
182
|
-
* bare field map (`{ field: {...} }`) → lean-extracted.
|
|
183
|
-
*
|
|
184
|
-
* Source-agnostic: the same `content.data` key may be filled by a fetched
|
|
185
|
-
* collection, a tagged code block, or an editor form — the schema (and its
|
|
186
|
-
* defaults) is identical, so there is one declaration surface. Returns the
|
|
187
|
-
* lean structure, or null when there's nothing to apply.
|
|
188
|
-
*
|
|
189
|
-
* @param {string|Object} value - The `data:` entry value
|
|
190
|
-
* @param {Object} dataSchemaMap - Resolved schemas keyed by ref
|
|
191
|
-
* @returns {Object|null}
|
|
192
|
-
*/
|
|
193
|
-
function leanDataSchema(value, dataSchemaMap) {
|
|
194
|
-
// Named ref → the resolved schema's fields. `'@std/article/*'` asks for whole records of it.
|
|
195
|
-
const { ref, whole } = dataRefOf(value)
|
|
196
|
-
if (ref) {
|
|
197
|
-
const resolved = dataSchemaMap[ref]
|
|
198
|
-
// ⭐ THE FIELDS OF WHAT THE COMPONENT RECEIVES (ruled 2026-09-27 [Diego]): a brief's fields at
|
|
199
|
-
// the top (`briefFieldMap`), or, for `'@x/y/*'`, the whole record as stored — one field per
|
|
200
|
-
// section, the brief's included (`wholeFieldMap`) — so a default lands where the record carries
|
|
201
|
-
// its field: `@std/article`'s `status` inside `body`. A section is filled only when the record
|
|
202
|
-
// holds it (`applySchemaToObject` recurses into what is there).
|
|
203
|
-
//
|
|
204
|
-
// ⛔ It was the merged `deliveredFields` until 2026-09-27 — the brief's fields beside the other
|
|
205
|
-
// sections' names, one map for two shapes; `flatRecordFields`, the retired flat form, until
|
|
206
|
-
// 2026-09-24; and before 2026-09-03 `resolved.fields`, which a sections-form schema does not
|
|
207
|
-
// have. `dataSchemaMap` holds each schema normalized in its authored form (`fields:` or
|
|
208
|
-
// `sections:`), and only these readers say what one record of it looks like.
|
|
209
|
-
const fields = whole ? wholeFieldMap(resolved) : briefFieldMap(resolved)
|
|
210
|
-
if (!fields) return null
|
|
211
|
-
const lean = extractSchemaFields(fields)
|
|
212
|
-
return Object.keys(lean).length > 0 ? lean : null
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
216
|
-
|
|
217
|
-
// Inline rich-form schema (fields: array) — drives FormBlock + defaults.
|
|
218
|
-
if (isRichSchema(value)) return normalizeRichSchema(value)
|
|
219
|
-
|
|
220
|
-
// Inline full-format schema or a bare field map.
|
|
221
|
-
const fields = isFullSchemaFormat(value) ? value.fields : value
|
|
222
|
-
const lean = extractSchemaFields(fields)
|
|
223
|
-
return Object.keys(lean).length > 0 ? lean : null
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
50
|
/**
|
|
228
51
|
* Extract param defaults from params object
|
|
229
52
|
*
|
|
@@ -246,27 +69,45 @@ function extractParamDefaults(params) {
|
|
|
246
69
|
return Object.keys(defaults).length > 0 ? defaults : null
|
|
247
70
|
}
|
|
248
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A section type's CSS variables as the runtime reads them: every declared name, with its
|
|
74
|
+
* `default` when it has one. `Block.mergeComponentVars` (`@uniweb/core`) emits each default
|
|
75
|
+
* on the section and takes a section's `vars:` value only for a name declared here, so a
|
|
76
|
+
* var with no default still needs its name. A label, a type, options and a description are
|
|
77
|
+
* the editor's. A shorthand value (`'card-gap': '1.5rem'`) is written as its `default`.
|
|
78
|
+
*
|
|
79
|
+
* ⛔ Not emitted until 2026-10-05, while core read it since 2026-03-02: a section type's
|
|
80
|
+
* `vars:` reached no page, and neither did a section's `vars:` for them.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} vars - the `vars:` of a meta.js
|
|
83
|
+
* @returns {Object|null} `{ <name>: { default? } }`, or null when none is declared
|
|
84
|
+
*/
|
|
85
|
+
function leanComponentVars(vars) {
|
|
86
|
+
if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return null
|
|
87
|
+
const out = {}
|
|
88
|
+
for (const [name, config] of Object.entries(vars)) {
|
|
89
|
+
if (config !== null && typeof config === 'object' && !Array.isArray(config)) {
|
|
90
|
+
out[name] = config.default !== undefined ? { default: config.default } : {}
|
|
91
|
+
} else {
|
|
92
|
+
out[name] = config === null || config === undefined ? {} : { default: config }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(out).length > 0 ? out : null
|
|
96
|
+
}
|
|
97
|
+
|
|
249
98
|
/**
|
|
250
99
|
* Extract lean runtime schema from a full meta.js object
|
|
251
100
|
*
|
|
252
101
|
* @param {Object} fullMeta - The full meta.js default export
|
|
253
|
-
* @param {Object} [dataSchemaMap] - Resolved data schemas keyed by ref (from
|
|
254
|
-
* resolve-data-schema.js), used to lean-extract field defaults
|
|
255
|
-
* for each `data:` binding.
|
|
256
102
|
* @returns {Object|null} - Lean runtime schema or null if empty
|
|
257
103
|
*/
|
|
258
|
-
export function extractRuntimeSchema(fullMeta
|
|
104
|
+
export function extractRuntimeSchema(fullMeta) {
|
|
259
105
|
if (!fullMeta || typeof fullMeta !== 'object') {
|
|
260
106
|
return null
|
|
261
107
|
}
|
|
262
108
|
|
|
263
109
|
const runtime = {}
|
|
264
110
|
|
|
265
|
-
// Inset flag: signals this component is available for inline @ references
|
|
266
|
-
if (fullMeta.inset) {
|
|
267
|
-
runtime.inset = true
|
|
268
|
-
}
|
|
269
|
-
|
|
270
111
|
// Background opt-out: 'self' means the component renders its own background
|
|
271
112
|
// layer (solid colors, insets, effects), so the runtime skips its Background.
|
|
272
113
|
if (fullMeta.background) {
|
|
@@ -277,12 +118,12 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
277
118
|
// data: it maps each `content.data` key to its schema. A value is a named ref
|
|
278
119
|
// (`'@/member'`), an inline field map, or an inline rich-form (`{ fields: [...] }`,
|
|
279
120
|
// an editor form). Source-agnostic — the data may arrive by fetch, tagged code
|
|
280
|
-
// block, or editor form
|
|
121
|
+
// block, or editor form, and it reaches the component as it arrived.
|
|
281
122
|
//
|
|
282
|
-
// ⭐ Every key reaches the runtime
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
123
|
+
// ⭐ Every key reaches the runtime — `data` lists them with their refs, because the keys
|
|
124
|
+
// ARE what the section receives, and a key's ref is what a fetch of another name fills
|
|
125
|
+
// it by. Its shape stays in schema.json, for the editor. `data: false` declares nothing,
|
|
126
|
+
// as no `data:` does.
|
|
286
127
|
//
|
|
287
128
|
// ⭐ A concept block's key is written as its fence is — `'md:faq': 'Questions and
|
|
288
129
|
// answers'` — and lowered to the key a component reads, `faq`, with no schema: the value
|
|
@@ -308,11 +149,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
308
149
|
}
|
|
309
150
|
runtime.data = runtime.data || {}
|
|
310
151
|
runtime.data[key] = dataRef(value)
|
|
311
|
-
const lean = leanDataSchema(value, dataSchemaMap)
|
|
312
|
-
if (lean) {
|
|
313
|
-
runtime.schemas = runtime.schemas || {}
|
|
314
|
-
runtime.schemas[key] = lean
|
|
315
|
-
}
|
|
316
152
|
}
|
|
317
153
|
} else if (data !== undefined) {
|
|
318
154
|
throw new Error(
|
|
@@ -321,6 +157,11 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
321
157
|
)
|
|
322
158
|
}
|
|
323
159
|
|
|
160
|
+
const vars = leanComponentVars(fullMeta.vars)
|
|
161
|
+
if (vars) {
|
|
162
|
+
runtime.vars = vars
|
|
163
|
+
}
|
|
164
|
+
|
|
324
165
|
const paramsObj = fullMeta.params
|
|
325
166
|
const defaults = extractParamDefaults(paramsObj)
|
|
326
167
|
if (defaults) {
|
|
@@ -339,9 +180,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
339
180
|
runtime.initialState = fullMeta.initialState
|
|
340
181
|
}
|
|
341
182
|
|
|
342
|
-
// (Top-level `schemas:` is gone — inline field maps and rich-forms are now
|
|
343
|
-
// just `data:` entries with an inline value. See leanDataSchema.)
|
|
344
|
-
|
|
345
183
|
return Object.keys(runtime).length > 0 ? runtime : null
|
|
346
184
|
}
|
|
347
185
|
|
|
@@ -351,11 +189,11 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
351
189
|
* @param {Object} componentsMeta - Map of componentName -> meta.js content
|
|
352
190
|
* @returns {Object} - Map of componentName -> runtime schema (excludes null entries)
|
|
353
191
|
*/
|
|
354
|
-
export function extractAllRuntimeSchemas(componentsMeta
|
|
192
|
+
export function extractAllRuntimeSchemas(componentsMeta) {
|
|
355
193
|
const schemas = {}
|
|
356
194
|
|
|
357
195
|
for (const [name, meta] of Object.entries(componentsMeta)) {
|
|
358
|
-
const schema = extractRuntimeSchema(meta
|
|
196
|
+
const schema = extractRuntimeSchema(meta)
|
|
359
197
|
if (schema) {
|
|
360
198
|
schemas[name] = schema
|
|
361
199
|
}
|
|
@@ -368,7 +206,6 @@ export function extractAllRuntimeSchemas(componentsMeta, dataSchemaMap = {}) {
|
|
|
368
206
|
* Extract lean runtime schema for a layout from its full meta.js
|
|
369
207
|
*
|
|
370
208
|
* Layout runtime metadata:
|
|
371
|
-
* - areas: Array of area names this layout supports
|
|
372
209
|
* - transitions: View transition name overrides (the runtime auto-names areas;
|
|
373
210
|
* this overrides per region, or `false` opts the layout out)
|
|
374
211
|
* - defaults: Param default values
|
|
@@ -385,9 +222,8 @@ export function extractLayoutRuntimeSchema(fullMeta) {
|
|
|
385
222
|
|
|
386
223
|
const runtime = {}
|
|
387
224
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
225
|
+
// ⛔ No `areas`: the areas a page renders are its site's `layout/` folder
|
|
226
|
+
// (`Website.getLayoutAreas`), and a layout's list of them is for the editor.
|
|
391
227
|
|
|
392
228
|
// `false` is a value, not an absence: it is the documented way a layout opts
|
|
393
229
|
// out of per-area view transitions. The previous guard was
|
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/data-schema.js
CHANGED
|
@@ -451,8 +451,10 @@ function lowerLeaf(field, resolve, optResolve, { multiple = false } = {}) {
|
|
|
451
451
|
if (Array.isArray(field.enum)) out.enum = field.enum
|
|
452
452
|
if (leafFormat) out.format = leafFormat
|
|
453
453
|
|
|
454
|
-
// `default
|
|
455
|
-
//
|
|
454
|
+
// No `default`: a named schema declares none (`resolve-data-schema.js` refuses one, since
|
|
455
|
+
// 2026-10-05). ⛔ This said it "rides in the foundation-schema blob (render / editor
|
|
456
|
+
// pre-fill)" until then — but that blob leaves `dataSchemas` out, so a named schema's
|
|
457
|
+
// default reached the runtime alone, and no editor.
|
|
456
458
|
return out
|
|
457
459
|
}
|
|
458
460
|
|
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) {
|