uniweb 0.2.13 → 0.2.14

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",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,8 +32,9 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "handlebars": "^4.7.8",
35
+ "js-yaml": "^4.1.0",
35
36
  "prompts": "^2.4.2",
36
37
  "tar": "^7.0.0",
37
- "@uniweb/build": "0.1.5"
38
+ "@uniweb/build": "0.1.6"
38
39
  }
39
40
  }
@@ -178,6 +178,112 @@ async function buildFoundation(projectDir, options = {}) {
178
178
  }
179
179
  }
180
180
 
181
+ /**
182
+ * Load site i18n configuration
183
+ *
184
+ * Resolves locales from config:
185
+ * - undefined → all available locales (from locales/*.json)
186
+ * - '*' → explicitly all available locales
187
+ * - ['es', 'fr'] → only those specific locales
188
+ */
189
+ async function loadI18nConfig(projectDir) {
190
+ const siteYmlPath = join(projectDir, 'site.yml')
191
+ if (!existsSync(siteYmlPath)) return null
192
+
193
+ const { readFile } = await import('node:fs/promises')
194
+ const yaml = await import('js-yaml')
195
+ const content = await readFile(siteYmlPath, 'utf-8')
196
+ const config = yaml.load(content) || {}
197
+
198
+ const localesDir = config.i18n?.localesDir || 'locales'
199
+ const localesPath = join(projectDir, localesDir)
200
+
201
+ // Resolve locales (undefined/'*' → all available, array → specific)
202
+ const { resolveLocales } = await import('@uniweb/build/i18n')
203
+ const locales = await resolveLocales(config.i18n?.locales, localesPath)
204
+
205
+ if (locales.length === 0) return null
206
+
207
+ return {
208
+ defaultLocale: config.defaultLanguage || 'en',
209
+ locales,
210
+ localesDir,
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Build localized content for all configured locales
216
+ */
217
+ async function buildLocalizedContent(projectDir, i18nConfig) {
218
+ const { buildLocalizedContent } = await import('@uniweb/build/i18n')
219
+
220
+ const outputs = await buildLocalizedContent(projectDir, {
221
+ localesDir: i18nConfig.localesDir,
222
+ locales: i18nConfig.locales,
223
+ outputDir: join(projectDir, 'dist'),
224
+ fallbackToSource: true,
225
+ })
226
+
227
+ return outputs
228
+ }
229
+
230
+ /**
231
+ * Generate index.html for each locale with hreflang tags
232
+ */
233
+ async function generateLocalizedHtml(projectDir, i18nConfig) {
234
+ const { readFile, writeFile, mkdir, copyFile } = await import('node:fs/promises')
235
+ const distDir = join(projectDir, 'dist')
236
+ const baseHtmlPath = join(distDir, 'index.html')
237
+
238
+ if (!existsSync(baseHtmlPath)) {
239
+ return
240
+ }
241
+
242
+ let baseHtml = await readFile(baseHtmlPath, 'utf-8')
243
+
244
+ // Build hreflang tags
245
+ const hreflangTags = i18nConfig.locales.map(locale =>
246
+ `<link rel="alternate" hreflang="${locale}" href="/${locale}/" />`
247
+ ).join('\n ')
248
+
249
+ const defaultHreflang = `<link rel="alternate" hreflang="x-default" href="/" />`
250
+ const allHreflangTags = `${hreflangTags}\n ${defaultHreflang}`
251
+
252
+ // Add hreflang to base HTML (for default locale)
253
+ if (!baseHtml.includes('hreflang')) {
254
+ baseHtml = baseHtml.replace('</head>', ` ${allHreflangTags}\n </head>`)
255
+ await writeFile(baseHtmlPath, baseHtml)
256
+ }
257
+
258
+ // Generate index.html for each locale
259
+ for (const locale of i18nConfig.locales) {
260
+ const localeDir = join(distDir, locale)
261
+ await mkdir(localeDir, { recursive: true })
262
+
263
+ // Read locale-specific site-content.json
264
+ const localeContentPath = join(localeDir, 'site-content.json')
265
+ let localeHtml = baseHtml
266
+
267
+ // Update html lang attribute
268
+ localeHtml = localeHtml.replace(/<html[^>]*lang="[^"]*"/, `<html lang="${locale}"`)
269
+ if (!localeHtml.includes('lang=')) {
270
+ localeHtml = localeHtml.replace('<html', `<html lang="${locale}"`)
271
+ }
272
+
273
+ // Update script src for locale-specific content if inlined
274
+ if (existsSync(localeContentPath)) {
275
+ const localeContent = await readFile(localeContentPath, 'utf-8')
276
+ // Replace the inlined content if present
277
+ localeHtml = localeHtml.replace(
278
+ /<script id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
279
+ `<script id="__SITE_CONTENT__" type="application/json">${localeContent}</script>`
280
+ )
281
+ }
282
+
283
+ await writeFile(join(localeDir, 'index.html'), localeHtml)
284
+ }
285
+ }
286
+
181
287
  /**
182
288
  * Build a site
183
289
  */
@@ -191,6 +297,35 @@ async function buildSite(projectDir, options = {}) {
191
297
 
192
298
  success('Site build complete')
193
299
 
300
+ // Check for i18n configuration
301
+ const i18nConfig = await loadI18nConfig(projectDir)
302
+
303
+ if (i18nConfig && i18nConfig.locales.length > 0) {
304
+ log('')
305
+ info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
306
+
307
+ try {
308
+ // Generate locale-specific site-content.json
309
+ const outputs = await buildLocalizedContent(projectDir, i18nConfig)
310
+
311
+ // Generate locale-specific index.html files
312
+ await generateLocalizedHtml(projectDir, i18nConfig)
313
+
314
+ success(`Generated ${Object.keys(outputs).length} locale(s)`)
315
+
316
+ for (const [locale, path] of Object.entries(outputs)) {
317
+ log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
318
+ }
319
+ } catch (err) {
320
+ error(`i18n build failed: ${err.message}`)
321
+ if (process.env.DEBUG) {
322
+ console.error(err.stack)
323
+ }
324
+ // Don't fail the build, just warn
325
+ log(`${colors.yellow}Continuing without localized content${colors.reset}`)
326
+ }
327
+ }
328
+
194
329
  // Pre-render if requested
195
330
  if (prerender) {
196
331
  log('')
@@ -0,0 +1,325 @@
1
+ /**
2
+ * i18n CLI Commands
3
+ *
4
+ * Commands for managing site content internationalization.
5
+ *
6
+ * Usage:
7
+ * uniweb i18n extract Extract translatable strings to manifest
8
+ * uniweb i18n sync Sync manifest with content changes
9
+ * uniweb i18n status Show translation coverage per locale
10
+ */
11
+
12
+ import { resolve, join } from 'path'
13
+ import { existsSync } from 'fs'
14
+ import { readFile } from 'fs/promises'
15
+ import yaml from 'js-yaml'
16
+
17
+ // Colors for terminal output
18
+ const colors = {
19
+ reset: '\x1b[0m',
20
+ bright: '\x1b[1m',
21
+ dim: '\x1b[2m',
22
+ cyan: '\x1b[36m',
23
+ green: '\x1b[32m',
24
+ yellow: '\x1b[33m',
25
+ red: '\x1b[31m',
26
+ }
27
+
28
+ function log(message) {
29
+ console.log(message)
30
+ }
31
+
32
+ function success(message) {
33
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
34
+ }
35
+
36
+ function warn(message) {
37
+ console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
38
+ }
39
+
40
+ function error(message) {
41
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
42
+ }
43
+
44
+ /**
45
+ * Main i18n command handler
46
+ * @param {string[]} args - Command arguments
47
+ */
48
+ export async function i18n(args) {
49
+ const subcommand = args[0]
50
+
51
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
52
+ showHelp()
53
+ return
54
+ }
55
+
56
+ // Find site root
57
+ const siteRoot = await findSiteRoot()
58
+ if (!siteRoot) {
59
+ error('Could not find site root. Make sure you are in a Uniweb site directory.')
60
+ process.exit(1)
61
+ }
62
+
63
+ // Load site config for locale settings
64
+ const config = await loadSiteConfig(siteRoot)
65
+
66
+ switch (subcommand) {
67
+ case 'extract':
68
+ await runExtract(siteRoot, config, args.slice(1))
69
+ break
70
+ case 'sync':
71
+ await runSync(siteRoot, config, args.slice(1))
72
+ break
73
+ case 'status':
74
+ await runStatus(siteRoot, config, args.slice(1))
75
+ break
76
+ default:
77
+ error(`Unknown subcommand: ${subcommand}`)
78
+ showHelp()
79
+ process.exit(1)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Find site root by looking for site.yml
85
+ */
86
+ async function findSiteRoot() {
87
+ let dir = process.cwd()
88
+
89
+ // Check current directory and parents
90
+ for (let i = 0; i < 5; i++) {
91
+ if (existsSync(join(dir, 'site.yml'))) {
92
+ return dir
93
+ }
94
+ const parent = resolve(dir, '..')
95
+ if (parent === dir) break
96
+ dir = parent
97
+ }
98
+
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Load site configuration
104
+ *
105
+ * Resolves locales from config:
106
+ * - undefined → all available locales (from locales/*.json)
107
+ * - '*' → explicitly all available locales
108
+ * - ['es', 'fr'] → only those specific locales
109
+ */
110
+ async function loadSiteConfig(siteRoot) {
111
+ const configPath = join(siteRoot, 'site.yml')
112
+ const content = await readFile(configPath, 'utf-8')
113
+ const config = yaml.load(content) || {}
114
+
115
+ const localesDir = config.i18n?.localesDir || 'locales'
116
+ const localesPath = join(siteRoot, localesDir)
117
+
118
+ // Resolve locales (undefined/'*' → all available, array → specific)
119
+ const { resolveLocales } = await import('@uniweb/build/i18n')
120
+ const locales = await resolveLocales(config.i18n?.locales, localesPath)
121
+
122
+ return {
123
+ defaultLocale: config.defaultLanguage || 'en',
124
+ locales,
125
+ localesDir,
126
+ ...config.i18n,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Extract command - extract translatable strings from site content
132
+ */
133
+ async function runExtract(siteRoot, config, args) {
134
+ const verbose = args.includes('--verbose') || args.includes('-v')
135
+
136
+ log(`\n${colors.cyan}Extracting translatable content...${colors.reset}\n`)
137
+
138
+ // Check if site has been built
139
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
140
+ if (!existsSync(siteContentPath)) {
141
+ error('Site content not found. Run "uniweb build" first.')
142
+ process.exit(1)
143
+ }
144
+
145
+ try {
146
+ // Dynamic import to avoid loading at CLI startup
147
+ const { extractManifest, formatSyncReport } = await import('@uniweb/build/i18n')
148
+
149
+ const { manifest, report } = await extractManifest(siteRoot, {
150
+ localesDir: config.localesDir,
151
+ siteContentPath,
152
+ verbose,
153
+ })
154
+
155
+ // Show results
156
+ const unitCount = Object.keys(manifest.units).length
157
+ success(`Extracted ${unitCount} translatable strings`)
158
+
159
+ if (report) {
160
+ log('')
161
+ log(formatSyncReport(report))
162
+ }
163
+
164
+ log(`\nManifest written to: ${colors.dim}${config.localesDir}/manifest.json${colors.reset}`)
165
+
166
+ if (config.locales.length === 0) {
167
+ log(`\n${colors.dim}No translation files found in ${config.localesDir}/.`)
168
+ log(`After translating, create locale files like ${config.localesDir}/es.json${colors.reset}`)
169
+ }
170
+ } catch (err) {
171
+ error(`Extraction failed: ${err.message}`)
172
+ if (verbose) console.error(err)
173
+ process.exit(1)
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Sync command - detect changes and update manifest
179
+ */
180
+ async function runSync(siteRoot, config, args) {
181
+ const verbose = args.includes('--verbose') || args.includes('-v')
182
+ const dryRun = args.includes('--dry-run')
183
+
184
+ log(`\n${colors.cyan}Syncing i18n manifest...${colors.reset}\n`)
185
+
186
+ // Check if site has been built
187
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
188
+ if (!existsSync(siteContentPath)) {
189
+ error('Site content not found. Run "uniweb build" first.')
190
+ process.exit(1)
191
+ }
192
+
193
+ // Check if manifest exists
194
+ const manifestPath = join(siteRoot, config.localesDir, 'manifest.json')
195
+ if (!existsSync(manifestPath)) {
196
+ warn('No existing manifest found. Running extract instead.')
197
+ return runExtract(siteRoot, config, args)
198
+ }
199
+
200
+ try {
201
+ const { extractManifest, formatSyncReport } = await import('@uniweb/build/i18n')
202
+
203
+ if (dryRun) {
204
+ log(`${colors.dim}(dry run - no files will be modified)${colors.reset}\n`)
205
+ }
206
+
207
+ const { manifest, report } = await extractManifest(siteRoot, {
208
+ localesDir: config.localesDir,
209
+ siteContentPath,
210
+ verbose,
211
+ dryRun,
212
+ })
213
+
214
+ log(formatSyncReport(report))
215
+
216
+ if (!dryRun) {
217
+ success('\nManifest updated')
218
+ }
219
+ } catch (err) {
220
+ error(`Sync failed: ${err.message}`)
221
+ if (verbose) console.error(err)
222
+ process.exit(1)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Status command - show translation coverage
228
+ */
229
+ async function runStatus(siteRoot, config, args) {
230
+ const locale = args.find(a => !a.startsWith('-'))
231
+
232
+ log(`\n${colors.cyan}Translation Status${colors.reset}\n`)
233
+
234
+ // Check if manifest exists
235
+ const manifestPath = join(siteRoot, config.localesDir, 'manifest.json')
236
+ if (!existsSync(manifestPath)) {
237
+ error('No manifest found. Run "uniweb i18n extract" first.')
238
+ process.exit(1)
239
+ }
240
+
241
+ if (config.locales.length === 0) {
242
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
243
+ log(`Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`)
244
+ return
245
+ }
246
+
247
+ try {
248
+ const { getTranslationStatus, formatTranslationStatus } = await import('@uniweb/build/i18n')
249
+
250
+ const localesToCheck = locale ? [locale] : config.locales
251
+
252
+ const status = await getTranslationStatus(siteRoot, {
253
+ localesDir: config.localesDir,
254
+ locales: localesToCheck,
255
+ })
256
+
257
+ log(formatTranslationStatus(status))
258
+
259
+ // Show next steps if there are missing translations
260
+ const hasMissing = Object.values(status.locales).some(l => l.missing > 0)
261
+ if (hasMissing) {
262
+ log(`\n${colors.dim}To translate missing strings, edit the locale files in ${config.localesDir}/`)
263
+ log(`Or use AI tools with the manifest.json as reference.${colors.reset}`)
264
+ }
265
+ } catch (err) {
266
+ error(`Status check failed: ${err.message}`)
267
+ process.exit(1)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Show help for i18n commands
273
+ */
274
+ function showHelp() {
275
+ log(`
276
+ ${colors.cyan}${colors.bright}Uniweb i18n${colors.reset}
277
+
278
+ Site content internationalization commands.
279
+
280
+ ${colors.bright}Usage:${colors.reset}
281
+ uniweb i18n <command> [options]
282
+
283
+ ${colors.bright}Commands:${colors.reset}
284
+ extract Extract translatable strings to locales/manifest.json
285
+ sync Update manifest with content changes (detects moved/changed content)
286
+ status Show translation coverage per locale
287
+
288
+ ${colors.bright}Options:${colors.reset}
289
+ --verbose Show detailed output
290
+ --dry-run (sync) Show changes without writing files
291
+
292
+ ${colors.bright}Configuration:${colors.reset}
293
+ Optional site.yml settings:
294
+
295
+ i18n:
296
+ locales: [es, fr] # Specific locales only (default: all available)
297
+ locales: '*' # Explicitly all available locales
298
+ localesDir: locales # Directory for translation files (default: locales)
299
+
300
+ By default, all *.json files in locales/ are treated as translation targets.
301
+
302
+ ${colors.bright}Workflow:${colors.reset}
303
+ 1. Build your site: uniweb build
304
+ 2. Extract strings: uniweb i18n extract
305
+ 3. Translate locale files: Edit locales/es.json, locales/fr.json, etc.
306
+ 4. Build with translations: uniweb build (generates locale-specific output)
307
+
308
+ ${colors.bright}File Structure:${colors.reset}
309
+ locales/
310
+ manifest.json Auto-generated: source strings + hashes + contexts
311
+ es.json Translations for Spanish
312
+ fr.json Translations for French
313
+ _memory.json Optional: translation memory for reuse
314
+
315
+ ${colors.bright}Examples:${colors.reset}
316
+ uniweb i18n extract # Extract all translatable strings
317
+ uniweb i18n extract --verbose # Show extracted strings
318
+ uniweb i18n sync # Update manifest after content changes
319
+ uniweb i18n sync --dry-run # Preview changes without writing
320
+ uniweb i18n status # Show coverage for all locales
321
+ uniweb i18n status es # Show coverage for Spanish only
322
+ `)
323
+ }
324
+
325
+ export default i18n
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url'
18
18
  import prompts from 'prompts'
19
19
  import { build } from './commands/build.js'
20
20
  import { docs } from './commands/docs.js'
21
+ import { i18n } from './commands/i18n.js'
21
22
  import { getVersionsForTemplates, getVersion } from './versions.js'
22
23
  import {
23
24
  resolveTemplate,
@@ -90,6 +91,12 @@ async function main() {
90
91
  return
91
92
  }
92
93
 
94
+ // Handle i18n command
95
+ if (command === 'i18n') {
96
+ await i18n(args.slice(1))
97
+ return
98
+ }
99
+
93
100
  // Handle create command
94
101
  if (command !== 'create') {
95
102
  error(`Unknown command: ${command}`)
@@ -246,6 +253,7 @@ ${colors.bright}Commands:${colors.reset}
246
253
  create [name] Create a new project
247
254
  build Build the current project
248
255
  docs Generate component documentation
256
+ i18n <cmd> Internationalization (extract, sync, status)
249
257
 
250
258
  ${colors.bright}Create Options:${colors.reset}
251
259
  --template <type> Project template
@@ -262,6 +270,11 @@ ${colors.bright}Docs Options:${colors.reset}
262
270
  --output <file> Output filename (default: COMPONENTS.md)
263
271
  --from-source Read meta.js files directly instead of schema.json
264
272
 
273
+ ${colors.bright}i18n Commands:${colors.reset}
274
+ extract Extract translatable strings to manifest
275
+ sync Update manifest with content changes
276
+ status Show translation coverage per locale
277
+
265
278
  ${colors.bright}Template Types:${colors.reset}
266
279
  single One site + one foundation (default)
267
280
  multi Multiple sites and foundations