@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.
@@ -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
- extractFromItem(item, collectionName, units)
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
- * Extract translatable strings from a collection item
68
- * @param {Object} item - Collection item
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
- return translateItemFields(translated, context, translations)
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 (or continue after partial free-form)
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
- // Translate all fields including content
348
- return translateItemFields(translated, context, translations, true)
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
- * Apply translations to a collection item (legacy sync function)
408
- * @deprecated Use translateItemAsync for free-form support
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