@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/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
|
|
|
@@ -345,16 +353,16 @@ function translateProseMirrorDoc(doc, context, translations, fallbackToSource) {
|
|
|
345
353
|
return changed
|
|
346
354
|
}
|
|
347
355
|
|
|
348
|
-
// Replace one block element's inline content with
|
|
349
|
-
//
|
|
350
|
-
// whitespace and returns the source on a miss, so `value === key`
|
|
351
|
-
// "no translation" → leave the element as-is. Returns true if it replaced.
|
|
356
|
+
// Replace one block element's inline content with what its translation gives it
|
|
357
|
+
// (`translatedInline`). `lookupTranslation` with the already-trimmed key adds no
|
|
358
|
+
// surrounding whitespace and returns the source on a miss, so `value === key`
|
|
359
|
+
// means "no translation" → leave the element as-is. Returns true if it replaced.
|
|
352
360
|
function applyElementTranslation(node, context, translations, fallbackToSource) {
|
|
353
361
|
const key = elementText(node)
|
|
354
362
|
if (!key) return false
|
|
355
363
|
const value = lookupTranslation(key, context, translations, fallbackToSource)
|
|
356
364
|
if (value === key) return false
|
|
357
|
-
const fragment =
|
|
365
|
+
const fragment = translatedInline(node.content, value)
|
|
358
366
|
if (fragment && fragment.length) {
|
|
359
367
|
node.content = fragment
|
|
360
368
|
return true
|
|
@@ -362,6 +370,129 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
|
|
|
362
370
|
return false
|
|
363
371
|
}
|
|
364
372
|
|
|
373
|
+
/**
|
|
374
|
+
* ⭐ WHAT A TRANSLATION GIVES AN ELEMENT: its value, parsed as inline markdown — with one
|
|
375
|
+
* exception. A value that names no link, given to an element made only of links, is their
|
|
376
|
+
* LABELS: a button's translation is its label (`Créer un groupe`), and buttons on consecutive
|
|
377
|
+
* lines take one label per line. Each keeps its source link — its target and its attributes
|
|
378
|
+
* (`{role=primary icon=…}`), which are structure, not words.
|
|
379
|
+
*
|
|
380
|
+
* ⛔ Until 2026-10-01 the value replaced the element whole, so a label translated on its own lost
|
|
381
|
+
* its link: the button became a plain paragraph, or vanished from a section that renders only
|
|
382
|
+
* links. Locale files keyed before whole-element keying hold exactly such labels.
|
|
383
|
+
*
|
|
384
|
+
* A value that writes a link is taken as written, re-targeted or not. So is a plain value for an
|
|
385
|
+
* element that mixes words and links, since which words were linked cannot be known;
|
|
386
|
+
* `uniweb i18n status` reports that loss (`translationKeepsMarkup`).
|
|
387
|
+
*
|
|
388
|
+
* @param {Array} source - the element's source inline content
|
|
389
|
+
* @param {string} value - the translation, inline markdown
|
|
390
|
+
* @returns {Array|null} the translated inline content; null for an empty value
|
|
391
|
+
*/
|
|
392
|
+
export function translatedInline(source, value) {
|
|
393
|
+
const fragment = inlineMarkdownToFragment(value)
|
|
394
|
+
if (!fragment) return null
|
|
395
|
+
return asLinkLabels(source, fragment) || fragment
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Does a translation keep the inline markup of its source element? The merge's own answer —
|
|
400
|
+
* what `translatedInline` makes of the value — so the audit can neither report a loss the merge
|
|
401
|
+
* does not make nor miss one it does.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} markup - the source element's inline markdown (a manifest unit's `markup`)
|
|
404
|
+
* @param {string} value - the translation
|
|
405
|
+
* @returns {boolean}
|
|
406
|
+
*/
|
|
407
|
+
export function translationKeepsMarkup(markup, value) {
|
|
408
|
+
const result = translatedInline(inlineMarkdownToFragment(markup) || [], value) || []
|
|
409
|
+
return result.some((node) => node.type !== 'text' || (node.marks && node.marks.length > 0))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The source's links with the fragment as their labels — the whole fragment for one link, a line
|
|
413
|
+
// each for several — or null: the fragment names a link of its own, the source is not only links,
|
|
414
|
+
// or the lines do not match the links one to one.
|
|
415
|
+
function asLinkLabels(source, fragment) {
|
|
416
|
+
if (fragment.some((node) => linkMarkOf(node))) return null
|
|
417
|
+
const links = linkRuns(source)
|
|
418
|
+
if (!links) return null
|
|
419
|
+
const labels = links.length === 1 ? [trimInline(fragment)] : inlineLines(fragment)
|
|
420
|
+
if (labels.length !== links.length || labels.some((label) => !label.length)) return null
|
|
421
|
+
const out = []
|
|
422
|
+
links.forEach((link, i) => {
|
|
423
|
+
if (i > 0) out.push(...link.before)
|
|
424
|
+
for (const node of labels[i]) out.push(withMarks(node, link.marks))
|
|
425
|
+
})
|
|
426
|
+
return out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// An element made only of links, as its runs: each link's marks (those all its text shares) and
|
|
430
|
+
// the line breaks or spaces before it. Null when anything else is in it — words outside a link,
|
|
431
|
+
// an icon, an image.
|
|
432
|
+
function linkRuns(source) {
|
|
433
|
+
const runs = []
|
|
434
|
+
let gap = []
|
|
435
|
+
for (const node of source || []) {
|
|
436
|
+
if (!node) continue
|
|
437
|
+
const link = linkMarkOf(node)
|
|
438
|
+
if (link) {
|
|
439
|
+
const last = runs[runs.length - 1]
|
|
440
|
+
if (last && !gap.length && sameMark(last.link, link)) {
|
|
441
|
+
last.marks = last.marks.filter((mark) => node.marks.some((own) => sameMark(own, mark)))
|
|
442
|
+
} else {
|
|
443
|
+
runs.push({ link, marks: [...node.marks], before: gap })
|
|
444
|
+
}
|
|
445
|
+
gap = []
|
|
446
|
+
} else if (node.type === 'hardBreak' || (node.type === 'text' && !node.marks?.length && !node.text?.trim())) {
|
|
447
|
+
gap.push(node)
|
|
448
|
+
} else {
|
|
449
|
+
return null
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return runs.length ? runs : null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function linkMarkOf(node) {
|
|
456
|
+
return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function sameMark(a, b) {
|
|
460
|
+
return Boolean(a && b) && a.type === b.type && JSON.stringify(a.attrs || {}) === JSON.stringify(b.attrs || {})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A fragment's lines, split at line breaks, each trimmed; blank lines dropped.
|
|
464
|
+
function inlineLines(fragment) {
|
|
465
|
+
const lines = [[]]
|
|
466
|
+
for (const node of fragment) {
|
|
467
|
+
if (node.type === 'hardBreak') {
|
|
468
|
+
lines.push([])
|
|
469
|
+
} else if (node.type === 'text' && node.text.includes('\n')) {
|
|
470
|
+
node.text.split('\n').forEach((part, i) => {
|
|
471
|
+
if (i > 0) lines.push([])
|
|
472
|
+
if (part) lines[lines.length - 1].push({ ...node, text: part })
|
|
473
|
+
})
|
|
474
|
+
} else {
|
|
475
|
+
lines[lines.length - 1].push(node)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return lines.map(trimInline).filter((line) => line.length)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Leading and trailing whitespace off a run of inline nodes.
|
|
482
|
+
function trimInline(nodes) {
|
|
483
|
+
const out = nodes.map((node) => ({ ...node }))
|
|
484
|
+
if (out[0]?.type === 'text') out[0].text = out[0].text.replace(/^\s+/, '')
|
|
485
|
+
const last = out[out.length - 1]
|
|
486
|
+
if (last?.type === 'text') last.text = last.text.replace(/\s+$/, '')
|
|
487
|
+
return out.filter((node) => node.type !== 'text' || node.text)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// A label node with its link's marks; a mark of its own of another type stays.
|
|
491
|
+
function withMarks(node, marks) {
|
|
492
|
+
const own = (node.marks || []).filter((mark) => !marks.some((linkMark) => linkMark.type === mark.type))
|
|
493
|
+
return { ...node, marks: [...marks, ...own] }
|
|
494
|
+
}
|
|
495
|
+
|
|
365
496
|
/**
|
|
366
497
|
* Translate the human-readable strings inside a section's TAGGED DATA BLOCKS,
|
|
367
498
|
* in place. The other half of the fix in `extract.js` — a manifest entry nobody
|
|
@@ -373,9 +504,15 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
|
|
|
373
504
|
* silently; it now does (`uwx/locale-sync.js::deriveStructuralMap`), so the two
|
|
374
505
|
* halves travel together.
|
|
375
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
|
|
376
513
|
* @returns {boolean} whether any value was translated
|
|
377
514
|
*/
|
|
378
|
-
function translateDataBlocks(doc, context, translations, fallbackToSource) {
|
|
515
|
+
function translateDataBlocks(doc, context, translations, fallbackToSource, modelFor = () => null) {
|
|
379
516
|
let changed = false
|
|
380
517
|
const walk = (nodes) => {
|
|
381
518
|
for (const node of nodes || []) {
|
|
@@ -383,11 +520,11 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
|
|
|
383
520
|
if (node.type === 'dataBlock') {
|
|
384
521
|
const data = node.attrs?.data
|
|
385
522
|
if (data && typeof data === 'object') {
|
|
386
|
-
|
|
523
|
+
visitDataBlockStrings(data, (value) => {
|
|
387
524
|
const translated = lookupTranslation(value, context, translations, fallbackToSource)
|
|
388
525
|
if (translated !== value) changed = true
|
|
389
526
|
return translated
|
|
390
|
-
})
|
|
527
|
+
}, modelFor(node.attrs?.tag))
|
|
391
528
|
}
|
|
392
529
|
} else if (Array.isArray(node.content)) {
|
|
393
530
|
walk(node.content)
|
|
@@ -406,12 +543,15 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
|
|
|
406
543
|
* (so the caller can omit an untranslated locale — it falls back to the source
|
|
407
544
|
* locale). Lets the sync producer emit a self-contained per-locale DOC instead of a
|
|
408
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.
|
|
409
549
|
*/
|
|
410
|
-
export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }) {
|
|
550
|
+
export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }, modelFor = () => null) {
|
|
411
551
|
if (!sourceDoc || sourceDoc.type !== 'doc' || !table) return null
|
|
412
552
|
const doc = JSON.parse(JSON.stringify(sourceDoc))
|
|
413
553
|
const elements = translateProseMirrorDoc(doc, context, table, true)
|
|
414
|
-
const data = translateDataBlocks(doc, context, table, true)
|
|
554
|
+
const data = translateDataBlocks(doc, context, table, true, modelFor)
|
|
415
555
|
return elements || data ? doc : null
|
|
416
556
|
}
|
|
417
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/runtime-schema.js
CHANGED
|
@@ -434,3 +434,87 @@ export function extractAllLayoutRuntimeSchemas(layoutsMeta) {
|
|
|
434
434
|
|
|
435
435
|
return schemas
|
|
436
436
|
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* ⭐ WHAT THE RUNTIME READS OF A FOUNDATION'S `main.js` — and nothing else of it. Ruled
|
|
440
|
+
* 2026-10-01 [Diego]: *"Only what runtime really reads for rendering should reach it."*
|
|
441
|
+
*
|
|
442
|
+
* Read off every runtime reader of the entry's `capabilities` (2026-10-01): core's `Uniweb` and
|
|
443
|
+
* `Website`, the runtime's wiring, navigation and scroll, `@uniweb/theming`, kit's xref, the
|
|
444
|
+
* fetch dispatcher, and press. Two kinds:
|
|
445
|
+
*
|
|
446
|
+
* code `handlers`, `defaultInsets`, `xref`, `transports`, `outputs` — functions and
|
|
447
|
+
* components, so the entry REFERENCES each from the module and the bundler drops the rest
|
|
448
|
+
* of `main.js`'s default export.
|
|
449
|
+
* data `defaultLayout`, `defaultSection`, `viewTransitions`, `scroll`; `vars`, each var's
|
|
450
|
+
* `default`, `type` and `applyTo` — all `@uniweb/theming` reads; `data`, each key's schema
|
|
451
|
+
* ref — all `declaredKeys` reads. Written as a literal, like a section type's lean schema.
|
|
452
|
+
*
|
|
453
|
+
* ⛔ Never: `name`, `description`, `extension`, a var's label or description, a data key's shape,
|
|
454
|
+
* or any key the framework does not read. ⛔ Until 2026-10-01 the entry spread the whole default
|
|
455
|
+
* export into `capabilities`, and a foundation's name and its vars' descriptions shipped in every
|
|
456
|
+
* published bundle.
|
|
457
|
+
*
|
|
458
|
+
* A data key whose value is not plain data (a function, say) is referenced like code, so nothing
|
|
459
|
+
* is lost to serialization.
|
|
460
|
+
*
|
|
461
|
+
* @param {Object} config - the foundation's `main.js`, as `loadFoundationConfig` reads it
|
|
462
|
+
* @returns {{ code: string[], data: Object }} the code keys to reference, and the data to inline
|
|
463
|
+
*/
|
|
464
|
+
export function extractFoundationRuntime(config = {}) {
|
|
465
|
+
const code = RUNTIME_CODE_CAPABILITIES.filter((key) => config?.[key] !== undefined)
|
|
466
|
+
const data = {}
|
|
467
|
+
for (const key of RUNTIME_DATA_CAPABILITIES) {
|
|
468
|
+
const value = config?.[key]
|
|
469
|
+
if (value === undefined) continue
|
|
470
|
+
if (isPlainData(value)) data[key] = value
|
|
471
|
+
else code.push(key)
|
|
472
|
+
}
|
|
473
|
+
const vars = leanVars(config?.vars)
|
|
474
|
+
if (vars) data.vars = vars
|
|
475
|
+
const keys = leanDataKeys(config?.data)
|
|
476
|
+
if (keys) data.data = keys
|
|
477
|
+
return { code, data }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The `main.js` capabilities the runtime calls or renders — referenced, never copied. */
|
|
481
|
+
export const RUNTIME_CODE_CAPABILITIES = ['handlers', 'defaultInsets', 'xref', 'transports', 'outputs']
|
|
482
|
+
|
|
483
|
+
/** The `main.js` values the runtime reads as data — written into the entry. */
|
|
484
|
+
const RUNTIME_DATA_CAPABILITIES = ['defaultLayout', 'defaultSection', 'viewTransitions', 'scroll']
|
|
485
|
+
|
|
486
|
+
/** What `@uniweb/theming` reads of a foundation var: its value, its kind, and a font role's selectors. */
|
|
487
|
+
const RUNTIME_VAR_FIELDS = ['default', 'type', 'applyTo']
|
|
488
|
+
|
|
489
|
+
function leanVars(vars) {
|
|
490
|
+
if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return null
|
|
491
|
+
const out = {}
|
|
492
|
+
for (const [name, config] of Object.entries(vars)) {
|
|
493
|
+
if (config !== null && typeof config === 'object' && !Array.isArray(config)) {
|
|
494
|
+
const lean = {}
|
|
495
|
+
for (const field of RUNTIME_VAR_FIELDS) if (config[field] !== undefined) lean[field] = config[field]
|
|
496
|
+
out[name] = lean
|
|
497
|
+
} else {
|
|
498
|
+
out[name] = config
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return Object.keys(out).length > 0 ? out : null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A foundation's `data:` as a section type's lean `data` is: each key → its schema ref, or null. */
|
|
505
|
+
function leanDataKeys(data) {
|
|
506
|
+
const lowered = lowerData(data)
|
|
507
|
+
if (!lowered || typeof lowered !== 'object' || Array.isArray(lowered)) return null
|
|
508
|
+
const out = {}
|
|
509
|
+
for (const [key, value] of Object.entries(lowered)) out[key] = dataRef(value)
|
|
510
|
+
return Object.keys(out).length > 0 ? out : null
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isPlainData(value) {
|
|
514
|
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return true
|
|
515
|
+
if (Array.isArray(value)) return value.every(isPlainData)
|
|
516
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
517
|
+
return Object.values(value).every(isPlainData)
|
|
518
|
+
}
|
|
519
|
+
return false
|
|
520
|
+
}
|