@uniweb/build 0.63.3 → 0.64.0

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.
@@ -26,7 +26,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
26
26
  import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
27
27
  import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
28
28
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
29
- import { readLayoutFolder, DEFAULT_LAYOUT } from './layout-folder.js'
29
+ import { readLayoutFolder, DEFAULT_LAYOUT, layoutAreaRoute } from './layout-folder.js'
30
30
  import { join, parse, relative, resolve, sep } from 'node:path'
31
31
  import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
32
32
  import yaml from 'js-yaml'
@@ -2419,7 +2419,7 @@ async function collectLayouts(layoutDir, siteRoot) {
2419
2419
  assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
2420
2420
  iconCollection = mergeIconCollections(iconCollection, result.iconCollection)
2421
2421
  }
2422
- const route = area.layout === DEFAULT_LAYOUT ? `/layout/${area.area}` : `/layout/${area.layout}/${area.area}`
2422
+ const route = layoutAreaRoute(area.layout, area.area)
2423
2423
  layouts[area.layout] ??= {}
2424
2424
  layouts[area.layout][area.area] = {
2425
2425
  route,
@@ -2693,6 +2693,11 @@ export async function collectSiteContent(sitePath, options = {}) {
2693
2693
  for (const { message } of langValidation.warnings) {
2694
2694
  console.warn(`[content-collector] ${message}`)
2695
2695
  }
2696
+ // Errors stop a publish build (below); in dev they are said, so the author meets them here
2697
+ // rather than first at a push.
2698
+ if (!dropUnpublished) {
2699
+ for (const { message } of langValidation.errors) console.warn(`[content-collector] ${message}`)
2700
+ }
2696
2701
  // Publish filter (the sync format's "Per-locale publish
2697
2702
  // readiness"): on published build paths, only the publishable intersection
2698
2703
  // (publishLanguages ∩ languages; absent field = all declared) ships — in
@@ -2956,9 +2961,35 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
2956
2961
  return Object.keys(result).length > 0 ? result : null
2957
2962
  }
2958
2963
 
2964
+ /**
2965
+ * One level's pages in the order a push walks them, and a pull reproduces: explicit `order:`, then
2966
+ * numeric-prefix filename order, then the parent config's `pages:` list. Shared by the push's walk
2967
+ * (`uwx/site.js::orderedSubfolders`) and the pull (`uwx/site-project.js`), so the two cannot
2968
+ * disagree on what a level's order is.
2969
+ *
2970
+ * ⚠️ The build's own page-folder sort (`collectPagesRecursive`) is not this function: it compares
2971
+ * folder names whole, as strings. The two differ only for numbered page folders — see the note at
2972
+ * `orderedSubfolders`.
2973
+ *
2974
+ * @param {Array<{ dirName: string, name: string, order?: number }>} folders
2975
+ * @param {Array} [parentPages] - the parent config's `pages:`
2976
+ * @returns {Array} the folders, ordered
2977
+ */
2978
+ function orderFolders(folders, parentPages) {
2979
+ const sorted = [...folders].sort(
2980
+ (a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || compareFilenames(a.dirName, b.dirName)
2981
+ )
2982
+ const parsed = Array.isArray(parentPages) ? parseWildcardArray(parentPages) : null
2983
+ return parsed && parsed.mode !== 'all' ? applyWildcardOrder(sorted, parsed) : sorted
2984
+ }
2985
+
2959
2986
  export {
2960
2987
  assertRouteFolder,
2961
2988
  buildRouteTranslations,
2989
+ composeLocalizedRoute,
2990
+ isValidSlugSegment,
2991
+ declaredHomepage,
2992
+ orderFolders,
2962
2993
  extractItemName,
2963
2994
  parseWildcardArray,
2964
2995
  applyWildcardOrder,
@@ -40,6 +40,21 @@ import { layoutNameKey } from '@uniweb/core/layout-name'
40
40
  /** The key the default layout's areas are held under, on every lane. */
41
41
  export const DEFAULT_LAYOUT = 'default'
42
42
 
43
+ /**
44
+ * The route a layout area renders at — `/layout/<area>` in the default layout,
45
+ * `/layout/<layout>/<area>` in a named one. A layout area's free-form translations are
46
+ * addressed by it (`i18n/freeform.js`), so the build, the push and the pull must all use
47
+ * this one rule: the push said "layout sections have no free-form home" until 2026-09-26,
48
+ * and a translation the build rendered never left the author's machine.
49
+ *
50
+ * @param {string} [layoutName]
51
+ * @param {string} area
52
+ * @returns {string}
53
+ */
54
+ export function layoutAreaRoute(layoutName, area) {
55
+ return !layoutName || layoutName === DEFAULT_LAYOUT ? `/layout/${area}` : `/layout/${layoutName}/${area}`
56
+ }
57
+
43
58
  /**
44
59
  * Area names a layout folder is most likely to have been meant as. A folder under
45
60
  * `layout/` named like one of them was, before this convention, an area of the
@@ -186,23 +186,34 @@ function extractPlainText(node) {
186
186
  if (node.type === 'text') {
187
187
  return node.text || ''
188
188
  }
189
+ if (node.type === 'hardBreak' || node.type === 'hard_break') return ' '
189
190
 
190
191
  if (Array.isArray(node.content)) {
191
- return node.content.map(extractPlainText).join('')
192
+ // A textblock's inline children join as written; blocks are separated by a space. ⛔ Until
193
+ // 2026-09-26 blocks joined with nothing too, so an excerpt ran one paragraph into the next
194
+ // ("…in 1816.The river…").
195
+ const inline = node.content.every((child) => child?.type === 'text' || INLINE_NODES.has(child?.type))
196
+ return node.content.map(extractPlainText).join(inline ? '' : ' ')
192
197
  }
193
198
 
194
199
  return ''
195
200
  }
196
201
 
202
+ // The inline nodes a textblock holds beside text.
203
+ const INLINE_NODES = new Set(['hardBreak', 'hard_break', 'image', 'inset_ref', 'inset_placeholder', 'icon'])
204
+
197
205
  /**
198
206
  * Extract excerpt from content
199
207
  *
208
+ * Exported for the localized build (`i18n/records.js`), which derives each locale's
209
+ * excerpt from that locale's body by this same rule.
210
+ *
200
211
  * @param {Object} frontmatter - Parsed frontmatter
201
212
  * @param {Object} content - ProseMirror content
202
213
  * @param {Object} excerptConfig - Excerpt configuration
203
214
  * @returns {string} Excerpt text
204
215
  */
205
- function extractExcerpt(frontmatter, content, excerptConfig) {
216
+ export function extractExcerpt(frontmatter, content, excerptConfig) {
206
217
  const { maxLength = 160, field = null } = excerptConfig || {}
207
218
 
208
219
  // Check for explicit excerpt in frontmatter
@@ -70,9 +70,14 @@ export function servedFingerprint(url) {
70
70
  * that works, not a local path to a file that is not there. Filling those in is
71
71
  * the download's job, not this one.
72
72
  *
73
- * `assetId` itself is not removed: it is not a markdown attribute, so the
74
- * serializer drops it on the way to disk, and leaving it lets a caller project
75
- * the same document twice without the second pass losing identity.
73
+ * ⭐ **And the identity goes with the URL it stood beside.** A restored reference is
74
+ * the author's path again, and that path's id is kept in `sync.json`, which the next
75
+ * push stamps from (`rewriteEntityAssets`). ⛔ Until 2026-09-26 `assetId` was left in
76
+ * place, on the ground that the markdown serializer drops it — true of a body, and of
77
+ * nothing else: a pull into the copy that pushed wrote `assetId`/`assetExt` into the
78
+ * frontmatter of every section whose background it restored, and a body holding a
79
+ * pushed image never compared as unchanged, since the author's markdown parses with
80
+ * no id. An id the map does not know keeps its identity, with its URL.
76
81
  *
77
82
  * @param {object} document - the site-content document (mutated in place)
78
83
  * @param {Record<string, { id: string, ext: string }>} map - readAssetMap()
@@ -98,6 +103,8 @@ export function restoreAssetRefs(document, map) {
98
103
  if (!ref) { stats.unknown++; continue }
99
104
  const urlAttr = slot.urls.find((k) => typeof node[k] === 'string') || slot.urls[0]
100
105
  node[urlAttr] = ref
106
+ delete node[slot.id]
107
+ delete node[slot.ext]
101
108
  stats.restored++
102
109
  }
103
110
  for (const v of Object.values(node)) visit(v)
@@ -254,7 +254,7 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
254
254
  * it has no name for is written as the uuid.
255
255
  * @returns {string} the source-file text
256
256
  */
257
- export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
257
+ export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
258
258
  const layout = recordFileLayout(declaration)
259
259
  if (layout.sections.length === 0) {
260
260
  throw new Error('uwx/render: the data schema declares no sections')
@@ -267,7 +267,7 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
267
267
  }
268
268
  // Markdown: the content body field, wherever its single section is, becomes the body.
269
269
  const { target } = contentBodyTarget(declaration)
270
- const dec = { sourceLocale, collector, freeformRelPath, refName, bodyAt: format === 'md' ? target : null, body: '' }
270
+ const dec = { sourceLocale, collector, freeformRelPath, refName, context, bodyAt: format === 'md' ? target : null, body: '' }
271
271
 
272
272
  const record = {}
273
273
  if (document?.$uuid) record.$uuid = document.$uuid
@@ -338,16 +338,16 @@ function decodeLeaf(raw, field, dec, isBody) {
338
338
  if (field?.type === 'entity_ref') return decodeReference(raw, field, dec)
339
339
  if (isProseMirrorField(field)) {
340
340
  const sourceDoc = field.localized
341
- ? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined)
341
+ ? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined, null, dec.context)
342
342
  : raw
343
343
  return sourceDoc ? proseMirrorToMarkdown(sourceDoc) : ''
344
344
  }
345
345
  if (!field?.localized) return raw
346
346
  if (Array.isArray(raw)) {
347
- if (!isBody) for (const item of raw) dec.collector?.add(item)
347
+ if (!isBody) for (const item of raw) dec.collector?.add(item, dec.context)
348
348
  return unwrapLocalizedList(raw, dec.sourceLocale)
349
349
  }
350
- if (!isBody) dec.collector?.add(raw)
350
+ if (!isBody) dec.collector?.add(raw, dec.context)
351
351
  return unwrapLocalized(raw, dec.sourceLocale)
352
352
  }
353
353
 
package/src/uwx/index.js CHANGED
@@ -71,7 +71,10 @@ export {
71
71
  declarationsToQueriesYml,
72
72
  folderToFolderYml,
73
73
  findRecordFileByUuid,
74
+ indexFolder,
74
75
  } from './records-project.js'
76
+ // Which stored record each of a copy's records is, when its map lost the answer.
77
+ export { matchStoredRecords } from './record-identity.js'
75
78
  // The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
76
79
  // up front and hands it to the synchronous projections above.
77
80
  export { siteSelfScope } from './self-scope.js'
@@ -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 `locales/` directory for a site (the i18n localesDir default; `paths` can
30
- // override it, but the default is `locales` — keep this the single place to change).
31
- // `subdir` scopes a lane: '' → site-content (locales/), 'records' → record
32
- // translations (locales/records/), matching the i18n manifest layout.
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
- return subdir ? join(siteRoot, 'locales', subdir) : join(siteRoot, 'locales')
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
- const resolved = resolveDocForLocale(doc, table)
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
- function add(localizedValue) {
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
- ;(byLocale[locale] ||= {})[hash] = value
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
- function addStructuralMap(locale, map) {
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
- ;(byLocale[locale] ||= {})[computeHash(src)] = target
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
- return { add, addStructuralMap, noteFreeform, byLocale, freeformPending }
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
- const map = deriveStructuralMap(source, value)
314
- if (map) collector?.addStructuralMap?.(locale, map)
315
- else collector?.noteFreeform?.(locale, value, source, freeformRelPath)
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
- if (current !== text) {
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). Keys are sorted so
398
- * the output is byte-stable / idempotent. Creates `locales/` if absent.
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 (const [locale, entries] of Object.entries(byLocale || {})) {
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 parsed = JSON.parse(readFileSync(filePath, 'utf8'))
412
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existing = parsed
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
- const merged = { ...existing, ...entries }
419
- const sorted = {}
420
- for (const key of Object.keys(merged).sort()) sorted[key] = merged[key]
421
- const next = JSON.stringify(sorted, null, 2) + '\n'
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 {