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