@uniweb/build 1.3.8 → 1.3.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,11 +56,11 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/content-reader": "~1.4.0",
60
- "@uniweb/content-writer": "~1.1.2",
59
+ "@uniweb/schemas": "~1.4.0",
61
60
  "@uniweb/projections": "~1.1.1",
62
- "@uniweb/schemas": "~1.3.1",
61
+ "@uniweb/content-reader": "~1.4.0",
63
62
  "@uniweb/semantic-parser": "~1.8.1",
63
+ "@uniweb/content-writer": "~1.1.2",
64
64
  "@uniweb/theming": "~1.2.0"
65
65
  },
66
66
  "optionalDependencies": {
@@ -92,6 +92,19 @@ export async function loadManifest(localeDir) {
92
92
  }
93
93
  }
94
94
 
95
+ /**
96
+ * A manifest as its file holds it — the one format for its two writers, the CLI's
97
+ * (`saveManifest`) and a pull's (`uwx/locale-sync.js::writeFreeformTranslations`).
98
+ * ⛔ Until 2026-10-17 the CLI wrote no final newline and a pull wrote one, so a pull
99
+ * that changed one entry of a CLI-written manifest also changed its last line.
100
+ *
101
+ * @param {Object} manifest
102
+ * @returns {string}
103
+ */
104
+ export function manifestText(manifest) {
105
+ return JSON.stringify(manifest, null, 2) + '\n'
106
+ }
107
+
95
108
  /**
96
109
  * Save the manifest for a locale
97
110
  *
@@ -106,7 +119,7 @@ export async function saveManifest(localeDir, manifest) {
106
119
  await mkdir(localeDir, { recursive: true })
107
120
  }
108
121
 
109
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
122
+ await writeFile(manifestPath, manifestText(manifest))
110
123
  }
111
124
 
112
125
  /**
@@ -346,8 +346,8 @@ function freeformSections(siteContent) {
346
346
 
347
347
  /**
348
348
  * What a site's content says about its free-form translations — the ONE index for the
349
- * build's stale and orphan check and for the CLI's `status --freeform`, `update-hash` and
350
- * `prune --freeform`, so no two of them can judge a file differently.
349
+ * build's stale and orphan check and for the CLI's `status --freeform`, `init-freeform`,
350
+ * `update-hash` and `prune --freeform`, so no two of them judge a file by different rules.
351
351
  *
352
352
  * - `validPaths` — every path the renderer reads a section's translation from
353
353
  * (`freeformPathsFor`), for every section it translates;
@@ -355,12 +355,19 @@ function freeformSections(siteContent) {
355
355
  * translates, which a recorded hash is compared with;
356
356
  * - `canJudge(path)` — whether this content can say a translation at `path` is
357
357
  * orphaned. ⛔ Only a page section's (`pages/…`, `page-ids/…`), and not one for a page
358
- * whose sections the content does not carry — a prerendered site with split content
359
- * rewrites `site-content.json` without them. A record's (`records/…`) is read from
360
- * records this content does not hold. A check that cannot see the source must never
361
- * call its translation orphaned: `prune --freeform` deletes what it is told is.
358
+ * whose sections the content does not carry — as a prerendered site's split
359
+ * `site-content.json` does not. A record's (`records/…`) is read from records this
360
+ * content does not hold. A check that cannot see the source must never call its
361
+ * translation orphaned: `prune --freeform` deletes what it is told is.
362
362
  *
363
- * @param {Object} siteContent - collected site content, as `dist/site-content.json` holds it
363
+ * ⚠️ The rules are one; the content is the caller's. The CLI's commands pass the site's
364
+ * SOURCE, collected as `uniweb i18n extract` collects it, `hidden:` pages and sections
365
+ * included (`cli/src/commands/i18n.js::collectSourceContent`) — ⛔ until 2026-10-17 they read
366
+ * `dist/site-content.json`, split or not. The build passes what it publishes, before its
367
+ * prerender splits it: no `hidden:` page or section, so it warns of their translations as
368
+ * orphaned (a warning only — it deletes nothing).
369
+ *
370
+ * @param {Object} siteContent - collected site content
364
371
  * @returns {{ validPaths: Set<string>, sourceHashes: Object<string, string>, canJudge: (path: string) => boolean }}
365
372
  */
