@uniweb/build 0.63.4 → 0.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,17 +15,25 @@ import { existsSync } from 'node:fs'
15
15
  import { join, resolve, sep } from 'node:path'
16
16
  import { pathToFileURL } from 'node:url'
17
17
  import { DATA_DIR } from '@uniweb/core'
18
+ import yaml from 'js-yaml'
19
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
20
+ import { markdownToProseMirror } from '@uniweb/content-reader'
21
+ import { proseMirrorToMarkdown } from '@uniweb/content-writer'
18
22
  import { computeHash } from './hash.js'
19
23
  import { loadFreeformRecord } from './freeform.js'
24
+ import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
25
+ import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
26
+ import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
27
+ import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
28
+ import { resolveDocForLocale } from './merge.js'
29
+ import { extractUnitsFromDoc } from './extract.js'
30
+ import { flatFormRefusal, extractExcerpt } from '../site/query-processor.js'
31
+ import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
20
32
  // The heuristic judgement about which strings inside structured data are prose.
21
33
  // It lives in its own module because the page lane needs exactly the same
22
34
  // answer for a tagged data block's payload — a `label` is prose and an `href`
23
35
  // is not, wherever the value came from. Moved rather than copied: two tuned
24
36
  // denylists would drift, and drift here is silent.
25
- import { resolveQueriesConfig, resolveRecordSchemas } from '../site/queries-config.js'
26
- import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
27
- import { flatFormRefusal } from '../site/query-processor.js'
28
- import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
29
37
  import {
30
38
  NON_TRANSLATABLE_TYPES,
31
39
  HEURISTIC_SKIP_FIELDS,
@@ -156,6 +164,200 @@ function isFieldTranslatable(fieldDef) {
156
164
  return 'no'
157
165
  }
158
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // A record's model — what a push sends it as
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * ⭐ A RECORD WITH A DATA SCHEMA IS TRANSLATED BY THE MODEL A PUSH SENDS IT AS — its schema
173
+ * lowered by the push's own rule (`uwx/data-schema.js::toDataSchemaDeclaration`), which marks
174
+ * `localized` exactly the fields that travel per locale: text and rich content — never an enum,
175
+ * a value format (a URL, an email), a `translatable: false` field, a number, a date, a file or a
176
+ * reference. So what the static build extracts and translates is what a push carries, field for
177
+ * field, and what a clone of the site, or the site as a backend serves it, renders.
178
+ *
179
+ * ⛔ Until 2026-09-26 the build guessed: a walk over every string, vetoed by the fields of the
180
+ * schema's single sections — so a `multi` section's enum, and the brief of a record a reference
181
+ * names, were extracted and translated while a push sent neither.
182
+ *
183
+ * @param {Object|null} schema - a normalized data schema
184
+ * @param {string} ref - the ref it was resolved from
185
+ * @returns {Object|null} the lowered declaration, or null when there is none
186
+ */
187
+ function modelOf(schema, ref) {
188
+ if (!schema) return null
189
+ try {
190
+ return toDataSchemaDeclaration(schema, { name: ref || '@/record', resolveName: (r) => r })
191
+ } catch {
192
+ return null // a schema a push refuses is refused there, and the build keeps its heuristic
193
+ }
194
+ }
195
+
196
+ /** The refs of the models a model's references name. */
197
+ function referencedModels(model) {
198
+ const out = new Set()
199
+ const walk = (fields) => {
200
+ for (const field of Object.values(fields || {})) {
201
+ if (field?.type === 'entity_ref' && typeof field.model === 'string') out.add(field.model)
202
+ else if (field?.type === 'section') walk(field.fields)
203
+ }
204
+ }
205
+ for (const section of Object.values(model?.sections || {})) walk(section?.fields)
206
+ return out
207
+ }
208
+
209
+ /**
210
+ * Each field of a model that a record holds a value for, in the shape the record is DELIVERED in
211
+ * (`toDeliveredRecord`): the brief's fields at the top — every field, for a model of one single
212
+ * section — and each other section under its name, a list of them for a `multiple` one, a nested
213
+ * section the same way. Calls `visit(holder, name, field, path)` for each.
214
+ */
215
+ function eachModelField(record, model, visit) {
216
+ const sections = Object.entries(model?.sections || {})
217
+ const flat = sections.length === 1 && !sections[0][1]?.multiple
218
+ for (const [name, section] of sections) {
219
+ if (flat || section?.brief) eachSectionField(record, section?.fields, '', visit)
220
+ else eachSectionValue(record?.[name], section, name, visit)
221
+ }
222
+ }
223
+
224
+ function eachSectionValue(value, section, path, visit) {
225
+ if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
226
+ if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
227
+ // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
228
+ else if (isOpenMapSection(section) && isPlainObject(value)) {
229
+ for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
230
+ }
231
+ }
232
+
233
+ function eachSectionField(holder, fields, path, visit) {
234
+ if (!isPlainObject(holder)) return
235
+ for (const [name, field] of Object.entries(fields || {})) {
236
+ if (holder[name] == null || isRecordSystemField(name, !path)) continue
237
+ const at = path ? `${path}.${name}` : name
238
+ if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
239
+ else visit(holder, name, field || {}, at)
240
+ }
241
+ }
242
+
243
+ /** The fields of a model's brief — what a reference to one of its records is delivered as. */
244
+ function briefFields(model) {
245
+ const sections = Object.values(model?.sections || {})
246
+ const brief = sections.find((s) => s?.brief) ?? (sections.length === 1 && !sections[0]?.multiple ? sections[0] : null)
247
+ return brief?.fields ?? null
248
+ }
249
+
250
+ /** A markdown string as a ProseMirror document, or null. */
251
+ function parseMarkdown(markdown) {
252
+ try {
253
+ const doc = markdownToProseMirror(markdown)
254
+ return isProseMirrorDoc(doc) ? doc : null
255
+ } catch {
256
+ return null
257
+ }
258
+ }
259
+
260
+ /**
261
+ * A record's units by its model: each localized field's value, a rich one element by element as
262
+ * a push sends it — and none from a reference, whose record makes its own.
263
+ *
264
+ * @param {Set<string>|null} skip - fields of the record's own level to leave out (`EXCERPT`)
265
+ */
266
+ function extractByModel(item, model, context, units, skip = null) {
267
+ eachModelField(item, model, (holder, name, field, path) => {
268
+ if (!field.localized || (holder === item && skip?.has(name))) return
269
+ const value = holder[name]
270
+ if (isProseMirrorField(field)) {
271
+ const doc = typeof value === 'string' ? parseMarkdown(value) : value
272
+ if (isProseMirrorDoc(doc)) extractFromProseMirrorDoc(doc, context, units, path)
273
+ } else if (typeof value === 'string') {
274
+ if (value.trim()) addUnit(units, value, path, context)
275
+ } else if (Array.isArray(value)) {
276
+ value.forEach((v, i) => {
277
+ if (typeof v === 'string' && v.trim()) addUnit(units, v, `${path}[${i}]`, context)
278
+ })
279
+ }
280
+ })
281
+ }
282
+
283
+ // A reference inside a reference's brief is delivered as its handle, not hydrated — this only bounds the walk.
284
+ const MAX_REFERENCE_DEPTH = 4
285
+
286
+ /**
287
+ * A record translated by its model (`extractByModel`'s rule) — and each reference's brief by the
288
+ * model it names, as the record it names is translated, and served, on its own.
289
+ */
290
+ function translateByModel(item, model, context, translations, targets, skip = null) {
291
+ const out = structuredClone(item)
292
+ eachModelField(out, model, (holder, name, field) => {
293
+ if (holder === out && skip?.has(name)) return
294
+ holder[name] = translateFieldValue(holder[name], field, context, translations, targets, 0)
295
+ })
296
+ return out
297
+ }
298
+
299
+ function translateFieldValue(value, field, context, translations, targets, depth) {
300
+ if (field.type === 'entity_ref') {
301
+ const fields = briefFields(targets?.get(field.model))
302
+ if (!fields || depth >= MAX_REFERENCE_DEPTH) return value
303
+ const one = (ref) => {
304
+ if (!isPlainObject(ref) || !isPlainObject(ref.brief)) return ref
305
+ const brief = { ...ref.brief }
306
+ eachSectionField(brief, fields, '', (holder, name, f) => {
307
+ holder[name] = translateFieldValue(holder[name], f, context, translations, targets, depth + 1)
308
+ })
309
+ return { ...ref, brief }
310
+ }
311
+ return Array.isArray(value) ? value.map(one) : one(value)
312
+ }
313
+ if (!field.localized) return value
314
+ if (isProseMirrorField(field)) {
315
+ if (isProseMirrorDoc(value)) return translateProseMirrorDoc(value, context, translations)
316
+ // Written as markdown: translated as a push sends it, element by element, and kept markdown.
317
+ const doc = typeof value === 'string' ? parseMarkdown(value) : null
318
+ if (!doc) return value
319
+ const translated = translateProseMirrorDoc(doc, context, translations)
320
+ return JSON.stringify(translated) === JSON.stringify(doc) ? value : proseMirrorToMarkdown(translated)
321
+ }
322
+ if (typeof value === 'string') return lookupTranslation(value, context, translations)
323
+ if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? lookupTranslation(v, context, translations) : v))
324
+ return value
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // A derived excerpt
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * ⭐ AN EXCERPT THE BUILD DERIVES IS DERIVED IN EVERY LANGUAGE. A record whose author wrote no
333
+ * excerpt gets one from its body on the static lane (`site/query-processor.js::extractExcerpt`),
334
+ * and a push sends none — it is not the author's. So it is no unit to translate: each locale's is
335
+ * derived again, by the same rule, from that locale's body. ⛔ Until 2026-09-26 it was offered for
336
+ * translation like authored text — a translation no push carries, so a clone showed it in the
337
+ * source language — and left untranslated, it summarized a translated body in the source language.
338
+ */
339
+ const EXCERPT = new Set(['excerpt'])
340
+
341
+ /** A record's body as a document — where its schema's content body field is, else `content`. */
342
+ function bodyDocOf(record, dataSchema) {
343
+ const target = dataSchema ? contentBodyField(dataSchema) : null
344
+ const holder = target?.section ? record?.[target.section] : record
345
+ const value = target ? holder?.[target.key] : record?.content
346
+ if (isProseMirrorDoc(value)) return value
347
+ return typeof value === 'string' && value.trim() ? parseMarkdown(value) : null
348
+ }
349
+
350
+ /** The excerpt the build derives for a record — from `whole`, the record with its body. */
351
+ function derivedExcerptOf(whole, dataSchema, config) {
352
+ return extractExcerpt({ ...whole, excerpt: undefined }, bodyDocOf(whole, dataSchema), config)
353
+ }
354
+
355
+ /** Whether a record's excerpt is the one the build derives, not one its author wrote. */
356
+ function derivesExcerpt(record, whole, dataSchema, config) {
357
+ if (typeof record?.excerpt !== 'string' || !record.excerpt || !config) return false
358
+ return derivedExcerptOf(whole ?? record, dataSchema, config) === record.excerpt
359
+ }
360
+
159
361
  /**
160
362
  * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
161
363
  * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
@@ -293,13 +495,16 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
293
495
  // ---------------------------------------------------------------------------
294
496
 
295
497
  /**
296
- * Extract translatable fields from an item using heuristics.
297
- * Recursively walks the data, extracting strings that look like human-readable text.
498
+ * Extract translatable fields from an item using heuristics — a record with no data schema, which
499
+ * says nothing of its fields. Recursively walks the data, extracting strings that look like
500
+ * human-readable text.
501
+ *
502
+ * @param {Set<string>|null} skip - fields of the record's own level to leave out (`EXCERPT`)
298
503
  */
