@uniweb/build 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Free-form Translation Support
3
+ *
4
+ * Enables complete content replacement for locales using markdown files,
5
+ * as an alternative to hash-based string merging. Free-form translations
6
+ * allow translators to completely reword sections rather than translating
7
+ * element-by-element.
8
+ *
9
+ * Directory structure:
10
+ * locales/freeform/{locale}/
11
+ * pages/{pageRoute}/{stableId}.md - By route
12
+ * page-ids/{pageId}/{stableId}.md - By page ID (stable)
13
+ * collections/{collectionName}/{slug}.md - Collection items
14
+ *
15
+ * Resolution order for sections:
16
+ * 1. page-ids/{pageId}/{stableId}.md (if page has id:)
17
+ * 2. pages/{pageRoute}/{stableId}.md
18
+ * 3. Return null (fall back to granular translation)
19
+ */
20
+
21
+ import { readFile, readdir, stat } from 'fs/promises'
22
+ import { existsSync } from 'fs'
23
+ import { join, relative, dirname } from 'path'
24
+ import yaml from 'js-yaml'
25
+
26
+ // Try to import content-reader for markdown → ProseMirror conversion
27
+ let markdownToProseMirror
28
+ try {
29
+ const contentReader = await import('@uniweb/content-reader')
30
+ markdownToProseMirror = contentReader.markdownToProseMirror
31
+ } catch {
32
+ // Simplified fallback - just wraps content as text
33
+ markdownToProseMirror = (markdown) => ({
34
+ type: 'doc',
35
+ content: [
36
+ {
37
+ type: 'paragraph',
38
+ content: [{ type: 'text', text: markdown.trim() }]
39
+ }
40
+ ]
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Parse YAML frontmatter from markdown content
46
+ * @param {string} content - Raw markdown content
47
+ * @returns {{ frontmatter: Object, body: string }}
48
+ */
49
+ function parseFrontmatter(content) {
50
+ if (!content.trim().startsWith('---')) {
51
+ return { frontmatter: {}, body: content }
52
+ }
53
+
54
+ const parts = content.split('---\n')
55
+ if (parts.length < 3) {
56
+ return { frontmatter: {}, body: content }
57
+ }
58
+
59
+ try {
60
+ const frontmatter = yaml.load(parts[1]) || {}
61
+ const body = parts.slice(2).join('---\n')
62
+ return { frontmatter, body }
63
+ } catch {
64
+ return { frontmatter: {}, body: content }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Normalize route for filesystem path
70
+ * Removes leading slash, replaces remaining slashes with path separators
71
+ * @param {string} route - Page route (e.g., '/about/team')
72
+ * @returns {string} Normalized path (e.g., 'about/team')
73
+ */
74
+ function normalizeRouteForPath(route) {
75
+ if (route === '/') return ''
76
+ return route.replace(/^\//, '').replace(/\//g, '/')
77
+ }
78
+
79
+ /**
80
+ * Load free-form translation for a section
81
+ *
82
+ * Resolution order:
83
+ * 1. page-ids/{pageId}/{stableId}.md (if page has id:)
84
+ * 2. pages/{pageRoute}/{stableId}.md
85
+ * 3. Return null (fall back to granular)
86
+ *
87
+ * @param {Object} section - Section object with stableId
88
+ * @param {Object} page - Page object with route and optional id
89
+ * @param {string} locale - Locale code (e.g., 'es', 'fr')
90
+ * @param {string} localesDir - Path to locales directory
91
+ * @returns {Promise<Object|null>} Parsed translation { content } or null
92
+ */
93
+ export async function loadFreeformTranslation(section, page, locale, localesDir) {
94
+ const stableId = section.stableId
95
+ if (!stableId) return null
96
+
97
+ const freeformDir = join(localesDir, 'freeform', locale)
98
+ if (!existsSync(freeformDir)) return null
99
+
100
+ const candidates = []
101
+
102
+ // 1. Try page-ids path (if page has stable id)
103
+ if (page.id) {
104
+ candidates.push(join(freeformDir, 'page-ids', page.id, `${stableId}.md`))
105
+ }
106
+
107
+ // 2. Try pages path (by route)
108
+ const routePath = normalizeRouteForPath(page.route)
109
+ if (routePath) {
110
+ candidates.push(join(freeformDir, 'pages', routePath, `${stableId}.md`))
111
+ } else {
112
+ // Root page
113
+ candidates.push(join(freeformDir, 'pages', `${stableId}.md`))
114
+ }
115
+
116
+ // Try each candidate in order
117
+ for (const filePath of candidates) {
118
+ if (!existsSync(filePath)) continue
119
+
120
+ try {
121
+ const content = await readFile(filePath, 'utf-8')
122
+ const { frontmatter, body } = parseFrontmatter(content)
123
+
124
+ // Convert markdown body to ProseMirror
125
+ const proseMirrorContent = markdownToProseMirror(body)
126
+
127
+ return {
128
+ content: proseMirrorContent,
129
+ frontmatter,
130
+ filePath,
131
+ relativePath: relative(join(localesDir, 'freeform', locale), filePath)
132
+ }
133
+ } catch (err) {
134
+ console.warn(`[i18n] Failed to load free-form translation ${filePath}: ${err.message}`)
135
+ return null
136
+ }
137
+ }
138
+
139
+ return null
140
+ }
141
+
142
+ /**
143
+ * Load free-form translation for a collection item
144
+ *
145
+ * Path: collections/{collectionName}/{slug}.md
146
+ *
147
+ * @param {Object} item - Collection item with slug
148
+ * @param {string} collectionName - Name of the collection
149
+ * @param {string} locale - Locale code
150
+ * @param {string} localesDir - Path to locales directory
151
+ * @returns {Promise<Object|null>} Parsed translation { frontmatter, content } or null
152
+ */
153
+ export async function loadFreeformCollectionItem(item, collectionName, locale, localesDir) {
154
+ const slug = item.slug
155
+ if (!slug) return null
156
+
157
+ const freeformDir = join(localesDir, 'freeform', locale)
158
+ if (!existsSync(freeformDir)) return null
159
+
160
+ const filePath = join(freeformDir, 'collections', collectionName, `${slug}.md`)
161
+ if (!existsSync(filePath)) return null
162
+
163
+ try {
164
+ const content = await readFile(filePath, 'utf-8')
165
+ const { frontmatter, body } = parseFrontmatter(content)
166
+
167
+ // Convert markdown body to ProseMirror (if body exists)
168
+ const proseMirrorContent = body.trim() ? markdownToProseMirror(body) : null
169
+
170
+ return {
171
+ frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
172
+ content: proseMirrorContent,
173
+ filePath,
174
+ relativePath: relative(join(localesDir, 'freeform', locale), filePath)
175
+ }
176
+ } catch (err) {
177
+ console.warn(`[i18n] Failed to load free-form collection item ${filePath}: ${err.message}`)
178
+ return null
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Recursively discover all markdown files in a directory
184
+ * @param {string} dir - Directory to scan
185
+ * @param {string} baseDir - Base directory for relative paths
186
+ * @returns {Promise<string[]>} Array of relative paths
187
+ */
188
+ async function discoverMarkdownFiles(dir, baseDir) {
189
+ const files = []
190
+
191
+ if (!existsSync(dir)) return files
192
+
193
+ const entries = await readdir(dir, { withFileTypes: true })
194
+
195
+ for (const entry of entries) {
196
+ const fullPath = join(dir, entry.name)
197
+
198
+ if (entry.isDirectory()) {
199
+ const subFiles = await discoverMarkdownFiles(fullPath, baseDir)
200
+ files.push(...subFiles)
201
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
202
+ files.push(relative(baseDir, fullPath))
203
+ }
204
+ }
205
+
206
+ return files
207
+ }
208
+
209
+ /**
210
+ * Discover all free-form translation files for a locale
211
+ *
212
+ * Used by status commands to show what translations exist.
213
+ *
214
+ * @param {string} locale - Locale code
215
+ * @param {string} localesDir - Path to locales directory
216
+ * @returns {Promise<Object>} { pages: string[], pageIds: string[], collections: string[] }
217
+ */
218
+ export async function discoverFreeformTranslations(locale, localesDir) {
219
+ const freeformDir = join(localesDir, 'freeform', locale)
220
+
221
+ const result = {
222
+ pages: [],
223
+ pageIds: [],
224
+ collections: []
225
+ }
226
+
227
+ if (!existsSync(freeformDir)) return result
228
+
229
+ // Discover pages translations
230
+ const pagesDir = join(freeformDir, 'pages')
231
+ if (existsSync(pagesDir)) {
232
+ result.pages = await discoverMarkdownFiles(pagesDir, pagesDir)
233
+ }
234
+
235
+ // Discover page-ids translations
236
+ const pageIdsDir = join(freeformDir, 'page-ids')
237
+ if (existsSync(pageIdsDir)) {
238
+ result.pageIds = await discoverMarkdownFiles(pageIdsDir, pageIdsDir)
239
+ }
240
+
241
+ // Discover collection translations
242
+ const collectionsDir = join(freeformDir, 'collections')
243
+ if (existsSync(collectionsDir)) {
244
+ result.collections = await discoverMarkdownFiles(collectionsDir, collectionsDir)
245
+ }
246
+
247
+ return result
248
+ }
249
+
250
+ /**
251
+ * Get metadata for a free-form translation file
252
+ *
253
+ * @param {string} filePath - Full path to translation file
254
+ * @returns {Promise<Object>} { mtime, size }
255
+ */
256
+ export async function getFreeformFileMeta(filePath) {
257
+ if (!existsSync(filePath)) return null
258
+
259
+ const stats = await stat(filePath)
260
+ return {
261
+ mtime: stats.mtime.toISOString(),
262
+ size: stats.size
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Parse a free-form translation file path to extract metadata
268
+ *
269
+ * @param {string} relativePath - Path relative to locale's freeform dir
270
+ * @returns {Object} { type, pageRoute?, pageId?, collectionName?, stableId, slug? }
271
+ */
272
+ export function parseFreeformPath(relativePath) {
273
+ const parts = relativePath.split('/')
274
+
275
+ if (parts[0] === 'pages') {
276
+ // pages/about/hero.md → { type: 'page', pageRoute: '/about', stableId: 'hero' }
277
+ const stableId = parts[parts.length - 1].replace('.md', '')
278
+ const routeParts = parts.slice(1, -1)
279
+ const pageRoute = routeParts.length > 0 ? '/' + routeParts.join('/') : '/'
280
+ return { type: 'page', pageRoute, stableId }
281
+ }
282
+
283
+ if (parts[0] === 'page-ids') {
284
+ // page-ids/installation/intro.md → { type: 'pageId', pageId: 'installation', stableId: 'intro' }
285
+ const stableId = parts[parts.length - 1].replace('.md', '')
286
+ const pageId = parts.slice(1, -1).join('/')
287
+ return { type: 'pageId', pageId, stableId }
288
+ }
289
+
290
+ if (parts[0] === 'collections') {
291
+ // collections/articles/getting-started.md → { type: 'collection', collectionName: 'articles', slug: 'getting-started' }
292
+ const slug = parts[parts.length - 1].replace('.md', '')
293
+ const collectionName = parts[1]
294
+ return { type: 'collection', collectionName, slug }
295
+ }
296
+
297
+ return { type: 'unknown', relativePath }
298
+ }
299
+
300
+ /**
301
+ * Build the expected free-form translation path for a section
302
+ *
303
+ * @param {Object} section - Section with stableId
304
+ * @param {Object} page - Page with route and optional id
305
+ * @param {boolean} preferPageId - Whether to prefer page-ids/ over pages/
306
+ * @returns {string} Relative path (e.g., 'pages/about/hero.md')
307
+ */
308
+ export function buildFreeformPath(section, page, preferPageId = true) {
309
+ const stableId = section.stableId
310
+ if (!stableId) return null
311
+
312
+ // Prefer page-ids if page has stable id
313
+ if (preferPageId && page.id) {
314
+ return `page-ids/${page.id}/${stableId}.md`
315
+ }
316
+
317
+ // Fall back to route-based path
318
+ const routePath = normalizeRouteForPath(page.route)
319
+ if (routePath) {
320
+ return `pages/${routePath}/${stableId}.md`
321
+ }
322
+
323
+ // Root page
324
+ return `pages/${stableId}.md`
325
+ }
326
+
327
+ /**
328
+ * Build the expected free-form translation path for a collection item
329
+ *
330
+ * @param {string} collectionName - Name of the collection
331
+ * @param {string} slug - Item slug
332
+ * @returns {string} Relative path (e.g., 'collections/articles/getting-started.md')
333
+ */
334
+ export function buildFreeformCollectionPath(collectionName, slug) {
335
+ return `collections/${collectionName}/${slug}.md`
336
+ }
package/src/i18n/index.js CHANGED
@@ -24,6 +24,30 @@ import {
24
24
  } from './collections.js'
25
25
  import { generateSearchIndex, isSearchEnabled } from '../search/index.js'
26
26
 
27
+ // Free-form translation support
28
+ import {
29
+ loadFreeformTranslation,
30
+ loadFreeformCollectionItem,
31
+ discoverFreeformTranslations,
32
+ getFreeformFileMeta,
33
+ parseFreeformPath,
34
+ buildFreeformPath,
35
+ buildFreeformCollectionPath
36
+ } from './freeform.js'
37
+ import {
38
+ computeSourceHash,
39
+ loadManifest as loadFreeformManifest,
40
+ saveManifest as saveFreeformManifest,
41
+ recordHash,
42
+ checkStaleness,
43
+ updateHash,
44
+ removeManifestEntries,
45
+ renameManifestEntries,
46
+ getStaleTranslations,
47
+ getOrphanedTranslations,
48
+ getUnregisteredTranslations
49
+ } from './freeform-manifest.js'
50
+
27
51
  export {
28
52
  // Hash utilities
29
53
  computeHash,
@@ -49,7 +73,29 @@ export {
49
73
 
50
74
  // Locale resolution
51
75
  getAvailableLocales,
52
- resolveLocales
76
+ resolveLocales,
77
+
78
+ // Free-form translation functions
79
+ loadFreeformTranslation,
80
+ loadFreeformCollectionItem,
81
+ discoverFreeformTranslations,
82
+ getFreeformFileMeta,
83
+ parseFreeformPath,
84
+ buildFreeformPath,
85
+ buildFreeformCollectionPath,
86
+
87
+ // Free-form manifest functions
88
+ computeSourceHash,
89
+ loadFreeformManifest,
90
+ saveFreeformManifest,
91
+ recordHash,
92
+ checkStaleness,
93
+ updateHash,
94
+ removeManifestEntries,
95
+ renameManifestEntries,
96
+ getStaleTranslations,
97
+ getOrphanedTranslations,
98
+ getUnregisteredTranslations
53
99
  }
54
100
 
55
101
  /**
@@ -278,7 +324,8 @@ export async function getTranslationStatus(siteRoot, options = {}) {
278
324
  * Build translated site content for all locales
279
325
  * @param {string} siteRoot - Site root directory
280
326
  * @param {Object} options - Options
281
- * @param {boolean} [options.generateSearchIndex=true] - Generate search indexes for each locale
327
+ * @param {boolean} [options.generateSearchIndexes=true] - Generate search indexes for each locale
328
+ * @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
282
329
  * @returns {Object} Map of locale to output paths
283
330
  */
284
331
  export async function buildLocalizedContent(siteRoot, options = {}) {
@@ -287,7 +334,8 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
287
334
  locales = [],
288
335
  outputDir = join(siteRoot, 'dist'),
289
336
  fallbackToSource = true,
290
- generateSearchIndexes = true
337
+ generateSearchIndexes = true,
338
+ freeformEnabled = true
291
339
  } = options
292
340
 
293
341
  const localesPath = join(siteRoot, localesDir)
@@ -309,10 +357,29 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
309
357
  translations = JSON.parse(localeRaw)
310
358
  }
311
359
 
312
- // Merge translations
313
- const translated = mergeTranslations(siteContent, translations, {
314
- fallbackToSource
315
- })
360
+ // Check if free-form translations exist for this locale
361
+ const freeformDir = join(localesPath, 'freeform', locale)
362
+ const hasFreeform = freeformEnabled && existsSync(freeformDir)
363
+
364
+ // Merge translations (with free-form support if enabled)
365
+ let translated
366
+ if (hasFreeform) {
367
+ // Use async merge with free-form support
368
+ translated = await mergeTranslations(siteContent, translations, {
369
+ fallbackToSource,
370
+ locale,
371
+ localesDir: localesPath,
372
+ freeformEnabled: true
373
+ })
374
+
375
+ // Check for stale/orphaned free-form translations and warn
376
+ await warnAboutFreeformIssues(locale, freeformDir, siteContent)
377
+ } else {
378
+ // Use sync merge (original behavior)
379
+ translated = mergeTranslations(siteContent, translations, {
380
+ fallbackToSource
381
+ })
382
+ }
316
383
 
317
384
  // Mark the active locale in the translated content
318
385
  translated.config = { ...translated.config, activeLocale: locale }
@@ -346,6 +413,63 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
346
413
  return outputs
347
414
  }
348
415
 
416
+ /**
417
+ * Check for stale/orphaned free-form translations and emit warnings
418
+ * @param {string} locale - Locale code
419
+ * @param {string} freeformDir - Path to locale's freeform directory
420
+ * @param {Object} siteContent - Site content for building source hashes
421
+ */
422
+ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
423
+ try {
424
+ // Build map of valid source hashes
425
+ const sourceHashes = {}
426
+ const validPaths = new Set()
427
+
428
+ for (const page of siteContent.pages || []) {
429
+ for (const section of page.sections || []) {
430
+ if (section.stableId) {
431
+ const path = buildFreeformPath(section, page)
432
+ if (path) {
433
+ validPaths.add(path)
434
+ // Compute source hash for staleness check
435
+ if (section.content) {
436
+ sourceHashes[path] = computeSourceHash(section.content)
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ // Check for stale translations
444
+ const stale = await getStaleTranslations(freeformDir, sourceHashes)
445
+ for (const item of stale) {
446
+ console.warn(`[i18n] Free-form translation stale: ${locale}/${item.path} (source changed ${item.recordedDate})`)
447
+ }
448
+
449
+ // Check for orphaned translations
450
+ const orphaned = await getOrphanedTranslations(freeformDir, validPaths)
451
+ for (const item of orphaned) {
452
+ console.warn(`[i18n] Free-form translation orphaned: ${locale}/${item.path}`)
453
+ }
454
+
455
+ // Check for unregistered translations (new files)
456
+ const discovered = await discoverFreeformTranslations(locale, dirname(dirname(freeformDir)))
457
+ const allPaths = [...discovered.pages, ...discovered.pageIds, ...discovered.collections]
458
+ const unregistered = await getUnregisteredTranslations(freeformDir, allPaths)
459
+ for (const path of unregistered) {
460
+ // Auto-register new free-form translations
461
+ const sourcePath = validPaths.has(path) ? path : null
462
+ if (sourcePath && sourceHashes[sourcePath]) {
463
+ await recordHash(freeformDir, path, sourceHashes[sourcePath])
464
+ console.log(`[i18n] Free-form translation registered: ${locale}/${path} (new file)`)
465
+ }
466
+ }
467
+ } catch (err) {
468
+ // Non-fatal: just log and continue
469
+ console.warn(`[i18n] Could not check free-form translation status for ${locale}: ${err.message}`)
470
+ }
471
+ }
472
+
349
473
  /**
350
474
  * Format translation status for console output
351
475
  * @param {Object} status - Status from getTranslationStatus
package/src/i18n/merge.js CHANGED
@@ -3,20 +3,55 @@
3
3
  *
4
4
  * Takes site-content.json and locale translations,
5
5
  * produces translated site-content for each locale.
6
+ *
7
+ * Supports two translation modes:
8
+ * 1. Hash-based (granular): Translate individual strings by hash lookup
9
+ * 2. Free-form: Complete content replacement from markdown files
10
+ *
11
+ * Free-form translations are checked first, falling back to hash-based
12
+ * when no free-form translation exists.
6
13
  */
7
14
 
8
15
  import { computeHash } from './hash.js'
16
+ import { loadFreeformTranslation } from './freeform.js'
9
17
 
10
18
  /**
11
19
  * Merge translations into site content for a specific locale
20
+ *
12
21
  * @param {Object} siteContent - Original site-content.json
13
22
  * @param {Object} translations - Locale translations (hash -> translation)
14
23
  * @param {Object} options - Merge options
15
- * @returns {Object} Translated site content
24
+ * @param {boolean} [options.fallbackToSource=true] - Return source if no translation
25
+ * @param {string} [options.locale] - Locale code for free-form lookups
26
+ * @param {string} [options.localesDir] - Path to locales directory
27
+ * @param {boolean} [options.freeformEnabled=false] - Enable free-form translation lookup
28
+ * @returns {Object|Promise<Object>} Translated site content (async if freeformEnabled)
16
29
  */
17
30
  export function mergeTranslations(siteContent, translations, options = {}) {
18
- const { fallbackToSource = true } = options
31
+ const {
32
+ fallbackToSource = true,
33
+ locale = null,
34
+ localesDir = null,
35
+ freeformEnabled = false
36
+ } = options
19
37
 
38
+ // If free-form is enabled, use async version
39
+ if (freeformEnabled && locale && localesDir) {
40
+ return mergeTranslationsAsync(siteContent, translations, {
41
+ fallbackToSource,
42
+ locale,
43
+ localesDir
44
+ })
45
+ }
46
+
47
+ // Sync version (original behavior)
48
+ return mergeTranslationsSync(siteContent, translations, fallbackToSource)
49
+ }
50
+
51
+ /**
52
+ * Synchronous merge (original behavior, no free-form)
53
+ */
54
+ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
20
55
  // Deep clone to avoid mutating original
21
56
  const translated = JSON.parse(JSON.stringify(siteContent))
22
57
 
@@ -28,7 +63,35 @@ export function mergeTranslations(siteContent, translations, options = {}) {
28
63
 
29
64
  // Translate section content
30
65
  for (const section of page.sections || []) {
31
- translateSection(section, pageRoute, translations, fallbackToSource)
66
+ translateSectionSync(section, pageRoute, translations, fallbackToSource)
67
+ }
68
+ }
69
+
70
+ return translated
71
+ }
72
+
73
+ /**
74
+ * Asynchronous merge with free-form support
75
+ */
76
+ async function mergeTranslationsAsync(siteContent, translations, options) {
77
+ const { fallbackToSource, locale, localesDir } = options
78
+
79
+ // Deep clone to avoid mutating original
80
+ const translated = JSON.parse(JSON.stringify(siteContent))
81
+
82
+ for (const page of translated.pages || []) {
83
+ const pageRoute = page.route || '/'
84
+
85
+ // Translate page metadata (always hash-based)
86
+ translatePageMeta(page, pageRoute, translations, fallbackToSource)
87
+
88
+ // Translate section content (with free-form check)
89
+ for (const section of page.sections || []) {
90
+ await translateSectionAsync(section, page, translations, {
91
+ fallbackToSource,
92
+ locale,
93
+ localesDir
94
+ })
32
95
  }
33
96
  }
34
97
 
@@ -77,9 +140,9 @@ function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
77
140
  }
78
141
 
79
142
  /**
80
- * Translate a section's content
143
+ * Translate a section's content (synchronous, hash-based only)
81
144
  */
82
- function translateSection(section, pageRoute, translations, fallbackToSource) {
145
+ function translateSectionSync(section, pageRoute, translations, fallbackToSource) {
83
146
  const sectionId = section.id || 'unknown'
84
147
  const context = { page: pageRoute, section: sectionId }
85
148
 
@@ -89,7 +152,40 @@ function translateSection(section, pageRoute, translations, fallbackToSource) {
89
152
 
90
153
  // Recursively translate subsections
91
154
  for (const subsection of section.subsections || []) {
92
- translateSection(subsection, pageRoute, translations, fallbackToSource)
155
+ translateSectionSync(subsection, pageRoute, translations, fallbackToSource)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Translate a section's content (async, with free-form check)
161
+ *
162
+ * Resolution order:
163
+ * 1. Check for free-form translation (complete replacement)
164
+ * 2. Fall back to hash-based translation (element-by-element)
165
+ */
166
+ async function translateSectionAsync(section, page, translations, options) {
167
+ const { fallbackToSource, locale, localesDir } = options
168
+ const pageRoute = page.route || '/'
169
+ const sectionId = section.id || 'unknown'
170
+ const context = { page: pageRoute, section: sectionId }
171
+
172
+ // Check for free-form translation first
173
+ const freeform = await loadFreeformTranslation(section, page, locale, localesDir)
174
+
175
+ if (freeform) {
176
+ // Complete content replacement
177
+ section.content = freeform.content
178
+ // Note: We could also merge frontmatter into params here if needed
179
+ } else {
180
+ // Fall back to hash-based translation
181
+ if (section.content?.type === 'doc') {
182
+ translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
183
+ }
184
+ }
185
+
186
+ // Recursively translate subsections
187
+ for (const subsection of section.subsections || []) {
188
+ await translateSectionAsync(subsection, page, translations, options)
93
189
  }
94
190
  }
95
191