@uniweb/build 0.2.5 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/content-reader": "1.0.7",
54
- "@uniweb/runtime": "0.3.1"
54
+ "@uniweb/runtime": "0.4.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.2.3"
63
+ "@uniweb/core": "0.2.4"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Audit translations for stale and missing entries
3
+ *
4
+ * Compares locale translation files against the manifest to identify:
5
+ * - Valid: translations that match current manifest entries
6
+ * - Missing: manifest entries without translations
7
+ * - Stale: translations for content that no longer exists
8
+ */
9
+
10
+ import { readFile, writeFile } from 'fs/promises'
11
+ import { existsSync } from 'fs'
12
+ import { join } from 'path'
13
+
14
+ /**
15
+ * Audit a locale file against the manifest
16
+ * @param {string} localesPath - Path to locales directory
17
+ * @param {string} locale - Locale code (e.g., 'es')
18
+ * @returns {Promise<Object>} Audit result { locale, exists, total, valid, missing, stale }
19
+ */
20
+ export async function auditLocale(localesPath, locale) {
21
+ const manifestPath = join(localesPath, 'manifest.json')
22
+ const localePath = join(localesPath, `${locale}.json`)
23
+
24
+ if (!existsSync(manifestPath)) {
25
+ throw new Error('Manifest not found. Run "uniweb i18n extract" first.')
26
+ }
27
+
28
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'))
29
+ const manifestHashes = new Set(Object.keys(manifest.units))
30
+
31
+ // Handle missing locale file
32
+ if (!existsSync(localePath)) {
33
+ return {
34
+ locale,
35
+ exists: false,
36
+ total: manifestHashes.size,
37
+ valid: [],
38
+ missing: [...manifestHashes],
39
+ stale: []
40
+ }
41
+ }
42
+
43
+ const translations = JSON.parse(await readFile(localePath, 'utf-8'))
44
+ const translationHashes = new Set(Object.keys(translations))
45
+
46
+ const valid = []
47
+ const missing = []
48
+ const stale = []
49
+
50
+ // Check manifest entries
51
+ for (const hash of manifestHashes) {
52
+ if (translationHashes.has(hash)) {
53
+ valid.push({
54
+ hash,
55
+ source: manifest.units[hash].source,
56
+ translation: getTranslationText(translations[hash])
57
+ })
58
+ } else {
59
+ missing.push({
60
+ hash,
61
+ source: manifest.units[hash].source,
62
+ field: manifest.units[hash].field,
63
+ contexts: manifest.units[hash].contexts
64
+ })
65
+ }
66
+ }
67
+
68
+ // Check for stale entries (in translations but not in manifest)
69
+ for (const hash of translationHashes) {
70
+ if (!manifestHashes.has(hash)) {
71
+ stale.push({
72
+ hash,
73
+ translation: getTranslationText(translations[hash])
74
+ })
75
+ }
76
+ }
77
+
78
+ return {
79
+ locale,
80
+ exists: true,
81
+ total: manifestHashes.size,
82
+ valid,
83
+ missing,
84
+ stale
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get translation text (handles string or object with default/overrides)
90
+ * @param {string|Object} translation - Translation value
91
+ * @returns {string} The translation text
92
+ */
93
+ function getTranslationText(translation) {
94
+ if (typeof translation === 'string') return translation
95
+ if (typeof translation === 'object' && translation !== null) {
96
+ if (translation.default) return translation.default
97
+ }
98
+ return String(translation)
99
+ }
100
+
101
+ /**
102
+ * Remove stale entries from a locale file
103
+ * @param {string} localesPath - Path to locales directory
104
+ * @param {string} locale - Locale code
105
+ * @param {string[]} staleHashes - Hashes to remove
106
+ * @returns {Promise<number>} Number of entries removed
107
+ */
108
+ export async function cleanLocale(localesPath, locale, staleHashes) {
109
+ const localePath = join(localesPath, `${locale}.json`)
110
+
111
+ if (!existsSync(localePath)) {
112
+ return 0
113
+ }
114
+
115
+ const translations = JSON.parse(await readFile(localePath, 'utf-8'))
116
+
117
+ let removed = 0
118
+ for (const hash of staleHashes) {
119
+ if (hash in translations) {
120
+ delete translations[hash]
121
+ removed++
122
+ }
123
+ }
124
+
125
+ if (removed > 0) {
126
+ await writeFile(localePath, JSON.stringify(translations, null, 2))
127
+ }
128
+
129
+ return removed
130
+ }
131
+
132
+ /**
133
+ * Format audit results for console output
134
+ * @param {Object[]} results - Array of audit results from auditLocale
135
+ * @param {Object} options - Formatting options
136
+ * @param {boolean} [options.verbose=false] - Show stale entry details
137
+ * @returns {string} Formatted report
138
+ */
139
+ export function formatAuditReport(results, options = {}) {
140
+ const { verbose = false } = options
141
+ const lines = []
142
+
143
+ for (const result of results) {
144
+ lines.push(`\n${result.locale}:`)
145
+
146
+ if (!result.exists) {
147
+ lines.push(` No translation file`)
148
+ lines.push(` ${result.total} strings need translation`)
149
+ continue
150
+ }
151
+
152
+ const coverage = result.total > 0
153
+ ? Math.round((result.valid.length / result.total) * 100)
154
+ : 100
155
+
156
+ lines.push(` Valid: ${result.valid.length} (${coverage}%)`)
157
+ lines.push(` Missing: ${result.missing.length}`)
158
+ lines.push(` Stale: ${result.stale.length}`)
159
+
160
+ if (verbose && result.stale.length > 0) {
161
+ lines.push(`\n Stale entries:`)
162
+ for (const entry of result.stale.slice(0, 10)) {
163
+ const preview = truncate(entry.translation, 40)
164
+ lines.push(` - ${entry.hash}: "${preview}"`)
165
+ }
166
+ if (result.stale.length > 10) {
167
+ lines.push(` ... and ${result.stale.length - 10} more`)
168
+ }
169
+ }
170
+ }
171
+
172
+ return lines.join('\n')
173
+ }
174
+
175
+ /**
176
+ * Truncate string for display
177
+ * @param {string} str - String to truncate
178
+ * @param {number} maxLen - Maximum length
179
+ * @returns {string} Truncated string
180
+ */
181
+ function truncate(str, maxLen) {
182
+ if (str.length <= maxLen) return str
183
+ return str.slice(0, maxLen - 3) + '...'
184
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Collection i18n support
3
+ *
4
+ * Extract translatable strings from collection items and merge translations.
5
+ * Collections are separate from page content (stored in public/data/*.json).
6
+ */
7
+
8
+ import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
9
+ import { existsSync } from 'fs'
10
+ import { join } from 'path'
11
+ import { computeHash } from './hash.js'
12
+
13
+ export const COLLECTIONS_DIR = 'collections'
14
+
15
+ /**
16
+ * Extract translatable content from all collections
17
+ * @param {string} siteRoot - Site root directory
18
+ * @param {Object} options - Options
19
+ * @returns {Promise<Object>} Manifest with translation units
20
+ */
21
+ export async function extractCollectionContent(siteRoot, options = {}) {
22
+ const dataDir = join(siteRoot, 'public', 'data')
23
+
24
+ if (!existsSync(dataDir)) {
25
+ return { version: '1.0', units: {} }
26
+ }
27
+
28
+ const units = {}
29
+
30
+ let files
31
+ try {
32
+ files = await readdir(dataDir)
33
+ } catch {
34
+ return { version: '1.0', units: {} }
35
+ }
36
+
37
+ const jsonFiles = files.filter(f => f.endsWith('.json'))
38
+
39
+ for (const file of jsonFiles) {
40
+ const collectionName = file.replace('.json', '')
41
+ const filePath = join(dataDir, file)
42
+
43
+ try {
44
+ const raw = await readFile(filePath, 'utf-8')
45
+ const items = JSON.parse(raw)
46
+
47
+ if (!Array.isArray(items)) continue
48
+
49
+ for (const item of items) {
50
+ extractFromItem(item, collectionName, units)
51
+ }
52
+ } catch (err) {
53
+ // Skip files that can't be parsed
54
+ console.warn(`[i18n] Skipping collection ${file}: ${err.message}`)
55
+ }
56
+ }
57
+
58
+ return {
59
+ version: '1.0',
60
+ extracted: new Date().toISOString(),
61
+ units
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Extract translatable strings from a collection item
67
+ * @param {Object} item - Collection item
68
+ * @param {string} collectionName - Name of the collection
69
+ * @param {Object} units - Units accumulator
70
+ */
71
+ function extractFromItem(item, collectionName, units) {
72
+ const slug = item.slug || 'unknown'
73
+ const context = { collection: collectionName, item: slug }
74
+
75
+ // Extract string fields from frontmatter
76
+ // Common translatable fields
77
+ const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
78
+
79
+ for (const field of translatableFields) {
80
+ if (item[field] && typeof item[field] === 'string' && item[field].trim()) {
81
+ addUnit(units, item[field], field, context)
82
+ }
83
+ }
84
+
85
+ // Extract from tags/categories if they're strings
86
+ if (Array.isArray(item.tags)) {
87
+ item.tags.forEach((tag, i) => {
88
+ if (typeof tag === 'string' && tag.trim()) {
89
+ addUnit(units, tag, `tag.${i}`, context)
90
+ }
91
+ })
92
+ }
93
+
94
+ if (Array.isArray(item.categories)) {
95
+ item.categories.forEach((cat, i) => {
96
+ if (typeof cat === 'string' && cat.trim()) {
97
+ addUnit(units, cat, `category.${i}`, context)
98
+ }
99
+ })
100
+ }
101
+
102
+ // Extract from ProseMirror content body
103
+ if (item.content?.type === 'doc') {
104
+ extractFromProseMirrorDoc(item.content, context, units)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Extract from ProseMirror document
110
+ * @param {Object} doc - ProseMirror document
111
+ * @param {Object} context - Context for the item
112
+ * @param {Object} units - Units accumulator
113
+ */
114
+ function extractFromProseMirrorDoc(doc, context, units) {
115
+ if (!doc.content) return
116
+
117
+ let headingIndex = 0
118
+ let paragraphIndex = 0
119
+
120
+ for (const node of doc.content) {
121
+ if (node.type === 'heading') {
122
+ const text = extractTextFromNode(node)
123
+ if (text) {
124
+ addUnit(units, text, `content.heading.${headingIndex}`, context)
125
+ headingIndex++
126
+ }
127
+ } else if (node.type === 'paragraph') {
128
+ const text = extractTextFromNode(node)
129
+ if (text) {
130
+ addUnit(units, text, `content.paragraph.${paragraphIndex}`, context)
131
+ paragraphIndex++
132
+ }
133
+ } else if (node.type === 'bulletList' || node.type === 'orderedList') {
134
+ extractFromList(node, context, units)
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Extract from list nodes
141
+ */
142
+ function extractFromList(listNode, context, units) {
143
+ if (!listNode.content) return
144
+
145
+ listNode.content.forEach((listItem, index) => {
146
+ if (listItem.type === 'listItem' && listItem.content) {
147
+ for (const child of listItem.content) {
148
+ if (child.type === 'paragraph') {
149
+ const text = extractTextFromNode(child)
150
+ if (text) {
151
+ addUnit(units, text, `content.list.${index}`, context)
152
+ }
153
+ }
154
+ }
155
+ }
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Extract text content from a node
161
+ */
162
+ function extractTextFromNode(node) {
163
+ if (!node.content) return ''
164
+ return node.content
165
+ .filter(n => n.type === 'text')
166
+ .map(n => n.text || '')
167
+ .join('')
168
+ .trim()
169
+ }
170
+
171
+ /**
172
+ * Add a translation unit to the accumulator
173
+ */
174
+ function addUnit(units, source, field, context) {
175
+ if (!source || source.length === 0) return
176
+
177
+ const hash = computeHash(source)
178
+
179
+ if (units[hash]) {
180
+ const existingContexts = units[hash].contexts
181
+ const contextKey = `${context.collection}:${context.item}`
182
+ const exists = existingContexts.some(
183
+ c => `${c.collection}:${c.item}` === contextKey
184
+ )
185
+ if (!exists) {
186
+ existingContexts.push({ ...context })
187
+ }
188
+ } else {
189
+ units[hash] = {
190
+ source,
191
+ field,
192
+ contexts: [{ ...context }]
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Merge translations into collection data and write locale-specific files
199
+ * @param {string} siteRoot - Site root directory
200
+ * @param {Object} options - Options
201
+ * @returns {Promise<Object>} Map of locale to output paths
202
+ */
203
+ export async function buildLocalizedCollections(siteRoot, options = {}) {
204
+ const {
205
+ locales = [],
206
+ outputDir = join(siteRoot, 'dist'),
207
+ collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR)
208
+ } = options
209
+
210
+ const dataDir = join(siteRoot, 'public', 'data')
211
+
212
+ if (!existsSync(dataDir)) {
213
+ return {}
214
+ }
215
+
216
+ let files
217
+ try {
218
+ files = await readdir(dataDir)
219
+ } catch {
220
+ return {}
221
+ }
222
+
223
+ const jsonFiles = files.filter(f => f.endsWith('.json'))
224
+
225
+ if (jsonFiles.length === 0) {
226
+ return {}
227
+ }
228
+
229
+ const outputs = {}
230
+
231
+ for (const locale of locales) {
232
+ // Load translations for this locale
233
+ const localePath = join(collectionsLocalesDir, `${locale}.json`)
234
+ let translations = {}
235
+ if (existsSync(localePath)) {
236
+ try {
237
+ translations = JSON.parse(await readFile(localePath, 'utf-8'))
238
+ } catch {
239
+ // Use empty translations if file can't be parsed
240
+ }
241
+ }
242
+
243
+ // Create locale data directory
244
+ const localeDataDir = join(outputDir, locale, 'data')
245
+ await mkdir(localeDataDir, { recursive: true })
246
+
247
+ outputs[locale] = {}
248
+
249
+ for (const file of jsonFiles) {
250
+ const collectionName = file.replace('.json', '')
251
+ const sourcePath = join(dataDir, file)
252
+
253
+ try {
254
+ const raw = await readFile(sourcePath, 'utf-8')
255
+ const items = JSON.parse(raw)
256
+
257
+ if (!Array.isArray(items)) {
258
+ // Copy as-is if not an array
259
+ const destPath = join(localeDataDir, file)
260
+ await writeFile(destPath, raw)
261
+ outputs[locale][collectionName] = destPath
262
+ continue
263
+ }
264
+
265
+ // Translate each item
266
+ const translatedItems = items.map(item =>
267
+ translateItem(item, collectionName, translations)
268
+ )
269
+
270
+ const destPath = join(localeDataDir, file)
271
+ await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
272
+ outputs[locale][collectionName] = destPath
273
+ } catch (err) {
274
+ console.warn(`[i18n] Failed to translate collection ${file}: ${err.message}`)
275
+ }
276
+ }
277
+ }
278
+
279
+ return outputs
280
+ }
281
+
282
+ /**
283
+ * Apply translations to a collection item
284
+ */
285
+ function translateItem(item, collectionName, translations) {
286
+ const translated = { ...item }
287
+ const slug = item.slug || 'unknown'
288
+ const context = { collection: collectionName, item: slug }
289
+
290
+ // Translate frontmatter fields
291
+ const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
292
+
293
+ for (const field of translatableFields) {
294
+ if (translated[field] && typeof translated[field] === 'string') {
295
+ translated[field] = lookupTranslation(
296
+ translated[field],
297
+ context,
298
+ translations
299
+ )
300
+ }
301
+ }
302
+
303
+ // Translate tags
304
+ if (Array.isArray(translated.tags)) {
305
+ translated.tags = translated.tags.map(tag => {
306
+ if (typeof tag === 'string') {
307
+ return lookupTranslation(tag, context, translations)
308
+ }
309
+ return tag
310
+ })
311
+ }
312
+
313
+ // Translate categories
314
+ if (Array.isArray(translated.categories)) {
315
+ translated.categories = translated.categories.map(cat => {
316
+ if (typeof cat === 'string') {
317
+ return lookupTranslation(cat, context, translations)
318
+ }
319
+ return cat
320
+ })
321
+ }
322
+
323
+ // Translate ProseMirror content
324
+ if (translated.content?.type === 'doc') {
325
+ translated.content = translateProseMirrorDoc(
326
+ translated.content,
327
+ context,
328
+ translations
329
+ )
330
+ }
331
+
332
+ return translated
333
+ }
334
+
335
+ /**
336
+ * Translate a ProseMirror document
337
+ */
338
+ function translateProseMirrorDoc(doc, context, translations) {
339
+ if (!doc.content) return doc
340
+
341
+ const translated = { ...doc, content: [] }
342
+
343
+ for (const node of doc.content) {
344
+ translated.content.push(translateNode(node, context, translations))
345
+ }
346
+
347
+ return translated
348
+ }
349
+
350
+ /**
351
+ * Recursively translate a node
352
+ */
353
+ function translateNode(node, context, translations) {
354
+ if (!node.content) return node
355
+
356
+ const translated = { ...node, content: [] }
357
+
358
+ for (const child of node.content) {
359
+ if (child.type === 'text' && child.text) {
360
+ const translatedText = lookupTranslation(child.text, context, translations)
361
+ translated.content.push({ ...child, text: translatedText })
362
+ } else {
363
+ translated.content.push(translateNode(child, context, translations))
364
+ }
365
+ }
366
+
367
+ return translated
368
+ }
369
+
370
+ /**
371
+ * Look up translation for a piece of text
372
+ */
373
+ function lookupTranslation(source, context, translations) {
374
+ const trimmed = source.trim()
375
+ if (!trimmed) return source
376
+
377
+ const hash = computeHash(trimmed)
378
+ const translation = translations[hash]
379
+
380
+ if (!translation) return source
381
+
382
+ if (typeof translation === 'string') {
383
+ // Preserve leading/trailing whitespace from original
384
+ const leadingSpace = source.match(/^\s*/)[0]
385
+ const trailingSpace = source.match(/\s*$/)[0]
386
+ return leadingSpace + translation + trailingSpace
387
+ }
388
+
389
+ if (typeof translation === 'object' && translation !== null) {
390
+ const contextKey = `${context.collection}:${context.item}`
391
+ if (translation.overrides?.[contextKey]) {
392
+ return translation.overrides[contextKey]
393
+ }
394
+ if (translation.default) {
395
+ return translation.default
396
+ }
397
+ }
398
+
399
+ return source
400
+ }
401
+
402
+ /**
403
+ * Get available collection locales
404
+ * @param {string} localesPath - Path to locales directory
405
+ * @returns {Promise<string[]>} Array of locale codes
406
+ */
407
+ export async function getCollectionLocales(localesPath) {
408
+ const collectionsDir = join(localesPath, COLLECTIONS_DIR)
409
+ if (!existsSync(collectionsDir)) return []
410
+
411
+ try {
412
+ const files = await readdir(collectionsDir)
413
+ return files
414
+ .filter(f => f.endsWith('.json') && f !== 'manifest.json')
415
+ .map(f => f.replace('.json', ''))
416
+ .sort()
417
+ } catch {
418
+ return []
419
+ }
420
+ }
package/src/i18n/index.js CHANGED
@@ -15,6 +15,13 @@ import { computeHash, normalizeText } from './hash.js'
15
15
  import { extractTranslatableContent } from './extract.js'
16
16
  import { syncManifests, formatSyncReport } from './sync.js'
17
17
  import { mergeTranslations, generateAllLocales } from './merge.js'
18
+ import { auditLocale, cleanLocale, formatAuditReport } from './audit.js'
19
+ import {
20
+ extractCollectionContent,
21
+ buildLocalizedCollections,
22
+ getCollectionLocales,
23
+ COLLECTIONS_DIR
24
+ } from './collections.js'
18
25
  import { generateSearchIndex, isSearchEnabled } from '../search/index.js'
19
26
 
20
27
  export {
@@ -29,6 +36,17 @@ export {
29
36
  mergeTranslations,
30
37
  generateAllLocales,
31
38
 
39
+ // Audit functions
40
+ auditLocale,
41
+ cleanLocale,
42
+ formatAuditReport,
43
+
44
+ // Collection functions
45
+ extractCollectionContent,
46
+ buildLocalizedCollections,
47
+ getCollectionLocales,
48
+ COLLECTIONS_DIR,
49
+
32
50
  // Locale resolution
33
51
  getAvailableLocales,
34
52
  resolveLocales
@@ -149,6 +167,46 @@ export async function extractManifest(siteRoot, options = {}) {
149
167
  return { manifest, report }
150
168
  }
151
169
 
170
+ /**
171
+ * Extract collection manifest from collection data and write to file
172
+ * @param {string} siteRoot - Site root directory
173
+ * @param {Object} options - Options
174
+ * @returns {Promise<Object>} { manifest, report }
175
+ */
176
+ export async function extractCollectionManifest(siteRoot, options = {}) {
177
+ const { localesDir = DEFAULTS.localesDir } = options
178
+
179
+ // Extract translatable content from collections
180
+ const manifest = await extractCollectionContent(siteRoot)
181
+
182
+ // Ensure collections locales directory exists
183
+ const collectionsDir = join(siteRoot, localesDir, COLLECTIONS_DIR)
184
+ if (!existsSync(collectionsDir)) {
185
+ await mkdir(collectionsDir, { recursive: true })
186
+ }
187
+
188
+ const manifestPath = join(collectionsDir, 'manifest.json')
189
+
190
+ // Load previous manifest for comparison
191
+ let previousManifest = null
192
+ if (existsSync(manifestPath)) {
193
+ try {
194
+ const prevRaw = await readFile(manifestPath, 'utf-8')
195
+ previousManifest = JSON.parse(prevRaw)
196
+ } catch {
197
+ // Ignore parse errors
198
+ }
199
+ }
200
+
201
+ // Generate sync report
202
+ const report = syncManifests(previousManifest, manifest)
203
+
204
+ // Write new manifest
205
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
206
+
207
+ return { manifest, report }
208
+ }
209
+
152
210
  /**
153
211
  * Get translation status for all configured locales
154
212
  * @param {string} siteRoot - Site root directory
package/src/prerender.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
12
- import { existsSync } from 'node:fs'
12
+ import { existsSync, readdirSync, statSync } from 'node:fs'
13
13
  import { join, dirname, resolve } from 'node:path'
14
14
  import { pathToFileURL } from 'node:url'
15
15
  import { createRequire } from 'node:module'
@@ -381,6 +381,62 @@ function createPageElement(page, website) {
381
381
  )
382
382
  }
383
383
 
384
+ /**
385
+ * Discover all locale content files in the dist directory
386
+ * Returns an array of { locale, contentPath, htmlPath, isDefault }
387
+ *
388
+ * @param {string} distDir - Path to dist directory
389
+ * @param {Object} defaultContent - Default site content (to get default locale)
390
+ * @returns {Array} Locale configurations
391
+ */
392
+ async function discoverLocaleContents(distDir, defaultContent) {
393
+ const locales = []
394
+ const defaultLocale = defaultContent.config?.defaultLanguage || 'en'
395
+
396
+ // Add the default locale (root level)
397
+ locales.push({
398
+ locale: defaultLocale,
399
+ contentPath: join(distDir, 'site-content.json'),
400
+ htmlPath: join(distDir, 'index.html'),
401
+ isDefault: true,
402
+ routePrefix: ''
403
+ })
404
+
405
+ // Check for locale subdirectories with site-content.json
406
+ try {
407
+ const entries = readdirSync(distDir)
408
+ for (const entry of entries) {
409
+ const entryPath = join(distDir, entry)
410
+ // Skip if not a directory
411
+ if (!statSync(entryPath).isDirectory()) continue
412
+
413
+ // Check if this looks like a locale code (2-3 letter code)
414
+ if (!/^[a-z]{2,3}(-[A-Z]{2})?$/.test(entry)) continue
415
+
416
+ // Check if it has a site-content.json
417
+ const localeContentPath = join(entryPath, 'site-content.json')
418
+ const localeHtmlPath = join(entryPath, 'index.html')
419
+
420
+ if (existsSync(localeContentPath)) {
421
+ locales.push({
422
+ locale: entry,
423
+ contentPath: localeContentPath,
424
+ htmlPath: localeHtmlPath,
425
+ isDefault: false,
426
+ routePrefix: `/${entry}`
427
+ })
428
+ }
429
+ }
430
+ } catch (err) {
431
+ // Ignore errors reading directory
432
+ if (process.env.DEBUG) {
433
+ console.error('Error discovering locale contents:', err.message)
434
+ }
435
+ }
436
+
437
+ return locales
438
+ }
439
+
384
440
  /**
385
441
  * Pre-render all pages in a built site to static HTML
386
442
  *
@@ -407,33 +463,21 @@ export async function prerenderSite(siteDir, options = {}) {
407
463
  onProgress('Loading dependencies...')
408
464
  await loadDependencies(siteDir)
409
465
 
410
- // Load site content
466
+ // Load default site content
411
467
  onProgress('Loading site content...')
412
468
  const contentPath = join(distDir, 'site-content.json')
413
469
  if (!existsSync(contentPath)) {
414
470
  throw new Error(`site-content.json not found at: ${contentPath}`)
415
471
  }
416
- const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
417
-
418
- // Execute data fetches (site, page, section levels)
419
- onProgress('Executing data fetches...')
420
- const { siteCascadedData, pageFetchedData } = await executeAllFetches(siteContent, siteDir, onProgress)
421
-
422
- // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
423
- if (siteContent.pages?.some(p => p.isDynamic)) {
424
- onProgress('Expanding dynamic routes...')
425
- siteContent.pages = expandDynamicPages(siteContent.pages, pageFetchedData, onProgress)
426
- }
472
+ const defaultSiteContent = JSON.parse(await readFile(contentPath, 'utf8'))
427
473
 
428
- // Load the HTML shell
429
- onProgress('Loading HTML shell...')
430
- const shellPath = join(distDir, 'index.html')
431
- if (!existsSync(shellPath)) {
432
- throw new Error(`index.html not found at: ${shellPath}`)
474
+ // Discover all locale content files
475
+ const localeConfigs = await discoverLocaleContents(distDir, defaultSiteContent)
476
+ if (localeConfigs.length > 1) {
477
+ onProgress(`Found ${localeConfigs.length} locales: ${localeConfigs.map(l => l.locale).join(', ')}`)
433
478
  }
434
- const htmlShell = await readFile(shellPath, 'utf8')
435
479
 
436
- // Load the foundation module
480
+ // Load the foundation module (shared across all locales)
437
481
  onProgress('Loading foundation...')
438
482
  const foundationPath = join(foundationDir, 'dist', 'foundation.js')
439
483
  if (!existsSync(foundationPath)) {
@@ -442,58 +486,87 @@ export async function prerenderSite(siteDir, options = {}) {
442
486
  const foundationUrl = pathToFileURL(foundationPath).href
443
487
  const foundation = await import(foundationUrl)
444
488
 
445
- // Initialize the Uniweb runtime (this sets globalThis.uniweb)
446
- onProgress('Initializing runtime...')
447
- const uniweb = createUniweb(siteContent)
448
- uniweb.setFoundation(foundation)
489
+ // Pre-render each locale
490
+ const renderedFiles = []
449
491
 
450
- // Set foundation capabilities (Layout, props, etc.)
451
- if (foundation.capabilities) {
452
- uniweb.setFoundationConfig(foundation.capabilities)
453
- }
492
+ for (const localeConfig of localeConfigs) {
493
+ const { locale, contentPath: localeContentPath, htmlPath, isDefault, routePrefix } = localeConfig
454
494
 
455
- // Pre-render each page
456
- const renderedFiles = []
457
- const pages = uniweb.activeWebsite.pages
458
- const website = uniweb.activeWebsite
495
+ onProgress(`\nRendering ${isDefault ? 'default' : locale} locale...`)
459
496
 
460
- for (const page of pages) {
461
- // Each page has a single canonical route
462
- // Index pages already have their parent route as their canonical route
463
- onProgress(`Rendering ${page.route}...`)
464
-
465
- // Set this as the active page
466
- uniweb.activeWebsite.setActivePage(page.route)
467
-
468
- // Create the page element using inline SSR rendering
469
- // (uses React from prerender's scope to avoid module resolution issues)
470
- const element = createPageElement(page, website)
471
-
472
- // Render to HTML string
473
- let renderedContent
474
- try {
475
- renderedContent = renderToString(element)
476
- } catch (err) {
477
- console.warn(`Warning: Failed to render ${page.route}: ${err.message}`)
478
- if (process.env.DEBUG) {
479
- console.error(err.stack)
480
- }
481
- continue
497
+ // Load locale-specific content
498
+ const siteContent = JSON.parse(await readFile(localeContentPath, 'utf8'))
499
+
500
+ // Set the active locale in the content
501
+ siteContent.config = siteContent.config || {}
502
+ siteContent.config.activeLocale = locale
503
+
504
+ // Execute data fetches (site, page, section levels)
505
+ onProgress('Executing data fetches...')
506
+ const { siteCascadedData, pageFetchedData } = await executeAllFetches(siteContent, siteDir, onProgress)
507
+
508
+ // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
509
+ if (siteContent.pages?.some(p => p.isDynamic)) {
510
+ onProgress('Expanding dynamic routes...')
511
+ siteContent.pages = expandDynamicPages(siteContent.pages, pageFetchedData, onProgress)
512
+ }
513
+
514
+ // Load the HTML shell for this locale
515
+ const shellPath = existsSync(htmlPath) ? htmlPath : join(distDir, 'index.html')
516
+ const htmlShell = await readFile(shellPath, 'utf8')
517
+
518
+ // Initialize the Uniweb runtime for this locale
519
+ onProgress('Initializing runtime...')
520
+ const uniweb = createUniweb(siteContent)
521
+ uniweb.setFoundation(foundation)
522
+
523
+ // Set foundation capabilities (Layout, props, etc.)
524
+ if (foundation.capabilities) {
525
+ uniweb.setFoundationConfig(foundation.capabilities)
482
526
  }
483
527
 
484
- // Inject into shell
485
- const html = injectContent(htmlShell, renderedContent, page, siteContent)
528
+ // Pre-render each page
529
+ const pages = uniweb.activeWebsite.pages
530
+ const website = uniweb.activeWebsite
486
531
 
487
- // Output to the canonical route
488
- const outputPath = getOutputPath(distDir, page.route)
489
- await mkdir(dirname(outputPath), { recursive: true })
490
- await writeFile(outputPath, html)
532
+ for (const page of pages) {
533
+ // Build the output route with locale prefix
534
+ const outputRoute = routePrefix + page.route
491
535
 
492
- renderedFiles.push(outputPath)
493
- onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
536
+ onProgress(`Rendering ${outputRoute}...`)
537
+
538
+ // Set this as the active page
539
+ uniweb.activeWebsite.setActivePage(page.route)
540
+
541
+ // Create the page element using inline SSR rendering
542
+ const element = createPageElement(page, website)
543
+
544
+ // Render to HTML string
545
+ let renderedContent
546
+ try {
547
+ renderedContent = renderToString(element)
548
+ } catch (err) {
549
+ console.warn(`Warning: Failed to render ${outputRoute}: ${err.message}`)
550
+ if (process.env.DEBUG) {
551
+ console.error(err.stack)
552
+ }
553
+ continue
554
+ }
555
+
556
+ // Inject into shell
557
+ const html = injectContent(htmlShell, renderedContent, page, siteContent)
558
+
559
+ // Output to the locale-prefixed route
560
+ const outputPath = getOutputPath(distDir, outputRoute)
561
+ await mkdir(dirname(outputPath), { recursive: true })
562
+ await writeFile(outputPath, html)
563
+
564
+ renderedFiles.push(outputPath)
565
+ onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
566
+ }
494
567
  }
495
568
 
496
- onProgress(`Pre-rendered ${renderedFiles.length} pages`)
569
+ onProgress(`\nPre-rendered ${renderedFiles.length} pages across ${localeConfigs.length} locale(s)`)
497
570
 
498
571
  return {
499
572
  pages: renderedFiles.length,
@@ -119,6 +119,7 @@ export function readSiteConfig(siteRoot) {
119
119
  * @param {Object} [options.assets] - Asset processing configuration
120
120
  * @param {Object} [options.search] - Search index configuration
121
121
  * @param {boolean} [options.tailwind] - Include Tailwind CSS v4 Vite plugin (default: true)
122
+ * @param {string} [options.base] - Base public path for deployment (e.g., '/demos/mysite/')
122
123
  * @returns {Promise<Object>} Vite configuration
123
124
  */
124
125
  export async function defineSiteConfig(options = {}) {
@@ -131,6 +132,7 @@ export async function defineSiteConfig(options = {}) {
131
132
  assets = {},
132
133
  search = {},
133
134
  tailwind = true,
135
+ base: baseOption,
134
136
  ...restOptions
135
137
  } = options
136
138
 
@@ -140,6 +142,11 @@ export async function defineSiteConfig(options = {}) {
140
142
  // Read site.yml
141
143
  const siteConfig = readSiteConfig(siteRoot)
142
144
 
145
+ // Determine base path for deployment (priority: option > env > site.yml)
146
+ // Ensures trailing slash for Vite compatibility
147
+ const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
148
+ const base = rawBase ? (rawBase.endsWith('/') ? rawBase : `${rawBase}/`) : undefined
149
+
143
150
  // Detect foundation type
144
151
  const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
145
152
 
@@ -269,6 +276,10 @@ export async function defineSiteConfig(options = {}) {
269
276
  }
270
277
 
271
278
  return {
279
+ // Base public path for deployment (e.g., '/demos/mysite/')
280
+ // Vite uses this to prefix all asset URLs and sets import.meta.env.BASE_URL
281
+ ...(base && { base }),
282
+
272
283
  plugins,
273
284
 
274
285
  define: {
@@ -29,6 +29,37 @@ const DEFAULT_FONTS = {
29
29
  mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
30
30
  }
31
31
 
32
+ /**
33
+ * Default code block theme configuration
34
+ * Uses Shiki CSS variable names for compatibility
35
+ * These values are NOT converted to CSS here - the kit's Code component
36
+ * injects them at runtime only when code blocks are used (tree-shaking)
37
+ */
38
+ const DEFAULT_CODE_THEME = {
39
+ // Background and foreground
40
+ background: '#1e1e2e', // Dark editor background
41
+ foreground: '#cdd6f4', // Default text color
42
+
43
+ // Syntax highlighting colors (Shiki token variables)
44
+ keyword: '#cba6f7', // Purple - keywords (if, else, function)
45
+ string: '#a6e3a1', // Green - strings
46
+ number: '#fab387', // Orange - numbers
47
+ comment: '#6c7086', // Gray - comments
48
+ function: '#89b4fa', // Blue - function names
49
+ variable: '#f5e0dc', // Light pink - variables
50
+ operator: '#89dceb', // Cyan - operators
51
+ punctuation: '#9399b2', // Gray - punctuation
52
+ type: '#f9e2af', // Yellow - types
53
+ constant: '#f38ba8', // Red - constants
54
+ property: '#94e2d5', // Teal - properties
55
+ tag: '#89b4fa', // Blue - HTML/JSX tags
56
+ attribute: '#f9e2af', // Yellow - attributes
57
+
58
+ // UI elements
59
+ lineNumber: '#6c7086', // Line number color
60
+ selection: '#45475a', // Selection background
61
+ }
62
+
32
63
  /**
33
64
  * Validate color configuration
34
65
  *
@@ -158,6 +189,35 @@ function validateAppearance(appearance) {
158
189
  return { valid: errors.length === 0, errors }
159
190
  }
160
191
 
192
+ /**
193
+ * Validate code block theme configuration
194
+ *
195
+ * @param {Object} code - Code theme configuration
196
+ * @returns {{ valid: boolean, errors: string[] }}
197
+ */
198
+ function validateCodeTheme(code) {
199
+ const errors = []
200
+
201
+ if (!code || typeof code !== 'object') {
202
+ return { valid: true, errors } // No code config is valid (use defaults)
203
+ }
204
+
205
+ // Validate color values
206
+ for (const [name, value] of Object.entries(code)) {
207
+ if (typeof value !== 'string') {
208
+ errors.push(`code.${name} must be a string, got ${typeof value}`)
209
+ continue
210
+ }
211
+
212
+ // Basic color format check (hex, rgb, hsl, or color name)
213
+ if (!isValidColor(value)) {
214
+ errors.push(`code.${name} has invalid color value: ${value}`)
215
+ }
216
+ }
217
+
218
+ return { valid: errors.length === 0, errors }
219
+ }
220
+
161
221
  /**
162
222
  * Validate foundation variables configuration
163
223
  *
@@ -203,11 +263,13 @@ export function validateThemeConfig(config) {
203
263
  const contextValidation = validateContexts(config.contexts)
204
264
  const fontValidation = validateFonts(config.fonts)
205
265
  const appearanceValidation = validateAppearance(config.appearance)
266
+ const codeValidation = validateCodeTheme(config.code)
206
267
 
207
268
  allErrors.push(...colorValidation.errors)
208
269
  allErrors.push(...contextValidation.errors)
209
270
  allErrors.push(...fontValidation.errors)
210
271
  allErrors.push(...appearanceValidation.errors)
272
+ allErrors.push(...codeValidation.errors)
211
273
 
212
274
  return {
213
275
  valid: allErrors.length === 0,
@@ -352,6 +414,14 @@ export function processTheme(rawConfig = {}, options = {}) {
352
414
  warnings.push(...foundationValidation.errors)
353
415
  }
354
416
 
417
+ // Process code block theme
418
+ // These values are stored for runtime injection by kit's Code component
419
+ // (not converted to CSS here - enables tree-shaking when code blocks aren't used)
420
+ const code = {
421
+ ...DEFAULT_CODE_THEME,
422
+ ...(rawConfig.code || {}),
423
+ }
424
+
355
425
  const config = {
356
426
  colors, // Raw colors for CSS generator
357
427
  palettes, // Generated palettes for Theme class
@@ -359,6 +429,7 @@ export function processTheme(rawConfig = {}, options = {}) {
359
429
  fonts,
360
430
  appearance,
361
431
  foundationVars: mergedFoundationVars,
432
+ code, // Code block theme for runtime injection
362
433
  }
363
434
 
364
435
  return { config, errors, warnings }