@uniweb/build 0.5.0 → 0.6.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 +4 -3
- package/src/docs.js +3 -3
- package/src/i18n/collections.js +479 -126
- package/src/i18n/index.js +2 -0
- package/src/prerender.js +59 -73
- package/src/runtime-schema.js +36 -7
- package/src/site/collection-processor.js +73 -9
- package/src/site/data-fetcher.js +3 -1
- package/src/site/plugin.js +93 -15
- package/src/theme/css-generator.js +21 -1
- package/src/theme/processor.js +8 -0
package/src/i18n/collections.js
CHANGED
|
@@ -3,16 +3,408 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extract translatable strings from collection items and merge translations.
|
|
5
5
|
* Collections are separate from page content (stored in public/data/*.json).
|
|
6
|
+
*
|
|
7
|
+
* Supports three extraction modes:
|
|
8
|
+
* 1. Schema-guided — companion .schema.js or standard @uniweb/schemas
|
|
9
|
+
* 2. Heuristic — recursive walk, extract all strings, skip structural patterns
|
|
10
|
+
* 3. Legacy — flat field list (fallback within heuristic)
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
|
|
9
14
|
import { existsSync } from 'fs'
|
|
10
15
|
import { join } from 'path'
|
|
16
|
+
import { pathToFileURL } from 'url'
|
|
11
17
|
import { computeHash } from './hash.js'
|
|
12
18
|
import { loadFreeformCollectionItem } from './freeform.js'
|
|
13
19
|
|
|
14
20
|
export const COLLECTIONS_DIR = 'collections'
|
|
15
21
|
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Types that are never translatable regardless of schema */
|
|
27
|
+
const NON_TRANSLATABLE_TYPES = new Set([
|
|
28
|
+
'number', 'boolean', 'date', 'datetime', 'url', 'email', 'image'
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/** Field names skipped by the heuristic extractor (structural, not human-readable) */
|
|
32
|
+
const HEURISTIC_SKIP_FIELDS = new Set([
|
|
33
|
+
'slug', 'id', 'type', 'status', 'href', 'url', 'src', 'icon',
|
|
34
|
+
'target', 'email', 'phone', 'orcid', 'doi', 'arxiv', 'isbn',
|
|
35
|
+
'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
|
|
36
|
+
'repository', 'caseStudy', 'website', 'avatar', 'image',
|
|
37
|
+
'thumbnail', 'currency', 'order', 'hidden', 'current',
|
|
38
|
+
'featured', 'published', 'allDay', 'remote', 'hybrid',
|
|
39
|
+
'noindex', 'corresponding', 'required', 'virtual',
|
|
40
|
+
'lastModified', 'date', 'updated', 'posted', 'submitted',
|
|
41
|
+
'accepted', 'startDate', 'endDate', 'deadline',
|
|
42
|
+
'readTime', 'citations', 'capacity', 'volume', 'issue', 'pages',
|
|
43
|
+
'time', 'timezone',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/** String patterns that indicate non-translatable values */
|
|
47
|
+
const HEURISTIC_SKIP_PATTERNS = [
|
|
48
|
+
/^https?:\/\//, // URLs
|
|
49
|
+
/^mailto:/, // mailto links
|
|
50
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/, // email addresses
|
|
51
|
+
/^\d{4}-\d{2}-\d{2}/, // ISO dates
|
|
52
|
+
/^#[0-9a-fA-F]{3,8}$/, // hex colors
|
|
53
|
+
/^[\w./\\-]+\.\w{2,4}$/, // file paths (e.g., ./logo.svg, /img/hero.jpg)
|
|
54
|
+
/^[A-Z]{3}$/, // currency codes (USD, EUR)
|
|
55
|
+
/^\d+(\.\d+)?$/, // plain numbers as strings
|
|
56
|
+
/^\d{1,2}:\d{2}(:\d{2})?$/, // times (09:00, 14:30:00)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** Max recursion depth for heuristic extraction */
|
|
60
|
+
const MAX_HEURISTIC_DEPTH = 5
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Schema resolution
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** Cache for resolved schemas (collection name → schema or null) */
|
|
67
|
+
const schemaCache = new Map()
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve schema for a collection.
|
|
71
|
+
*
|
|
72
|
+
* Discovery order:
|
|
73
|
+
* 1. Companion file: public/data/<name>.schema.js (ESM default export)
|
|
74
|
+
* 2. Standard schema: @uniweb/schemas by collection name (with naive singularization)
|
|
75
|
+
* 3. null (no schema found → heuristic fallback)
|
|
76
|
+
*
|
|
77
|
+
* @param {string} collectionName
|
|
78
|
+
* @param {string} siteRoot
|
|
79
|
+
* @returns {Promise<Object|null>}
|
|
80
|
+
*/
|
|
81
|
+
async function resolveSchema(collectionName, siteRoot) {
|
|
82
|
+
if (schemaCache.has(collectionName)) {
|
|
83
|
+
return schemaCache.get(collectionName)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let schema = null
|
|
87
|
+
|
|
88
|
+
// 1. Companion schema file
|
|
89
|
+
const companionPath = join(siteRoot, 'public', 'data', `${collectionName}.schema.js`)
|
|
90
|
+
if (existsSync(companionPath)) {
|
|
91
|
+
try {
|
|
92
|
+
const mod = await import(pathToFileURL(companionPath).href)
|
|
93
|
+
schema = mod.default || mod
|
|
94
|
+
schemaCache.set(collectionName, schema)
|
|
95
|
+
return schema
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`[i18n] Failed to load companion schema ${companionPath}: ${err.message}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Standard schema from @uniweb/schemas (try exact name + singularized)
|
|
102
|
+
try {
|
|
103
|
+
const schemasModule = await import('@uniweb/schemas')
|
|
104
|
+
const names = [collectionName, singularize(collectionName)]
|
|
105
|
+
|
|
106
|
+
for (const name of names) {
|
|
107
|
+
if (schemasModule.schemas?.[name]) {
|
|
108
|
+
schema = schemasModule.schemas[name]
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// @uniweb/schemas not installed — that's fine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
schemaCache.set(collectionName, schema)
|
|
117
|
+
return schema
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Naive singularization for schema lookup.
|
|
122
|
+
* Handles common plural suffixes: articles→article, opportunities→opportunity
|
|
123
|
+
*/
|
|
124
|
+
function singularize(name) {
|
|
125
|
+
if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
|
|
126
|
+
if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) return name.slice(0, -2)
|
|
127
|
+
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1)
|
|
128
|
+
return name
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Field translatability
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Determine if a schema field should be extracted for translation.
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} fieldDef - Schema field definition
|
|
139
|
+
* @returns {'yes'|'no'|'recurse'} Whether the field is translatable
|
|
140
|
+
*/
|
|
141
|
+
function isFieldTranslatable(fieldDef) {
|
|
142
|
+
// Explicit override always wins
|
|
143
|
+
if (fieldDef.translatable === true) return 'yes'
|
|
144
|
+
if (fieldDef.translatable === false) return 'no'
|
|
145
|
+
|
|
146
|
+
const type = fieldDef.type
|
|
147
|
+
|
|
148
|
+
// Types that are never translatable
|
|
149
|
+
if (NON_TRANSLATABLE_TYPES.has(type)) return 'no'
|
|
150
|
+
|
|
151
|
+
// Markdown is always translatable
|
|
152
|
+
if (type === 'markdown') return 'yes'
|
|
153
|
+
|
|
154
|
+
// Strings with enum default to NOT translatable (status codes, types)
|
|
155
|
+
if (type === 'string' && fieldDef.enum) return 'no'
|
|
156
|
+
|
|
157
|
+
// Plain strings default to translatable
|
|
158
|
+
if (type === 'string') return 'yes'
|
|
159
|
+
|
|
160
|
+
// Objects and arrays: recurse into their nested definitions
|
|
161
|
+
if (type === 'object' || type === 'array') return 'recurse'
|
|
162
|
+
|
|
163
|
+
// Unknown types: skip
|
|
164
|
+
return 'no'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Schema-guided extraction
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract translatable fields from an item using a schema.
|
|
173
|
+
*
|
|
174
|
+
* @param {Object} item - Data item
|
|
175
|
+
* @param {Object} schema - Schema with `fields`
|
|
176
|
+
* @param {string} collectionName
|
|
177
|
+
* @param {Object} units - Accumulator
|
|
178
|
+
*/
|
|
179
|
+
function extractWithSchema(item, schema, collectionName, units) {
|
|
180
|
+
const slug = item.slug || item.id || item.name || 'unknown'
|
|
181
|
+
const context = { collection: collectionName, item: slug }
|
|
182
|
+
|
|
183
|
+
extractFromItemWithSchema(item, schema.fields, '', context, units)
|
|
184
|
+
|
|
185
|
+
// Also extract ProseMirror content body (not covered by schema fields)
|
|
186
|
+
if (item.content?.type === 'doc') {
|
|
187
|
+
extractFromProseMirrorDoc(item.content, context, units)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Recursively extract translatable fields guided by schema.
|
|
193
|
+
*/
|
|
194
|
+
function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
|
|
195
|
+
if (!data || typeof data !== 'object') return
|
|
196
|
+
|
|
197
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
198
|
+
const value = data[fieldName]
|
|
199
|
+
if (value === undefined || value === null) continue
|
|
200
|
+
|
|
201
|
+
const fieldPath = pathPrefix ? `${pathPrefix}.${fieldName}` : fieldName
|
|
202
|
+
const translatable = isFieldTranslatable(fieldDef)
|
|
203
|
+
|
|
204
|
+
if (translatable === 'yes') {
|
|
205
|
+
if (typeof value === 'string' && value.trim()) {
|
|
206
|
+
addUnit(units, value, fieldPath, context)
|
|
207
|
+
}
|
|
208
|
+
} else if (translatable === 'recurse') {
|
|
209
|
+
if (fieldDef.type === 'object' && fieldDef.fields && typeof value === 'object' && !Array.isArray(value)) {
|
|
210
|
+
extractFromItemWithSchema(value, fieldDef.fields, fieldPath, context, units)
|
|
211
|
+
} else if (fieldDef.type === 'array' && Array.isArray(value)) {
|
|
212
|
+
const itemDef = fieldDef.items
|
|
213
|
+
if (itemDef) {
|
|
214
|
+
value.forEach((elem, i) => {
|
|
215
|
+
const elemPath = `${fieldPath}[${i}]`
|
|
216
|
+
if (itemDef.type === 'object' && itemDef.fields && typeof elem === 'object') {
|
|
217
|
+
extractFromItemWithSchema(elem, itemDef.fields, elemPath, context, units)
|
|
218
|
+
} else if (itemDef.type === 'string') {
|
|
219
|
+
// Array of strings — check item-level translatable
|
|
220
|
+
const itemTranslatable = isFieldTranslatable(itemDef)
|
|
221
|
+
if (itemTranslatable === 'yes' && typeof elem === 'string' && elem.trim()) {
|
|
222
|
+
addUnit(units, elem, elemPath, context)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// translatable === 'no' → skip
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Heuristic extraction (no schema)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Extract translatable fields from an item using heuristics.
|
|
239
|
+
* Recursively walks the data, extracting strings that look like human-readable text.
|
|
240
|
+
*/
|
|
241
|
+
function extractHeuristic(item, collectionName, units) {
|
|
242
|
+
const slug = item.slug || item.id || item.name || 'unknown'
|
|
243
|
+
const context = { collection: collectionName, item: slug }
|
|
244
|
+
|
|
245
|
+
extractFromItemHeuristic(item, '', context, units, 0)
|
|
246
|
+
|
|
247
|
+
// Also extract ProseMirror content body
|
|
248
|
+
if (item.content?.type === 'doc') {
|
|
249
|
+
extractFromProseMirrorDoc(item.content, context, units)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Recursively extract strings that look translatable.
|
|
255
|
+
*/
|
|
256
|
+
function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
|
|
257
|
+
if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
|
|
258
|
+
|
|
259
|
+
const entries = Array.isArray(data)
|
|
260
|
+
? data.map((v, i) => [`[${i}]`, v])
|
|
261
|
+
: Object.entries(data)
|
|
262
|
+
|
|
263
|
+
for (const [key, value] of entries) {
|
|
264
|
+
// Build the field path
|
|
265
|
+
const fieldPath = Array.isArray(data)
|
|
266
|
+
? `${pathPrefix}${key}`
|
|
267
|
+
: (pathPrefix ? `${pathPrefix}.${key}` : key)
|
|
268
|
+
|
|
269
|
+
if (value === undefined || value === null) continue
|
|
270
|
+
|
|
271
|
+
// Skip ProseMirror content (handled separately)
|
|
272
|
+
if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
|
|
273
|
+
|
|
274
|
+
if (typeof value === 'string') {
|
|
275
|
+
// Skip known structural field names
|
|
276
|
+
if (!Array.isArray(data) && HEURISTIC_SKIP_FIELDS.has(key)) continue
|
|
277
|
+
|
|
278
|
+
// Skip strings matching structural patterns
|
|
279
|
+
if (isStructuralString(value)) continue
|
|
280
|
+
|
|
281
|
+
// Must have non-empty trimmed content
|
|
282
|
+
if (!value.trim()) continue
|
|
283
|
+
|
|
284
|
+
addUnit(units, value, fieldPath, context)
|
|
285
|
+
} else if (typeof value === 'object') {
|
|
286
|
+
// Recurse into objects and arrays
|
|
287
|
+
extractFromItemHeuristic(value, fieldPath, context, units, depth + 1)
|
|
288
|
+
}
|
|
289
|
+
// Skip numbers, booleans
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if a string value looks structural (not human-readable).
|
|
295
|
+
*/
|
|
296
|
+
function isStructuralString(value) {
|
|
297
|
+
return HEURISTIC_SKIP_PATTERNS.some(pattern => pattern.test(value))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Schema-guided translation
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Translate item fields using schema guidance.
|
|
306
|
+
*/
|
|
307
|
+
function translateWithSchema(item, schema, context, translations, includeContent = true) {
|
|
308
|
+
const translated = { ...item }
|
|
309
|
+
translateItemWithSchema(translated, schema.fields, context, translations)
|
|
310
|
+
|
|
311
|
+
// Translate ProseMirror content
|
|
312
|
+
if (includeContent && translated.content?.type === 'doc') {
|
|
313
|
+
translated.content = translateProseMirrorDoc(translated.content, context, translations)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return translated
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Recursively translate fields guided by schema.
|
|
321
|
+
*/
|
|
322
|
+
function translateItemWithSchema(data, fields, context, translations) {
|
|
323
|
+
if (!data || typeof data !== 'object') return
|
|
324
|
+
|
|
325
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
326
|
+
const value = data[fieldName]
|
|
327
|
+
if (value === undefined || value === null) continue
|
|
328
|
+
|
|
329
|
+
const translatable = isFieldTranslatable(fieldDef)
|
|
330
|
+
|
|
331
|
+
if (translatable === 'yes') {
|
|
332
|
+
if (typeof value === 'string') {
|
|
333
|
+
data[fieldName] = lookupTranslation(value, context, translations)
|
|
334
|
+
}
|
|
335
|
+
} else if (translatable === 'recurse') {
|
|
336
|
+
if (fieldDef.type === 'object' && fieldDef.fields && typeof value === 'object' && !Array.isArray(value)) {
|
|
337
|
+
translateItemWithSchema(value, fieldDef.fields, context, translations)
|
|
338
|
+
} else if (fieldDef.type === 'array' && Array.isArray(value)) {
|
|
339
|
+
const itemDef = fieldDef.items
|
|
340
|
+
if (itemDef) {
|
|
341
|
+
value.forEach((elem, i) => {
|
|
342
|
+
if (itemDef.type === 'object' && itemDef.fields && typeof elem === 'object') {
|
|
343
|
+
translateItemWithSchema(elem, itemDef.fields, context, translations)
|
|
344
|
+
} else if (itemDef.type === 'string') {
|
|
345
|
+
const itemTranslatable = isFieldTranslatable(itemDef)
|
|
346
|
+
if (itemTranslatable === 'yes' && typeof elem === 'string') {
|
|
347
|
+
value[i] = lookupTranslation(elem, context, translations)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Heuristic translation (no schema)
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Translate item fields using heuristics.
|
|
363
|
+
*/
|
|
364
|
+
function translateHeuristic(item, context, translations, includeContent = true) {
|
|
365
|
+
const translated = { ...item }
|
|
366
|
+
translateItemHeuristic(translated, context, translations, 0)
|
|
367
|
+
|
|
368
|
+
if (includeContent && translated.content?.type === 'doc') {
|
|
369
|
+
translated.content = translateProseMirrorDoc(translated.content, context, translations)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return translated
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Recursively translate strings that look translatable.
|
|
377
|
+
*/
|
|
378
|
+
function translateItemHeuristic(data, context, translations, depth) {
|
|
379
|
+
if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
|
|
380
|
+
|
|
381
|
+
const keys = Array.isArray(data)
|
|
382
|
+
? data.map((_, i) => i)
|
|
383
|
+
: Object.keys(data)
|
|
384
|
+
|
|
385
|
+
for (const key of keys) {
|
|
386
|
+
const value = data[key]
|
|
387
|
+
if (value === undefined || value === null) continue
|
|
388
|
+
|
|
389
|
+
// Skip ProseMirror content (handled separately)
|
|
390
|
+
if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
|
|
391
|
+
|
|
392
|
+
if (typeof value === 'string') {
|
|
393
|
+
if (!Array.isArray(data) && HEURISTIC_SKIP_FIELDS.has(key)) continue
|
|
394
|
+
if (isStructuralString(value)) continue
|
|
395
|
+
if (!value.trim()) continue
|
|
396
|
+
|
|
397
|
+
data[key] = lookupTranslation(value, context, translations)
|
|
398
|
+
} else if (typeof value === 'object') {
|
|
399
|
+
translateItemHeuristic(value, context, translations, depth + 1)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Main extraction entry point
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
16
408
|
/**
|
|
17
409
|
* Extract translatable content from all collections
|
|
18
410
|
* @param {string} siteRoot - Site root directory
|
|
@@ -47,8 +439,15 @@ export async function extractCollectionContent(siteRoot, options = {}) {
|
|
|
47
439
|
|
|
48
440
|
if (!Array.isArray(items)) continue
|
|
49
441
|
|
|
442
|
+
// Resolve schema once per collection
|
|
443
|
+
const schema = await resolveSchema(collectionName, siteRoot)
|
|
444
|
+
|
|
50
445
|
for (const item of items) {
|
|
51
|
-
|
|
446
|
+
if (schema?.fields) {
|
|
447
|
+
extractWithSchema(item, schema, collectionName, units)
|
|
448
|
+
} else {
|
|
449
|
+
extractHeuristic(item, collectionName, units)
|
|
450
|
+
}
|
|
52
451
|
}
|
|
53
452
|
} catch (err) {
|
|
54
453
|
// Skip files that can't be parsed
|
|
@@ -63,54 +462,12 @@ export async function extractCollectionContent(siteRoot, options = {}) {
|
|
|
63
462
|
}
|
|
64
463
|
}
|
|
65
464
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* @param {string} collectionName - Name of the collection
|
|
70
|
-
* @param {Object} units - Units accumulator
|
|
71
|
-
*/
|
|
72
|
-
function extractFromItem(item, collectionName, units) {
|
|
73
|
-
const slug = item.slug || 'unknown'
|
|
74
|
-
const context = { collection: collectionName, item: slug }
|
|
75
|
-
|
|
76
|
-
// Extract string fields from frontmatter
|
|
77
|
-
// Common translatable fields
|
|
78
|
-
const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
|
|
79
|
-
|
|
80
|
-
for (const field of translatableFields) {
|
|
81
|
-
if (item[field] && typeof item[field] === 'string' && item[field].trim()) {
|
|
82
|
-
addUnit(units, item[field], field, context)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Extract from tags/categories if they're strings
|
|
87
|
-
if (Array.isArray(item.tags)) {
|
|
88
|
-
item.tags.forEach((tag, i) => {
|
|
89
|
-
if (typeof tag === 'string' && tag.trim()) {
|
|
90
|
-
addUnit(units, tag, `tag.${i}`, context)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (Array.isArray(item.categories)) {
|
|
96
|
-
item.categories.forEach((cat, i) => {
|
|
97
|
-
if (typeof cat === 'string' && cat.trim()) {
|
|
98
|
-
addUnit(units, cat, `category.${i}`, context)
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Extract from ProseMirror content body
|
|
104
|
-
if (item.content?.type === 'doc') {
|
|
105
|
-
extractFromProseMirrorDoc(item.content, context, units)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// ProseMirror extraction helpers (unchanged)
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
108
468
|
|
|
109
469
|
/**
|
|
110
470
|
* Extract from ProseMirror document
|
|
111
|
-
* @param {Object} doc - ProseMirror document
|
|
112
|
-
* @param {Object} context - Context for the item
|
|
113
|
-
* @param {Object} units - Units accumulator
|
|
114
471
|
*/
|
|
115
472
|
function extractFromProseMirrorDoc(doc, context, units) {
|
|
116
473
|
if (!doc.content) return
|
|
@@ -169,6 +526,10 @@ function extractTextFromNode(node) {
|
|
|
169
526
|
.trim()
|
|
170
527
|
}
|
|
171
528
|
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Unit accumulator
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
172
533
|
/**
|
|
173
534
|
* Add a translation unit to the accumulator
|
|
174
535
|
*/
|
|
@@ -196,6 +557,10 @@ function addUnit(units, source, field, context) {
|
|
|
196
557
|
}
|
|
197
558
|
}
|
|
198
559
|
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Translation entry points
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
199
564
|
/**
|
|
200
565
|
* Merge translations into collection data and write locale-specific files
|
|
201
566
|
* @param {string} siteRoot - Site root directory
|
|
@@ -271,10 +636,13 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
271
636
|
continue
|
|
272
637
|
}
|
|
273
638
|
|
|
639
|
+
// Resolve schema once per collection
|
|
640
|
+
const schema = await resolveSchema(collectionName, siteRoot)
|
|
641
|
+
|
|
274
642
|
// Translate each item (with free-form support)
|
|
275
643
|
const translatedItems = await Promise.all(
|
|
276
644
|
items.map(item =>
|
|
277
|
-
translateItemAsync(item, collectionName, translations, {
|
|
645
|
+
translateItemAsync(item, collectionName, translations, schema, {
|
|
278
646
|
locale,
|
|
279
647
|
localesDir,
|
|
280
648
|
freeformEnabled: hasFreeform
|
|
@@ -299,18 +667,12 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
299
667
|
*
|
|
300
668
|
* Resolution order:
|
|
301
669
|
* 1. Check for free-form translation (complete or partial replacement)
|
|
302
|
-
* 2. Fall back to hash-based translation
|
|
303
|
-
*
|
|
304
|
-
* @param {Object} item - Collection item
|
|
305
|
-
* @param {string} collectionName - Name of the collection
|
|
306
|
-
* @param {Object} translations - Hash-based translations
|
|
307
|
-
* @param {Object} options - Options
|
|
308
|
-
* @returns {Promise<Object>} Translated item
|
|
670
|
+
* 2. Fall back to hash-based translation (schema-guided or heuristic)
|
|
309
671
|
*/
|
|
310
|
-
async function translateItemAsync(item, collectionName, translations, options = {}) {
|
|
672
|
+
async function translateItemAsync(item, collectionName, translations, schema, options = {}) {
|
|
311
673
|
const { locale, localesDir, freeformEnabled } = options
|
|
312
674
|
const translated = { ...item }
|
|
313
|
-
const slug = item.slug || 'unknown'
|
|
675
|
+
const slug = item.slug || item.id || item.name || 'unknown'
|
|
314
676
|
const context = { collection: collectionName, item: slug }
|
|
315
677
|
|
|
316
678
|
// Check for free-form translation first
|
|
@@ -320,96 +682,41 @@ async function translateItemAsync(item, collectionName, translations, options =
|
|
|
320
682
|
if (freeform) {
|
|
321
683
|
// Merge free-form data (supports partial: frontmatter only, body only, or both)
|
|
322
684
|
if (freeform.frontmatter) {
|
|
323
|
-
// Merge frontmatter fields from free-form
|
|
324
685
|
Object.assign(translated, freeform.frontmatter)
|
|
325
686
|
}
|
|
326
687
|
if (freeform.content) {
|
|
327
|
-
// Replace content entirely with free-form content
|
|
328
688
|
translated.content = freeform.content
|
|
329
689
|
// Skip hash-based content translation since we have free-form
|
|
330
|
-
|
|
690
|
+
// Still translate frontmatter fields via schema/heuristic
|
|
691
|
+
if (schema?.fields) {
|
|
692
|
+
return translateWithSchema(translated, schema, context, translations, false)
|
|
693
|
+
}
|
|
694
|
+
return translateHeuristic(translated, context, translations, false)
|
|
331
695
|
}
|
|
332
696
|
}
|
|
333
697
|
}
|
|
334
698
|
|
|
335
|
-
// Fall back to hash-based translation
|
|
336
|
-
return translateItemSync(translated, collectionName, translations)
|
|
699
|
+
// Fall back to hash-based translation
|
|
700
|
+
return translateItemSync(translated, collectionName, translations, schema)
|
|
337
701
|
}
|
|
338
702
|
|
|
339
703
|
/**
|
|
340
704
|
* Apply translations to a collection item (sync, hash-based only)
|
|
341
705
|
*/
|
|
342
|
-
function translateItemSync(item, collectionName, translations) {
|
|
706
|
+
function translateItemSync(item, collectionName, translations, schema) {
|
|
343
707
|
const translated = { ...item }
|
|
344
|
-
const slug = item.slug || 'unknown'
|
|
708
|
+
const slug = item.slug || item.id || item.name || 'unknown'
|
|
345
709
|
const context = { collection: collectionName, item: slug }
|
|
346
710
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Translate item fields (frontmatter and optionally content)
|
|
353
|
-
*
|
|
354
|
-
* @param {Object} translated - Item being translated
|
|
355
|
-
* @param {Object} context - Translation context
|
|
356
|
-
* @param {Object} translations - Hash-based translations
|
|
357
|
-
* @param {boolean} includeContent - Whether to translate ProseMirror content
|
|
358
|
-
* @returns {Object} Translated item
|
|
359
|
-
*/
|
|
360
|
-
function translateItemFields(translated, context, translations, includeContent = false) {
|
|
361
|
-
// Translate frontmatter fields
|
|
362
|
-
const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
|
|
363
|
-
|
|
364
|
-
for (const field of translatableFields) {
|
|
365
|
-
if (translated[field] && typeof translated[field] === 'string') {
|
|
366
|
-
translated[field] = lookupTranslation(
|
|
367
|
-
translated[field],
|
|
368
|
-
context,
|
|
369
|
-
translations
|
|
370
|
-
)
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Translate tags
|
|
375
|
-
if (Array.isArray(translated.tags)) {
|
|
376
|
-
translated.tags = translated.tags.map(tag => {
|
|
377
|
-
if (typeof tag === 'string') {
|
|
378
|
-
return lookupTranslation(tag, context, translations)
|
|
379
|
-
}
|
|
380
|
-
return tag
|
|
381
|
-
})
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Translate categories
|
|
385
|
-
if (Array.isArray(translated.categories)) {
|
|
386
|
-
translated.categories = translated.categories.map(cat => {
|
|
387
|
-
if (typeof cat === 'string') {
|
|
388
|
-
return lookupTranslation(cat, context, translations)
|
|
389
|
-
}
|
|
390
|
-
return cat
|
|
391
|
-
})
|
|
711
|
+
if (schema?.fields) {
|
|
712
|
+
return translateWithSchema(translated, schema, context, translations)
|
|
392
713
|
}
|
|
393
|
-
|
|
394
|
-
// Translate ProseMirror content (only if requested)
|
|
395
|
-
if (includeContent && translated.content?.type === 'doc') {
|
|
396
|
-
translated.content = translateProseMirrorDoc(
|
|
397
|
-
translated.content,
|
|
398
|
-
context,
|
|
399
|
-
translations
|
|
400
|
-
)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return translated
|
|
714
|
+
return translateHeuristic(translated, context, translations)
|
|
404
715
|
}
|
|
405
716
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
*/
|
|
410
|
-
function translateItem(item, collectionName, translations) {
|
|
411
|
-
return translateItemSync(item, collectionName, translations)
|
|
412
|
-
}
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
// ProseMirror translation helpers (unchanged)
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
413
720
|
|
|
414
721
|
/**
|
|
415
722
|
* Translate a ProseMirror document
|
|
@@ -478,6 +785,52 @@ function lookupTranslation(source, context, translations) {
|
|
|
478
785
|
return source
|
|
479
786
|
}
|
|
480
787
|
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
// Public translation entry point (for dev server middleware)
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Translate a collection's items array for a given locale.
|
|
794
|
+
* Used by dev server middleware for on-the-fly translation.
|
|
795
|
+
*
|
|
796
|
+
* @param {Array} items - Collection items array
|
|
797
|
+
* @param {string} collectionName - Collection name (e.g., 'articles')
|
|
798
|
+
* @param {string} siteRoot - Site root directory
|
|
799
|
+
* @param {Object} options - Translation options
|
|
800
|
+
* @param {string} options.locale - Target locale code
|
|
801
|
+
* @param {string} options.localesDir - Absolute path to locales directory
|
|
802
|
+
* @param {Object} [options.translations={}] - Hash-based translations
|
|
803
|
+
* @param {boolean} [options.freeformEnabled=false] - Enable free-form translations
|
|
804
|
+
* @returns {Promise<Array>} Translated items
|
|
805
|
+
*/
|
|
806
|
+
export async function translateCollectionData(items, collectionName, siteRoot, options = {}) {
|
|
807
|
+
const { locale, localesDir, translations = {}, freeformEnabled = false } = options
|
|
808
|
+
|
|
809
|
+
if (!Array.isArray(items)) return items
|
|
810
|
+
|
|
811
|
+
const schema = await resolveSchema(collectionName, siteRoot)
|
|
812
|
+
|
|
813
|
+
if (freeformEnabled) {
|
|
814
|
+
return Promise.all(
|
|
815
|
+
items.map(item =>
|
|
816
|
+
translateItemAsync(item, collectionName, translations, schema, {
|
|
817
|
+
locale,
|
|
818
|
+
localesDir,
|
|
819
|
+
freeformEnabled
|
|
820
|
+
})
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return items.map(item =>
|
|
826
|
+
translateItemSync(item, collectionName, translations, schema)
|
|
827
|
+
)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Locale helpers
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
|
|
481
834
|
/**
|
|
482
835
|
* Get available collection locales
|
|
483
836
|
* @param {string} localesPath - Path to locales directory
|