@uniweb/build 0.63.3 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/index.js +5 -32
- package/src/i18n/locales.js +51 -0
- package/src/i18n/merge.js +22 -17
- package/src/i18n/records.js +389 -161
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/query-processor.js +13 -2
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/index.js +3 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/record-identity.js +46 -0
- package/src/uwx/records-project.js +109 -22
- package/src/uwx/records.js +19 -16
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +322 -38
- package/src/uwx/site.js +151 -36
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 } 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
|
|
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
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
|
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
|
|
483
|
-
const resolved = (await resolveRecordSchemas(siteRoot,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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 =
|
|
823
|
-
? await translateItemAsync(record, recordDir, translations, schema,
|
|
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
|
-
*
|
|
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
|
|
871
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
}
|