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 +35 -43
- package/package.json +4 -4
- package/src/commands/build.js +25 -0
- package/src/commands/i18n.js +317 -35
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
|
|
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
|
-
##
|
|
254
|
+
## Create a Project
|
|
249
255
|
|
|
250
256
|
```bash
|
|
251
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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:
|
|
692
|
-
{ title:
|
|
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
|
|
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
|
|
725
|
-
|
|
726
|
-
| Project root
|
|
727
|
-
| Project root
|
|
728
|
-
| `foundation/` | `uniweb build` | Builds just the foundation
|
|
729
|
-
| `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
|
+
"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.
|
|
41
|
-
"@uniweb/runtime": "0.3.1",
|
|
40
|
+
"@uniweb/build": "0.3.0",
|
|
42
41
|
"@uniweb/core": "0.2.3",
|
|
43
|
-
"@uniweb/
|
|
42
|
+
"@uniweb/runtime": "0.4.0",
|
|
43
|
+
"@uniweb/kit": "0.3.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -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) {
|
package/src/commands/i18n.js
CHANGED
|
@@ -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
|
-
|
|
214
|
+
// Extract page content (unless --collections only)
|
|
215
|
+
if (!collectionsOnly) {
|
|
216
|
+
log(`\n${colors.cyan}Extracting translatable content...${colors.reset}\n`)
|
|
210
217
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
try {
|
|
226
|
+
// Dynamic import to avoid loading at CLI startup
|
|
227
|
+
const { extractManifest, formatSyncReport } = await import('@uniweb/build/i18n')
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
229
|
+
const { manifest, report } = await extractManifest(siteRoot, {
|
|
230
|
+
localesDir: config.localesDir,
|
|
231
|
+
siteContentPath,
|
|
232
|
+
verbose,
|
|
233
|
+
})
|
|
227
234
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
// Show results
|
|
236
|
+
const unitCount = Object.keys(manifest.units).length
|
|
237
|
+
success(`Extracted ${unitCount} translatable strings`)
|
|
231
238
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
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}
|