@uniweb/build 0.3.1 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,8 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/content-reader": "1.0.7",
54
- "@uniweb/runtime": "0.4.2"
53
+ "@uniweb/content-reader": "1.1.0",
54
+ "@uniweb/runtime": "0.4.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.2.4"
63
+ "@uniweb/core": "0.3.0"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
@@ -9,6 +9,7 @@ import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
9
9
  import { existsSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { computeHash } from './hash.js'
12
+ import { loadFreeformCollectionItem } from './freeform.js'
12
13
 
13
14
  export const COLLECTIONS_DIR = 'collections'
14
15
 
@@ -198,13 +199,16 @@ function addUnit(units, source, field, context) {
198
199
  * Merge translations into collection data and write locale-specific files
199
200
  * @param {string} siteRoot - Site root directory
200
201
  * @param {Object} options - Options
202
+ * @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
201
203
  * @returns {Promise<Object>} Map of locale to output paths
202
204
  */
203
205
  export async function buildLocalizedCollections(siteRoot, options = {}) {
204
206
  const {
205
207
  locales = [],
206
208
  outputDir = join(siteRoot, 'dist'),
207
- collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR)
209
+ collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR),
210
+ localesDir = join(siteRoot, 'locales'),
211
+ freeformEnabled = true
208
212
  } = options
209
213
 
210
214
  const dataDir = join(siteRoot, 'public', 'data')
@@ -240,6 +244,10 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
240
244
  }
241
245
  }
242
246
 
247
+ // Check if free-form translations exist for this locale
248
+ const freeformDir = join(localesDir, 'freeform', locale)
249
+ const hasFreeform = freeformEnabled && existsSync(freeformDir)
250
+
243
251
  // Create locale data directory
244
252
  const localeDataDir = join(outputDir, locale, 'data')
245
253
  await mkdir(localeDataDir, { recursive: true })
@@ -262,9 +270,15 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
262
270
  continue
263
271
  }
264
272
 
265
- // Translate each item
266
- const translatedItems = items.map(item =>
267
- translateItem(item, collectionName, translations)
273
+ // Translate each item (with free-form support)
274
+ const translatedItems = await Promise.all(
275
+ items.map(item =>
276
+ translateItemAsync(item, collectionName, translations, {
277
+ locale,
278
+ localesDir,
279
+ freeformEnabled: hasFreeform
280
+ })
281
+ )
268
282
  )
269
283
 
270
284
  const destPath = join(localeDataDir, file)
@@ -280,13 +294,69 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
280
294
  }
281
295
 
282
296
  /**
283
- * Apply translations to a collection item
297
+ * Apply translations to a collection item (async, with free-form support)
298
+ *
299
+ * Resolution order:
300
+ * 1. Check for free-form translation (complete or partial replacement)
301
+ * 2. Fall back to hash-based translation
302
+ *
303
+ * @param {Object} item - Collection item
304
+ * @param {string} collectionName - Name of the collection
305
+ * @param {Object} translations - Hash-based translations
306
+ * @param {Object} options - Options
307
+ * @returns {Promise<Object>} Translated item
284
308
  */
