@uniweb/build 1.3.8 → 1.3.9
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 +7 -7
- package/src/i18n/freeform-manifest.js +14 -1
- package/src/i18n/freeform.js +14 -7
- package/src/uwx/locale-sync.js +116 -34
- package/src/uwx/open-graph.js +2 -1
- package/src/uwx/records-project.js +1 -1
- package/src/uwx/site-project.js +13 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.9",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -56,12 +56,12 @@
|
|
|
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
59
|
"@uniweb/content-writer": "~1.1.2",
|
|
61
|
-
"@uniweb/projections": "~1.1.1",
|
|
62
60
|
"@uniweb/schemas": "~1.3.1",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/theming": "~1.2.0"
|
|
61
|
+
"@uniweb/content-reader": "~1.4.0",
|
|
62
|
+
"@uniweb/theming": "~1.2.0",
|
|
63
|
+
"@uniweb/projections": "~1.1.1",
|
|
64
|
+
"@uniweb/semantic-parser": "~1.8.1"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@uniweb/runtime": "~1.1.4"
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"@tailwindcss/vite": "^4.0.0",
|
|
74
74
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
75
75
|
"vite-plugin-svgr": "^4.0.0",
|
|
76
|
-
"@uniweb/
|
|
77
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/runtime": "~1.1.4",
|
|
77
|
+
"@uniweb/core": "~1.2.2"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
|
@@ -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,
|
|
122
|
+
await writeFile(manifestPath, manifestText(manifest))
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
/**
|
package/src/i18n/freeform.js
CHANGED
|
@@ -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`, `
|
|
350
|
-
* `prune --freeform`, so no two of them
|
|
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
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
*
|
|
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) {
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
const
|
|
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 (
|
|
535
|
-
|
|
536
|
-
for (const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 =
|
|
607
|
+
def = places.find(([k]) => k !== null)?.[1]
|
|
542
608
|
}
|
|
543
609
|
const overrides = { ...own }
|
|
544
|
-
for (const [k,
|
|
545
|
-
if (
|
|
546
|
-
|
|
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
|
|
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
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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,
|
|
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]) => [
|
|
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))) {
|
package/src/uwx/open-graph.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
821
|
-
ctx.collector?.add(record.
|
|
822
|
-
ctx.collector?.add(record.
|
|
823
|
-
ctx.collector?.add(record.
|
|
824
|
-
ctx.collector?.add(record.
|
|
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)
|