@uniweb/build 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/site/content-collector.js +45 -12
- package/src/site/icons.js +180 -0
- package/src/site/plugin.js +46 -10
|
@@ -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
|
|