@uniweb/build 0.63.4 → 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.
@@ -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 } 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,196 @@ 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
+ }
228
+
229
+ function eachSectionField(holder, fields, path, visit) {
230
+ if (!isPlainObject(holder)) return
231
+ for (const [name, field] of Object.entries(fields || {})) {
232
+ if (holder[name] == null || isRecordSystemField(name, !path)) continue
233
+ const at = path ? `${path}.${name}` : name
234
+ if (field?.type === 'section') eachSectionValue(holder[name], field, at, visit)
235
+ else visit(holder, name, field || {}, at)
236
+ }
237
+ }
238
+
239
+ /** The fields of a model's brief — what a reference to one of its records is delivered as. */
240
+ function briefFields(model) {
241
+ const sections = Object.values(model?.sections || {})
242
+ const brief = sections.find((s) => s?.brief) ?? (sections.length === 1 && !sections[0]?.multiple ? sections[0] : null)
243
+ return brief?.fields ?? null
244
+ }
245
+
246
+ /** A markdown string as a ProseMirror document, or null. */
247
+ function parseMarkdown(markdown) {
248
+ try {
249
+ const doc = markdownToProseMirror(markdown)
250
+ return isProseMirrorDoc(doc) ? doc : null
251
+ } catch {
252
+ return null
253
+ }
254
+ }
255
+
256
+ /**
257
+ * A record's units by its model: each localized field's value, a rich one element by element as
258
+ * a push sends it — and none from a reference, whose record makes its own.
259
+ *
260
+ * @param {Set<string>|null} skip - fields of the record's own level to leave out (`EXCERPT`)
261
+ */
262
+ function extractByModel(item, model, context, units, skip = null) {
263
+ eachModelField(item, model, (holder, name, field, path) => {
264
+ if (!field.localized || (holder === item && skip?.has(name))) return
265
+ const value = holder[name]
266
+ if (isProseMirrorField(field)) {
267
+ const doc = typeof value === 'string' ? parseMarkdown(value) : value
268
+ if (isProseMirrorDoc(doc)) extractFromProseMirrorDoc(doc, context, units, path)
269
+ } else if (typeof value === 'string') {
270
+ if (value.trim()) addUnit(units, value, path, context)
271
+ } else if (Array.isArray(value)) {
272
+ value.forEach((v, i) => {
273
+ if (typeof v === 'string' && v.trim()) addUnit(units, v, `${path}[${i}]`, context)
274
+ })
275
+ }
276
+ })
277
+ }
278
+
279
+ // A reference inside a reference's brief is delivered as its handle, not hydrated — this only bounds the walk.
280
+ const MAX_REFERENCE_DEPTH = 4
281
+
282
+ /**
283
+ * A record translated by its model (`extractByModel`'s rule) — and each reference's brief by the
284
+ * model it names, as the record it names is translated, and served, on its own.
285
+ */
286
+ function translateByModel(item, model, context, translations, targets, skip = null) {
287
+ const out = structuredClone(item)
288
+ eachModelField(out, model, (holder, name, field) => {
289
+ if (holder === out && skip?.has(name)) return
290
+ holder[name] = translateFieldValue(holder[name], field, context, translations, targets, 0)
291
+ })
292
+ return out
293
+ }
294
+
295
+ function translateFieldValue(value, field, context, translations, targets, depth) {
296
+ if (field.type === 'entity_ref') {
297
+ const fields = briefFields(targets?.get(field.model))
298
+ if (!fields || depth >= MAX_REFERENCE_DEPTH) return value
299
+ const one = (ref) => {
300
+ if (!isPlainObject(ref) || !isPlainObject(ref.brief)) return ref
301
+ const brief = { ...ref.brief }
302
+ eachSectionField(brief, fields, '', (holder, name, f) => {
303
+ holder[name] = translateFieldValue(holder[name], f, context, translations, targets, depth + 1)
304
+ })
305
+ return { ...ref, brief }
306
+ }
307
+ return Array.isArray(value) ? value.map(one) : one(value)
308
+ }
309
+ if (!field.localized) return value
310
+ if (isProseMirrorField(field)) {
311
+ if (isProseMirrorDoc(value)) return translateProseMirrorDoc(value, context, translations)
312
+ // Written as markdown: translated as a push sends it, element by element, and kept markdown.
313
+ const doc = typeof value === 'string' ? parseMarkdown(value) : null
314
+ if (!doc) return value
315
+ const translated = translateProseMirrorDoc(doc, context, translations)
316
+ return JSON.stringify(translated) === JSON.stringify(doc) ? value : proseMirrorToMarkdown(translated)
317
+ }
318
+ if (typeof value === 'string') return lookupTranslation(value, context, translations)
319
+ if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? lookupTranslation(v, context, translations) : v))
320
+ return value
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // A derived excerpt
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /**
328
+ * ⭐ AN EXCERPT THE BUILD DERIVES IS DERIVED IN EVERY LANGUAGE. A record whose author wrote no
329
+ * excerpt gets one from its body on the static lane (`site/query-processor.js::extractExcerpt`),
330
+ * and a push sends none — it is not the author's. So it is no unit to translate: each locale's is
331
+ * derived again, by the same rule, from that locale's body. ⛔ Until 2026-09-26 it was offered for
332
+ * translation like authored text — a translation no push carries, so a clone showed it in the
333
+ * source language — and left untranslated, it summarized a translated body in the source language.
334
+ */
335
+ const EXCERPT = new Set(['excerpt'])
336
+
337
+ /** A record's body as a document — where its schema's content body field is, else `content`. */
338
+ function bodyDocOf(record, dataSchema) {
339
+ const target = dataSchema ? contentBodyField(dataSchema) : null
340
+ const holder = target?.section ? record?.[target.section] : record
341
+ const value = target ? holder?.[target.key] : record?.content
342
+ if (isProseMirrorDoc(value)) return value
343
+ return typeof value === 'string' && value.trim() ? parseMarkdown(value) : null
344
+ }
345
+
346
+ /** The excerpt the build derives for a record — from `whole`, the record with its body. */
347
+ function derivedExcerptOf(whole, dataSchema, config) {
348
+ return extractExcerpt({ ...whole, excerpt: undefined }, bodyDocOf(whole, dataSchema), config)
349
+ }
350
+
351
+ /** Whether a record's excerpt is the one the build derives, not one its author wrote. */
352
+ function derivesExcerpt(record, whole, dataSchema, config) {
353
+ if (typeof record?.excerpt !== 'string' || !record.excerpt || !config) return false
354
+ return derivedExcerptOf(whole ?? record, dataSchema, config) === record.excerpt
355
+ }
356
+
159
357
  /**
160
358
  * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
161
359
  * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
@@ -293,13 +491,16 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
293
491
  // ---------------------------------------------------------------------------
294
492
 
295
493
  /**
296
- * Extract translatable fields from an item using heuristics.
297
- * Recursively walks the data, extracting strings that look like human-readable text.
494
+ * Extract translatable fields from an item using heuristics — a record with no data schema, which
495
+ * says nothing of its fields. Recursively walks the data, extracting strings that look like
496
+ * human-readable text.
497
+ *
498
+ * @param {Set<string>|null} skip - fields of the record's own level to leave out (`EXCERPT`)
298
499
  */