366
373
  export function freeformSourceIndex(siteContent) {
@@ -25,7 +25,7 @@ import { computeHash } from '../i18n/hash.js'
25
25
  import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
26
26
  import { visitDataBlockStrings } from '../i18n/data-strings.js'
27
27
  import { resolveDocForLocale, translatedInline, linkLabels, toPlainText } from '../i18n/merge.js'
28
- import { computeSourceHash } from '../i18n/freeform-manifest.js'
28
+ import { computeSourceHash, manifestText } from '../i18n/freeform-manifest.js'
29
29
  import { sameMarkdownDocument, canonicalJson } from './same-content.js'
30
30
  import { parseFrontmatter } from '../utils/frontmatter.js'
31
31
 
@@ -304,12 +304,23 @@ export function isLocalizedContent(value) {
304
304
  */
305
305
  export function createTranslationCollector(sourceLocale, { siteRoot = null } = {}) {
306
306
  const byLocale = {} // { locale: { hash: target } }
307
+ // ⭐ What says each value read, in the place it was read — the push's own rule there — so the
308
+ // writer keeps an author's entry that says the same thing in another spelling (`reconcileTranslation`).
309
+ const says = {} // { locale: { hash: { plain?: (value) => boolean, values: { [contextKey]: (value) => boolean } } } }
307
310
  const freeformPending = [] // [{ locale, content }] — target-locale free-form bodies
308
311
 
312
+ function noteSays(locale, hash, context, check) {
313
+ const entry = ((says[locale] ||= {})[hash] ||= { plain: undefined, values: {} })
314
+ if (context) entry.values[contextKey(context)] = check
315
+ else entry.plain = check
316
+ }
317
+
309
318
  // Record a localized SCALAR field's target locales (hash of the source scalar). ⭐ With `context`
310
319
  // — the record it was read in (`{ key: '<pool>/<handle>' }`) — a translation that differs from one
311
320
  // record to another comes back as the author's `{ default, overrides }`, as a page section's does.
312
- function add(localizedValue, context = null) {
321
+ // ⭐ `plainText` for a field the push sends as text (`plainTextTargets`): any value with those words
322
+ // says it.
323
+ function add(localizedValue, context = null, { plainText = false } = {}) {
313
324
  if (!isLocalizedMap(localizedValue)) return
314
325
  const source = localizedValue[sourceLocale]
315
326
  if (typeof source !== 'string' || source.length === 0) return
@@ -318,6 +329,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
318
329
  if (locale === sourceLocale) continue
319
330
  if (typeof value !== 'string') continue
320
331
  const table = (byLocale[locale] ||= {})
332
+ noteSays(locale, hash, context, plainText ? saysAsText(value) : saysAsIs(value))
321
333
  if (!context) {
322
334
  if (table[hash] instanceof SeenInContexts) table[hash].plain = value
323
335
  else table[hash] = value
@@ -335,12 +347,14 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
335
347
  // from one section to another is kept per section, and comes back as the author's
336
348
  // context-specific overrides (`writeLocaleTranslations`). ⛔ Until 2026-09-26 the last section
337
349
  // read won, and a pull wrote that one string over the author's `{ default, overrides }`.
338
- function addStructuralMap(locale, map, context = null) {
350
+ // `checks` — what says each element's translation (`deriveStructuralMap`); a value as read, else.
351
+ function addStructuralMap(locale, map, context = null, checks = null) {
339
352
  if (locale === sourceLocale || !map || typeof map !== 'object' || Array.isArray(map)) return
340
353
  const table = (byLocale[locale] ||= {})
341
354
  for (const [src, target] of Object.entries(map)) {
342
355
  if (typeof src !== 'string' || typeof target !== 'string') continue
343
356
  const hash = computeHash(src)
357
+ noteSays(locale, hash, context, checks?.[src] ?? saysAsIs(target))
344
358
  if (!context) {
345
359
  if (table[hash] instanceof SeenInContexts) table[hash].plain = target
346
360
  else table[hash] = target
@@ -375,7 +389,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
375
389
  return null
376
390
  }
377
391
 
378
- return { add, addStructuralMap, noteFreeform, keptFreeform, byLocale, freeformPending }
392
+ return { add, addStructuralMap, noteFreeform, keptFreeform, byLocale, says, freeformPending }
379
393
  }
380
394
 
381
395
  /**
@@ -391,7 +405,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
391
405
  * per-locale link hrefs survive. Keyed by the source element's cleaned text, so
392
406
  * `computeHash(key)` matches the extractor's unit hash — closing the round trip.
393
407
  */
394
- function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
408
+ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null, checks = {}) {
395
409
  if (!isProseMirrorDoc(sourceDoc) || !isProseMirrorDoc(targetDoc)) return null
396
410
  const src = blockElements(sourceDoc)
397
411
  const tgt = blockElements(targetDoc)
@@ -406,12 +420,47 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
406
420
  const tgtMd = serializeInlineContent(tgt[i].content || [])
407
421
  if (srcMd === tgtMd) continue // unchanged → untranslated → omit
408
422
  map[key] = labelsAlone(src[i].content || [], tgt[i].content || [], tgtMd) ?? tgtMd
423
+ checks[key] = saysElement(src[i].content || [], tgtMd)
409
424
  }
410
425
  } catch {
411
426
  // a mark we can't serialize → don't risk a lossy map; store as free-form
412
427
  return null
413
428
  }
414
- return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
429
+ return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor, checks)
430
+ }
431
+
432
+ // ⭐ WHAT SAYS A TRANSLATION, WHERE A PULL READ IT — the push's own rule for that place. A pull reads
433
+ // one spelling back, and an author's entry may say the same thing in another: `[Faire un don](/contact)`
434
+ // where the merge reads the label onto the source's own link, or `[Se connecter](/sign-in)` for a page
435
+ // title, which the push sends as its words.
436
+ // - an element of a section: what `merge.js::translatedInline` makes of a value there;
437
+ // - a plain-text field (`plainTextTargets`): a value's words;
438
+ // - anything else: the value as read.
439
+ function saysElement(source, pushed) {
440
+ return (value) => {
441
+ if (typeof value !== 'string') return false
442
+ try {
443
+ const back = translatedInline(source, value)
444
+ return !!back && serializeInlineContent(back) === pushed
445
+ } catch {
446
+ return false
447
+ }
448
+ }
449
+ }
450
+
451
+ function saysAsText(read) {
452
+ return (value) => {
453
+ if (typeof value !== 'string') return false
454
+ try {
455
+ return toPlainText(value) === read
456
+ } catch {
457
+ return false
458
+ }
459
+ }
460
+ }
461
+
462
+ function saysAsIs(read) {
463
+ return (value) => value === read
415
464
  }
416
465
 
417
466
  // Whether a free-form file says `target`, read as a push reads it — its body, as stored.
@@ -447,7 +496,7 @@ function labelsAlone(source, target, whole) {
447
496
  // A target that is not the source with only those strings changed — another shape, another `href` —
448
497
  // cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
449
498
  // was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
450
- function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null) {
499
+ function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null, checks = null) {
451
500
  const srcBlocks = dataBlockNodes(sourceDoc)
452
501
  const tgtBlocks = dataBlockNodes(targetDoc)
453
502
  if (srcBlocks.length !== tgtBlocks.length) return null
@@ -476,7 +525,10 @@ function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null) {
476
525
  const rebuilt = JSON.parse(JSON.stringify(s.data))
477
526
  visitDataBlockStrings(rebuilt, (value) => pairs.get(value) ?? value, modelFor(s.tag))
478
527
  if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
479
- for (const [from, to] of pairs) map[from.trim()] = to
528
+ for (const [from, to] of pairs) {
529
+ map[from.trim()] = to
530
+ if (checks) checks[from.trim()] = saysAsIs(to)
531
+ }
480
532
  }
481
533
  return map
482
534
  }
@@ -501,8 +553,9 @@ export function unwrapLocalizedContent(content, sourceLocale, collector, freefor
501
553
  if (isProseMirrorDoc(value)) {
502
554
  // A translation the site keeps as a free-form file stays one, even when it lines up.
503
555
  const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
504
- const map = kept ? null : deriveStructuralMap(source, value, modelFor)
505
- if (map) collector?.addStructuralMap?.(locale, map, context)
556
+ const checks = {}
557
+ const map = kept ? null : deriveStructuralMap(source, value, modelFor, checks)
558
+ if (map) collector?.addStructuralMap?.(locale, map, context, checks)
506
559
  else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
507
560
  } else {
508
561
  collector?.addStructuralMap?.(locale, value, context)
@@ -525,37 +578,58 @@ class SeenInContexts {
525
578
  // still hold. The default is what the places without an override of their own say (the most of
526
579
  // them; the author's default when tied, else the first read). An override for a place this pull did
527
580
  // not see is kept, and one that now says the default is dropped.
528
- function reconcileTranslation(existing, seen) {
581
+ // ⭐ A value AGREES with a place when, pushed there, it says what the pull read (`check`, from the
582
+ // collector — the read value itself, where none was noted). So the author's entry is kept as written
583
+ // wherever it still says what every place read: a pull that changes nothing writes nothing.
584
+ // ⛔ Until 2026-10-17 values agreed only when spelled alike, and a pull rewrote an entry that says the
585
+ // same thing another way: `[Faire un don](/contact)` to its label, a string a heading and a link
586
+ // share into per-place overrides, a page title's `[Se connecter](/sign-in)` to its words.
587
+ function reconcileTranslation(existing, seen, check = null) {
529
588
  const own = existing && typeof existing === 'object' && existing.overrides && typeof existing.overrides === 'object' ? existing.overrides : {}
530
589
  const authoredDefault = typeof existing === 'string' ? existing : existing && typeof existing === 'object' ? existing.default : undefined
531
- const contexts = Object.entries(seen.values)
532
- const candidates = [...(seen.plain !== undefined ? [seen.plain] : []), ...contexts.filter(([k]) => !(k in own)).map(([, v]) => v)]
590
+ // Each place read: its context's key (null where it has none), what it read, and what says that.
591
+ const places = [
592
+ ...(seen.plain !== undefined ? [[null, seen.plain, check?.plain ?? saysAsIs(seen.plain)]] : []),
593
+ ...Object.entries(seen.values).map(([k, v]) => [k, v, check?.values?.[k] ?? saysAsIs(v)]),
594
+ ]
595
+ const at = (k) => (k !== null && k in own ? own[k] : authoredDefault)
596
+ if (existing !== undefined && places.length && places.every(([k, , says]) => says(at(k)))) return existing
597
+ // The places the default speaks for: those with no override of the author's own.
598
+ const free = places.filter(([k]) => k === null || !(k in own))
533
599
  let def = authoredDefault
534
- if (candidates.length) {
535
- const counts = new Map()
536
- for (const v of candidates) counts.set(v, (counts.get(v) || 0) + 1)
537
- const most = Math.max(...counts.values())
538
- const top = [...counts].filter(([, n]) => n === most).map(([v]) => v)
539
- def = top.includes(authoredDefault) ? authoredDefault : top[0]
600
+ if (free.length) {
601
+ let most = -1
602
+ for (const candidate of [...(authoredDefault !== undefined ? [authoredDefault] : []), ...free.map(([, read]) => read)]) {
603
+ const agree = free.filter(([, , says]) => says(candidate)).length
604
+ if (agree > most) [def, most] = [candidate, agree]
605
+ }
540
606
  } else if (def === undefined) {
541
- def = contexts[0]?.[1]
607
+ def = places.find(([k]) => k !== null)?.[1]
542
608
  }
543
609
  const overrides = { ...own }
544
- for (const [k, v] of contexts) {
545
- if (v === def) delete overrides[k]
546
- else overrides[k] = v
610
+ for (const [k, read, says] of places) {
611
+ if (k === null) continue
612
+ if (says(def)) delete overrides[k]
613
+ else if (!(k in own && says(own[k]))) overrides[k] = read
547
614
  }
548
615
  return Object.keys(overrides).length ? { default: def, overrides } : def
549
616
  }
550
617
 
551
618
  /**
552
619
  * Write captured target-locale FREE-FORM bodies to `locales/freeform/{locale}/<relpath>`
553
- * (markdown, body-only) and record each one's SOURCE hash in that locale's
620
+ * (markdown, body-only) and record the SOURCE hash of each one it writes in that locale's
554
621
  * `.manifest.json` for staleness tracking. Sync + idempotent: a body is rewritten
555
622
  * only when its markdown changes, and a manifest `recorded` date is (re)stamped only
556
623
  * when the source hash changes — so a no-op re-projection is byte-stable. Entries
557
624
  * with no `relpath` (unplaceable, e.g. a layout section) are surfaced as `skipped`.
558
625
  *
626
+ * ⭐ A MANIFEST ENTRY SAYS WHICH SOURCE A TRANSLATION WAS MADE FROM, so it is stamped only with a
627
+ * translation this pull writes. One the pull leaves as it is keeps the author's entry, whatever it
628
+ * says: a translation the source has moved past stays stale. ⛔ Until 2026-10-17 every pull
629
+ * stamped the hash of the source it brought — the stale translation came back current, and a
630
+ * manifest recorded under another reading of an unchanged source was rewritten by a pull that
631
+ * changed nothing.
632
+ *
559
633
  * @returns {{ written: string[], skipped: object[] }}
560
634
  */
561
635
  export function writeFreeformTranslations(siteRoot, freeformPending) {
@@ -600,12 +674,11 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
600
674
  // markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
601
675
  // Compared as a push reads it (`i18n/freeform.js::loadFreeformTranslation`). ⛔ Until 2026-10-08 the
602
676
  // file was trimmed first, so one whose last line ended in a space was rewritten by every pull.
603
- if (current !== text && !(current !== null && sameStoredFreeform(current, filePath, target))) {
604
- mkdirSync(dirname(filePath), { recursive: true })
605
- writeFileSync(filePath, text)
606
- report.written.push(filePath)
607
- }
608
- // Staleness hash of the SOURCE; re-stamp `recorded` only when it changes.
677
+ if (current === text || (current !== null && sameStoredFreeform(current, filePath, target))) continue
678
+ mkdirSync(dirname(filePath), { recursive: true })
679
+ writeFileSync(filePath, text)
680
+ report.written.push(filePath)
681
+ // Staleness hash of the SOURCE this translation comes with; re-stamp `recorded` only when it changes.
609
682
  const hash = computeSourceHash(source)
610
683
  const existing = manifest[relpath]
611
684
  if (!existing || existing.hash !== hash) {
@@ -616,7 +689,7 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
616
689
 
617
690
  if (manifestChanged) {
618
691
  mkdirSync(localeDir, { recursive: true })
619
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
692
+ writeFileSync(manifestPath, manifestText(manifest))
620
693
  }
621
694
  }
622
695
 
@@ -664,9 +737,14 @@ function jsonObjectText(entries) {
664
737
  * in the file, and new ones follow it, sorted, so a pull that changes nothing writes
665
738
  * nothing. Creates `locales/` if absent.
666
739
  *
740
+ * @param {string} siteRoot
741
+ * @param {object} byLocale - the collector's `byLocale`
742
+ * @param {string} [subdir] - `records` for record translations
743
+ * @param {object} [says] - the collector's `says`: what says each value read, in its place
744
+ * (`reconcileTranslation`). Without it, a value says only itself.
667
745
  * @returns {{ [locale: string]: 'updated' | 'unchanged' }}
668
746
  */
669
- export function writeLocaleTranslations(siteRoot, byLocale, subdir = '') {
747
+ export function writeLocaleTranslations(siteRoot, byLocale, subdir = '', says = null) {
670
748
  const report = {}
671
749
  for (let [locale, entries] of Object.entries(byLocale || {})) {
672
750
  if (!entries || Object.keys(entries).length === 0) continue
@@ -692,9 +770,13 @@ export function writeLocaleTranslations(siteRoot, byLocale, subdir = '') {
692
770
  // whole file by hash — measured on the `international` template, whose `es.json` runs in page
693
771
  // order: a pull that changed no translation rewrote all 77 lines.
694
772
  // A translation read in several sections becomes the string or the `{ default, overrides }` the
695
- // author keeps (`reconcileTranslation`).
773
+ // author keeps (`reconcileTranslation`) — and one read in one place too, which keeps an entry
774
+ // that says it in another spelling, and an override for a place the pull did not see.
696
775
  entries = Object.fromEntries(
697
- Object.entries(entries).map(([key, value]) => [key, value instanceof SeenInContexts ? reconcileTranslation(existing[key], value) : value])
776
+ Object.entries(entries).map(([key, value]) => [
777
+ key,
778
+ reconcileTranslation(existing[key], value instanceof SeenInContexts ? value : new SeenInContexts(value), says?.[locale]?.[key]),
779
+ ])
698
780
  )
699
781
  const fresh = Object.keys(entries).filter((key) => !(key in existing)).sort()
700
782
  if (fresh.length === 0 && Object.entries(entries).every(([key, value]) => canonicalJson(existing[key]) === canonicalJson(value))) {
@@ -61,7 +61,8 @@ export function foldOpenGraph(holder, seo, sourceLocale, collector = null, conte
61
61
  for (const [wire, key] of OPEN_GRAPH_KEYS) {
62
62
  const value = holder?.[wire]
63
63
  if (value === undefined) continue
64
- collector?.add(value, context)
64
+ // A plain-text field: the push sends a translation's words (`plainTextTargets`).
65
+ collector?.add(value, context, { plainText: true })
65
66
  const source = unwrapLocalized(value, sourceLocale)
66
67
  if (typeof source === 'string' && source !== '') out[key] = source
67
68
  }
@@ -900,7 +900,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
900
900
 
901
901
  // Flush localized record-field translations to locales/records/{locale}.json,
902
902
  // and any prosemirror free-form body overrides to locales/freeform/{locale}/.
903
- const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
903
+ const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records', collector.says)
904
904
  const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
905
905
 
906
906
  if (opts.backend && Object.keys(learned).length) {
@@ -224,7 +224,8 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
224
224
  // Localized text fields → unwrapped to the source locale (the target locales are
225
225
  // captured into the locales/ collector when one is supplied).
226
226
  collector?.add(info.name)
227
- collector?.add(info.description)
227
+ // A plain-text field: the push sends a translation's words (`plainTextTargets`).
228
+ collector?.add(info.description, null, { plainText: true })
228
229
  const name = unwrapLocalized(info.name, sourceLocale)
229
230
  if (name !== undefined) siteChanges.name = name
230
231
  const description = unwrapLocalized(info.description, sourceLocale)
@@ -273,7 +274,7 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
273
274
 
274
275
  // `settings.keywords` is a LOCALIZED list (it renders into `<meta name="keywords">`)
275
276
  // → unwrap to the source locale; the target locales go to the collector.
276
- if (Array.isArray(settingsSection.keywords)) settingsSection.keywords.forEach((kw) => collector?.add(kw))
277
+ if (Array.isArray(settingsSection.keywords)) settingsSection.keywords.forEach((kw) => collector?.add(kw, null, { plainText: true }))
277
278
  const settingsKeywords = unwrapLocalizedList(settingsSection.keywords, sourceLocale)
278
279
  if (settingsKeywords !== undefined) siteChanges.keywords = settingsKeywords
279
280
 
@@ -816,15 +817,17 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
816
817
  // Capture target-locale translations of the page's localized scalars; the
817
818
  // source value goes inline into page.yml below (pageRecordToYml unwraps it). In the page's
818
819
  // context, as the build translates them (`<route>:_meta`), so an override for one page comes back.
820
+ // Each a plain-text field: the push sends a translation's words (`site.js::localizePageMeta`).
819
821
  const meta = { page: canon, section: '_meta' }
820
- ctx.collector?.add(record.title, meta)
821
- ctx.collector?.add(record.label, meta)
822
- ctx.collector?.add(record.description, meta)
823
- ctx.collector?.add(record.og_title, meta)
824
- ctx.collector?.add(record.og_description, meta)
822
+ const text = { plainText: true }
823
+ ctx.collector?.add(record.title, meta, text)
824
+ ctx.collector?.add(record.label, meta, text)
825
+ ctx.collector?.add(record.description, meta, text)
826
+ ctx.collector?.add(record.og_title, meta, text)
827
+ ctx.collector?.add(record.og_description, meta, text)
825
828
  // keywords is a localized ARRAY — capture each element's target locales.
826
- if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta))
827
- else ctx.collector?.add(record.keywords, meta)
829
+ if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta, text))
830
+ else ctx.collector?.add(record.keywords, meta, text)
828
831
 
829
832
  const route = routePrefix ? `${routePrefix}/${slug}` : slug
830
833
  // Addressed as the push addresses it: its free-form translations and its translations' contexts
@@ -1347,7 +1350,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1347
1350
  }
1348
1351
  projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
1349
1352
 
1350
- report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
1353
+ report.locales = writeLocaleTranslations(siteRoot, collector.byLocale, '', collector.says)
1351
1354
  // Target-locale FREE-FORM bodies → locales/freeform/{locale}/<relpath> + manifest.
1352
1355
  report.freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
1353
1356
  writePullIndex(siteRoot, ctx.newIndex)