299
- function extractHeuristic(item, recordDir, units) {
504
+ function extractHeuristic(item, recordDir, units, skip = null) {
300
505
  const context = { record: `${recordDir}/${recordHandle(item)}` }
301
506
 
302
- extractFromItemHeuristic(item, '', context, units, 0)
507
+ extractFromItemHeuristic(item, '', context, units, 0, skip)
303
508
 
304
509
  // Also extract the record's ProseMirror documents, wherever it holds them
305
510
  for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
@@ -308,7 +513,7 @@ function extractHeuristic(item, recordDir, units) {
308
513
  /**
309
514
  * Recursively extract strings that look translatable.
310
515
  */
311
- function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
516
+ function extractFromItemHeuristic(data, pathPrefix, context, units, depth, skip = null) {
312
517
  if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
313
518
 
314
519
  const entries = Array.isArray(data)
@@ -323,6 +528,7 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
323
528
 
324
529
  if (value === undefined || value === null) continue
325
530
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
531
+ if (depth === 0 && skip?.has(key)) continue
326
532
 
327
533
  // Skip a ProseMirror document (handled separately)
328
534
  if (isProseMirrorDoc(value)) continue
@@ -405,16 +611,16 @@ function translateItemWithSchema(data, fields, context, translations, topLevel =
405
611
  /**
406
612
  * Translate item fields using heuristics.
407
613
  */
408
- function translateHeuristic(item, context, translations) {
614
+ function translateHeuristic(item, context, translations, skip = null) {
409
615
  const translated = translateDocs(item, context, translations)
410
- translateItemHeuristic(translated, context, translations, 0)
616
+ translateItemHeuristic(translated, context, translations, 0, skip)
411
617
  return translated
412
618
  }
413
619
 
414
620
  /**
415
621
  * Recursively translate strings that look translatable.
416
622
  */
417
- function translateItemHeuristic(data, context, translations, depth) {
623
+ function translateItemHeuristic(data, context, translations, depth, skip = null) {
418
624
  if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
419
625
 
420
626
  const keys = Array.isArray(data)
@@ -425,6 +631,7 @@ function translateItemHeuristic(data, context, translations, depth) {
425
631
  const value = data[key]
426
632
  if (value === undefined || value === null) continue
427
633
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
634
+ if (depth === 0 && skip?.has(key)) continue
428
635
 
429
636
  // Skip a ProseMirror document (translated separately)
430
637
  if (isProseMirrorDoc(value)) continue
@@ -463,30 +670,86 @@ async function poolDirsByQuery(siteRoot) {
463
670
  }
464
671
 
465
672
  /**
466
- * Each query's pool directory (`poolDirsByQuery`) and the data schema its records are
467
- * delivered in (`resolveRecordSchemas`, null for none) — what a free-form translation
468
- * needs to put a body and frontmatter where the delivered record holds them.
673
+ * What the localized build needs to know of each query:
674
+ *
675
+ * - `poolDirs` — its pool directory (`poolDirsByQuery`), which a record's translations are keyed by;
676
+ * - `schemas` — the data schema its records are DELIVERED in (`resolveRecordSchemas`, null for
677
+ * none), which says where a free-form body and frontmatter go, and where the body is;
678
+ * - `models` — the model its records are pushed as (`modelOf`), which says which of their fields
679
+ * are translated; `targets` — the model each reference names, by its ref, transitively;
680
+ * - `excerpts` — its excerpt settings, which derive an excerpt (`EXCERPT`).
469
681
  *
470
- * @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null> }>}
682
+ * ⭐ THE MODEL IS THE TYPE THE RECORDS ARE OF, which is not always the schema they are delivered
683
+ * in. A query whose name-defaulted schema (`@/team`) no data schema answers, for a data key the
684
+ * foundation types (`data: { team: '@/member' }`), holds records of that type — a push sends them
685
+ * as its entities (`uwx/data-key-types.js`) — while the build delivers them as they are written.
686
+ * The type is read from the foundation's built `schema.json`, the input a push reads it from.
687
+ *
688
+ * @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null>, models: Map<string, Object|null>, targets: Map<string, Object|null>, excerpts: Map<string, Object> }>}
471
689
  */
472
690
  async function recordQueries(siteRoot) {
473
691
  const poolDirs = new Map()
474
692
  const schemas = new Map()
693
+ const models = new Map()
694
+ const targets = new Map()
695
+ const excerpts = new Map()
475
696
  try {
476
- const { declarations } = await resolveQueriesConfig(siteRoot)
697
+ const siteYml = await readSiteYml(siteRoot)
698
+ const { declarations } = await resolveQueriesConfig(siteRoot, { siteYml })
477
699
  const entries = Object.entries(declarations || {})
478
700
  for (const [name, decl] of entries) {
479
701
  const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
480
702
  poolDirs.set(name, dirs ? dirs.join('/') : name)
703
+ // As `site/query-processor.js` parses a query's `excerpt:`.
704
+ excerpts.set(name, { maxLength: decl.excerpt?.maxLength || 160, field: decl.excerpt?.field || null })
481
705
  }
482
- const refs = entries.filter(([, d]) => d.url === undefined && d.schema).map(([, d]) => d.schema)
483
- const resolved = (await resolveRecordSchemas(siteRoot, refs)).schemas
706
+ const local = entries.filter(([, d]) => d.url === undefined && d.schema)
707
+ const resolved = (await resolveRecordSchemas(siteRoot, local.map(([, d]) => d.schema), { siteYml })).schemas
484
708
  for (const [name, decl] of entries) schemas.set(name, (decl.schema && resolved[decl.schema]) || null)
709
+
710
+ const keyTyped = new Map() // query name → the type of the data key it is named for
711
+ const untyped = local.filter(([, d]) => !d.schemaExplicit && !resolved[d.schema])
712
+ if (untyped.length) {
713
+ const keyTypes = dataKeyTypes(foundationSchemaJson(siteRoot, siteYml))
714
+ for (const [name, d] of untyped) {
715
+ const type = keyTypes.get(keyOfDefaultRef(d.schema))
716
+ if (type) keyTyped.set(name, type)
717
+ }
718
+ }
719
+ const types = keyTyped.size ? (await resolveRecordSchemas(siteRoot, keyTyped.values(), { siteYml })).schemas : {}
720
+ for (const [name, decl] of entries) {
721
+ const type = keyTyped.get(name)
722
+ models.set(name, schemas.get(name) ? modelOf(schemas.get(name), decl.schema) : type ? modelOf(types[type], type) : null)
723
+ }
724
+
725
+ // The models the records' references name, and the ones theirs name.
726
+ let pending = [...new Set([...models.values()].flatMap((m) => [...referencedModels(m)]))]
727
+ while (pending.length) {
728
+ const got = (await resolveRecordSchemas(siteRoot, pending, { siteYml })).schemas
729
+ const next = new Set()
730
+ for (const ref of pending) {
731
+ const model = modelOf(got[ref], ref)
732
+ targets.set(ref, model)
733
+ for (const r of referencedModels(model)) if (!targets.has(r) && !pending.includes(r)) next.add(r)
734
+ }
735
+ pending = [...next]
736
+ }
485
737
  } catch {
486
738
  // No resolvable config — every record keys by its query name, which is what
487
739
  // the extractor did before records existed.
488
740
  }
489
- return { poolDirs, schemas }
741
+ return { poolDirs, schemas, models, targets, excerpts }
742
+ }
743
+
744
+ /** A site's `site.yml`, or `{}`. */
745
+ async function readSiteYml(siteRoot) {
746
+ const path = join(siteRoot, 'site.yml')
747
+ if (!existsSync(path)) return {}
748
+ try {
749
+ return yaml.load(await readFile(path, 'utf-8'), YAML_OPTIONS) || {}
750
+ } catch {
751
+ return {}
752
+ }
490
753
  }
491
754
 
492
755
 
@@ -504,7 +767,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
504
767
  }
505
768
 
506
769
  const units = {}
507
- const poolDirs = await poolDirsByQuery(siteRoot)
770
+ const { poolDirs, schemas: dataSchemas, models, excerpts } = await recordQueries(siteRoot)
508
771
 
509
772
  let files
510
773
  try {
@@ -528,28 +791,26 @@ export async function extractRecordContent(siteRoot, options = {}) {
528
791
  // Resolve schema once per collection
529
792
  const schema = await resolveSchema(queryName, siteRoot)
530
793
  const recordDir = poolDirs.get(queryName) ?? queryName
794
+ const model = models.get(queryName) ?? null
795
+ // ⭐ AND THE QUERY'S PER-RECORD FILES. A query with `deferred:` fields writes each
796
+ // record whole beside its lean list, and a deferred field is exactly what the list
797
+ // does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
798
+ // one derived from a schema's brief included — never reached the manifest.
799
+ const wholes = await wholeRecords(dataDir, queryName)
531
800
  const extract = (item) => {
532
- if (schema?.fields) {
801
+ const whole = wholes.get(recordHandle(item))
802
+ const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
803
+ if (model) {
804
+ extractByModel(item, model, { record: `${recordDir}/${recordHandle(item)}` }, units, skip)
805
+ } else if (schema?.fields) {
533
806
  extractWithSchema(item, schema, recordDir, units)
534
807
  } else {
535
- extractHeuristic(item, recordDir, units)
808
+ extractHeuristic(item, recordDir, units, skip)
536
809
  }
537
810
  }
538
811
 
539
812
  for (const item of items) extract(item)
540
-
541
- // ⭐ AND THE QUERY'S PER-RECORD FILES. A query with `deferred:` fields writes each
542
- // record whole beside its lean list, and a deferred field is exactly what the list
543
- // does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
544
- // one derived from a schema's brief included — never reached the manifest.
545
- for (const name of await recordFileNames(dataDir, queryName)) {
546
- try {
547
- const item = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
548
- if (item && typeof item === 'object' && !Array.isArray(item)) extract(item)
549
- } catch (err) {
550
- console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
551
- }
552
- }
813
+ for (const item of wholes.values()) extract(item)
553
814
  } catch (err) {
554
815
  // Skip files that can't be parsed
555
816
  console.warn(`[i18n] Skipping ${file}: ${err.message}`)
@@ -585,6 +846,24 @@ async function recordFileNames(dataDir, queryName) {
585
846
  }
586
847
  }
587
848
 
849
+ /**
850
+ * A query's per-record files (`recordFileNames`), each record whole, by its handle.
851
+ *
852
+ * @returns {Promise<Map<string, Object>>}
853
+ */
854
+ async function wholeRecords(dataDir, queryName) {
855
+ const out = new Map()
856
+ for (const name of await recordFileNames(dataDir, queryName)) {
857
+ try {
858
+ const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
859
+ if (isPlainObject(record)) out.set(recordHandle(record), record)
860
+ } catch (err) {
861
+ console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
862
+ }
863
+ }
864
+ return out
865
+ }
866
+
588
867
  /** Is `path` strictly inside `dir`? A name read off disk must not lead out of it. */
589
868
  function isInside(path, dir) {
590
869
  return resolve(path).startsWith(resolve(dir) + sep)
@@ -614,61 +893,12 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
614
893
  * (`content`, `article_body.content`), which each unit's `field` starts with.
615
894
  */
616
895
  function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
617
- if (!doc.content) return
618
-
619
- let headingIndex = 0
620
- let paragraphIndex = 0
621
-
622
- for (const node of doc.content) {
623
- if (node.type === 'heading') {
624
- const text = extractTextFromNode(node)
625
- if (text) {
626
- addUnit(units, text, `${field}.heading.${headingIndex}`, context)
627
- headingIndex++
628
- }
629
- } else if (node.type === 'paragraph') {
630
- const text = extractTextFromNode(node)
631
- if (text) {
632
- addUnit(units, text, `${field}.paragraph.${paragraphIndex}`, context)
633
- paragraphIndex++
634
- }
635
- } else if (node.type === 'bulletList' || node.type === 'orderedList') {
636
- extractFromList(node, context, units, field)
637
- }
638
- }
896
+ // The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
897
+ // looks up — each under the field the record holds the body in.
898
+ for (const unit of Object.values(extractUnitsFromDoc(doc))) addUnit(units, unit.source, `${field}.${unit.field}`, context)
639
899
  }
640
900
 
641
- /**
642
- * Extract from list nodes
643
- */
644
- function extractFromList(listNode, context, units, field = 'content') {
645
- if (!listNode.content) return
646
-
647
- listNode.content.forEach((listItem, index) => {
648
- if (listItem.type === 'listItem' && listItem.content) {
649
- for (const child of listItem.content) {
650
- if (child.type === 'paragraph') {
651
- const text = extractTextFromNode(child)
652
- if (text) {
653
- addUnit(units, text, `${field}.list.${index}`, context)
654
- }
655
- }
656
- }
657
- }
658
- })
659
- }
660
901
 
661
- /**
662
- * Extract text content from a node
663
- */
664
- function extractTextFromNode(node) {
665
- if (!node.content) return ''
666
- return node.content
667
- .filter(n => n.type === 'text')
668
- .map(n => n.text || '')
669
- .join('')
670
- .trim()
671
- }
672
902
 
673
903
  // ---------------------------------------------------------------------------
674
904
  // Unit accumulator
@@ -743,7 +973,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
743
973
  // simply miss and every string falls back to its source. (This was missing for
744
974
  // a while and the failure was swallowed by the per-file catch below — the build
745
975
  // stayed green while nothing was translated.)
746
- const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
976
+ const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
747
977
 
748
978
  const outputs = {}
749
979
  // Reported rather than only logged — see the catch below.
@@ -790,23 +1020,15 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
790
1020
  // Resolve schema once per collection
791
1021
  const schema = await resolveSchema(queryName, siteRoot)
792
1022
  const recordDir = poolDirs.get(queryName) ?? queryName
793
- const dataSchema = dataSchemas.get(queryName) ?? null
794
-
795
- // Translate each item (with free-form support)
796
- const translatedItems = await Promise.all(
797
- items.map(item =>
798
- translateItemAsync(item, recordDir, translations, schema, {
799
- locale,
800
- localesDir,
801
- freeformEnabled: hasFreeform,
802
- dataSchema
803
- })
804
- )
805
- )
806
-
807
- const destPath = join(localeDataDir, file)
808
- await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
809
- outputs[locale][queryName] = destPath
1023
+ const recordOptions = {
1024
+ locale,
1025
+ localesDir,
1026
+ freeformEnabled: hasFreeform,
1027
+ dataSchema: dataSchemas.get(queryName) ?? null,
1028
+ model: models.get(queryName) ?? null,
1029
+ targets,
1030
+ excerpt: excerpts.get(queryName),
1031
+ }
810
1032
 
811
1033
  // ⭐ AND THE QUERY'S PER-RECORD FILES, beside the translated list and with the
812
1034
  // same translations — `/<locale>/data/<query>/<slug>.json`. A query with
@@ -814,14 +1036,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
814
1036
  // its record from that file. ⛔ Until 2026-09-14 they were not written for any
815
1037
  // locale, so a localized site's records showed their deferred fields — an
816
1038
  // article's body — in the source language under a translated list.
1039
+ // They are translated first: a lean list's derived excerpt is derived from its record's
1040
+ // translated body (`EXCERPT`).
817
1041
  const localeRecordsDir = join(localeDataDir, queryName)
818
1042
  const recordNames = await recordFileNames(dataDir, queryName)
1043
+ const wholes = new Map() // handle → { source, translated }
819
1044
  for (const name of recordNames) {
820
1045
  try {
821
1046
  const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
822
- const translatedRecord = record && typeof record === 'object' && !Array.isArray(record)
823
- ? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform, dataSchema })
1047
+ const translatedRecord = isPlainObject(record)
1048
+ ? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
824
1049
  : record
1050
+ if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
825
1051
  await mkdir(localeRecordsDir, { recursive: true })
826
1052
  await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
827
1053
  } catch (err) {
@@ -831,6 +1057,17 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
831
1057
  }
832
1058
  // A record file the source no longer has is not left behind, translated, in the locale.
833
1059
  await pruneRecordFiles(localeRecordsDir, localeDataDir, new Set(recordNames))
1060
+
1061
+ // Translate each item (with free-form support)
1062
+ const translatedItems = await Promise.all(
1063
+ items.map((item) =>
1064
+ translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) })
1065
+ )
1066
+ )
1067
+
1068
+ const destPath = join(localeDataDir, file)
1069
+ await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
1070
+ outputs[locale][queryName] = destPath
834
1071
  } catch (err) {
835
1072
  // ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
836
1073
  // hid a real bug for the length of a session: a `ReferenceError` in this
@@ -859,17 +1096,29 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
859
1096
  * Apply translations to a collection item (async, with free-form support)
860
1097
  *
861
1098
  * The hash-based translations apply first — every field and every ProseMirror document
862
- * (schema-guided or heuristic) — and a free-form translation then replaces what it
863
- * states (`applyFreeform`): its frontmatter, its body, or both.
1099
+ * the record's model says is translated (`translateItemSync`) — and a free-form translation
1100
+ * then replaces what it states (`applyFreeform`): its frontmatter, its body, or both. A
1101
+ * derived excerpt is then derived from the translated body (`EXCERPT`).
864
1102
  *
865
1103
  * @param {Object} [options.dataSchema] - the data schema the record is delivered in
866
1104
  * (`recordQueries`), which says where a free-form body and frontmatter go
1105
+ * @param {Object} [options.model] - the model the record is pushed as, and `targets`, the models
1106
+ * its references name (`recordQueries`) — which fields are translated
1107
+ * @param {Object} [options.excerpt] - the query's excerpt settings (`EXCERPT`)
1108
+ * @param {{ source: Object, translated: Object }} [options.whole] - for an item of a lean list, its
1109
+ * record whole and translated, whose body a derived excerpt is derived from
867
1110
  */
868
1111
  async function translateItemAsync(item, recordDir, translations, schema, options = {}) {
869
- const { locale, localesDir, freeformEnabled, dataSchema = null } = options
870
- const translated = translateItemSync(item, recordDir, translations, schema)
871
- if (!freeformEnabled || !locale || !localesDir) return translated
1112
+ const { locale, localesDir, freeformEnabled, dataSchema = null, model = null, targets = null, excerpt = null, whole = null } = options
1113
+ const derived = derivesExcerpt(item, whole?.source, dataSchema, excerpt)
1114
+ let translated = translateItemSync(item, recordDir, translations, schema, { model, targets, skip: derived ? EXCERPT : null })
1115
+ if (freeformEnabled && locale && localesDir) translated = await applyFreeformFile(translated, item, recordDir, locale, localesDir, dataSchema)
1116
+ if (!derived) return translated
1117
+ return { ...translated, excerpt: derivedExcerptOf(whole ? whole.translated : translated, dataSchema, excerpt) }
1118
+ }
872
1119
 
1120
+ /** The record with its free-form translation for `locale` applied, if it has one. */
1121
+ async function applyFreeformFile(translated, item, recordDir, locale, localesDir, dataSchema) {
873
1122
  // ⛔ The loader takes the record's SCHEMA ref and derives the pool path from it
874
1123
  // (`buildFreeformRecordPath`); handed the pool directory itself (`article`), it
875
1124
  // derived nothing and found no file — so until 2026-09-14 no free-form record
@@ -922,16 +1171,18 @@ function isPlainObject(value) {
922
1171
  }
923
1172
 
924
1173
  /**
925
- * Apply translations to a collection item (sync, hash-based only)
1174
+ * Apply translations to a collection item (sync, hash-based only) — by the model it is pushed as
1175
+ * when it has one (`translateByModel`), else a companion schema's fields, else the heuristic.
926
1176
  */
927
- function translateItemSync(item, recordDir, translations, schema) {
928
- const translated = { ...item }
1177
+ function translateItemSync(item, recordDir, translations, schema, { model = null, targets = null, skip = null } = {}) {
929
1178
  const context = { record: `${recordDir}/${recordHandle(item)}` }
930
1179
 
1180
+ if (model) return translateByModel(item, model, context, translations, targets, skip)
1181
+ const translated = { ...item }
931
1182
  if (schema?.fields) {
932
1183
  return translateWithSchema(translated, schema, context, translations)
933
1184
  }
934
- return translateHeuristic(translated, context, translations)
1185
+ return translateHeuristic(translated, context, translations, skip)
935
1186
  }
936
1187
 
937
1188
  // ---------------------------------------------------------------------------
@@ -942,35 +1193,11 @@ function translateItemSync(item, recordDir, translations, schema) {
942
1193
  * Translate a ProseMirror document
943
1194
  */
944
1195
  function translateProseMirrorDoc(doc, context, translations) {
945
- if (!doc.content) return doc
946
-
947
- const translated = { ...doc, content: [] }
948
-
949
- for (const node of doc.content) {
950
- translated.content.push(translateNode(node, context, translations))
951
- }
952
-
953
- return translated
954
- }
955
-
956
- /**
957
- * Recursively translate a node
958
- */
959
- function translateNode(node, context, translations) {
960
- if (!node.content) return node
961
-
962
- const translated = { ...node, content: [] }
963
-
964
- for (const child of node.content) {
965
- if (child.type === 'text' && child.text) {
966
- const translatedText = lookupTranslation(child.text, context, translations)
967
- translated.content.push({ ...child, text: translatedText })
968
- } else {
969
- translated.content.push(translateNode(child, context, translations))
970
- }
971
- }
972
-
973
- return translated
1196
+ // ⭐ Element by element, by the rule a page section is translated by (`merge.js`) — keyed by the
1197
+ // record's identity for an override. ⛔ Until 2026-09-26 a record's rich text was looked up text node
1198
+ // by text node, so a paragraph holding a link or an emphasis — several text nodes — never matched its
1199
+ // translation, which is keyed by the whole paragraph, and rendered untranslated.
1200
+ return resolveDocForLocale(doc, translations, { key: context.record }) ?? doc
974
1201
  }
975
1202
 
976
1203
  /**
@@ -1041,23 +1268,28 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1041
1268
  const records = one ? [items] : items
1042
1269
 
1043
1270
  const schema = await resolveSchema(queryName, siteRoot)
1044
- const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
1271
+ const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
1045
1272
  const recordDir = poolDirs.get(queryName) ?? queryName
1273
+ const recordOptions = {
1274
+ locale,
1275
+ localesDir,
1276
+ freeformEnabled,
1277
+ dataSchema: dataSchemas.get(queryName) ?? null,
1278
+ model: models.get(queryName) ?? null,
1279
+ targets,
1280
+ excerpt: excerpts.get(queryName),
1281
+ }
1046
1282
 
1047
- const translated = freeformEnabled
1048
- ? await Promise.all(
1049
- records.map(item =>
1050
- translateItemAsync(item, recordDir, translations, schema, {
1051
- locale,
1052
- localesDir,
1053
- freeformEnabled,
1054
- dataSchema: dataSchemas.get(queryName) ?? null
1055
- })
1056
- )
1057
- )
1058
- : records.map(item =>
1059
- translateItemSync(item, recordDir, translations, schema)
1060
- )
1283
+ // A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
1284
+ const wholes = new Map()
1285
+ if (!one) {
1286
+ for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
1287
+ wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
1288
+ }
1289
+ }
1290
+ const translated = await Promise.all(
1291
+ records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
1292
+ )
1061
1293
 
1062
1294
  return one ? translated[0] : translated
1063
1295
  }