@uniweb/build 1.3.7 → 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 +4 -4
- package/src/i18n/freeform-manifest.js +14 -1
- package/src/i18n/freeform.js +14 -7
- package/src/site/entity-pool.js +16 -6
- package/src/uwx/backfill.js +7 -1
- package/src/uwx/locale-sync.js +116 -34
- package/src/uwx/open-graph.js +2 -1
- package/src/uwx/project-writer.js +9 -7
- package/src/uwx/records-project.js +83 -6
- 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": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
59
|
"@uniweb/content-writer": "~1.1.2",
|
|
60
|
+
"@uniweb/schemas": "~1.3.1",
|
|
60
61
|
"@uniweb/content-reader": "~1.4.0",
|
|
61
|
-
"@uniweb/projections": "~1.1.1",
|
|
62
|
-
"@uniweb/semantic-parser": "~1.8.1",
|
|
63
62
|
"@uniweb/theming": "~1.2.0",
|
|
64
|
-
"@uniweb/
|
|
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"
|
|
@@ -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/site/entity-pool.js
CHANGED
|
@@ -53,8 +53,7 @@
|
|
|
53
53
|
// there is no population to carry): a leftover `entities/` beside `records/` would
|
|
54
54
|
// otherwise hold records nothing reads, silently.
|
|
55
55
|
|
|
56
|
-
import {
|
|
57
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
56
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
58
57
|
import { join, extname, basename, resolve } from 'node:path'
|
|
59
58
|
import yaml from 'js-yaml'
|
|
60
59
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
@@ -263,6 +262,17 @@ export function poolPathReadings(dirs) {
|
|
|
263
262
|
* leaves the backend's folder alone for a site with no records directory.
|
|
264
263
|
*/
|
|
265
264
|
export async function readEntityPool(siteRoot, opts = {}) {
|
|
265
|
+
return readEntityPoolSync(siteRoot, opts)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `readEntityPool`, for a caller that cannot wait — the pull's projection, which asks what a
|
|
270
|
+
* `folder.yml` already says (`records-project.js`). One walk, which the async form returns.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} siteRoot
|
|
273
|
+
* @param {object} [opts] - as `readEntityPool`'s
|
|
274
|
+
*/
|
|
275
|
+
export function readEntityPoolSync(siteRoot, opts = {}) {
|
|
266
276
|
let rel
|
|
267
277
|
if (opts.dir) {
|
|
268
278
|
rel = String(opts.dir).replace(/\/+$/, '')
|
|
@@ -280,10 +290,10 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
280
290
|
// A file record is named by its stem, so two files of one stem cannot both be one.
|
|
281
291
|
const fileStems = new Map()
|
|
282
292
|
|
|
283
|
-
const walk =
|
|
293
|
+
const walk = (dir, dirs) => {
|
|
284
294
|
let listing
|
|
285
295
|
try {
|
|
286
|
-
listing =
|
|
296
|
+
listing = readdirSync(dir, { withFileTypes: true })
|
|
287
297
|
} catch {
|
|
288
298
|
return
|
|
289
299
|
}
|
|
@@ -304,7 +314,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
304
314
|
)
|
|
305
315
|
continue
|
|
306
316
|
}
|
|
307
|
-
|
|
317
|
+
walk(full, [...dirs, e.name])
|
|
308
318
|
continue
|
|
309
319
|
}
|
|
310
320
|
if (!e.isFile()) continue
|
|
@@ -356,7 +366,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
356
366
|
}
|
|
357
367
|
}
|
|
358
368
|
|
|
359
|
-
|
|
369
|
+
walk(base, [])
|
|
360
370
|
return { entities, errors, exists: true, dir: rel }
|
|
361
371
|
}
|
|
362
372
|
|
package/src/uwx/backfill.js
CHANGED
|
@@ -296,7 +296,13 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
296
296
|
if (document?.$disabled === true) record.draft = true
|
|
297
297
|
|
|
298
298
|
if (format === 'json') return JSON.stringify(record, null, 2) + '\n'
|
|
299
|
-
if (format === 'md')
|
|
299
|
+
if (format === 'md') {
|
|
300
|
+
// ⭐ Written as a section is (`project-writer.js::assembleSection`): a blank line after the front
|
|
301
|
+
// matter, and a final newline. ⛔ Until 2026-10-17 it had neither, so a pull that rewrote an
|
|
302
|
+
// edited record took both out of the author's file.
|
|
303
|
+
const body = String(dec.body ?? '').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '')
|
|
304
|
+
return `---\n${yaml.dump(record)}---\n${body ? `\n${body}\n` : ''}`
|
|
305
|
+
}
|
|
300
306
|
return yaml.dump(record) // yaml / yaml
|
|
301
307
|
}
|
|
302
308
|
|
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
|
}
|
|
@@ -418,14 +418,16 @@ function restates(authored, decl) {
|
|
|
418
418
|
* (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
|
|
419
419
|
*
|
|
420
420
|
* ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
|
|
421
|
-
* other projected config
|
|
422
|
-
*
|
|
423
|
-
* There is also nothing a shallow merge could mean here — the file is a sequence,
|
|
424
|
-
* and merging two lists either duplicates entries or silently drops them.
|
|
421
|
+
* other projected config: the file is a sequence, and merging two lists either
|
|
422
|
+
* duplicates entries or silently drops them.
|
|
425
423
|
*
|
|
426
|
-
* The caller decides whether there is anything to write: a
|
|
427
|
-
*
|
|
428
|
-
*
|
|
424
|
+
* The caller decides whether there is anything to write: a file that already says
|
|
425
|
+
* what the pulled folder says — its patterns included — is left alone
|
|
426
|
+
* (`records-project.js::organizesAlike`), and a folder with no sub-folders is no file
|
|
427
|
+
* at all (`folderToFolderYml` removes it), since missing and empty mean the same.
|
|
428
|
+
* ⛔ *Until 2026-10-17 this called the file a mirror — "concrete refs on both sides,
|
|
429
|
+
* nothing to invert" — and every pull wrote the folder's paths over a developer's
|
|
430
|
+
* patterns.* ⚠️ *Until 2026-09-21 this said an empty file was destructive
|
|
429
431
|
* on the next push; that stopped being true when the directory, not this file,
|
|
430
432
|
* became what decides which records the folder holds.*
|
|
431
433
|
*
|
|
@@ -43,8 +43,9 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
43
43
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
44
|
import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
45
45
|
import { dataKeyTypes } from './data-key-types.js'
|
|
46
|
-
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
47
|
-
import { folderYmlPath } from '../site/records-config.js'
|
|
46
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir, readEntityPoolSync } from '../site/entity-pool.js'
|
|
47
|
+
import { folderYmlPath, resolveFolder } from '../site/records-config.js'
|
|
48
|
+
import { canonicalJson } from './same-content.js'
|
|
48
49
|
import { contentBodyTarget } from './record-layout.js'
|
|
49
50
|
import { qualifyRef } from '@uniweb/schemas/refs'
|
|
50
51
|
import { unresolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
|
|
@@ -501,9 +502,14 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
501
502
|
* Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
|
|
502
503
|
* directory (`records/folder.yml`, or under `site.yml::paths.records`).
|
|
503
504
|
*
|
|
504
|
-
* ⭐ THE FOLDER
|
|
505
|
-
* `
|
|
506
|
-
*
|
|
505
|
+
* ⭐ A FILE THAT SAYS WHAT THE PULLED FOLDER SAYS IS LEFT AS WRITTEN — its patterns
|
|
506
|
+
* (`member/*.md`), its comments, its order (`organizesAlike`). Otherwise the folder
|
|
507
|
+
* is written as the paths the backend's folder places, which is what it says.
|
|
508
|
+
* ⛔ *This read "the folder round-trips trivially: `folder.yml` holds concrete paths
|
|
509
|
+
* on both sides, so there is nothing to invert" until 2026-10-17 — read as "the file
|
|
510
|
+
* holds no patterns", which it does — and every pull with a folder wrote its paths
|
|
511
|
+
* over the developer's patterns: a record added afterwards fell outside them.* The
|
|
512
|
+
* old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
|
|
507
513
|
* collections — and inverting a macro is not possible in general. Taking queries
|
|
508
514
|
* out of the folder is what dissolved that.
|
|
509
515
|
*
|
|
@@ -600,6 +606,16 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
600
606
|
|
|
601
607
|
const entries = walk(folderDoc.contents, false)
|
|
602
608
|
if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
|
|
609
|
+
if (existsSync(abs) && organizesAlike({ abs, entries, siteRoot, poolPathByUuid })) {
|
|
610
|
+
return { status: 'unchanged', entries, warnings, file }
|
|
611
|
+
}
|
|
612
|
+
// Said when it replaces a developer's pattern: a record they add from here is placed by nothing.
|
|
613
|
+
if (existsSync(abs) && holdsPattern(abs)) {
|
|
614
|
+
warnings.push(
|
|
615
|
+
`${file}: the folder no longer places its records as its patterns say, so it was written as their ` +
|
|
616
|
+
`paths. A record you add from here sits at the top of the folder until ${file} places it.`
|
|
617
|
+
)
|
|
618
|
+
}
|
|
603
619
|
if (entries.length === 0) {
|
|
604
620
|
if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
|
|
605
621
|
unlinkSync(abs)
|
|
@@ -608,6 +624,67 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
608
624
|
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
|
|
609
625
|
}
|
|
610
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Does the `folder.yml` on disk say what the pulled folder says — read by the rule a push reads it
|
|
629
|
+
* by (`resolveFolder`), over the records directory as this pull left it?
|
|
630
|
+
*
|
|
631
|
+
* ⭐ About the records the backend's folder holds, every word: the folders, by name, label and tags,
|
|
632
|
+
* in order; and each record's folder, its tags and label, and its place among the others. Records
|
|
633
|
+
* the backend does not hold yet — a file the developer added and has not pushed — are theirs, so a
|
|
634
|
+
* pattern placing one is not a difference; nor is a folder holding only those. So `member/*.md` is
|
|
635
|
+
* kept while it places exactly the members the backend's folder has there, and goes on placing the
|
|
636
|
+
* next one the developer adds.
|
|
637
|
+
*
|
|
638
|
+
* @returns {boolean}
|
|
639
|
+
*/
|
|
640
|
+
function organizesAlike({ abs, entries, siteRoot, poolPathByUuid }) {
|
|
641
|
+
let authored
|
|
642
|
+
try {
|
|
643
|
+
authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
|
|
644
|
+
} catch {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
if (!Array.isArray(authored)) return false
|
|
648
|
+
const pool = readEntityPoolSync(siteRoot).entities
|
|
649
|
+
const pulled = new Set(poolPathByUuid.values())
|
|
650
|
+
const held = new Set(pool.filter((e) => pulled.has(e.poolPath)).map((e) => e.id))
|
|
651
|
+
const mine = resolveFolder(authored, pool)
|
|
652
|
+
const theirs = resolveFolder(entries, pool)
|
|
653
|
+
if (mine.errors.length || theirs.errors.length) return false
|
|
654
|
+
const theirFolders = new Set()
|
|
655
|
+
const said = (n) => ({ ...(n.label != null ? { label: n.label } : {}), ...(n.tags ? { tags: n.tags } : {}) })
|
|
656
|
+
// A tree as it reads for the held records. `keep(path, holds)` decides whether a folder stays.
|
|
657
|
+
const shape = (nodes, at, keep) =>
|
|
658
|
+
(nodes || []).flatMap((n) => {
|
|
659
|
+
if (n.kind === 'ref') return held.has(n.$entityId) ? [{ record: n.$entityId, ...said(n) }] : []
|
|
660
|
+
if (n.kind !== 'branch') return []
|
|
661
|
+
const path = [...at, n.name]
|
|
662
|
+
const records = shape(n.$children, path, keep)
|
|
663
|
+
return keep(path.join('/'), records.length > 0) ? [{ folder: n.name, ...said(n), records }] : []
|
|
664
|
+
})
|
|
665
|
+
// Every folder the backend has stays, empty or not; one of the file's stays when it holds a held
|
|
666
|
+
// record or the backend has it too — else it is the developer's, not pushed yet.
|
|
667
|
+
const theirShape = shape(theirs.nodes, [], (path) => (theirFolders.add(path), true))
|
|
668
|
+
const myShape = shape(mine.nodes, [], (path, holds) => holds || theirFolders.has(path))
|
|
669
|
+
return canonicalJson(myShape) === canonicalJson(theirShape)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Does the `folder.yml` on disk place records by a pattern (`member/*.md`) anywhere?
|
|
673
|
+
function holdsPattern(abs) {
|
|
674
|
+
let authored
|
|
675
|
+
try {
|
|
676
|
+
authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
|
|
677
|
+
} catch {
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
680
|
+
const has = (entries) =>
|
|
681
|
+
(Array.isArray(entries) ? entries : []).some((entry) => {
|
|
682
|
+
const path = typeof entry === 'string' ? entry : entry?.path
|
|
683
|
+
return (typeof path === 'string' && /[*?]/.test(path)) || has(entry?.records)
|
|
684
|
+
})
|
|
685
|
+
return has(authored)
|
|
686
|
+
}
|
|
687
|
+
|
|
611
688
|
/**
|
|
612
689
|
* Project a pulled folder + its record entities to `records/**` files.
|
|
613
690
|
*
|
|
@@ -823,7 +900,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
823
900
|
|
|
824
901
|
// Flush localized record-field translations to locales/records/{locale}.json,
|
|
825
902
|
// and any prosemirror free-form body overrides to locales/freeform/{locale}/.
|
|
826
|
-
const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
|
|
903
|
+
const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records', collector.says)
|
|
827
904
|
const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
|
|
828
905
|
|
|
829
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)
|