285
- function translateItem(item, collectionName, translations) {
309
+ async function translateItemAsync(item, collectionName, translations, options = {}) {
310
+ const { locale, localesDir, freeformEnabled } = options
286
311
  const translated = { ...item }
287
312
  const slug = item.slug || 'unknown'
288
313
  const context = { collection: collectionName, item: slug }
289
314
 
315
+ // Check for free-form translation first
316
+ if (freeformEnabled && locale && localesDir) {
317
+ const freeform = await loadFreeformCollectionItem(item, collectionName, locale, localesDir)
318
+
319
+ if (freeform) {
320
+ // Merge free-form data (supports partial: frontmatter only, body only, or both)
321
+ if (freeform.frontmatter) {
322
+ // Merge frontmatter fields from free-form
323
+ Object.assign(translated, freeform.frontmatter)
324
+ }
325
+ if (freeform.content) {
326
+ // Replace content entirely with free-form content
327
+ translated.content = freeform.content
328
+ // Skip hash-based content translation since we have free-form
329
+ return translateItemFields(translated, context, translations)
330
+ }
331
+ }
332
+ }
333
+
334
+ // Fall back to hash-based translation (or continue after partial free-form)
335
+ return translateItemSync(translated, collectionName, translations)
336
+ }
337
+
338
+ /**
339
+ * Apply translations to a collection item (sync, hash-based only)
340
+ */
341
+ function translateItemSync(item, collectionName, translations) {
342
+ const translated = { ...item }
343
+ const slug = item.slug || 'unknown'
344
+ const context = { collection: collectionName, item: slug }
345
+
346
+ // Translate all fields including content
347
+ return translateItemFields(translated, context, translations, true)
348
+ }
349
+
350
+ /**
351
+ * Translate item fields (frontmatter and optionally content)
352
+ *
353
+ * @param {Object} translated - Item being translated
354
+ * @param {Object} context - Translation context
355
+ * @param {Object} translations - Hash-based translations
356
+ * @param {boolean} includeContent - Whether to translate ProseMirror content
357
+ * @returns {Object} Translated item
358
+ */
359
+ function translateItemFields(translated, context, translations, includeContent = false) {
290
360
  // Translate frontmatter fields
291
361
  const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
292
362
 
@@ -320,8 +390,8 @@ function translateItem(item, collectionName, translations) {
320
390
  })
321
391
  }
322
392
 
