uniweb 0.3.7 → 0.4.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/README.md CHANGED
@@ -6,8 +6,10 @@ 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
- npx uniweb@latest create my-site --template marketing
12
+ pnpm create uniweb my-site --template marketing
11
13
  cd my-site
12
14
  pnpm install
13
15
  pnpm dev
@@ -33,16 +35,16 @@ The `marketing` template includes real components (Hero, Features, Pricing, Test
33
35
 
34
36
  ```bash
35
37
  # Multilingual business site (English, Spanish, French)
36
- npx uniweb@latest create my-site --template international
38
+ pnpm create uniweb my-site --template international
37
39
 
38
40
  # Academic site (researcher portfolios, lab pages)
39
- npx uniweb@latest create my-site --template academic
41
+ pnpm create uniweb my-site --template academic
40
42
 
41
43
  # Documentation site
42
- npx uniweb@latest create my-site --template docs
44
+ pnpm create uniweb my-site --template docs
43
45
 
44
46
  # Minimal starter (build from scratch)
45
- npx uniweb@latest create my-site
47
+ pnpm create uniweb my-site
46
48
  ```
47
49
 
48
50
  Every project is a workspace with two packages:
@@ -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,14 +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
- # Use directly with npx (recommended)
252
- npx uniweb@latest <command>
257
+ # pnpm (recommended)
258
+ pnpm create uniweb my-site --template marketing
253
259
 
254
- # Or install globally
260
+ # npm (use -- before options)
261
+ npm create uniweb@latest my-site -- --template marketing
262
+
263
+ # npx
264
+ npx uniweb@latest create my-site --template marketing
265
+ ```
266
+
267
+ Alternatively, install the CLI globally:
268
+
269
+ ```bash
255
270
  npm install -g uniweb
271
+ uniweb create my-site --template marketing
256
272
  ```
257
273
 
258
274
  **Requirements:**
@@ -276,7 +292,7 @@ Or see the [official pnpm installation guide](https://pnpm.io/installation) for
276
292
 
277
293
  ### `create`
278
294
 
279
- Create a new Uniweb project.
295
+ Create a new Uniweb project. See [Create a Project](#create-a-project) for usage examples.
280
296
 
281
297
  ```bash
282
298
  uniweb create [project-name] [options]
@@ -298,31 +314,6 @@ uniweb create [project-name] [options]
298
314
  | GitHub | `github:user/repo` | GitHub repositories |
299
315
  | GitHub URL | `https://github.com/user/repo` | Full GitHub URLs |
300
316
 
301
- **Examples:**
302
-
303
- ```bash
304
- # Interactive prompts
305
- uniweb create
306
-
307
- # Create with specific name (defaults to single template)
308
- uniweb create my-project
309
-
310
- # Single project with site + foundation
311
- uniweb create my-project --template single
312
-
313
- # Multi-site/foundation monorepo
314
- uniweb create my-workspace --template multi
315
-
316
- # Official marketing template (landing pages, pricing, testimonials)
317
- uniweb create my-site --template marketing
318
-
319
- # From npm package
320
- uniweb create my-site --template @myorg/starter-template
321
-
322
- # From GitHub repository
323
- uniweb create my-site --template github:myorg/uniweb-template
324
- ```
325
-
326
317
  ### `build`
327
318
 
328
319
  Build the current project.
@@ -425,7 +416,7 @@ Feature-rich templates with real components and sample content.
425
416
  ### Marketing
426
417
 
427
418
  ```bash
428
- uniweb create my-site --template marketing
419
+ pnpm create uniweb my-site --template marketing
429
420
  ```
430
421
 
431
422
  **Includes:** Hero, Features, Pricing, Testimonials, CTA, FAQ, Stats, LogoCloud, Video, Gallery, Team
@@ -435,13 +426,13 @@ Perfect for product launches, SaaS websites, and business landing pages.
435
426
  **Tailwind v3 variant:**
436
427
 
437
428
  ```bash
438
- uniweb create my-site --template marketing --variant tailwind3
429
+ pnpm create uniweb my-site --template marketing --variant tailwind3
439
430
  ```
440
431
 
441
432
  ### Academic
442
433
 
443
434
  ```bash
444
- uniweb create my-site --template academic
435
+ pnpm create uniweb my-site --template academic
445
436
  ```
446
437
 
447
438
  **Includes:** ProfileHero, PublicationList, ResearchAreas, TeamGrid, Timeline, ContactCard, Navbar, Footer
@@ -451,7 +442,7 @@ Perfect for researcher portfolios, lab websites, and academic department sites.
451
442
  ### Docs
452
443
 
453
444
  ```bash
454
- uniweb create my-site --template docs
445
+ pnpm create uniweb my-site --template docs
455
446
  ```
456
447
 
457
448
  **Includes:** Header, LeftPanel, DocSection, CodeBlock, Footer
@@ -461,7 +452,7 @@ Perfect for technical documentation, guides, and API references.
461
452
  ### International
462
453
 
463
454
  ```bash
464
- uniweb create my-site --template international
455
+ pnpm create uniweb my-site --template international
465
456
  ```
466
457
 
467
458
  **Includes:** Hero, Features, Team, CTA, Header (with language switcher), Footer (with language links)
@@ -489,13 +480,13 @@ Use templates from npm or GitHub:
489
480
 
490
481
  ```bash
491
482
  # npm package
492
- uniweb create my-site --template @myorg/template-name
483
+ pnpm create uniweb my-site --template @myorg/template-name
493
484
 
494
485
  # GitHub repository
495
- uniweb create my-site --template github:user/repo
486
+ pnpm create uniweb my-site --template github:user/repo
496
487
 
497
488
  # GitHub with specific branch/tag
498
- uniweb create my-site --template github:user/repo#v1.0.0
489
+ pnpm create uniweb my-site --template github:user/repo#v1.0.0
499
490
  ```
500
491
 
501
492
  ## Dependency Management
@@ -665,7 +656,7 @@ Set your homepage with `index:` in `site.yml`:
665
656
 
666
657
  ```yaml
667
658
  # site.yml
668
- index: home # The page folder that becomes /
659
+ index: home # The page folder that becomes /
669
660
  ```
670
661
 
671
662
  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 `/`.
@@ -685,8 +676,8 @@ Becomes:
685
676
 
686
677
  ```js
687
678
  content.items = [
688
- { title: "First item", paragraphs: [], links: [], imgs: [] },
689
- { title: "Second item", paragraphs: [], links: [], imgs: [] }
679
+ { title: 'First item', paragraphs: [], links: [], imgs: [] },
680
+ { title: 'Second item', paragraphs: [], links: [], imgs: [] },
690
681
  ]
691
682
  ```
692
683
 
@@ -708,7 +699,11 @@ Are accessible via `content.data`:
708
699
  ```jsx
709
700
  function TeamMember({ content }) {
710
701
  const member = content.data['team-member']
711
- return <div>{member.name} - {member.role}</div>
702
+ return (
703
+ <div>
704
+ {member.name} - {member.role}
705
+ </div>
706
+ )
712
707
  }
713
708
  ```
714
709
 
@@ -718,12 +713,12 @@ The tag name (after the colon) becomes the key in `content.data`.
718
713
 
719
714
  In a Uniweb workspace, commands run differently at different levels:
720
715
 
721
- | Location | Command | What it does |
722
- |----------|---------|--------------|
723
- | Project root | `pnpm build` | Builds all packages (foundation + site) |
724
- | Project root | `pnpm dev` | Starts dev server for site |
725
- | `foundation/` | `uniweb build` | Builds just the foundation |
726
- | `site/` | `uniweb build` | Builds just the site |
716
+ | Location | Command | What it does |
717
+ | ------------- | -------------- | --------------------------------------- |
718
+ | Project root | `pnpm build` | Builds all packages (foundation + site) |
719
+ | Project root | `pnpm dev` | Starts dev server for site |
720
+ | `foundation/` | `uniweb build` | Builds just the foundation |
721
+ | `site/` | `uniweb build` | Builds just the site |
727
722
 
728
723
  For day-to-day development, run `pnpm dev` from the project root. The workspace scripts handle the rest.
729
724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
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.4",
40
+ "@uniweb/build": "0.3.0",
41
41
  "@uniweb/core": "0.2.3",
42
- "@uniweb/kit": "0.2.3",
43
- "@uniweb/runtime": "0.3.1"
42
+ "@uniweb/runtime": "0.4.0",
43
+ "@uniweb/kit": "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}