299
- function extractHeuristic(item, recordDir, units) {
500
+ function extractHeuristic(item, recordDir, units, skip = null) {
300
501
  const context = { record: `${recordDir}/${recordHandle(item)}` }
301
502
 
302
- extractFromItemHeuristic(item, '', context, units, 0)
503
+ extractFromItemHeuristic(item, '', context, units, 0, skip)
303
504
 
304
505
  // Also extract the record's ProseMirror documents, wherever it holds them
305
506
  for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
@@ -308,7 +509,7 @@ function extractHeuristic(item, recordDir, units) {
308
509
  /**
309
510
  * Recursively extract strings that look translatable.
310
511
  */
311
- function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
512
+ function extractFromItemHeuristic(data, pathPrefix, context, units, depth, skip = null) {
312
513
  if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
313
514
 
314
515
  const entries = Array.isArray(data)
@@ -323,6 +524,7 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
323
524
 
324
525
  if (value === undefined || value === null) continue
325
526
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
527
+ if (depth === 0 && skip?.has(key)) continue
326
528
 
327
529
  // Skip a ProseMirror document (handled separately)
328
530
  if (isProseMirrorDoc(value)) continue
@@ -405,16 +607,16 @@ function translateItemWithSchema(data, fields, context, translations, topLevel =
405
607
  /**
406
608
  * Translate item fields using heuristics.
407
609
  */
408
- function translateHeuristic(item, context, translations) {
610
+ function translateHeuristic(item, context, translations, skip = null) {
409
611
  const translated = translateDocs(item, context, translations)
410
- translateItemHeuristic(translated, context, translations, 0)
612
+ translateItemHeuristic(translated, context, translations, 0, skip)
411
613
  return translated
412
614
  }
413
615
 
414
616
  /**
415
617
  * Recursively translate strings that look translatable.
416
618
  */
417
- function translateItemHeuristic(data, context, translations, depth) {
619
+ function translateItemHeuristic(data, context, translations, depth, skip = null) {
418
620
  if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
419
621
 
420
622
  const keys = Array.isArray(data)
@@ -425,6 +627,7 @@ function translateItemHeuristic(data, context, translations, depth) {
425
627
  const value = data[key]
426
628
  if (value === undefined || value === null) continue
427
629
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
630
+ if (depth === 0 && skip?.has(key)) continue
428
631
 
429
632
  // Skip a ProseMirror document (translated separately)
430
633
  if (isProseMirrorDoc(value)) continue
@@ -463,30 +666,86 @@ async function poolDirsByQuery(siteRoot) {
463
666
  }
464
667
 
465
668
  /**
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.
669
+ * What the localized build needs to know of each query:
670
+ *
671
+ * - `poolDirs` — its pool directory (`poolDirsByQuery`), which a record's translations are keyed by;
672
+ * - `schemas` — the data schema its records are DELIVERED in (`resolveRecordSchemas`, null for
673
+ * none), which says where a free-form body and frontmatter go, and where the body is;
674
+ * - `models` — the model its records are pushed as (`modelOf`), which says which of their fields
675
+ * are translated; `targets` — the model each reference names, by its ref, transitively;
676
+ * - `excerpts` — its excerpt settings, which derive an excerpt (`EXCERPT`).
677
+ *
678
+ * ⭐ THE MODEL IS THE TYPE THE RECORDS ARE OF, which is not always the schema they are delivered
679
+ * in. A query whose name-defaulted schema (`@/team`) no data schema answers, for a data key the
680
+ * foundation types (`data: { team: '@/member' }`), holds records of that type — a push sends them
681
+ * as its entities (`uwx/data-key-types.js`) — while the build delivers them as they are written.
682
+ * The type is read from the foundation's built `schema.json`, the input a push reads it from.
469
683
  *
470
- * @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null> }>}
684
+ * @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
685
  */
472
686
  async function recordQueries(siteRoot) {
473
687
  const poolDirs = new Map()
474
688
  const schemas = new Map()
689
+ const models = new Map()
690
+ const targets = new Map()
691
+ const excerpts = new Map()
475
692
  try {
476
- const { declarations } = await resolveQueriesConfig(siteRoot)
693
+ const siteYml = await readSiteYml(siteRoot)
694
+ const { declarations } = await resolveQueriesConfig(siteRoot, { siteYml })
477
695
  const entries = Object.entries(declarations || {})
478
696
  for (const [name, decl] of entries) {
479
697
  const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
480
698
  poolDirs.set(name, dirs ? dirs.join('/') : name)
699
+ // As `site/query-processor.js` parses a query's `excerpt:`.
700
+ excerpts.set(name, { maxLength: decl.excerpt?.maxLength || 160, field: decl.excerpt?.field || null })
481
701
  }
482
- const refs = entries.filter(([, d]) => d.url === undefined && d.schema).map(([, d]) => d.schema)
483
- const resolved = (await resolveRecordSchemas(siteRoot, refs)).schemas
702
+ const local = entries.filter(([, d]) => d.url === undefined && d.schema)
703
+ const resolved = (await resolveRecordSchemas(siteRoot, local.map(([, d]) => d.schema), { siteYml })).schemas
484
704
  for (const [name, decl] of entries) schemas.set(name, (decl.schema && resolved[decl.schema]) || null)
705
+
706
+ const keyTyped = new Map() // query name → the type of the data key it is named for
707
+ const untyped = local.filter(([, d]) => !d.schemaExplicit && !resolved[d.schema])
708
+ if (untyped.length) {
709
+ const keyTypes = dataKeyTypes(foundationSchemaJson(siteRoot, siteYml))
710
+ for (const [name, d] of untyped) {
711
+ const type = keyTypes.get(keyOfDefaultRef(d.schema))
712
+ if (type) keyTyped.set(name, type)
713
+ }
714
+ }
715
+ const types = keyTyped.size ? (await resolveRecordSchemas(siteRoot, keyTyped.values(), { siteYml })).schemas : {}
716
+ for (const [name, decl] of entries) {
717
+ const type = keyTyped.get(name)
718
+ models.set(name, schemas.get(name) ? modelOf(schemas.get(name), decl.schema) : type ? modelOf(types[type], type) : null)
719
+ }
720
+
721
+ // The models the records' references name, and the ones theirs name.
722
+ let pending = [...new Set([...models.values()].flatMap((m) => [...referencedModels(m)]))]
723
+ while (pending.length) {
724
+ const got = (await resolveRecordSchemas(siteRoot, pending, { siteYml })).schemas
725
+ const next = new Set()
726
+ for (const ref of pending) {
727
+ const model = modelOf(got[ref], ref)
728
+ targets.set(ref, model)
729
+ for (const r of referencedModels(model)) if (!targets.has(r) && !pending.includes(r)) next.add(r)
730
+ }
731
+ pending = [...next]
732
+ }
485
733
  } catch {
486
734
  // No resolvable config — every record keys by its query name, which is what
487
735
  // the extractor did before records existed.
488
736
  }
489
- return { poolDirs, schemas }
737
+ return { poolDirs, schemas, models, targets, excerpts }
738
+ }
739
+
740
+ /** A site's `site.yml`, or `{}`. */
741
+ async function readSiteYml(siteRoot) {
742
+ const path = join(siteRoot, 'site.yml')
743
+ if (!existsSync(path)) return {}
744
+ try {
745
+ return yaml.load(await readFile(path, 'utf-8'), YAML_OPTIONS) || {}
746
+ } catch {
747
+ return {}
748
+ }
490
749
  }
491
750
 
492
751
 
@@ -504,7 +763,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
504
763
  }
505
764
 
506
765
  const units = {}
507
- const poolDirs = await poolDirsByQuery(siteRoot)
766
+ const { poolDirs, schemas: dataSchemas, models, excerpts } = await recordQueries(siteRoot)
508
767
 
509
768
  let files
510
769
  try {
@@ -528,28 +787,26 @@ export async function extractRecordContent(siteRoot, options = {}) {
528
787
  // Resolve schema once per collection
529
788
  const schema = await resolveSchema(queryName, siteRoot)
530
789
  const recordDir = poolDirs.get(queryName) ?? queryName
790
+ const model = models.get(queryName) ?? null
791
+ // ⭐ AND THE QUERY'S PER-RECORD FILES. A query with `deferred:` fields writes each
792
+ // record whole beside its lean list, and a deferred field is exactly what the list
793
+ // does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
794
+ // one derived from a schema's brief included — never reached the manifest.
795
+ const wholes = await wholeRecords(dataDir, queryName)
531
796
  const extract = (item) => {
532
- if (schema?.fields) {
797
+ const whole = wholes.get(recordHandle(item))
798
+ const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
799
+ if (model) {
800
+ extractByModel(item, model, { record: `${recordDir}/${recordHandle(item)}` }, units, skip)
801
+ } else if (schema?.fields) {
533
802
  extractWithSchema(item, schema, recordDir, units)
534
803
  } else {
535
- extractHeuristic(item, recordDir, units)
804
+ extractHeuristic(item, recordDir, units, skip)
536
805
  }
537
806
  }
538
807
 
539
808
  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
- }
809
+ for (const item of wholes.values()) extract(item)
553
810
  } catch (err) {
554
811
  // Skip files that can't be parsed
555
812
  console.warn(`[i18n] Skipping ${file}: ${err.message}`)
@@ -585,6 +842,24 @@ async function recordFileNames(dataDir, queryName) {
585
842
  }
586
843
  }
587
844
 
845
+ /**
846
+ * A query's per-record files (`recordFileNames`), each record whole, by its handle.
847
+ *
848
+ * @returns {Promise<Map<string, Object>>}
849
+ */
850
+ async function wholeRecords(dataDir, queryName) {
851
+ const out = new Map()
852
+ for (const name of await recordFileNames(dataDir, queryName)) {
853
+ try {
854
+ const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
855
+ if (isPlainObject(record)) out.set(recordHandle(record), record)
856
+ } catch (err) {
857
+ console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
858
+ }
859
+ }
860
+ return out
861
+ }
862
+
588
863
  /** Is `path` strictly inside `dir`? A name read off disk must not lead out of it. */
589
864
  function isInside(path, dir) {
590
865
  return resolve(path).startsWith(resolve(dir) + sep)
@@ -614,61 +889,12 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
614
889
  * (`content`, `article_body.content`), which each unit's `field` starts with.
615
890
  */
616
891
  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
- }
892
+ // The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
893
+ // looks up — each under the field the record holds the body in.
894
+ for (const unit of Object.values(extractUnitsFromDoc(doc))) addUnit(units, unit.source, `${field}.${unit.field}`, context)
639
895
  }
640
896
 
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
897
 
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
898
 
673
899
  // ---------------------------------------------------------------------------
674
900
  // Unit accumulator
@@ -743,7 +969,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
743
969
  // simply miss and every string falls back to its source. (This was missing for
744
970
  // a while and the failure was swallowed by the per-file catch below — the build
745
971
  // stayed green while nothing was translated.)
746
- const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
972
+ const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
747
973
 
748
974
  const outputs = {}
749
975
  // Reported rather than only logged — see the catch below.
@@ -790,23 +1016,15 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
790
1016
  // Resolve schema once per collection
791
1017
  const schema = await resolveSchema(queryName, siteRoot)
792
1018
  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
1019
+ const recordOptions = {
1020
+ locale,
1021
+ localesDir,
1022
+ freeformEnabled: hasFreeform,
1023
+ dataSchema: dataSchemas.get(queryName) ?? null,
1024
+ model: models.get(queryName) ?? null,
1025
+ targets,
1026
+ excerpt: excerpts.get(queryName),
1027
+ }
810
1028
 
811
1029
  // ⭐ AND THE QUERY'S PER-RECORD FILES, beside the translated list and with the
812
1030
  // same translations — `/<locale>/data/<query>/<slug>.json`. A query with
@@ -814,14 +1032,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
814
1032
  // its record from that file. ⛔ Until 2026-09-14 they were not written for any
815
1033
  // locale, so a localized site's records showed their deferred fields — an
816
1034
  // article's body — in the source language under a translated list.
1035
+ // They are translated first: a lean list's derived excerpt is derived from its record's
1036
+ // translated body (`EXCERPT`).
817
1037
  const localeRecordsDir = join(localeDataDir, queryName)
818
1038
  const recordNames = await recordFileNames(dataDir, queryName)
1039
+ const wholes = new Map() // handle → { source, translated }
819
1040
  for (const name of recordNames) {
820
1041
  try {
821
1042
  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 })
1043
+ const translatedRecord = isPlainObject(record)
1044
+ ? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
824
1045
  : record
1046
+ if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
825
1047
  await mkdir(localeRecordsDir, { recursive: true })
826
1048
  await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
827
1049
  } catch (err) {
@@ -831,6 +1053,17 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
831
1053
  }
832
1054
  // A record file the source no longer has is not left behind, translated, in the locale.
833
1055
  await pruneRecordFiles(localeRecordsDir, localeDataDir, new Set(recordNames))
1056
+
1057
+ // Translate each item (with free-form support)
1058
+ const translatedItems = await Promise.all(
1059
+ items.map((item) =>
1060
+ translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) })
1061
+ )
1062
+ )
1063
+
1064
+ const destPath = join(localeDataDir, file)
1065
+ await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
1066
+ outputs[locale][queryName] = destPath
834
1067
  } catch (err) {
835
1068
  // ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
836
1069
  // hid a real bug for the length of a session: a `ReferenceError` in this
@@ -859,17 +1092,29 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
859
1092
  * Apply translations to a collection item (async, with free-form support)
860
1093
  *
861
1094
  * 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.
1095
+ * the record's model says is translated (`translateItemSync`) — and a free-form translation
1096
+ * then replaces what it states (`applyFreeform`): its frontmatter, its body, or both. A
1097
+ * derived excerpt is then derived from the translated body (`EXCERPT`).
864
1098
  *
865
1099
  * @param {Object} [options.dataSchema] - the data schema the record is delivered in
866
1100
  * (`recordQueries`), which says where a free-form body and frontmatter go
1101
+ * @param {Object} [options.model] - the model the record is pushed as, and `targets`, the models
1102
+ * its references name (`recordQueries`) — which fields are translated
1103
+ * @param {Object} [options.excerpt] - the query's excerpt settings (`EXCERPT`)
1104
+ * @param {{ source: Object, translated: Object }} [options.whole] - for an item of a lean list, its
1105
+ * record whole and translated, whose body a derived excerpt is derived from
867
1106
  */
868
1107
  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
1108
+ const { locale, localesDir, freeformEnabled, dataSchema = null, model = null, targets = null, excerpt = null, whole = null } = options
1109
+ const derived = derivesExcerpt(item, whole?.source, dataSchema, excerpt)
1110
+ let translated = translateItemSync(item, recordDir, translations, schema, { model, targets, skip: derived ? EXCERPT : null })
1111
+ if (freeformEnabled && locale && localesDir) translated = await applyFreeformFile(translated, item, recordDir, locale, localesDir, dataSchema)
1112
+ if (!derived) return translated
1113
+ return { ...translated, excerpt: derivedExcerptOf(whole ? whole.translated : translated, dataSchema, excerpt) }
1114
+ }
872
1115
 
1116
+ /** The record with its free-form translation for `locale` applied, if it has one. */
1117
+ async function applyFreeformFile(translated, item, recordDir, locale, localesDir, dataSchema) {
873
1118
  // ⛔ The loader takes the record's SCHEMA ref and derives the pool path from it
874
1119
  // (`buildFreeformRecordPath`); handed the pool directory itself (`article`), it
875
1120
  // derived nothing and found no file — so until 2026-09-14 no free-form record
@@ -922,16 +1167,18 @@ function isPlainObject(value) {
922
1167
  }
923
1168
 
924
1169
  /**
925
- * Apply translations to a collection item (sync, hash-based only)
1170
+ * Apply translations to a collection item (sync, hash-based only) — by the model it is pushed as
1171
+ * when it has one (`translateByModel`), else a companion schema's fields, else the heuristic.
926
1172
  */
927
- function translateItemSync(item, recordDir, translations, schema) {
928
- const translated = { ...item }
1173
+ function translateItemSync(item, recordDir, translations, schema, { model = null, targets = null, skip = null } = {}) {
929
1174
  const context = { record: `${recordDir}/${recordHandle(item)}` }
930
1175
 
1176
+ if (model) return translateByModel(item, model, context, translations, targets, skip)
1177
+ const translated = { ...item }
931
1178
  if (schema?.fields) {
932
1179
  return translateWithSchema(translated, schema, context, translations)
933
1180
  }
934
- return translateHeuristic(translated, context, translations)
1181
+ return translateHeuristic(translated, context, translations, skip)
935
1182
  }
936
1183
 
937
1184
  // ---------------------------------------------------------------------------
@@ -942,35 +1189,11 @@ function translateItemSync(item, recordDir, translations, schema) {
942
1189
  * Translate a ProseMirror document
943
1190
  */
944
1191
  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
1192
+ // ⭐ Element by element, by the rule a page section is translated by (`merge.js`) — keyed by the
1193
+ // record's identity for an override. ⛔ Until 2026-09-26 a record's rich text was looked up text node
1194
+ // by text node, so a paragraph holding a link or an emphasis — several text nodes — never matched its
1195
+ // translation, which is keyed by the whole paragraph, and rendered untranslated.
1196
+ return resolveDocForLocale(doc, translations, { key: context.record }) ?? doc
974
1197
  }
975
1198
 
976
1199
  /**
@@ -1041,23 +1264,28 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1041
1264
  const records = one ? [items] : items
1042
1265
 
1043
1266
  const schema = await resolveSchema(queryName, siteRoot)
1044
- const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
1267
+ const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
1045
1268
  const recordDir = poolDirs.get(queryName) ?? queryName
1269
+ const recordOptions = {
1270
+ locale,
1271
+ localesDir,
1272
+ freeformEnabled,
1273
+ dataSchema: dataSchemas.get(queryName) ?? null,
1274
+ model: models.get(queryName) ?? null,
1275
+ targets,
1276
+ excerpt: excerpts.get(queryName),
1277
+ }
1046
1278
 
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
- )
1279
+ // A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
1280
+ const wholes = new Map()
1281
+ if (!one) {
1282
+ for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
1283
+ wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
1284
+ }
1285
+ }
1286
+ const translated = await Promise.all(
1287
+ records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
1288
+ )
1061
1289
 
1062
1290
  return one ? translated[0] : translated
1063
1291
  }