@uniweb/build 0.1.4 → 0.1.6
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 +11 -4
- package/src/docs.js +217 -0
- package/src/generate-entry.js +40 -1
- package/src/i18n/extract.js +259 -0
- package/src/i18n/hash.js +30 -0
- package/src/i18n/index.js +301 -0
- package/src/i18n/merge.js +192 -0
- package/src/i18n/sync.js +174 -0
- package/src/index.js +6 -0
- package/src/prerender.js +112 -22
- package/src/site/content-collector.js +150 -24
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uniweb/build i18n module
|
|
3
|
+
*
|
|
4
|
+
* Site content internationalization utilities.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { extractManifest, syncManifest, mergeLocale } from '@uniweb/build/i18n'
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile, mkdir, readdir } from 'fs/promises'
|
|
11
|
+
import { existsSync } from 'fs'
|
|
12
|
+
import { join, dirname } from 'path'
|
|
13
|
+
|
|
14
|
+
import { computeHash, normalizeText } from './hash.js'
|
|
15
|
+
import { extractTranslatableContent } from './extract.js'
|
|
16
|
+
import { syncManifests, formatSyncReport } from './sync.js'
|
|
17
|
+
import { mergeTranslations, generateAllLocales } from './merge.js'
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
// Hash utilities
|
|
21
|
+
computeHash,
|
|
22
|
+
normalizeText,
|
|
23
|
+
|
|
24
|
+
// Core functions
|
|
25
|
+
extractTranslatableContent,
|
|
26
|
+
syncManifests,
|
|
27
|
+
formatSyncReport,
|
|
28
|
+
mergeTranslations,
|
|
29
|
+
generateAllLocales,
|
|
30
|
+
|
|
31
|
+
// Locale resolution
|
|
32
|
+
getAvailableLocales,
|
|
33
|
+
resolveLocales
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default paths
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
localesDir: 'locales',
|
|
41
|
+
manifestFile: 'manifest.json',
|
|
42
|
+
memoryFile: '_memory.json'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reserved files in the locales directory (not locale translation files)
|
|
47
|
+
*/
|
|
48
|
+
const RESERVED_FILES = new Set(['manifest.json', '_memory.json'])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get available locales by scanning the locales directory for *.json files
|
|
52
|
+
* @param {string} localesPath - Path to locales directory
|
|
53
|
+
* @returns {Promise<string[]>} Array of locale codes found
|
|
54
|
+
*/
|
|
55
|
+
async function getAvailableLocales(localesPath) {
|
|
56
|
+
if (!existsSync(localesPath)) {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const files = await readdir(localesPath)
|
|
62
|
+
return files
|
|
63
|
+
.filter(f => f.endsWith('.json') && !RESERVED_FILES.has(f))
|
|
64
|
+
.map(f => f.replace('.json', ''))
|
|
65
|
+
.sort()
|
|
66
|
+
} catch {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve locales configuration to actual locale list
|
|
73
|
+
*
|
|
74
|
+
* Handles:
|
|
75
|
+
* - undefined → all available locales (from locales/*.json)
|
|
76
|
+
* - '*' → explicitly all available locales
|
|
77
|
+
* - ['es', 'fr'] → only those specific locales
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]|string|undefined} configLocales - Locales from config
|
|
80
|
+
* @param {string} localesPath - Path to locales directory
|
|
81
|
+
* @returns {Promise<string[]>} Resolved array of locale codes
|
|
82
|
+
*/
|
|
83
|
+
async function resolveLocales(configLocales, localesPath) {
|
|
84
|
+
// Explicit list of locales
|
|
85
|
+
if (Array.isArray(configLocales) && configLocales.length > 0) {
|
|
86
|
+
// Check for '*' in array (e.g., locales: ['*'])
|
|
87
|
+
if (configLocales.includes('*')) {
|
|
88
|
+
return getAvailableLocales(localesPath)
|
|
89
|
+
}
|
|
90
|
+
return configLocales
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// String value '*' means all available
|
|
94
|
+
if (configLocales === '*') {
|
|
95
|
+
return getAvailableLocales(localesPath)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// undefined, null, or empty array → all available
|
|
99
|
+
return getAvailableLocales(localesPath)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract manifest from site content and write to file
|
|
104
|
+
* @param {string} siteRoot - Site root directory
|
|
105
|
+
* @param {Object} options - Options
|
|
106
|
+
* @returns {Object} { manifest, report }
|
|
107
|
+
*/
|
|
108
|
+
export async function extractManifest(siteRoot, options = {}) {
|
|
109
|
+
const {
|
|
110
|
+
localesDir = DEFAULTS.localesDir,
|
|
111
|
+
siteContentPath = join(siteRoot, 'dist', 'site-content.json'),
|
|
112
|
+
verbose = false
|
|
113
|
+
} = options
|
|
114
|
+
|
|
115
|
+
// Load site content
|
|
116
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
117
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
118
|
+
|
|
119
|
+
// Extract translatable content
|
|
120
|
+
const manifest = extractTranslatableContent(siteContent)
|
|
121
|
+
|
|
122
|
+
// Ensure locales directory exists
|
|
123
|
+
const localesPath = join(siteRoot, localesDir)
|
|
124
|
+
if (!existsSync(localesPath)) {
|
|
125
|
+
await mkdir(localesPath, { recursive: true })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Load previous manifest for comparison
|
|
129
|
+
const manifestPath = join(localesPath, DEFAULTS.manifestFile)
|
|
130
|
+
let previousManifest = null
|
|
131
|
+
if (existsSync(manifestPath)) {
|
|
132
|
+
const prevRaw = await readFile(manifestPath, 'utf-8')
|
|
133
|
+
previousManifest = JSON.parse(prevRaw)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Generate sync report
|
|
137
|
+
const report = syncManifests(previousManifest, manifest)
|
|
138
|
+
|
|
139
|
+
// Write new manifest
|
|
140
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
|
141
|
+
|
|
142
|
+
if (verbose) {
|
|
143
|
+
console.log(formatSyncReport(report))
|
|
144
|
+
console.log(`\nManifest written to: ${manifestPath}`)
|
|
145
|
+
console.log(`Total units: ${Object.keys(manifest.units).length}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { manifest, report }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get translation status for all configured locales
|
|
153
|
+
* @param {string} siteRoot - Site root directory
|
|
154
|
+
* @param {Object} options - Options
|
|
155
|
+
* @returns {Object} Status per locale
|
|
156
|
+
*/
|
|
157
|
+
export async function getTranslationStatus(siteRoot, options = {}) {
|
|
158
|
+
const {
|
|
159
|
+
localesDir = DEFAULTS.localesDir,
|
|
160
|
+
locales = []
|
|
161
|
+
} = options
|
|
162
|
+
|
|
163
|
+
const localesPath = join(siteRoot, localesDir)
|
|
164
|
+
const manifestPath = join(localesPath, DEFAULTS.manifestFile)
|
|
165
|
+
|
|
166
|
+
if (!existsSync(manifestPath)) {
|
|
167
|
+
throw new Error('Manifest not found. Run extract first.')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const manifestRaw = await readFile(manifestPath, 'utf-8')
|
|
171
|
+
const manifest = JSON.parse(manifestRaw)
|
|
172
|
+
const totalUnits = Object.keys(manifest.units).length
|
|
173
|
+
|
|
174
|
+
const status = {}
|
|
175
|
+
|
|
176
|
+
for (const locale of locales) {
|
|
177
|
+
const localePath = join(localesPath, `${locale}.json`)
|
|
178
|
+
|
|
179
|
+
if (!existsSync(localePath)) {
|
|
180
|
+
status[locale] = {
|
|
181
|
+
exists: false,
|
|
182
|
+
translated: 0,
|
|
183
|
+
missing: totalUnits,
|
|
184
|
+
coverage: 0
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const localeRaw = await readFile(localePath, 'utf-8')
|
|
190
|
+
const translations = JSON.parse(localeRaw)
|
|
191
|
+
const translatedHashes = new Set(Object.keys(translations))
|
|
192
|
+
|
|
193
|
+
let translated = 0
|
|
194
|
+
let missing = 0
|
|
195
|
+
|
|
196
|
+
for (const hash of Object.keys(manifest.units)) {
|
|
197
|
+
if (translatedHashes.has(hash)) {
|
|
198
|
+
translated++
|
|
199
|
+
} else {
|
|
200
|
+
missing++
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
status[locale] = {
|
|
205
|
+
exists: true,
|
|
206
|
+
translated,
|
|
207
|
+
missing,
|
|
208
|
+
coverage: totalUnits > 0 ? Math.round((translated / totalUnits) * 100) : 100
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
totalUnits,
|
|
214
|
+
locales: status
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build translated site content for all locales
|
|
220
|
+
* @param {string} siteRoot - Site root directory
|
|
221
|
+
* @param {Object} options - Options
|
|
222
|
+
* @returns {Object} Map of locale to output path
|
|
223
|
+
*/
|
|
224
|
+
export async function buildLocalizedContent(siteRoot, options = {}) {
|
|
225
|
+
const {
|
|
226
|
+
localesDir = DEFAULTS.localesDir,
|
|
227
|
+
locales = [],
|
|
228
|
+
outputDir = join(siteRoot, 'dist'),
|
|
229
|
+
fallbackToSource = true
|
|
230
|
+
} = options
|
|
231
|
+
|
|
232
|
+
const localesPath = join(siteRoot, localesDir)
|
|
233
|
+
|
|
234
|
+
// Load source site content
|
|
235
|
+
const siteContentPath = join(outputDir, 'site-content.json')
|
|
236
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
237
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
238
|
+
|
|
239
|
+
const outputs = {}
|
|
240
|
+
|
|
241
|
+
for (const locale of locales) {
|
|
242
|
+
const localePath = join(localesPath, `${locale}.json`)
|
|
243
|
+
|
|
244
|
+
// Load translations (or empty object if not exists)
|
|
245
|
+
let translations = {}
|
|
246
|
+
if (existsSync(localePath)) {
|
|
247
|
+
const localeRaw = await readFile(localePath, 'utf-8')
|
|
248
|
+
translations = JSON.parse(localeRaw)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Merge translations
|
|
252
|
+
const translated = mergeTranslations(siteContent, translations, {
|
|
253
|
+
fallbackToSource
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Write to locale subdirectory
|
|
257
|
+
const localeOutputDir = join(outputDir, locale)
|
|
258
|
+
if (!existsSync(localeOutputDir)) {
|
|
259
|
+
await mkdir(localeOutputDir, { recursive: true })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const outputPath = join(localeOutputDir, 'site-content.json')
|
|
263
|
+
await writeFile(outputPath, JSON.stringify(translated, null, 2))
|
|
264
|
+
|
|
265
|
+
outputs[locale] = outputPath
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return outputs
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Format translation status for console output
|
|
273
|
+
* @param {Object} status - Status from getTranslationStatus
|
|
274
|
+
* @returns {string} Formatted status
|
|
275
|
+
*/
|
|
276
|
+
export function formatTranslationStatus(status) {
|
|
277
|
+
const lines = [`Translation status (${status.totalUnits} total strings):\n`]
|
|
278
|
+
|
|
279
|
+
for (const [locale, info] of Object.entries(status.locales)) {
|
|
280
|
+
if (!info.exists) {
|
|
281
|
+
lines.push(` ${locale}: No translation file`)
|
|
282
|
+
} else {
|
|
283
|
+
const bar = createProgressBar(info.coverage, 20)
|
|
284
|
+
lines.push(` ${locale}: ${bar} ${info.coverage}% (${info.translated}/${status.totalUnits})`)
|
|
285
|
+
if (info.missing > 0) {
|
|
286
|
+
lines.push(` ${info.missing} strings missing`)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.join('\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create ASCII progress bar
|
|
296
|
+
*/
|
|
297
|
+
function createProgressBar(percent, width) {
|
|
298
|
+
const filled = Math.round((percent / 100) * width)
|
|
299
|
+
const empty = width - filled
|
|
300
|
+
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'
|
|
301
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge translations into site content
|
|
3
|
+
*
|
|
4
|
+
* Takes site-content.json and locale translations,
|
|
5
|
+
* produces translated site-content for each locale.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { computeHash } from './hash.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Merge translations into site content for a specific locale
|
|
12
|
+
* @param {Object} siteContent - Original site-content.json
|
|
13
|
+
* @param {Object} translations - Locale translations (hash -> translation)
|
|
14
|
+
* @param {Object} options - Merge options
|
|
15
|
+
* @returns {Object} Translated site content
|
|
16
|
+
*/
|
|
17
|
+
export function mergeTranslations(siteContent, translations, options = {}) {
|
|
18
|
+
const { fallbackToSource = true } = options
|
|
19
|
+
|
|
20
|
+
// Deep clone to avoid mutating original
|
|
21
|
+
const translated = JSON.parse(JSON.stringify(siteContent))
|
|
22
|
+
|
|
23
|
+
for (const page of translated.pages || []) {
|
|
24
|
+
const pageRoute = page.route || '/'
|
|
25
|
+
|
|
26
|
+
// Translate page metadata
|
|
27
|
+
translatePageMeta(page, pageRoute, translations, fallbackToSource)
|
|
28
|
+
|
|
29
|
+
// Translate section content
|
|
30
|
+
for (const section of page.sections || []) {
|
|
31
|
+
translateSection(section, pageRoute, translations, fallbackToSource)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return translated
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate page metadata (title, description, keywords, etc.)
|
|
40
|
+
*/
|
|
41
|
+
function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
|
|
42
|
+
const context = { page: pageRoute, section: '_meta' }
|
|
43
|
+
|
|
44
|
+
// Translate title
|
|
45
|
+
if (page.title && typeof page.title === 'string') {
|
|
46
|
+
page.title = lookupTranslation(page.title, context, translations, fallbackToSource)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Translate description
|
|
50
|
+
if (page.description && typeof page.description === 'string') {
|
|
51
|
+
page.description = lookupTranslation(page.description, context, translations, fallbackToSource)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Translate SEO fields
|
|
55
|
+
if (page.seo) {
|
|
56
|
+
if (page.seo.ogTitle && typeof page.seo.ogTitle === 'string') {
|
|
57
|
+
page.seo.ogTitle = lookupTranslation(page.seo.ogTitle, context, translations, fallbackToSource)
|
|
58
|
+
}
|
|
59
|
+
if (page.seo.ogDescription && typeof page.seo.ogDescription === 'string') {
|
|
60
|
+
page.seo.ogDescription = lookupTranslation(page.seo.ogDescription, context, translations, fallbackToSource)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Translate keywords
|
|
65
|
+
if (page.keywords) {
|
|
66
|
+
if (Array.isArray(page.keywords)) {
|
|
67
|
+
page.keywords = page.keywords.map(keyword => {
|
|
68
|
+
if (keyword && typeof keyword === 'string') {
|
|
69
|
+
return lookupTranslation(keyword, context, translations, fallbackToSource)
|
|
70
|
+
}
|
|
71
|
+
return keyword
|
|
72
|
+
})
|
|
73
|
+
} else if (typeof page.keywords === 'string') {
|
|
74
|
+
page.keywords = lookupTranslation(page.keywords, context, translations, fallbackToSource)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Translate a section's content
|
|
81
|
+
*/
|
|
82
|
+
function translateSection(section, pageRoute, translations, fallbackToSource) {
|
|
83
|
+
const sectionId = section.id || 'unknown'
|
|
84
|
+
const context = { page: pageRoute, section: sectionId }
|
|
85
|
+
|
|
86
|
+
if (section.content?.type === 'doc') {
|
|
87
|
+
translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recursively translate subsections
|
|
91
|
+
for (const subsection of section.subsections || []) {
|
|
92
|
+
translateSection(subsection, pageRoute, translations, fallbackToSource)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Translate text nodes in a ProseMirror document
|
|
98
|
+
*/
|
|
99
|
+
function translateProseMirrorDoc(doc, context, translations, fallbackToSource) {
|
|
100
|
+
if (!doc.content) return
|
|
101
|
+
|
|
102
|
+
for (const node of doc.content) {
|
|
103
|
+
translateNode(node, context, translations, fallbackToSource)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Recursively translate a node and its children
|
|
109
|
+
*/
|
|
110
|
+
function translateNode(node, context, translations, fallbackToSource) {
|
|
111
|
+
// Translate text content
|
|
112
|
+
if (node.content) {
|
|
113
|
+
for (const child of node.content) {
|
|
114
|
+
if (child.type === 'text' && child.text) {
|
|
115
|
+
const translated = lookupTranslation(
|
|
116
|
+
child.text,
|
|
117
|
+
context,
|
|
118
|
+
translations,
|
|
119
|
+
fallbackToSource
|
|
120
|
+
)
|
|
121
|
+
if (translated !== child.text) {
|
|
122
|
+
child.text = translated
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Recurse into child nodes
|
|
126
|
+
translateNode(child, context, translations, fallbackToSource)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Look up translation for a piece of text
|
|
134
|
+
* @param {string} source - Source text
|
|
135
|
+
* @param {Object} context - Current context (page, section)
|
|
136
|
+
* @param {Object} translations - Translation map
|
|
137
|
+
* @param {boolean} fallbackToSource - Return source if no translation
|
|
138
|
+
* @returns {string} Translated text or source
|
|
139
|
+
*/
|
|
140
|
+
function lookupTranslation(source, context, translations, fallbackToSource) {
|
|
141
|
+
const trimmed = source.trim()
|
|
142
|
+
if (!trimmed) return source
|
|
143
|
+
|
|
144
|
+
const hash = computeHash(trimmed)
|
|
145
|
+
const translation = translations[hash]
|
|
146
|
+
|
|
147
|
+
if (!translation) {
|
|
148
|
+
return fallbackToSource ? source : source
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle simple string translation
|
|
152
|
+
if (typeof translation === 'string') {
|
|
153
|
+
// Preserve leading/trailing whitespace from original
|
|
154
|
+
const leadingSpace = source.match(/^\s*/)[0]
|
|
155
|
+
const trailingSpace = source.match(/\s*$/)[0]
|
|
156
|
+
return leadingSpace + translation + trailingSpace
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Handle translation with overrides
|
|
160
|
+
if (typeof translation === 'object') {
|
|
161
|
+
const contextKey = `${context.page}:${context.section}`
|
|
162
|
+
|
|
163
|
+
// Check for context-specific override
|
|
164
|
+
if (translation.overrides?.[contextKey]) {
|
|
165
|
+
return translation.overrides[contextKey]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fall back to default
|
|
169
|
+
if (translation.default) {
|
|
170
|
+
return translation.default
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return fallbackToSource ? source : source
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Generate translated site content for all configured locales
|
|
179
|
+
* @param {Object} siteContent - Original site-content.json
|
|
180
|
+
* @param {Object} localeFiles - Map of locale code to translations
|
|
181
|
+
* @param {Object} options - Merge options
|
|
182
|
+
* @returns {Object} Map of locale code to translated content
|
|
183
|
+
*/
|
|
184
|
+
export function generateAllLocales(siteContent, localeFiles, options = {}) {
|
|
185
|
+
const results = {}
|
|
186
|
+
|
|
187
|
+
for (const [locale, translations] of Object.entries(localeFiles)) {
|
|
188
|
+
results[locale] = mergeTranslations(siteContent, translations, options)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return results
|
|
192
|
+
}
|
package/src/i18n/sync.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync detection for i18n manifests
|
|
3
|
+
*
|
|
4
|
+
* Compares current extracted content with previous manifest
|
|
5
|
+
* to detect moved, changed, new, and removed content.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compare two manifests and generate a sync report
|
|
10
|
+
* @param {Object} previous - Previous manifest (or null if first run)
|
|
11
|
+
* @param {Object} current - Newly extracted manifest
|
|
12
|
+
* @returns {Object} Sync report with changes
|
|
13
|
+
*/
|
|
14
|
+
export function syncManifests(previous, current) {
|
|
15
|
+
const report = {
|
|
16
|
+
unchanged: [],
|
|
17
|
+
moved: [],
|
|
18
|
+
changed: [],
|
|
19
|
+
added: [],
|
|
20
|
+
removed: []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const previousUnits = previous?.units || {}
|
|
24
|
+
const currentUnits = current.units || {}
|
|
25
|
+
|
|
26
|
+
const previousHashes = new Set(Object.keys(previousUnits))
|
|
27
|
+
const currentHashes = new Set(Object.keys(currentUnits))
|
|
28
|
+
|
|
29
|
+
// Check each current unit
|
|
30
|
+
for (const hash of currentHashes) {
|
|
31
|
+
const currentUnit = currentUnits[hash]
|
|
32
|
+
|
|
33
|
+
if (previousHashes.has(hash)) {
|
|
34
|
+
// Hash exists in both - check if contexts changed
|
|
35
|
+
const prevUnit = previousUnits[hash]
|
|
36
|
+
const contextsChanged = !contextsEqual(prevUnit.contexts, currentUnit.contexts)
|
|
37
|
+
|
|
38
|
+
if (contextsChanged) {
|
|
39
|
+
report.moved.push({
|
|
40
|
+
hash,
|
|
41
|
+
source: currentUnit.source,
|
|
42
|
+
previousContexts: prevUnit.contexts,
|
|
43
|
+
currentContexts: currentUnit.contexts
|
|
44
|
+
})
|
|
45
|
+
} else {
|
|
46
|
+
report.unchanged.push({ hash, source: currentUnit.source })
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// New hash - could be new content or changed content
|
|
50
|
+
// Check if any previous context now has this new hash
|
|
51
|
+
const matchingContext = findMatchingContext(currentUnit.contexts, previousUnits)
|
|
52
|
+
|
|
53
|
+
if (matchingContext) {
|
|
54
|
+
// Same context, different hash = content changed
|
|
55
|
+
report.changed.push({
|
|
56
|
+
hash,
|
|
57
|
+
previousHash: matchingContext.hash,
|
|
58
|
+
source: currentUnit.source,
|
|
59
|
+
previousSource: matchingContext.source,
|
|
60
|
+
contexts: currentUnit.contexts
|
|
61
|
+
})
|
|
62
|
+
} else {
|
|
63
|
+
// Completely new content
|
|
64
|
+
report.added.push({
|
|
65
|
+
hash,
|
|
66
|
+
source: currentUnit.source,
|
|
67
|
+
field: currentUnit.field,
|
|
68
|
+
contexts: currentUnit.contexts
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for removed content
|
|
75
|
+
for (const hash of previousHashes) {
|
|
76
|
+
if (!currentHashes.has(hash)) {
|
|
77
|
+
const prevUnit = previousUnits[hash]
|
|
78
|
+
// Only mark as removed if not detected as changed above
|
|
79
|
+
const wasChanged = report.changed.some(c => c.previousHash === hash)
|
|
80
|
+
if (!wasChanged) {
|
|
81
|
+
report.removed.push({
|
|
82
|
+
hash,
|
|
83
|
+
source: prevUnit.source,
|
|
84
|
+
contexts: prevUnit.contexts
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return report
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if two context arrays are equal
|
|
95
|
+
*/
|
|
96
|
+
function contextsEqual(contexts1, contexts2) {
|
|
97
|
+
if (contexts1.length !== contexts2.length) return false
|
|
98
|
+
|
|
99
|
+
const set1 = new Set(contexts1.map(c => `${c.page}:${c.section}`))
|
|
100
|
+
const set2 = new Set(contexts2.map(c => `${c.page}:${c.section}`))
|
|
101
|
+
|
|
102
|
+
if (set1.size !== set2.size) return false
|
|
103
|
+
for (const key of set1) {
|
|
104
|
+
if (!set2.has(key)) return false
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find if any context in the current unit matches a context in previous units
|
|
111
|
+
* Returns the previous unit info if found
|
|
112
|
+
*/
|
|
113
|
+
function findMatchingContext(currentContexts, previousUnits) {
|
|
114
|
+
for (const context of currentContexts) {
|
|
115
|
+
const contextKey = `${context.page}:${context.section}`
|
|
116
|
+
|
|
117
|
+
for (const [hash, unit] of Object.entries(previousUnits)) {
|
|
118
|
+
const hasContext = unit.contexts.some(
|
|
119
|
+
c => `${c.page}:${c.section}` === contextKey
|
|
120
|
+
)
|
|
121
|
+
if (hasContext) {
|
|
122
|
+
return { hash, source: unit.source, contexts: unit.contexts }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Format sync report for console output
|
|
131
|
+
* @param {Object} report - Sync report from syncManifests
|
|
132
|
+
* @returns {string} Formatted report
|
|
133
|
+
*/
|
|
134
|
+
export function formatSyncReport(report) {
|
|
135
|
+
const lines = ['i18n sync results:']
|
|
136
|
+
|
|
137
|
+
if (report.unchanged.length > 0) {
|
|
138
|
+
lines.push(` ✓ ${report.unchanged.length} strings unchanged`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (report.moved.length > 0) {
|
|
142
|
+
lines.push(` ↻ ${report.moved.length} strings moved (contexts updated)`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (report.changed.length > 0) {
|
|
146
|
+
lines.push(` ⚠ ${report.changed.length} strings changed (need re-translation)`)
|
|
147
|
+
for (const item of report.changed.slice(0, 5)) {
|
|
148
|
+
const prevPreview = truncate(item.previousSource, 30)
|
|
149
|
+
const currPreview = truncate(item.source, 30)
|
|
150
|
+
lines.push(` - ${item.previousHash}: "${prevPreview}" → "${currPreview}"`)
|
|
151
|
+
}
|
|
152
|
+
if (report.changed.length > 5) {
|
|
153
|
+
lines.push(` ... and ${report.changed.length - 5} more`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (report.added.length > 0) {
|
|
158
|
+
lines.push(` + ${report.added.length} new strings`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (report.removed.length > 0) {
|
|
162
|
+
lines.push(` - ${report.removed.length} strings removed`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return lines.join('\n')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Truncate string for display
|
|
170
|
+
*/
|
|
171
|
+
function truncate(str, maxLength) {
|
|
172
|
+
if (str.length <= maxLength) return str
|
|
173
|
+
return str.slice(0, maxLength - 3) + '...'
|
|
174
|
+
}
|
package/src/index.js
CHANGED
|
@@ -40,5 +40,11 @@ export {
|
|
|
40
40
|
prerenderSite,
|
|
41
41
|
} from './prerender.js'
|
|
42
42
|
|
|
43
|
+
// Documentation generation
|
|
44
|
+
export {
|
|
45
|
+
generateDocs,
|
|
46
|
+
generateDocsFromSchema,
|
|
47
|
+
} from './docs.js'
|
|
48
|
+
|
|
43
49
|
// Default export is the combined Vite plugin
|
|
44
50
|
export { default } from './vite-foundation-plugin.js'
|