323
- // Translate ProseMirror content
324
- if (translated.content?.type === 'doc') {
393
+ // Translate ProseMirror content (only if requested)
394
+ if (includeContent && translated.content?.type === 'doc') {
325
395
  translated.content = translateProseMirrorDoc(
326
396
  translated.content,
327
397
  context,
@@ -332,6 +402,14 @@ function translateItem(item, collectionName, translations) {
332
402
  return translated
333
403
  }
334
404
 
405
+ /**
406
+ * Apply translations to a collection item (legacy sync function)
407
+ * @deprecated Use translateItemAsync for free-form support
408
+ */
409
+ function translateItem(item, collectionName, translations) {
410
+ return translateItemSync(item, collectionName, translations)
411
+ }
412
+
335
413
  /**
336
414
  * Translate a ProseMirror document
337
415
  */
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Free-form Translation Manifest Management
3
+ *
4
+ * Tracks source content hashes for free-form translations to detect
5
+ * when source content changes (staleness). The manifest lives at:
6
+ * locales/freeform/{locale}/.manifest.json
7
+ *
8
+ * Manifest format:
9
+ * {
10
+ * "pages/about/hero.md": { "hash": "a1b2c3d4", "recorded": "2025-01-28" },
11
+ * "page-ids/installation/intro.md": { "hash": "b2c3d4e5", "recorded": "2025-01-25" },
12
+ * "collections/articles/getting-started.md": { "hash": "c3d4e5f6", "recorded": "2025-01-27" }
13
+ * }
14
+ */
15
+
16
+ import { readFile, writeFile, mkdir } from 'fs/promises'
17
+ import { existsSync } from 'fs'
18
+ import { join, dirname } from 'path'
19
+ import { createHash } from 'crypto'
20
+
21
+ const MANIFEST_FILENAME = '.manifest.json'
22
+
23
+ /**
24
+ * Compute a hash of a ProseMirror document for staleness detection
25
+ *
26
+ * Extracts text content and computes a hash that changes when
27
+ * the content changes (ignoring formatting-only changes).
28
+ *
29
+ * @param {Object} doc - ProseMirror document
30
+ * @returns {string} 8-character hex hash
31
+ */
32
+ export function computeSourceHash(doc) {
33
+ if (!doc) return ''
34
+
35
+ // Extract all text content from the document
36
+ const textContent = extractTextFromDoc(doc)
37
+
38
+ // Hash the normalized text
39
+ return createHash('sha256')
40
+ .update(textContent)
41
+ .digest('hex')
42
+ .slice(0, 8)
43
+ }
44
+
45
+ /**
46
+ * Recursively extract text from a ProseMirror document
47
+ * @param {Object} node - ProseMirror node
48
+ * @returns {string} Concatenated text content
49
+ */
50
+ function extractTextFromDoc(node) {
51
+ if (!node) return ''
52
+
53
+ if (node.type === 'text') {
54
+ return node.text || ''
55
+ }
56
+
57
+ if (!node.content || !Array.isArray(node.content)) {
58
+ return ''
59
+ }
60
+
61
+ return node.content.map(extractTextFromDoc).join(' ')
62
+ }
63
+
64
+ /**
65
+ * Get the manifest path for a locale
66
+ * @param {string} localeDir - Path to locale's freeform directory
67
+ * @returns {string} Path to manifest file
68
+ */
69
+ function getManifestPath(localeDir) {
70
+ return join(localeDir, MANIFEST_FILENAME)
71
+ }
72
+
73
+ /**
74
+ * Load the manifest for a locale
75
+ *
76
+ * @param {string} localeDir - Path to locale's freeform directory (locales/freeform/{locale})
77
+ * @returns {Promise<Object>} Manifest object (empty if not found)
78
+ */
79
+ export async function loadManifest(localeDir) {
80
+ const manifestPath = getManifestPath(localeDir)
81
+
82
+ if (!existsSync(manifestPath)) {
83
+ return {}
84
+ }
85
+
86
+ try {
87
+ const content = await readFile(manifestPath, 'utf-8')
88
+ return JSON.parse(content)
89
+ } catch (err) {
90
+ console.warn(`[i18n] Failed to load manifest ${manifestPath}: ${err.message}`)
91
+ return {}
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Save the manifest for a locale
97
+ *
98
+ * @param {string} localeDir - Path to locale's freeform directory
99
+ * @param {Object} manifest - Manifest object to save
100
+ */
101
+ export async function saveManifest(localeDir, manifest) {
102
+ const manifestPath = getManifestPath(localeDir)
103
+
104
+ // Ensure directory exists
105
+ if (!existsSync(localeDir)) {
106
+ await mkdir(localeDir, { recursive: true })
107
+ }
108
+
109
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
110
+ }
111
+
112
+ /**
113
+ * Record a source hash in the manifest
114
+ *
115
+ * Called when a new free-form translation is created to record
116
+ * the hash of the source content at that time.
117
+ *
118
+ * @param {string} localeDir - Path to locale's freeform directory
119
+ * @param {string} relativePath - Path relative to locale dir (e.g., 'pages/about/hero.md')
120
+ * @param {string} sourceHash - Hash of the source content
121
+ * @returns {Promise<void>}
122
+ */
123
+ export async function recordHash(localeDir, relativePath, sourceHash) {
124
+ const manifest = await loadManifest(localeDir)
125
+
126
+ manifest[relativePath] = {
127
+ hash: sourceHash,
128
+ recorded: new Date().toISOString().split('T')[0] // YYYY-MM-DD
129
+ }
130
+
131
+ await saveManifest(localeDir, manifest)
132
+ }
133
+
134
+ /**
135
+ * Check if a translation is stale (source content changed)
136
+ *
137
+ * @param {string} localeDir - Path to locale's freeform directory
138
+ * @param {string} relativePath - Path relative to locale dir
139
+ * @param {string} currentSourceHash - Current hash of source content
140
+ * @returns {Promise<Object>} { isStale, recordedHash, recordedDate, currentHash }
141
+ */
142
+ export async function checkStaleness(localeDir, relativePath, currentSourceHash) {
143
+ const manifest = await loadManifest(localeDir)
144
+ const entry = manifest[relativePath]
145
+
146
+ if (!entry) {
147
+ // No recorded hash - translation was never registered
148
+ return {
149
+ isStale: false, // Not stale, just unregistered
150
+ isNew: true,
151
+ recordedHash: null,
152
+ recordedDate: null,
153
+ currentHash: currentSourceHash
154
+ }
155
+ }
156
+
157
+ const isStale = entry.hash !== currentSourceHash
158
+
159
+ return {
160
+ isStale,
161
+ isNew: false,
162
+ recordedHash: entry.hash,
163
+ recordedDate: entry.recorded,
164
+ currentHash: currentSourceHash
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Update the hash for a translation (after translator reviews changes)
170
+ *
171
+ * @param {string} localeDir - Path to locale's freeform directory
172
+ * @param {string} relativePath - Path relative to locale dir
173
+ * @param {string} newHash - New hash to record
174
+ * @returns {Promise<void>}
175
+ */
176
+ export async function updateHash(localeDir, relativePath, newHash) {
177
+ return recordHash(localeDir, relativePath, newHash)
178
+ }
179
+
180
+ /**
181
+ * Remove entries from the manifest
182
+ *
183
+ * Used when translations are deleted.
184
+ *
185
+ * @param {string} localeDir - Path to locale's freeform directory
186
+ * @param {string[]} paths - Paths to remove
187
+ * @returns {Promise<number>} Number of entries removed
188
+ */
189
+ export async function removeManifestEntries(localeDir, paths) {
190
+ const manifest = await loadManifest(localeDir)
191
+ let removed = 0
192
+
193
+ for (const path of paths) {
194
+ if (manifest[path]) {
195
+ delete manifest[path]
196
+ removed++
197
+ }
198
+ }
199
+
200
+ if (removed > 0) {
201
+ await saveManifest(localeDir, manifest)
202
+ }
203
+
204
+ return removed
205
+ }
206
+
207
+ /**
208
+ * Rename entries in the manifest
209
+ *
210
+ * Used when translations are moved/renamed.
211
+ *
212
+ * @param {string} localeDir - Path to locale's freeform directory
213
+ * @param {string[]} oldPaths - Old paths
214
+ * @param {string[]} newPaths - New paths (same order as oldPaths)
215
+ * @returns {Promise<number>} Number of entries renamed
216
+ */
217
+ export async function renameManifestEntries(localeDir, oldPaths, newPaths) {
218
+ if (oldPaths.length !== newPaths.length) {
219
+ throw new Error('oldPaths and newPaths must have the same length')
220
+ }
221
+
222
+ const manifest = await loadManifest(localeDir)
223
+ let renamed = 0
224
+
225
+ for (let i = 0; i < oldPaths.length; i++) {
226
+ const oldPath = oldPaths[i]
227
+ const newPath = newPaths[i]
228
+
229
+ if (manifest[oldPath]) {
230
+ manifest[newPath] = manifest[oldPath]
231
+ delete manifest[oldPath]
232
+ renamed++
233
+ }
234
+ }
235
+
236
+ if (renamed > 0) {
237
+ await saveManifest(localeDir, manifest)
238
+ }
239
+
240
+ return renamed
241
+ }
242
+
243
+ /**
244
+ * Get all stale translations for a locale
245
+ *
246
+ * Compares manifest hashes against current source content.
247
+ *
248
+ * @param {string} localeDir - Path to locale's freeform directory
249
+ * @param {Object} sourceHashes - Map of relativePath → currentHash
250
+ * @returns {Promise<Object[]>} Array of { path, recordedHash, recordedDate, currentHash }
251
+ */
252
+ export async function getStaleTranslations(localeDir, sourceHashes) {
253
+ const manifest = await loadManifest(localeDir)
254
+ const stale = []
255
+
256
+ for (const [path, entry] of Object.entries(manifest)) {
257
+ const currentHash = sourceHashes[path]
258
+
259
+ // If we have a current hash and it differs, it's stale
260
+ if (currentHash && currentHash !== entry.hash) {
261
+ stale.push({
262
+ path,
263
+ recordedHash: entry.hash,
264
+ recordedDate: entry.recorded,
265
+ currentHash
266
+ })
267
+ }
268
+ }
269
+
270
+ return stale
271
+ }
272
+
273
+ /**
274
+ * Get orphaned translations (translations without source)
275
+ *
276
+ * @param {string} localeDir - Path to locale's freeform directory
277
+ * @param {Set<string>} validPaths - Set of valid translation paths that have source content
278
+ * @returns {Promise<Object[]>} Array of { path, recordedHash, recordedDate }
279
+ */
280
+ export async function getOrphanedTranslations(localeDir, validPaths) {
281
+ const manifest = await loadManifest(localeDir)
282
+ const orphaned = []
283
+
284
+ for (const [path, entry] of Object.entries(manifest)) {
285
+ if (!validPaths.has(path)) {
286
+ orphaned.push({
287
+ path,
288
+ recordedHash: entry.hash,
289
+ recordedDate: entry.recorded
290
+ })
291
+ }
292
+ }
293
+
294
+ return orphaned
295
+ }
296
+
297
+ /**
298
+ * Get unregistered translations (translations without manifest entry)
299
+ *
300
+ * @param {string} localeDir - Path to locale's freeform directory
301
+ * @param {string[]} translationPaths - All translation file paths found
302
+ * @returns {Promise<string[]>} Paths that exist as files but not in manifest
303
+ */
304
+ export async function getUnregisteredTranslations(localeDir, translationPaths) {
305
+ const manifest = await loadManifest(localeDir)
306
+ return translationPaths.filter(path => !manifest[path])
307
+ }
@@ -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
 
package/src/prerender.js CHANGED
@@ -301,8 +301,7 @@ function renderBlock(block) {
301
301
  const componentProps = {
302
302
  content,
303
303
  params,
304
- block,
305
- input: block.input
304
+ block
306
305
  }
307
306
 
308
307
  // Wrapper props
@@ -523,7 +523,6 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
523
523
  title: pageConfig.title || pageName,
524
524
  description: pageConfig.description || '',
525
525
  label: pageConfig.label || null, // Short label for navigation (defaults to title)
526
- order: pageConfig.order,
527
526
  lastModified: lastModified?.toISOString(),
528
527
 
529
528
  // Dynamic route metadata
@@ -599,8 +598,9 @@ function determineIndexPage(orderConfig, availableFolders) {
599
598
 
600
599
  const sorted = [...staticFolders].sort((a, b) => {
601
600
  // Sort by order (lower first), then alphabetically
602
- const orderA = a.order ?? 999
603
- const orderB = b.order ?? 999
601
+ // Pages without explicit order come after ordered pages
602
+ const orderA = a.order ?? Infinity
603
+ const orderB = b.order ?? Infinity
604
604
  if (orderA !== orderB) return orderA - orderB
605
605
  return a.name.localeCompare(b.name)
606
606
  })
@@ -655,6 +655,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
655
655
  })
656
656
  }
657
657
 
658
+ // Sort page folders by order (ascending), then alphabetically
659
+ // Pages without explicit order come after ordered pages (order ?? Infinity)
660
+ pageFolders.sort((a, b) => {
661
+ const orderA = a.order ?? Infinity
662
+ const orderB = b.order ?? Infinity
663
+ if (orderA !== orderB) return orderA - orderB
664
+ return a.name.localeCompare(b.name)
665
+ })
666
+
658
667
  // Check if this directory contains version folders (versioned section)
659
668
  const folderNames = pageFolders.map(f => f.name)
660
669
  const detectedVersions = detectVersions(folderNames)
@@ -93,16 +93,20 @@ async function processDevSectionFetches(sections, cascadedData, fetchOptions) {
93
93
  if (sectionFetch) {
94
94
  const result = await executeFetch(sectionFetch, fetchOptions)
95
95
  if (result.data && !result.error) {
96
- // Merge fetched data into cascadedData for this section
97
- cascadedData = {
98
- ...cascadedData,
99
- [sectionFetch.schema]: result.data
100
- }
96
+ // Merge fetched data into section's parsedContent (not cascadedData)
97
+ // This matches prerender behavior - section's own fetch goes to content.data
98
+ section.parsedContent = mergeDataIntoContent(
99
+ section.parsedContent || {},
100
+ result.data,
101
+ sectionFetch.schema,
102
+ sectionFetch.merge
103
+ )
101
104
  }
102
105
  }
103
106
 
104
- // Attach cascaded data to section
105
- section.cascadedData = { ...cascadedData }
107
+ // Attach cascaded data for components with inheritData
108
+ // Note: cascadedData is from page/site level only, not section's own fetch
109
+ section.cascadedData = cascadedData
106
110
 
107
111
  // Process subsections recursively
108
112
  if (section.subsections && section.subsections.length > 0) {
@@ -395,14 +399,30 @@ export function siteContentPlugin(options = {}) {
395
399
 
396
400
  /**
397
401
  * Get translated content for a locale
402
+ *
403
+ * Supports both hash-based (granular) and free-form (complete replacement) translations.
404
+ * Free-form translations are checked first, falling back to hash-based when not found.
398
405
  */
399
406
  async function getTranslatedContent(locale) {
400
407
  if (!siteContent) return null
401
408
 
402
409
  const translations = await loadLocaleTranslations(locale)
403
- if (!translations) return null
404
-
405
- return mergeTranslations(siteContent, translations)
410
+ // Even with no hash-based translations, free-form might exist
411
+ const hasTranslations = translations !== null
412
+
413
+ // Check if free-form translations exist for this locale
414
+ const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
415
+ const hasFreeform = existsSync(freeformDir)
416
+
417
+ // If no translations at all (neither hash-based nor free-form), return null
418
+ if (!hasTranslations && !hasFreeform) return null
419
+
420
+ // Use free-form enabled merge
421
+ return mergeTranslations(siteContent, translations || {}, {
422
+ locale,
423
+ localesDir: join(resolvedSitePath, localesDir),
424
+ freeformEnabled: hasFreeform
425
+ })
406
426
  }
407
427
 
408
428
  return {
@@ -570,6 +590,8 @@ export function siteContentPlugin(options = {}) {
570
590
 
571
591
  // Watch locales directory for translation changes
572
592
  const localesPath = resolve(resolvedSitePath, localesDir)
593
+ const additionalWatchers = []
594
+
573
595
  if (existsSync(localesPath)) {
574
596
  try {
575
597
  const localeWatcher = watch(localesPath, { recursive: false }, () => {
@@ -577,17 +599,35 @@ export function siteContentPlugin(options = {}) {
577
599
  localeTranslations = {}
578
600
  server.ws.send({ type: 'full-reload' })
579
601
  })
580
- if (watcher) {
581
- const originalClose = watcher.close
582
- watcher.close = () => {
583
- originalClose()
584
- localeWatcher.close()
585
- }
586
- }
602
+ additionalWatchers.push(localeWatcher)
587
603
  console.log(`[site-content] Watching ${localesPath} for translation changes`)
588
604
  } catch (err) {
589
605
  // locales dir may not exist, that's ok
590
606
  }
607
+
608
+ // Watch free-form translations directory
609
+ const freeformPath = resolve(localesPath, 'freeform')
610
+ if (existsSync(freeformPath)) {
611
+ try {
612
+ const freeformWatcher = watch(freeformPath, { recursive: true }, () => {
613
+ console.log('[site-content] Free-form translation changed, reloading...')
614
+ server.ws.send({ type: 'full-reload' })
615
+ })
616
+ additionalWatchers.push(freeformWatcher)
617
+ console.log(`[site-content] Watching ${freeformPath} for free-form translation changes`)
618
+ } catch (err) {
619
+ // freeform dir may not exist, that's ok
620
+ }
621
+ }
622
+ }
623
+
624
+ // Add additional watchers to cleanup
625
+ if (additionalWatchers.length > 0 && watcher) {
626
+ const originalClose = watcher.close
627
+ watcher.close = () => {
628
+ originalClose()
629
+ additionalWatchers.forEach(w => w.close())
630
+ }
591
631
  }
592
632
 
593
633
  // Serve content and SEO files