@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 +3 -3
- package/src/i18n/audit.js +184 -0
- package/src/i18n/collections.js +420 -0
- package/src/i18n/index.js +58 -0
- package/src/prerender.js +136 -63
- package/src/site/config.js +11 -0
- 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.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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
//
|
|
446
|
-
|
|
447
|
-
const uniweb = createUniweb(siteContent)
|
|
448
|
-
uniweb.setFoundation(foundation)
|
|
489
|
+
// Pre-render each locale
|
|
490
|
+
const renderedFiles = []
|
|
449
491
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
uniweb.setFoundationConfig(foundation.capabilities)
|
|
453
|
-
}
|
|
492
|
+
for (const localeConfig of localeConfigs) {
|
|
493
|
+
const { locale, contentPath: localeContentPath, htmlPath, isDefault, routePrefix } = localeConfig
|
|
454
494
|
|
|
455
|
-
|
|
456
|
-
const renderedFiles = []
|
|
457
|
-
const pages = uniweb.activeWebsite.pages
|
|
458
|
-
const website = uniweb.activeWebsite
|
|
495
|
+
onProgress(`\nRendering ${isDefault ? 'default' : locale} locale...`)
|
|
459
496
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
//
|
|
485
|
-
const
|
|
528
|
+
// Pre-render each page
|
|
529
|
+
const pages = uniweb.activeWebsite.pages
|
|
530
|
+
const website = uniweb.activeWebsite
|
|
486
531
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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(
|
|
569
|
+
onProgress(`\nPre-rendered ${renderedFiles.length} pages across ${localeConfigs.length} locale(s)`)
|
|
497
570
|
|
|
498
571
|
return {
|
|
499
572
|
pages: renderedFiles.length,
|
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: {
|
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 }
|