@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 +4 -4
- package/src/i18n/collections.js +86 -8
- package/src/i18n/freeform-manifest.js +307 -0
- package/src/i18n/freeform.js +336 -0
- package/src/i18n/index.js +131 -7
- package/src/i18n/merge.js +102 -6
- package/src/prerender.js +1 -2
- package/src/site/content-collector.js +12 -3
- package/src/site/plugin.js +57 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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
|
|
54
|
-
"@uniweb/runtime": "0.4.
|
|
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.
|
|
63
|
+
"@uniweb/core": "0.3.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependenciesMeta": {
|
|
66
66
|
"vite": {
|
package/src/i18n/collections.js
CHANGED
|
@@ -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 =
|
|
267
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
313
|
-
const
|
|
314
|
-
|
|
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
|
-
* @
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
603
|
-
const
|
|
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)
|
package/src/site/plugin.js
CHANGED
|
@@ -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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|