@uniweb/build 0.63.4 → 0.64.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 +7 -7
- package/src/i18n/extract.js +43 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +36 -17
- package/src/i18n/records.js +393 -161
- package/src/runtime-schema.js +3 -1
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/queries-config.js +8 -1
- package/src/site/query-processor.js +13 -2
- package/src/site/registered-foundation.js +47 -0
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +20 -9
- package/src/uwx/data-schema.js +34 -0
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/open-graph.js +69 -0
- package/src/uwx/project-writer.js +109 -31
- package/src/uwx/records-project.js +156 -28
- package/src/uwx/records.js +67 -18
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +334 -39
- package/src/uwx/site.js +178 -44
- package/src/uwx/sync-package.js +24 -1
- package/src/validate-data.js +15 -5
package/src/uwx/locale-sync.js
CHANGED
|
@@ -16,22 +16,52 @@
|
|
|
16
16
|
// surface, used by the projector (pull → files) and, later, the producer
|
|
17
17
|
// (files → push).
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
20
20
|
import { join, dirname } from 'node:path'
|
|
21
|
+
import yaml from 'js-yaml'
|
|
22
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
21
23
|
import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
|
|
22
24
|
import { computeHash } from '../i18n/hash.js'
|
|
23
|
-
import { blockElements, elementText } from '../i18n/extract.js'
|
|
25
|
+
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
|
+
import { visitDataStrings } from '../i18n/data-strings.js'
|
|
24
27
|
import { resolveDocForLocale } from '../i18n/merge.js'
|
|
25
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
|
+
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
26
30
|
|
|
27
31
|
const FREEFORM_MANIFEST = '.manifest.json'
|
|
28
32
|
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
+
// The directory a site keeps its translations in — `site.yml`'s `i18n.localesDir`, else `locales`,
|
|
34
|
+
// the rule the build and `uniweb i18n` read it by — for the push and the pull alike. `subdir` scopes a
|
|
35
|
+
// lane: '' → site-content (locales/), 'records' → record translations (locales/records/), matching the
|
|
36
|
+
// i18n manifest layout.
|
|
37
|
+
// ⛔ It was `locales/` whatever the site said until 2026-09-26: a site keeping its translations in
|
|
38
|
+
// `translations/` pushed its source language alone, and a pull wrote into a directory it never read.
|
|
33
39
|
export function localesDir(siteRoot, subdir = '') {
|
|
34
|
-
|
|
40
|
+
const base = join(siteRoot, localesDirName(siteRoot))
|
|
41
|
+
return subdir ? join(base, subdir) : base
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `i18n.localesDir`, read once per version of the file — every translation a pull writes asks for it.
|
|
45
|
+
const localesDirCache = new Map() // siteRoot → { mtimeMs, name }
|
|
46
|
+
function localesDirName(siteRoot) {
|
|
47
|
+
const file = join(siteRoot, 'site.yml')
|
|
48
|
+
let mtimeMs = null
|
|
49
|
+
try {
|
|
50
|
+
mtimeMs = statSync(file).mtimeMs
|
|
51
|
+
} catch {
|
|
52
|
+
return 'locales'
|
|
53
|
+
}
|
|
54
|
+
const cached = localesDirCache.get(siteRoot)
|
|
55
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.name
|
|
56
|
+
let name = 'locales'
|
|
57
|
+
try {
|
|
58
|
+
const dir = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)?.i18n?.localesDir
|
|
59
|
+
if (typeof dir === 'string' && dir.trim()) name = dir.trim()
|
|
60
|
+
} catch {
|
|
61
|
+
// unreadable — the default
|
|
62
|
+
}
|
|
63
|
+
localesDirCache.set(siteRoot, { mtimeMs, name })
|
|
64
|
+
return name
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
export function localeFilePath(siteRoot, locale, subdir = '') {
|
|
@@ -138,7 +168,7 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
|
|
|
138
168
|
* @param {string[]} [targetLocales]
|
|
139
169
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
140
170
|
*/
|
|
141
|
-
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations) {
|
|
171
|
+
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined) {
|
|
142
172
|
// Non-docs (null, an already-localized map) pass through untouched.
|
|
143
173
|
if (!isProseMirrorDoc(doc)) return doc
|
|
144
174
|
|
|
@@ -147,7 +177,8 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
147
177
|
for (const locale of targetLocales) {
|
|
148
178
|
const table = translations[locale]
|
|
149
179
|
if (!table) continue
|
|
150
|
-
|
|
180
|
+
// With the section's context, so a context-specific override resolves as the build resolves it.
|
|
181
|
+
const resolved = resolveDocForLocale(doc, table, context)
|
|
151
182
|
if (resolved) result[locale] = resolved
|
|
152
183
|
}
|
|
153
184
|
}
|
|
@@ -164,20 +195,46 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
164
195
|
* @param {string} sourceLocale
|
|
165
196
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
166
197
|
*/
|
|
167
|
-
export function localizeScalar(value, sourceLocale, translations) {
|
|
198
|
+
export function localizeScalar(value, sourceLocale, translations, context = null) {
|
|
168
199
|
if (value == null) return undefined
|
|
169
200
|
if (typeof value === 'object') return value // already a { lang: value } map
|
|
170
201
|
const out = { [sourceLocale]: value }
|
|
171
202
|
if (translations && typeof value === 'string') {
|
|
172
203
|
const hash = computeHash(value)
|
|
173
204
|
for (const [locale, table] of Object.entries(translations)) {
|
|
174
|
-
const tgt = table?.[hash]
|
|
205
|
+
const tgt = translationFor(table?.[hash], context)
|
|
175
206
|
if (typeof tgt === 'string') out[locale] = tgt
|
|
176
207
|
}
|
|
177
208
|
}
|
|
178
209
|
return out
|
|
179
210
|
}
|
|
180
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A translation entry's text in one place: a string as it is; `{ default, overrides }` — a
|
|
214
|
+
* string that reads differently in some places — by the place's key (`contextKey`), else its
|
|
215
|
+
* default. ⛔ Until 2026-09-26 a scalar's entry in that form was dropped whole, its default too:
|
|
216
|
+
* the build rendered it, and a push carried the source alone.
|
|
217
|
+
*
|
|
218
|
+
* @param {*} entry - a translation file's entry
|
|
219
|
+
* @param {{ key?: string, page?: string, section?: string }|null} context - where the value is
|
|
220
|
+
* @returns {string|undefined}
|
|
221
|
+
*/
|
|
222
|
+
export function translationFor(entry, context) {
|
|
223
|
+
if (typeof entry === 'string') return entry
|
|
224
|
+
if (!entry || typeof entry !== 'object') return undefined
|
|
225
|
+
const override = context ? entry.overrides?.[contextKey(context)] : undefined
|
|
226
|
+
return typeof override === 'string' ? override : typeof entry.default === 'string' ? entry.default : undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The key an override names a place by: a record's identity (`<pool>/<handle>`, as `key`), or
|
|
231
|
+
* a page section's `<route>:<id>` (`i18n/extract.js::translationContext`) — as `i18n/merge.js`
|
|
232
|
+
* reads it.
|
|
233
|
+
*/
|
|
234
|
+
export function contextKey(context) {
|
|
235
|
+
return context?.key ?? `${context?.page}:${context?.section}`
|
|
236
|
+
}
|
|
237
|
+
|
|
181
238
|
/**
|
|
182
239
|
* Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
|
|
183
240
|
* of localized strings") into per-locale form by localizing each element. Container
|
|
@@ -189,10 +246,10 @@ export function localizeScalar(value, sourceLocale, translations) {
|
|
|
189
246
|
* @param {string} sourceLocale
|
|
190
247
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
191
248
|
*/
|
|
192
|
-
export function localizeScalarList(value, sourceLocale, translations) {
|
|
249
|
+
export function localizeScalarList(value, sourceLocale, translations, context = null) {
|
|
193
250
|
if (value == null) return undefined
|
|
194
|
-
if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations)
|
|
195
|
-
return value.map((item) => localizeScalar(item, sourceLocale, translations))
|
|
251
|
+
if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations, context)
|
|
252
|
+
return value.map((item) => localizeScalar(item, sourceLocale, translations, context))
|
|
196
253
|
}
|
|
197
254
|
|
|
198
255
|
// True for a ProseMirror document node (`{ type: 'doc', content: [...] }`).
|
|
@@ -217,12 +274,14 @@ export function isLocalizedContent(value) {
|
|
|
217
274
|
* (source text hashed exactly as `i18n extract` keys it). Non-string or missing
|
|
218
275
|
* values are skipped (only hashable scalars belong in the hash manifest).
|
|
219
276
|
*/
|
|
220
|
-
export function createTranslationCollector(sourceLocale) {
|
|
277
|
+
export function createTranslationCollector(sourceLocale, { siteRoot = null } = {}) {
|
|
221
278
|
const byLocale = {} // { locale: { hash: target } }
|
|
222
279
|
const freeformPending = [] // [{ locale, content }] — target-locale free-form bodies
|
|
223
280
|
|
|
224
|
-
// Record a localized SCALAR field's target locales (hash of the source scalar).
|
|
225
|
-
|
|
281
|
+
// Record a localized SCALAR field's target locales (hash of the source scalar). ⭐ With `context`
|
|
282
|
+
// — the record it was read in (`{ key: '<pool>/<handle>' }`) — a translation that differs from one
|
|
283
|
+
// record to another comes back as the author's `{ default, overrides }`, as a page section's does.
|
|
284
|
+
function add(localizedValue, context = null) {
|
|
226
285
|
if (!isLocalizedMap(localizedValue)) return
|
|
227
286
|
const source = localizedValue[sourceLocale]
|
|
228
287
|
if (typeof source !== 'string' || source.length === 0) return
|
|
@@ -230,17 +289,38 @@ export function createTranslationCollector(sourceLocale) {
|
|
|
230
289
|
for (const [locale, value] of Object.entries(localizedValue)) {
|
|
231
290
|
if (locale === sourceLocale) continue
|
|
232
291
|
if (typeof value !== 'string') continue
|
|
233
|
-
|
|
292
|
+
const table = (byLocale[locale] ||= {})
|
|
293
|
+
if (!context) {
|
|
294
|
+
if (table[hash] instanceof SeenInContexts) table[hash].plain = value
|
|
295
|
+
else table[hash] = value
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const seen = table[hash] instanceof SeenInContexts ? table[hash] : new SeenInContexts(table[hash])
|
|
299
|
+
seen.values[contextKey(context)] = value
|
|
300
|
+
table[hash] = seen
|
|
234
301
|
}
|
|
235
302
|
}
|
|
236
303
|
|
|
237
304
|
// Record a structural translation MAP for one target locale: `{ src-text: target }`
|
|
238
305
|
// keyed by the source text exactly as `i18n extract` keys it (hash of the source).
|
|
239
|
-
|
|
306
|
+
// ⭐ With `context` — where the map was read (`translationContext`) — a translation that differs
|
|
307
|
+
// from one section to another is kept per section, and comes back as the author's
|
|
308
|
+
// context-specific overrides (`writeLocaleTranslations`). ⛔ Until 2026-09-26 the last section
|
|
309
|
+
// read won, and a pull wrote that one string over the author's `{ default, overrides }`.
|
|
310
|
+
function addStructuralMap(locale, map, context = null) {
|
|
240
311
|
if (locale === sourceLocale || !map || typeof map !== 'object' || Array.isArray(map)) return
|
|
312
|
+
const table = (byLocale[locale] ||= {})
|
|
241
313
|
for (const [src, target] of Object.entries(map)) {
|
|
242
314
|
if (typeof src !== 'string' || typeof target !== 'string') continue
|
|
243
|
-
|
|
315
|
+
const hash = computeHash(src)
|
|
316
|
+
if (!context) {
|
|
317
|
+
if (table[hash] instanceof SeenInContexts) table[hash].plain = target
|
|
318
|
+
else table[hash] = target
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
const seen = table[hash] instanceof SeenInContexts ? table[hash] : new SeenInContexts(table[hash])
|
|
322
|
+
seen.values[contextKey(context)] = target
|
|
323
|
+
table[hash] = seen
|
|
244
324
|
}
|
|
245
325
|
}
|
|
246
326
|
|
|
@@ -253,7 +333,21 @@ export function createTranslationCollector(sourceLocale) {
|
|
|
253
333
|
freeformPending.push({ locale, target, source, relpath: relpath || null })
|
|
254
334
|
}
|
|
255
335
|
|
|
256
|
-
|
|
336
|
+
// ⭐ The FREE-FORM file the site keeps for this translation, if any — the first of `relpaths` (the
|
|
337
|
+
// order the renderer looks them up in, `freeformPathsFor`) that exists for `locale`. A pulled
|
|
338
|
+
// translation goes back into that file, whatever its shape. ⛔ Until 2026-09-25 a free-form body
|
|
339
|
+
// whose paragraphs lined up with its source's was read as a structural translation, and a pull into
|
|
340
|
+
// the author's copy wrote its text over their `locales/{locale}.json` entries for those paragraphs —
|
|
341
|
+
// measured on the `international` template's `about/story`, in Spanish and French.
|
|
342
|
+
function keptFreeform(locale, relpaths) {
|
|
343
|
+
if (!siteRoot) return null
|
|
344
|
+
for (const rel of [].concat(relpaths || [])) {
|
|
345
|
+
if (rel && existsSync(join(localesDir(siteRoot), 'freeform', locale, rel))) return rel
|
|
346
|
+
}
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { add, addStructuralMap, noteFreeform, keptFreeform, byLocale, freeformPending }
|
|
257
351
|
}
|
|
258
352
|
|
|
259
353
|
/**
|
|
@@ -289,6 +383,45 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
|
289
383
|
// a mark we can't serialize → don't risk a lossy map; store as free-form
|
|
290
384
|
return null
|
|
291
385
|
}
|
|
386
|
+
return addDataBlockStrings(map, sourceDoc, targetDoc)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
|
|
390
|
+
// (`visitDataStrings`, the extractor's judgement), keyed by their source text as the build keys them.
|
|
391
|
+
// A target that is not the source with only those strings changed — another shape, another `href` —
|
|
392
|
+
// cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
|
|
393
|
+
// was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
|
|
394
|
+
function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
395
|
+
const srcBlocks = dataBlockNodes(sourceDoc)
|
|
396
|
+
const tgtBlocks = dataBlockNodes(targetDoc)
|
|
397
|
+
if (srcBlocks.length !== tgtBlocks.length) return null
|
|
398
|
+
for (let i = 0; i < srcBlocks.length; i++) {
|
|
399
|
+
const s = srcBlocks[i].attrs || {}
|
|
400
|
+
const t = tgtBlocks[i].attrs || {}
|
|
401
|
+
if (s.tag !== t.tag) return null
|
|
402
|
+
if (!s.data || typeof s.data !== 'object') {
|
|
403
|
+
if (canonicalJson(s.data) !== canonicalJson(t.data)) return null
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
const strings = (data) => {
|
|
407
|
+
const out = []
|
|
408
|
+
visitDataStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value))
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
const from = strings(s.data)
|
|
412
|
+
const to = strings(t.data)
|
|
413
|
+
if (from.length !== to.length) return null
|
|
414
|
+
const pairs = new Map()
|
|
415
|
+
for (let j = 0; j < from.length; j++) {
|
|
416
|
+
if (from[j] === to[j]) continue
|
|
417
|
+
if (pairs.has(from[j]) && pairs.get(from[j]) !== to[j]) return null // one string, two translations
|
|
418
|
+
pairs.set(from[j], to[j])
|
|
419
|
+
}
|
|
420
|
+
const rebuilt = JSON.parse(JSON.stringify(s.data))
|
|
421
|
+
visitDataStrings(rebuilt, (value) => pairs.get(value) ?? value)
|
|
422
|
+
if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
|
|
423
|
+
for (const [from, to] of pairs) map[from.trim()] = to
|
|
424
|
+
}
|
|
292
425
|
return map
|
|
293
426
|
}
|
|
294
427
|
|
|
@@ -303,23 +436,62 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
|
303
436
|
* reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
|
|
304
437
|
* doc (no locale wrap) is returned unchanged.
|
|
305
438
|
*/
|
|
306
|
-
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath) {
|
|
439
|
+
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null) {
|
|
307
440
|
if (!isLocalizedContent(content)) return content
|
|
308
441
|
const source = content[sourceLocale]
|
|
309
442
|
for (const [locale, value] of Object.entries(content)) {
|
|
310
443
|
if (locale === sourceLocale) continue
|
|
311
444
|
if (locale === '@' || locale.startsWith('$')) continue // reserved metadata, not a locale
|
|
312
445
|
if (isProseMirrorDoc(value)) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
446
|
+
// A translation the site keeps as a free-form file stays one, even when it lines up.
|
|
447
|
+
const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
|
|
448
|
+
const map = kept ? null : deriveStructuralMap(source, value)
|
|
449
|
+
if (map) collector?.addStructuralMap?.(locale, map, context)
|
|
450
|
+
else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
|
|
316
451
|
} else {
|
|
317
|
-
collector?.addStructuralMap?.(locale, value)
|
|
452
|
+
collector?.addStructuralMap?.(locale, value, context)
|
|
318
453
|
}
|
|
319
454
|
}
|
|
320
455
|
return source
|
|
321
456
|
}
|
|
322
457
|
|
|
458
|
+
// A translation read in several sections: what each section's context says, and what a place with
|
|
459
|
+
// no context (a page title) says. Reconciled with the author's entry when the file is written.
|
|
460
|
+
class SeenInContexts {
|
|
461
|
+
constructor(plain) {
|
|
462
|
+
this.plain = typeof plain === 'string' ? plain : undefined
|
|
463
|
+
this.values = {} // a place's key (`contextKey`) → translation
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ⭐ A translation seen in several places comes back as the author keeps it: one string where every
|
|
468
|
+
// place agrees, else `{ default, overrides }` — their default and their overrides kept wherever they
|
|
469
|
+
// still hold. The default is what the places without an override of their own say (the most of
|
|
470
|
+
// them; the author's default when tied, else the first read). An override for a place this pull did
|
|
471
|
+
// not see is kept, and one that now says the default is dropped.
|
|
472
|
+
function reconcileTranslation(existing, seen) {
|
|
473
|
+
const own = existing && typeof existing === 'object' && existing.overrides && typeof existing.overrides === 'object' ? existing.overrides : {}
|
|
474
|
+
const authoredDefault = typeof existing === 'string' ? existing : existing && typeof existing === 'object' ? existing.default : undefined
|
|
475
|
+
const contexts = Object.entries(seen.values)
|
|
476
|
+
const candidates = [...(seen.plain !== undefined ? [seen.plain] : []), ...contexts.filter(([k]) => !(k in own)).map(([, v]) => v)]
|
|
477
|
+
let def = authoredDefault
|
|
478
|
+
if (candidates.length) {
|
|
479
|
+
const counts = new Map()
|
|
480
|
+
for (const v of candidates) counts.set(v, (counts.get(v) || 0) + 1)
|
|
481
|
+
const most = Math.max(...counts.values())
|
|
482
|
+
const top = [...counts].filter(([, n]) => n === most).map(([v]) => v)
|
|
483
|
+
def = top.includes(authoredDefault) ? authoredDefault : top[0]
|
|
484
|
+
} else if (def === undefined) {
|
|
485
|
+
def = contexts[0]?.[1]
|
|
486
|
+
}
|
|
487
|
+
const overrides = { ...own }
|
|
488
|
+
for (const [k, v] of contexts) {
|
|
489
|
+
if (v === def) delete overrides[k]
|
|
490
|
+
else overrides[k] = v
|
|
491
|
+
}
|
|
492
|
+
return Object.keys(overrides).length ? { default: def, overrides } : def
|
|
493
|
+
}
|
|
494
|
+
|
|
323
495
|
/**
|
|
324
496
|
* Write captured target-locale FREE-FORM bodies to `locales/freeform/{locale}/<relpath>`
|
|
325
497
|
* (markdown, body-only) and record each one's SOURCE hash in that locale's
|
|
@@ -368,7 +540,9 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
368
540
|
} catch {
|
|
369
541
|
// absent
|
|
370
542
|
}
|
|
371
|
-
|
|
543
|
+
// ⭐ A file whose markdown already says this is left as the author wrote it — the writer's
|
|
544
|
+
// markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
|
|
545
|
+
if (current !== text && !(current !== null && sameMarkdownDocument(current.trim(), target))) {
|
|
372
546
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
373
547
|
writeFileSync(filePath, text)
|
|
374
548
|
report.written.push(filePath)
|
|
@@ -391,34 +565,91 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
391
565
|
return report
|
|
392
566
|
}
|
|
393
567
|
|
|
568
|
+
/**
|
|
569
|
+
* The keys of a JSON object's top level, in the order its text writes them.
|
|
570
|
+
*
|
|
571
|
+
* ⛔ NOT `Object.keys(JSON.parse(text))`: a JavaScript object puts every integer-like key
|
|
572
|
+
* first, and a translation's key is a hash — `19544096` is one, as about one in fifty are.
|
|
573
|
+
* Until 2026-09-26 a pull that changed one translation also moved each such entry to the top
|
|
574
|
+
* of the author's file.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} text - a JSON object
|
|
577
|
+
* @returns {string[]}
|
|
578
|
+
*/
|
|
579
|
+
function topLevelKeyOrder(text) {
|
|
580
|
+
const keys = []
|
|
581
|
+
let depth = 0
|
|
582
|
+
for (let i = 0; i < text.length; i++) {
|
|
583
|
+
const c = text[i]
|
|
584
|
+
if (c === '"') {
|
|
585
|
+
let end = i + 1
|
|
586
|
+
while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1
|
|
587
|
+
if (depth === 1 && /^\s*:/.test(text.slice(end + 1, end + 64))) keys.push(JSON.parse(text.slice(i, end + 1)))
|
|
588
|
+
i = end
|
|
589
|
+
} else if (c === '{' || c === '[') depth++
|
|
590
|
+
else if (c === '}' || c === ']') depth--
|
|
591
|
+
}
|
|
592
|
+
return keys
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** `JSON.stringify(object, null, 2)` of these entries, in their order — see `topLevelKeyOrder`. */
|
|
596
|
+
function jsonObjectText(entries) {
|
|
597
|
+
if (entries.length === 0) return '{}\n'
|
|
598
|
+
const lines = entries.map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value, null, 2).replace(/\n/g, '\n ')}`)
|
|
599
|
+
return `{\n${lines.join(',\n')}\n}\n`
|
|
600
|
+
}
|
|
601
|
+
|
|
394
602
|
/**
|
|
395
603
|
* Merge `{ locale: { hash: target } }` into each `locales/{locale}.json`,
|
|
396
604
|
* preserving existing entries (translations for strings not in this projection are
|
|
397
|
-
* kept — `uniweb i18n audit --clean` removes genuine orphans).
|
|
398
|
-
* the
|
|
605
|
+
* kept — `uniweb i18n audit --clean` removes genuine orphans). An entry keeps its place
|
|
606
|
+
* in the file, and new ones follow it, sorted, so a pull that changes nothing writes
|
|
607
|
+
* nothing. Creates `locales/` if absent.
|
|
399
608
|
*
|
|
400
609
|
* @returns {{ [locale: string]: 'updated' | 'unchanged' }}
|
|
401
610
|
*/
|
|
402
611
|
export function writeLocaleTranslations(siteRoot, byLocale, subdir = '') {
|
|
403
612
|
const report = {}
|
|
404
|
-
for (
|
|
613
|
+
for (let [locale, entries] of Object.entries(byLocale || {})) {
|
|
405
614
|
if (!entries || Object.keys(entries).length === 0) continue
|
|
406
615
|
const filePath = localeFilePath(siteRoot, locale, subdir)
|
|
407
616
|
|
|
408
617
|
let existing = {}
|
|
618
|
+
let order = []
|
|
409
619
|
if (existsSync(filePath)) {
|
|
410
620
|
try {
|
|
411
|
-
const
|
|
412
|
-
|
|
621
|
+
const text = readFileSync(filePath, 'utf8')
|
|
622
|
+
const parsed = JSON.parse(text)
|
|
623
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
624
|
+
existing = parsed
|
|
625
|
+
order = topLevelKeyOrder(text).filter((key) => key in parsed)
|
|
626
|
+
}
|
|
413
627
|
} catch {
|
|
414
628
|
// unreadable / invalid → start fresh
|
|
415
629
|
}
|
|
416
630
|
}
|
|
417
631
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
632
|
+
// ⭐ A FILE THE PULL ADDS NOTHING TO IS LEFT AS IT IS, and one it does keeps its order: an entry
|
|
633
|
+
// changes in its place, and new ones follow, sorted. ⛔ Until 2026-09-26 every pull re-sorted the
|
|
634
|
+
// whole file by hash — measured on the `international` template, whose `es.json` runs in page
|
|
635
|
+
// order: a pull that changed no translation rewrote all 77 lines.
|
|
636
|
+
// A translation read in several sections becomes the string or the `{ default, overrides }` the
|
|
637
|
+
// author keeps (`reconcileTranslation`).
|
|
638
|
+
entries = Object.fromEntries(
|
|
639
|
+
Object.entries(entries).map(([key, value]) => [key, value instanceof SeenInContexts ? reconcileTranslation(existing[key], value) : value])
|
|
640
|
+
)
|
|
641
|
+
const fresh = Object.keys(entries).filter((key) => !(key in existing)).sort()
|
|
642
|
+
if (fresh.length === 0 && Object.entries(entries).every(([key, value]) => canonicalJson(existing[key]) === canonicalJson(value))) {
|
|
643
|
+
report[locale] = 'unchanged'
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
// An entry the pull did not change keeps its value as written — key order included.
|
|
647
|
+
const merged = [...new Set([...order, ...Object.keys(existing)])].map((key) => [
|
|
648
|
+
key,
|
|
649
|
+
key in entries && canonicalJson(entries[key]) !== canonicalJson(existing[key]) ? entries[key] : existing[key],
|
|
650
|
+
])
|
|
651
|
+
for (const key of fresh) merged.push([key, entries[key]])
|
|
652
|
+
const next = jsonObjectText(merged)
|
|
422
653
|
|
|
423
654
|
let current = null
|
|
424
655
|
try {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ⭐ A PAGE'S OR A SITE'S OPEN GRAPH TITLE AND DESCRIPTION, PER LANGUAGE — `og_title` and
|
|
2
|
+
// `og_description` on the wire, `seo.ogTitle` / `seo.ogDescription` in a file.
|
|
3
|
+
//
|
|
4
|
+
// A file keeps them inside `seo`, whose other keys are crawler and sitemap directives a deployment
|
|
5
|
+
// stores as one object. Where the deployment's page — or `settings` — Section declares the two keys
|
|
6
|
+
// (`GET /dev/config` → `siteContent.pageFields` / `settingsFields`), a push sends them per language
|
|
7
|
+
// through those keys and the rest of `seo` as it is; elsewhere they ride inside `seo` as authored,
|
|
8
|
+
// in the source language only. A pull puts them back inside `seo`.
|
|
9
|
+
import { localizeScalar } from './locale-sync.js'
|
|
10
|
+
import { unwrapLocalized } from './backfill.js'
|
|
11
|
+
|
|
12
|
+
// wire key → the key inside a file's `seo`
|
|
13
|
+
const OPEN_GRAPH_KEYS = [
|
|
14
|
+
['og_title', 'ogTitle'],
|
|
15
|
+
['og_description', 'ogDescription'],
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
19
|
+
|
|
20
|
+
/** Whether a Section's declared keys take the two. */
|
|
21
|
+
export function takesOpenGraph(fields) {
|
|
22
|
+
return Array.isArray(fields) && OPEN_GRAPH_KEYS.every(([wire]) => fields.includes(wire))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A file's `seo` as a push sends it: `{ og_title?, og_description?, seo? }`. The two are taken out
|
|
27
|
+
* of `seo` and localized only where the Section takes them; `seo` is left out when nothing else is
|
|
28
|
+
* in it. A value that is not a string stays where it is.
|
|
29
|
+
*/
|
|
30
|
+
export function splitOpenGraph(seo, fields, sourceLocale, translations, context = null) {
|
|
31
|
+
if (!takesOpenGraph(fields) || !isRecord(seo)) return { seo }
|
|
32
|
+
const rest = { ...seo }
|
|
33
|
+
const out = {}
|
|
34
|
+
for (const [wire, key] of OPEN_GRAPH_KEYS) {
|
|
35
|
+
if (typeof seo[key] !== 'string') continue
|
|
36
|
+
out[wire] = localizeScalar(seo[key], sourceLocale, translations, context)
|
|
37
|
+
delete rest[key]
|
|
38
|
+
}
|
|
39
|
+
out.seo = Object.keys(rest).length ? rest : undefined
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One of the two, as a push sends it — or undefined where the Section does not take them. */
|
|
44
|
+
export function openGraphValue(seo, wire, fields, sourceLocale, translations, context = null) {
|
|
45
|
+
return splitOpenGraph(seo, fields, sourceLocale, translations, context)[wire]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `seo` as a push sends it beside the two — without them where the Section takes them. */
|
|
49
|
+
export function seoBesideOpenGraph(seo, fields) {
|
|
50
|
+
return splitOpenGraph(seo, fields, null, null).seo
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A document's `og_title` / `og_description` folded back into a file's `seo` — the source
|
|
55
|
+
* language's value — with the other languages handed to `collector` in `context`. Returns `seo`
|
|
56
|
+
* unchanged when the holder carries neither, and undefined for an empty result.
|
|
57
|
+
*/
|
|
58
|
+
export function foldOpenGraph(holder, seo, sourceLocale, collector = null, context = null) {
|
|
59
|
+
if (!OPEN_GRAPH_KEYS.some(([wire]) => holder?.[wire] !== undefined)) return seo
|
|
60
|
+
const out = isRecord(seo) ? { ...seo } : {}
|
|
61
|
+
for (const [wire, key] of OPEN_GRAPH_KEYS) {
|
|
62
|
+
const value = holder?.[wire]
|
|
63
|
+
if (value === undefined) continue
|
|
64
|
+
collector?.add(value, context)
|
|
65
|
+
const source = unwrapLocalized(value, sourceLocale)
|
|
66
|
+
if (typeof source === 'string' && source !== '') out[key] = source
|
|
67
|
+
}
|
|
68
|
+
return Object.keys(out).length ? out : undefined
|
|
69
|
+
}
|