@uniweb/build 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/i18n/audit.js +184 -0
- package/src/i18n/collections.js +420 -0
- package/src/i18n/index.js +58 -0
- package/src/site/config.js +12 -1
- package/src/theme/processor.js +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"sharp": "^0.33.2"
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
|
-
"@uniweb/content-reader": "1.0.
|
|
53
|
+
"@uniweb/content-reader": "1.0.7",
|
|
54
54
|
"@uniweb/runtime": "0.3.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
@@ -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/site/config.js
CHANGED
|
@@ -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: {
|
|
@@ -287,7 +298,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
287
298
|
// Allow parent directory for foundation sibling access
|
|
288
299
|
allow: ['..']
|
|
289
300
|
},
|
|
290
|
-
port: siteConfig.build
|
|
301
|
+
...(siteConfig.build?.port && { port: siteConfig.build.port }),
|
|
291
302
|
...serverOverrides
|
|
292
303
|
},
|
|
293
304
|
|
package/src/theme/processor.js
CHANGED
|
@@ -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 }
|