uniweb 0.3.8 → 0.4.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/README.md CHANGED
@@ -6,6 +6,8 @@ Create well-structured Vite + React projects with file-based routing, localizati
6
6
 
7
7
  ## Quick Start
8
8
 
9
+ > **Need pnpm?** Run `npm install -g pnpm` or see [pnpm installation](https://pnpm.io/installation).
10
+
9
11
  ```bash
10
12
  pnpm create uniweb my-site --template marketing
11
13
  cd my-site
@@ -111,7 +113,11 @@ Access the parsed data via `content.data`:
111
113
  ```jsx
112
114
  function TeamCard({ content }) {
113
115
  const member = content.data['team-member']
114
- return <div>{member.name} — {member.role}</div>
116
+ return (
117
+ <div>
118
+ {member.name} — {member.role}
119
+ </div>
120
+ )
115
121
  }
116
122
  ```
117
123
 
@@ -245,17 +251,24 @@ Start with local files deployed anywhere. The same foundation works across all t
245
251
 
246
252
  ---
247
253
 
248
- ## Installation
254
+ ## Create a Project
249
255
 
250
256
  ```bash
251
- # Create a new project (recommended)
252
- pnpm create uniweb my-site
257
+ # pnpm (recommended)
258
+ pnpm create uniweb my-site --template marketing
253
259
 
254
- # Or use npx directly
255
- npx uniweb@latest create my-site
260
+ # npm (use -- before options)
261
+ npm create uniweb@latest my-site -- --template marketing
256
262
 
257
- # Or install globally
263
+ # npx
264
+ npx uniweb@latest create my-site --template marketing
265
+ ```
266
+
267
+ Alternatively, install the CLI globally:
268
+
269
+ ```bash
258
270
  npm install -g uniweb
271
+ uniweb create my-site --template marketing
259
272
  ```
260
273
 
261
274
  **Requirements:**
@@ -279,7 +292,7 @@ Or see the [official pnpm installation guide](https://pnpm.io/installation) for
279
292
 
280
293
  ### `create`
281
294
 
282
- Create a new Uniweb project.
295
+ Create a new Uniweb project. See [Create a Project](#create-a-project) for usage examples.
283
296
 
284
297
  ```bash
285
298
  uniweb create [project-name] [options]
@@ -301,31 +314,6 @@ uniweb create [project-name] [options]
301
314
  | GitHub | `github:user/repo` | GitHub repositories |
302
315
  | GitHub URL | `https://github.com/user/repo` | Full GitHub URLs |
303
316
 
304
- **Examples:**
305
-
306
- ```bash
307
- # Interactive prompts
308
- pnpm create uniweb
309
-
310
- # Create with specific name (defaults to single template)
311
- pnpm create uniweb my-project
312
-
313
- # Single project with site + foundation
314
- pnpm create uniweb my-project --template single
315
-
316
- # Multi-site/foundation monorepo
317
- pnpm create uniweb my-workspace --template multi
318
-
319
- # Official marketing template (landing pages, pricing, testimonials)
320
- pnpm create uniweb my-site --template marketing
321
-
322
- # From npm package
323
- pnpm create uniweb my-site --template @myorg/starter-template
324
-
325
- # From GitHub repository
326
- pnpm create uniweb my-site --template github:myorg/uniweb-template
327
- ```
328
-
329
317
  ### `build`
330
318
 
331
319
  Build the current project.
@@ -423,29 +411,21 @@ Use this when you need multiple sites sharing foundations, multiple foundations
423
411
 
424
412
  ## Official Templates
425
413
 
426
- Feature-rich templates with real components and sample content.
414
+ Feature-rich templates with real components and sample content. **[View all demos](https://uniweb.github.io/templates/)**
427
415
 
428
416
  ### Marketing
429
417
 
430
- ```bash
431
- pnpm create uniweb my-site --template marketing
432
- ```
418
+ [**Live Demo**](https://uniweb.github.io/templates/marketing/) · `pnpm create uniweb my-site --template marketing`
433
419
 
434
420
  **Includes:** Hero, Features, Pricing, Testimonials, CTA, FAQ, Stats, LogoCloud, Video, Gallery, Team
435
421
 
436
422
  Perfect for product launches, SaaS websites, and business landing pages.
437
423
 
438
- **Tailwind v3 variant:**
439
-
440
- ```bash
441
- pnpm create uniweb my-site --template marketing --variant tailwind3
442
- ```
424
+ **Tailwind v3 variant:** `--variant tailwind3`
443
425
 
444
426
  ### Academic
445
427
 
446
- ```bash
447
- pnpm create uniweb my-site --template academic
448
- ```
428
+ [**Live Demo**](https://uniweb.github.io/templates/academic/) · `pnpm create uniweb my-site --template academic`
449
429
 
450
430
  **Includes:** ProfileHero, PublicationList, ResearchAreas, TeamGrid, Timeline, ContactCard, Navbar, Footer
451
431
 
@@ -453,9 +433,7 @@ Perfect for researcher portfolios, lab websites, and academic department sites.
453
433
 
454
434
  ### Docs
455
435
 
456
- ```bash
457
- pnpm create uniweb my-site --template docs
458
- ```
436
+ [**Live Demo**](https://uniweb.github.io/templates/docs/) · `pnpm create uniweb my-site --template docs`
459
437
 
460
438
  **Includes:** Header, LeftPanel, DocSection, CodeBlock, Footer
461
439
 
@@ -463,9 +441,7 @@ Perfect for technical documentation, guides, and API references.
463
441
 
464
442
  ### International
465
443
 
466
- ```bash
467
- pnpm create uniweb my-site --template international
468
- ```
444
+ [**Live Demo**](https://uniweb.github.io/templates/international/) · `pnpm create uniweb my-site --template international`
469
445
 
470
446
  **Includes:** Hero, Features, Team, CTA, Header (with language switcher), Footer (with language links)
471
447
 
@@ -474,14 +450,9 @@ pnpm create uniweb my-site --template international
474
450
  A multilingual business site demonstrating Uniweb's i18n capabilities. Includes pre-configured translation files and a complete localization workflow:
475
451
 
476
452
  ```bash
477
- # Extract translatable strings
478
- uniweb i18n extract
479
-
480
- # Check translation coverage
481
- uniweb i18n status
482
-
483
- # Build generates locale-specific output (dist/es/, dist/fr/)
484
- uniweb build
453
+ uniweb i18n extract # Extract translatable strings
454
+ uniweb i18n status # Check translation coverage
455
+ uniweb build # Generates dist/es/, dist/fr/
485
456
  ```
486
457
 
487
458
  Perfect for international businesses and learning the i18n workflow.
@@ -668,7 +639,7 @@ Set your homepage with `index:` in `site.yml`:
668
639
 
669
640
  ```yaml
670
641
  # site.yml
671
- index: home # The page folder that becomes /
642
+ index: home # The page folder that becomes /
672
643
  ```
673
644
 
674
645
  The `index:` option tells the build which page folder becomes the root route (`/`). The page still exists in `pages/home/`, but visitors access it at `/`.
@@ -688,8 +659,8 @@ Becomes:
688
659
 
689
660
  ```js
690
661
  content.items = [
691
- { title: "First item", paragraphs: [], links: [], imgs: [] },
692
- { title: "Second item", paragraphs: [], links: [], imgs: [] }
662
+ { title: 'First item', paragraphs: [], links: [], imgs: [] },
663
+ { title: 'Second item', paragraphs: [], links: [], imgs: [] },
693
664
  ]
694
665
  ```
695
666
 
@@ -711,7 +682,11 @@ Are accessible via `content.data`:
711
682
  ```jsx
712
683
  function TeamMember({ content }) {
713
684
  const member = content.data['team-member']
714
- return <div>{member.name} - {member.role}</div>
685
+ return (
686
+ <div>
687
+ {member.name} - {member.role}
688
+ </div>
689
+ )
715
690
  }
716
691
  ```
717
692
 
@@ -721,12 +696,12 @@ The tag name (after the colon) becomes the key in `content.data`.
721
696
 
722
697
  In a Uniweb workspace, commands run differently at different levels:
723
698
 
724
- | Location | Command | What it does |
725
- |----------|---------|--------------|
726
- | Project root | `pnpm build` | Builds all packages (foundation + site) |
727
- | Project root | `pnpm dev` | Starts dev server for site |
728
- | `foundation/` | `uniweb build` | Builds just the foundation |
729
- | `site/` | `uniweb build` | Builds just the site |
699
+ | Location | Command | What it does |
700
+ | ------------- | -------------- | --------------------------------------- |
701
+ | Project root | `pnpm build` | Builds all packages (foundation + site) |
702
+ | Project root | `pnpm dev` | Starts dev server for site |
703
+ | `foundation/` | `uniweb build` | Builds just the foundation |
704
+ | `site/` | `uniweb build` | Builds just the site |
730
705
 
731
706
  For day-to-day development, run `pnpm dev` from the project root. The workspace scripts handle the rest.
732
707
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.3.8",
3
+ "version": "0.4.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,9 +37,9 @@
37
37
  "js-yaml": "^4.1.0",
38
38
  "prompts": "^2.4.2",
39
39
  "tar": "^7.0.0",
40
- "@uniweb/build": "0.2.5",
41
- "@uniweb/runtime": "0.3.1",
40
+ "@uniweb/runtime": "0.4.1",
41
+ "@uniweb/kit": "0.3.0",
42
42
  "@uniweb/core": "0.2.3",
43
- "@uniweb/kit": "0.2.3"
43
+ "@uniweb/build": "0.3.0"
44
44
  }
45
45
  }
@@ -407,6 +407,31 @@ async function buildSite(projectDir, options = {}) {
407
407
  for (const [locale, path] of Object.entries(outputs)) {
408
408
  log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
409
409
  }
410
+
411
+ // Translate collections if they exist
412
+ try {
413
+ const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
414
+
415
+ const collectionOutputs = await buildLocalizedCollections(projectDir, {
416
+ locales: i18nConfig.locales,
417
+ outputDir: join(projectDir, 'dist'),
418
+ collectionsLocalesDir: join(projectDir, i18nConfig.localesDir, 'collections')
419
+ })
420
+
421
+ // Count collections translated
422
+ const collectionCount = Object.values(collectionOutputs).reduce(
423
+ (sum, localeOutputs) => sum + Object.keys(localeOutputs).length, 0
424
+ )
425
+
426
+ if (collectionCount > 0) {
427
+ success(`Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`)
428
+ }
429
+ } catch (err) {
430
+ // Collection translation is optional, don't fail build
431
+ if (process.env.DEBUG) {
432
+ console.error('Collection translation:', err.message)
433
+ }
434
+ }
410
435
  } catch (err) {
411
436
  error(`i18n build failed: ${err.message}`)
412
437
  if (process.env.DEBUG) {
@@ -105,6 +105,9 @@ export async function i18n(args) {
105
105
  case 'status':
106
106
  await runStatus(siteRoot, config, effectiveArgs)
107
107
  break
108
+ case 'audit':
109
+ await runAudit(siteRoot, config, effectiveArgs)
110
+ break
108
111
  default:
109
112
  error(`Unknown subcommand: ${effectiveSubcommand}`)
110
113
  showHelp()
@@ -205,45 +208,92 @@ async function loadSiteConfig(siteRoot) {
205
208
  */
206
209
  async function runExtract(siteRoot, config, args) {
207
210
  const verbose = args.includes('--verbose') || args.includes('-v')
211
+ const collectionsOnly = args.includes('--collections')
212
+ const withCollections = args.includes('--with-collections')
208
213
 
209
- log(`\n${colors.cyan}Extracting translatable content...${colors.reset}\n`)
214
+ // Extract page content (unless --collections only)
215
+ if (!collectionsOnly) {
216
+ log(`\n${colors.cyan}Extracting translatable content...${colors.reset}\n`)
210
217
 
211
- // Check if site has been built
212
- const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
213
- if (!existsSync(siteContentPath)) {
214
- error('Site content not found. Run "uniweb build" first.')
215
- process.exit(1)
216
- }
218
+ // Check if site has been built
219
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
220
+ if (!existsSync(siteContentPath)) {
221
+ error('Site content not found. Run "uniweb build" first.')
222
+ process.exit(1)
223
+ }
217
224
 
218
- try {
219
- // Dynamic import to avoid loading at CLI startup
220
- const { extractManifest, formatSyncReport } = await import('@uniweb/build/i18n')
225
+ try {
226
+ // Dynamic import to avoid loading at CLI startup
227
+ const { extractManifest, formatSyncReport } = await import('@uniweb/build/i18n')
221
228
 
222
- const { manifest, report } = await extractManifest(siteRoot, {
223
- localesDir: config.localesDir,
224
- siteContentPath,
225
- verbose,
226
- })
229
+ const { manifest, report } = await extractManifest(siteRoot, {
230
+ localesDir: config.localesDir,
231
+ siteContentPath,
232
+ verbose,
233
+ })
227
234
 
228
- // Show results
229
- const unitCount = Object.keys(manifest.units).length
230
- success(`Extracted ${unitCount} translatable strings`)
235
+ // Show results
236
+ const unitCount = Object.keys(manifest.units).length
237
+ success(`Extracted ${unitCount} translatable strings`)
231
238
 
232
- if (report) {
233
- log('')
234
- log(formatSyncReport(report))
239
+ if (report) {
240
+ log('')
241
+ log(formatSyncReport(report))
242
+ }
243
+
244
+ log(`\nManifest written to: ${colors.dim}${config.localesDir}/manifest.json${colors.reset}`)
245
+
246
+ if (config.locales.length === 0) {
247
+ log(`\n${colors.dim}No translation files found in ${config.localesDir}/.`)
248
+ log(`After translating, create locale files like ${config.localesDir}/es.json${colors.reset}`)
249
+ }
250
+ } catch (err) {
251
+ error(`Extraction failed: ${err.message}`)
252
+ if (verbose) console.error(err)
253
+ process.exit(1)
235
254
  }
255
+ }
236
256
 
237
- log(`\nManifest written to: ${colors.dim}${config.localesDir}/manifest.json${colors.reset}`)
257
+ // Extract collection content (if --collections or --with-collections)
258
+ if (collectionsOnly || withCollections) {
259
+ log(`\n${colors.cyan}Extracting collection content...${colors.reset}\n`)
238
260
 
239
- if (config.locales.length === 0) {
240
- log(`\n${colors.dim}No translation files found in ${config.localesDir}/.`)
241
- log(`After translating, create locale files like ${config.localesDir}/es.json${colors.reset}`)
261
+ // Check if collections exist
262
+ const dataDir = join(siteRoot, 'public', 'data')
263
+ if (!existsSync(dataDir)) {
264
+ if (collectionsOnly) {
265
+ error('No collections found. Create collection data in public/data/.')
266
+ process.exit(1)
267
+ }
268
+ log(`${colors.dim}No collections found in public/data/.${colors.reset}`)
269
+ return
270
+ }
271
+
272
+ try {
273
+ const { extractCollectionManifest, formatSyncReport } = await import('@uniweb/build/i18n')
274
+
275
+ const { manifest, report } = await extractCollectionManifest(siteRoot, {
276
+ localesDir: config.localesDir,
277
+ })
278
+
279
+ const unitCount = Object.keys(manifest.units).length
280
+ if (unitCount > 0) {
281
+ success(`Extracted ${unitCount} translatable strings from collections`)
282
+
283
+ if (report) {
284
+ log('')
285
+ log(formatSyncReport(report))
286
+ }
287
+
288
+ log(`\nManifest written to: ${colors.dim}${config.localesDir}/collections/manifest.json${colors.reset}`)
289
+ } else {
290
+ log(`${colors.dim}No translatable content found in collections.${colors.reset}`)
291
+ }
292
+ } catch (err) {
293
+ error(`Collection extraction failed: ${err.message}`)
294
+ if (verbose) console.error(err)
295
+ if (collectionsOnly) process.exit(1)
242
296
  }
243
- } catch (err) {
244
- error(`Extraction failed: ${err.message}`)
245
- if (verbose) console.error(err)
246
- process.exit(1)
247
297
  }
248
298
  }
249
299
 
@@ -301,19 +351,36 @@ async function runSync(siteRoot, config, args) {
301
351
  */
302
352
  async function runStatus(siteRoot, config, args) {
303
353
  const locale = args.find(a => !a.startsWith('-'))
304
-
305
- log(`\n${colors.cyan}Translation Status${colors.reset}\n`)
354
+ const showMissing = args.includes('--missing')
355
+ const outputJson = args.includes('--json')
356
+ const byPage = args.includes('--by-page')
306
357
 
307
358
  // Check if manifest exists
308
- const manifestPath = join(siteRoot, config.localesDir, 'manifest.json')
359
+ const localesPath = join(siteRoot, config.localesDir)
360
+ const manifestPath = join(localesPath, 'manifest.json')
309
361
  if (!existsSync(manifestPath)) {
310
362
  error('No manifest found. Run "uniweb i18n extract" first.')
311
363
  process.exit(1)
312
364
  }
313
365
 
366
+ // For --missing mode, use auditLocale which returns detailed missing info
367
+ if (showMissing) {
368
+ await runStatusMissing(siteRoot, config, locale, { outputJson, byPage })
369
+ return
370
+ }
371
+
372
+ // Standard status mode
373
+ if (!outputJson) {
374
+ log(`\n${colors.cyan}Translation Status${colors.reset}\n`)
375
+ }
376
+
314
377
  if (config.locales.length === 0) {
315
- log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
316
- log(`Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`)
378
+ if (outputJson) {
379
+ log(JSON.stringify({ error: 'No translation files found', locales: [] }, null, 2))
380
+ } else {
381
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
382
+ log(`Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`)
383
+ }
317
384
  return
318
385
  }
319
386
 
@@ -327,13 +394,18 @@ async function runStatus(siteRoot, config, args) {
327
394
  locales: localesToCheck,
328
395
  })
329
396
 
397
+ if (outputJson) {
398
+ log(JSON.stringify(status, null, 2))
399
+ return
400
+ }
401
+
330
402
  log(formatTranslationStatus(status))
331
403
 
332
404
  // Show next steps if there are missing translations
333
405
  const hasMissing = Object.values(status.locales).some(l => l.missing > 0)
334
406
  if (hasMissing) {
335
407
  log(`\n${colors.dim}To translate missing strings, edit the locale files in ${config.localesDir}/`)
336
- log(`Or use AI tools with the manifest.json as reference.${colors.reset}`)
408
+ log(`Or use: uniweb i18n status --missing --json > missing.json${colors.reset}`)
337
409
  }
338
410
  } catch (err) {
339
411
  error(`Status check failed: ${err.message}`)
@@ -341,6 +413,202 @@ async function runStatus(siteRoot, config, args) {
341
413
  }
342
414
  }
343
415
 
416
+ /**
417
+ * Status --missing mode - show detailed missing strings
418
+ */
419
+ async function runStatusMissing(siteRoot, config, locale, options = {}) {
420
+ const { outputJson = false, byPage = false } = options
421
+ const localesPath = join(siteRoot, config.localesDir)
422
+
423
+ if (config.locales.length === 0) {
424
+ if (outputJson) {
425
+ log(JSON.stringify({ error: 'No translation files found', missing: [] }, null, 2))
426
+ } else {
427
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.${colors.reset}`)
428
+ }
429
+ return
430
+ }
431
+
432
+ try {
433
+ const { auditLocale } = await import('@uniweb/build/i18n')
434
+
435
+ const localesToCheck = locale ? [locale] : config.locales
436
+
437
+ // Collect missing strings from all requested locales
438
+ const allMissing = []
439
+ const byLocale = {}
440
+
441
+ for (const loc of localesToCheck) {
442
+ const result = await auditLocale(localesPath, loc)
443
+ byLocale[loc] = {
444
+ total: result.total,
445
+ translated: result.valid.length,
446
+ missing: result.missing.length,
447
+ coverage: result.total > 0
448
+ ? Math.round((result.valid.length / result.total) * 100)
449
+ : 100
450
+ }
451
+
452
+ // Add locale info to each missing entry
453
+ for (const entry of result.missing) {
454
+ allMissing.push({
455
+ locale: loc,
456
+ ...entry
457
+ })
458
+ }
459
+ }
460
+
461
+ // JSON output
462
+ if (outputJson) {
463
+ const output = {
464
+ locales: byLocale,
465
+ missing: allMissing
466
+ }
467
+ log(JSON.stringify(output, null, 2))
468
+ return
469
+ }
470
+
471
+ // Human-readable output
472
+ log(`\n${colors.cyan}Missing Translations${colors.reset}\n`)
473
+
474
+ // Show summary per locale
475
+ for (const [loc, info] of Object.entries(byLocale)) {
476
+ log(`${loc}: ${info.missing} missing (${info.coverage}% coverage)`)
477
+ }
478
+
479
+ if (allMissing.length === 0) {
480
+ log(`\n${colors.green}All strings are translated!${colors.reset}`)
481
+ return
482
+ }
483
+
484
+ log('')
485
+
486
+ // Group by page if requested
487
+ if (byPage) {
488
+ const grouped = groupByPage(allMissing)
489
+ for (const [page, entries] of Object.entries(grouped)) {
490
+ log(`${colors.bright}${page}${colors.reset}`)
491
+ for (const entry of entries) {
492
+ const preview = truncateString(entry.source, 50)
493
+ log(` ${colors.dim}${entry.hash}${colors.reset} "${preview}"`)
494
+ }
495
+ log('')
496
+ }
497
+ } else {
498
+ // Flat list
499
+ for (const entry of allMissing.slice(0, 20)) {
500
+ const preview = truncateString(entry.source, 60)
501
+ const context = entry.contexts?.[0]
502
+ const location = context ? `${context.page}:${context.section}` : ''
503
+ log(` ${colors.dim}${entry.hash}${colors.reset} "${preview}"`)
504
+ if (location) {
505
+ log(` ${colors.dim}→ ${location}${colors.reset}`)
506
+ }
507
+ }
508
+
509
+ if (allMissing.length > 20) {
510
+ log(`\n ${colors.dim}... and ${allMissing.length - 20} more${colors.reset}`)
511
+ }
512
+ }
513
+
514
+ log(`\n${colors.dim}Use --json to export for translation tools.${colors.reset}`)
515
+ } catch (err) {
516
+ error(`Status check failed: ${err.message}`)
517
+ process.exit(1)
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Group missing entries by page
523
+ */
524
+ function groupByPage(entries) {
525
+ const grouped = {}
526
+ for (const entry of entries) {
527
+ const page = entry.contexts?.[0]?.page || 'unknown'
528
+ if (!grouped[page]) grouped[page] = []
529
+ grouped[page].push(entry)
530
+ }
531
+ return grouped
532
+ }
533
+
534
+ /**
535
+ * Truncate string for display
536
+ */
537
+ function truncateString(str, maxLen) {
538
+ if (str.length <= maxLen) return str
539
+ return str.slice(0, maxLen - 3) + '...'
540
+ }
541
+
542
+ /**
543
+ * Audit command - find stale and missing translations
544
+ */
545
+ async function runAudit(siteRoot, config, args) {
546
+ const locale = args.find(a => !a.startsWith('-'))
547
+ const clean = args.includes('--clean')
548
+ const verbose = args.includes('--verbose') || args.includes('-v')
549
+
550
+ log(`\n${colors.cyan}Translation Audit${colors.reset}\n`)
551
+
552
+ // Check if manifest exists
553
+ const localesPath = join(siteRoot, config.localesDir)
554
+ const manifestPath = join(localesPath, 'manifest.json')
555
+ if (!existsSync(manifestPath)) {
556
+ error('No manifest found. Run "uniweb i18n extract" first.')
557
+ process.exit(1)
558
+ }
559
+
560
+ if (config.locales.length === 0) {
561
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
562
+ log(`Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`)
563
+ return
564
+ }
565
+
566
+ try {
567
+ const { auditLocale, cleanLocale, formatAuditReport } = await import('@uniweb/build/i18n')
568
+
569
+ const localesToAudit = locale ? [locale] : config.locales
570
+ const results = []
571
+
572
+ for (const loc of localesToAudit) {
573
+ const result = await auditLocale(localesPath, loc)
574
+ results.push(result)
575
+ }
576
+
577
+ // Show report
578
+ log(formatAuditReport(results, { verbose }))
579
+
580
+ // Clean if requested
581
+ if (clean) {
582
+ log('')
583
+ let totalRemoved = 0
584
+
585
+ for (const result of results) {
586
+ if (result.stale.length > 0) {
587
+ const staleHashes = result.stale.map(s => s.hash)
588
+ const removed = await cleanLocale(localesPath, result.locale, staleHashes)
589
+ if (removed > 0) {
590
+ success(`Removed ${removed} stale entries from ${result.locale}.json`)
591
+ totalRemoved += removed
592
+ }
593
+ }
594
+ }
595
+
596
+ if (totalRemoved === 0) {
597
+ log(`${colors.dim}No stale entries to remove.${colors.reset}`)
598
+ }
599
+ } else {
600
+ // Suggest --clean if there are stale entries
601
+ const hasStale = results.some(r => r.stale.length > 0)
602
+ if (hasStale) {
603
+ log(`\n${colors.dim}Run with --clean to remove stale entries.${colors.reset}`)
604
+ }
605
+ }
606
+ } catch (err) {
607
+ error(`Audit failed: ${err.message}`)
608
+ process.exit(1)
609
+ }
610
+ }
611
+
344
612
  /**
345
613
  * Show help for i18n commands
346
614
  */
@@ -358,11 +626,18 @@ ${colors.bright}Commands:${colors.reset}
358
626
  extract Extract translatable strings to locales/manifest.json
359
627
  sync Update manifest with content changes (detects moved/changed content)
360
628
  status Show translation coverage per locale
629
+ audit Find stale translations (no longer in manifest) and missing ones
361
630
 
362
631
  ${colors.bright}Options:${colors.reset}
363
632
  -t, --target <path> Site directory (auto-detected if not specified)
364
633
  --verbose Show detailed output
365
634
  --dry-run (sync) Show changes without writing files
635
+ --clean (audit) Remove stale entries from locale files
636
+ --missing (status) List all missing strings instead of summary
637
+ --json (status) Output as JSON for translation tools
638
+ --by-page (status --missing) Group missing strings by page
639
+ --collections (extract/status/audit) Process only collections
640
+ --with-collections (extract/status/audit) Include collections with pages
366
641
 
367
642
  ${colors.bright}Configuration:${colors.reset}
368
643
  Optional site.yml settings:
@@ -390,10 +665,17 @@ ${colors.bright}File Structure:${colors.reset}
390
665
  ${colors.bright}Examples:${colors.reset}
391
666
  uniweb i18n extract # Extract all translatable strings
392
667
  uniweb i18n extract --verbose # Show extracted strings
668
+ uniweb i18n extract --with-collections # Extract pages + collections
669
+ uniweb i18n extract --collections # Extract collections only
393
670
  uniweb i18n sync # Update manifest after content changes
394
671
  uniweb i18n sync --dry-run # Preview changes without writing
395
672
  uniweb i18n status # Show coverage for all locales
396
673
  uniweb i18n status es # Show coverage for Spanish only
674
+ uniweb i18n status --missing # List all missing strings
675
+ uniweb i18n status es --missing --json # Export missing for AI translation
676
+ uniweb i18n status --missing --by-page # Group missing by page
677
+ uniweb i18n audit # Find stale and missing translations
678
+ uniweb i18n audit --clean # Remove stale entries
397
679
  uniweb i18n --target site # Specify site directory explicitly
398
680
 
399
681
  ${colors.bright}Notes:${colors.reset}