@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.
@@ -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
+ }
@@ -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'