uniweb 0.3.8 → 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,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
259
+
260
+ # npm (use -- before options)
261
+ npm create uniweb@latest my-site -- --template marketing
253
262
 
254
- # Or use npx directly
255
- npx uniweb@latest create my-site
263
+ # npx
264
+ npx uniweb@latest create my-site --template marketing
265
+ ```
266
+
267
+ Alternatively, install the CLI globally:
256
268
 
257
- # Or install globally
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.
@@ -668,7 +656,7 @@ Set your homepage with `index:` in `site.yml`:
668
656
 
669
657
  ```yaml
670
658
  # site.yml
671
- index: home # The page folder that becomes /
659
+ index: home # The page folder that becomes /
672
660
  ```
673
661
 
674
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 `/`.
@@ -688,8 +676,8 @@ Becomes:
688
676
 
689
677
  ```js
690
678
  content.items = [
691
- { title: "First item", paragraphs: [], links: [], imgs: [] },
692
- { title: "Second item", paragraphs: [], links: [], imgs: [] }
679
+ { title: 'First item', paragraphs: [], links: [], imgs: [] },
680
+ { title: 'Second item', paragraphs: [], links: [], imgs: [] },
693
681
  ]
694
682
  ```
695
683
 
@@ -711,7 +699,11 @@ Are accessible via `content.data`:
711
699
  ```jsx
712
700
  function TeamMember({ content }) {
713
701
  const member = content.data['team-member']
714
- return <div>{member.name} - {member.role}</div>
702
+ return (
703
+ <div>
704
+ {member.name} - {member.role}
705
+ </div>
706
+ )
715
707
  }
716
708
  ```
717
709
 
@@ -721,12 +713,12 @@ The tag name (after the colon) becomes the key in `content.data`.
721
713
 
722
714
  In a Uniweb workspace, commands run differently at different levels:
723
715
 
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 |
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 |
730
722
 
731
723
  For day-to-day development, run `pnpm dev` from the project root. The workspace scripts handle the rest.
732
724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.3.8",
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.5",
41
- "@uniweb/runtime": "0.3.1",
40
+ "@uniweb/build": "0.3.0",
42
41
  "@uniweb/core": "0.2.3",
43
- "@uniweb/kit": "0.2.3"
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}