@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.
- package/package.json +7 -7
- package/src/i18n/extract.js +43 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/merge.js +36 -17
- package/src/i18n/records.js +393 -161
- package/src/runtime-schema.js +3 -1
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/queries-config.js +8 -1
- package/src/site/query-processor.js +13 -2
- package/src/site/registered-foundation.js +47 -0
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +20 -9
- package/src/uwx/data-schema.js +34 -0
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +9 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/open-graph.js +69 -0
- package/src/uwx/project-writer.js +109 -31
- package/src/uwx/records-project.js +156 -28
- package/src/uwx/records.js +67 -18
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +334 -39
- package/src/uwx/site.js +178 -44
- package/src/uwx/sync-package.js +24 -1
- package/src/validate-data.js +15 -5
package/src/i18n/records.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
483
|
-
const resolved = (await resolveRecordSchemas(siteRoot,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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 =
|
|
823
|
-
? await translateItemAsync(record, recordDir, translations, schema,
|
|
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
|
-
*
|
|
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
|
|
871
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
}
|