@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/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 { visitDataStrings } from './data-strings.js'
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 the parsed translation
349
- // fragment. `lookupTranslation` with the already-trimmed key adds no surrounding
350
- // whitespace and returns the source on a miss, so `value === key` means
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 = inlineMarkdownToFragment(value)
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
- visitDataStrings(data, (value) => {
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
+ }
@@ -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, isOpenMapSection } from '../uwx/data-schema.js'
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.
@@ -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
+ }