uniweb 0.4.4 → 0.5.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/package.json +5 -5
- package/src/commands/i18n.js +754 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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/core": "0.
|
|
42
|
-
"@uniweb/runtime": "0.
|
|
43
|
-
"@uniweb/kit": "0.
|
|
40
|
+
"@uniweb/build": "0.4.0",
|
|
41
|
+
"@uniweb/core": "0.3.0",
|
|
42
|
+
"@uniweb/runtime": "0.5.0",
|
|
43
|
+
"@uniweb/kit": "0.4.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/commands/i18n.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* prompts for selection.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { resolve, join } from 'path'
|
|
16
|
+
import { resolve, join, dirname, basename, relative } from 'path'
|
|
17
17
|
import { existsSync } from 'fs'
|
|
18
|
-
import { readFile } from 'fs/promises'
|
|
18
|
+
import { readFile, writeFile, mkdir, readdir, unlink, rename } from 'fs/promises'
|
|
19
19
|
import yaml from 'js-yaml'
|
|
20
20
|
import {
|
|
21
21
|
isWorkspaceRoot,
|
|
@@ -108,6 +108,22 @@ export async function i18n(args) {
|
|
|
108
108
|
case 'audit':
|
|
109
109
|
await runAudit(siteRoot, config, effectiveArgs)
|
|
110
110
|
break
|
|
111
|
+
// Free-form translation commands
|
|
112
|
+
case 'init-freeform':
|
|
113
|
+
await runInitFreeform(siteRoot, config, effectiveArgs)
|
|
114
|
+
break
|
|
115
|
+
case 'update-hash':
|
|
116
|
+
await runUpdateHash(siteRoot, config, effectiveArgs)
|
|
117
|
+
break
|
|
118
|
+
case 'move':
|
|
119
|
+
await runMove(siteRoot, config, effectiveArgs)
|
|
120
|
+
break
|
|
121
|
+
case 'rename':
|
|
122
|
+
await runRename(siteRoot, config, effectiveArgs)
|
|
123
|
+
break
|
|
124
|
+
case 'prune':
|
|
125
|
+
await runPrune(siteRoot, config, effectiveArgs)
|
|
126
|
+
break
|
|
111
127
|
default:
|
|
112
128
|
error(`Unknown subcommand: ${effectiveSubcommand}`)
|
|
113
129
|
showHelp()
|
|
@@ -352,6 +368,7 @@ async function runSync(siteRoot, config, args) {
|
|
|
352
368
|
async function runStatus(siteRoot, config, args) {
|
|
353
369
|
const locale = args.find(a => !a.startsWith('-'))
|
|
354
370
|
const showMissing = args.includes('--missing')
|
|
371
|
+
const showFreeform = args.includes('--freeform')
|
|
355
372
|
const outputJson = args.includes('--json')
|
|
356
373
|
const byPage = args.includes('--by-page')
|
|
357
374
|
|
|
@@ -369,6 +386,12 @@ async function runStatus(siteRoot, config, args) {
|
|
|
369
386
|
return
|
|
370
387
|
}
|
|
371
388
|
|
|
389
|
+
// For --freeform mode, show free-form translation status
|
|
390
|
+
if (showFreeform) {
|
|
391
|
+
await runStatusFreeform(siteRoot, config, locale, { outputJson })
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
372
395
|
// Standard status mode
|
|
373
396
|
if (!outputJson) {
|
|
374
397
|
log(`\n${colors.cyan}Translation Status${colors.reset}\n`)
|
|
@@ -413,6 +436,143 @@ async function runStatus(siteRoot, config, args) {
|
|
|
413
436
|
}
|
|
414
437
|
}
|
|
415
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Status --freeform mode - show free-form translation status
|
|
441
|
+
*/
|
|
442
|
+
async function runStatusFreeform(siteRoot, config, locale, options = {}) {
|
|
443
|
+
const { outputJson = false } = options
|
|
444
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
445
|
+
const freeformPath = join(localesPath, 'freeform')
|
|
446
|
+
|
|
447
|
+
if (!existsSync(freeformPath)) {
|
|
448
|
+
if (outputJson) {
|
|
449
|
+
log(JSON.stringify({ error: 'No free-form translations found', locales: {} }, null, 2))
|
|
450
|
+
} else {
|
|
451
|
+
log(`${colors.dim}No free-form translations found in ${config.localesDir}/freeform/.${colors.reset}`)
|
|
452
|
+
}
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!outputJson) {
|
|
457
|
+
log(`\n${colors.cyan}Free-form Translation Status${colors.reset}\n`)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// Load site content for source hashes
|
|
462
|
+
const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
|
|
463
|
+
if (!existsSync(siteContentPath)) {
|
|
464
|
+
error('Site content not found. Run "uniweb build" first.')
|
|
465
|
+
process.exit(1)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
469
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
470
|
+
|
|
471
|
+
const {
|
|
472
|
+
discoverFreeformTranslations,
|
|
473
|
+
buildFreeformPath,
|
|
474
|
+
computeSourceHash,
|
|
475
|
+
getStaleTranslations,
|
|
476
|
+
getOrphanedTranslations
|
|
477
|
+
} = await import('@uniweb/build/i18n')
|
|
478
|
+
|
|
479
|
+
// Build source hashes
|
|
480
|
+
const sourceHashes = {}
|
|
481
|
+
const validPaths = new Set()
|
|
482
|
+
for (const page of siteContent.pages || []) {
|
|
483
|
+
for (const section of page.sections || []) {
|
|
484
|
+
if (section.stableId && section.content) {
|
|
485
|
+
const path = buildFreeformPath(section, page)
|
|
486
|
+
if (path) {
|
|
487
|
+
validPaths.add(path)
|
|
488
|
+
sourceHashes[path] = computeSourceHash(section.content)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Find all locales
|
|
495
|
+
const entries = await readdir(freeformPath, { withFileTypes: true })
|
|
496
|
+
const locales = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
497
|
+
const localesToCheck = locale ? [locale] : locales
|
|
498
|
+
|
|
499
|
+
const results = {}
|
|
500
|
+
|
|
501
|
+
for (const loc of localesToCheck) {
|
|
502
|
+
const localeDir = join(freeformPath, loc)
|
|
503
|
+
if (!existsSync(localeDir)) continue
|
|
504
|
+
|
|
505
|
+
// Discover translations
|
|
506
|
+
const discovered = await discoverFreeformTranslations(loc, localesPath)
|
|
507
|
+
const allPaths = [...discovered.pages, ...discovered.pageIds, ...discovered.collections]
|
|
508
|
+
|
|
509
|
+
// Check staleness
|
|
510
|
+
const stale = await getStaleTranslations(localeDir, sourceHashes)
|
|
511
|
+
const orphaned = await getOrphanedTranslations(localeDir, validPaths)
|
|
512
|
+
|
|
513
|
+
const upToDate = allPaths.filter(p =>
|
|
514
|
+
!stale.some(s => s.path === p) &&
|
|
515
|
+
!orphaned.some(o => o.path === p)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
results[loc] = {
|
|
519
|
+
total: allPaths.length,
|
|
520
|
+
upToDate: upToDate.length,
|
|
521
|
+
stale: stale.map(s => ({ path: s.path, recordedDate: s.recordedDate })),
|
|
522
|
+
orphaned: orphaned.map(o => ({ path: o.path, recordedDate: o.recordedDate }))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (outputJson) {
|
|
527
|
+
log(JSON.stringify({ locales: results }, null, 2))
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Human-readable output
|
|
532
|
+
for (const [loc, info] of Object.entries(results)) {
|
|
533
|
+
log(`${colors.bright}${loc}:${colors.reset}`)
|
|
534
|
+
|
|
535
|
+
if (info.total === 0) {
|
|
536
|
+
log(` ${colors.dim}No free-form translations${colors.reset}`)
|
|
537
|
+
log('')
|
|
538
|
+
continue
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Group by path prefix (pages, page-ids, collections)
|
|
542
|
+
if (info.stale.length > 0) {
|
|
543
|
+
log(` ${colors.yellow}Stale (source changed):${colors.reset}`)
|
|
544
|
+
for (const item of info.stale) {
|
|
545
|
+
log(` ${colors.yellow}⚠${colors.reset} ${item.path} ${colors.dim}(${item.recordedDate})${colors.reset}`)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (info.orphaned.length > 0) {
|
|
550
|
+
log(` ${colors.red}Orphaned (source not found):${colors.reset}`)
|
|
551
|
+
for (const item of info.orphaned) {
|
|
552
|
+
log(` ${colors.red}✗${colors.reset} ${item.path}`)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
log(` ${colors.dim}Summary: ${info.upToDate} up to date, ${info.stale.length} stale, ${info.orphaned.length} orphaned${colors.reset}`)
|
|
557
|
+
log('')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Show next steps
|
|
561
|
+
const hasStale = Object.values(results).some(r => r.stale.length > 0)
|
|
562
|
+
const hasOrphaned = Object.values(results).some(r => r.orphaned.length > 0)
|
|
563
|
+
|
|
564
|
+
if (hasStale) {
|
|
565
|
+
log(`${colors.dim}Run 'uniweb i18n update-hash <locale> --all-stale' to update hashes after reviewing.${colors.reset}`)
|
|
566
|
+
}
|
|
567
|
+
if (hasOrphaned) {
|
|
568
|
+
log(`${colors.dim}Run 'uniweb i18n prune --freeform' to remove orphaned translations.${colors.reset}`)
|
|
569
|
+
}
|
|
570
|
+
} catch (err) {
|
|
571
|
+
error(`Status check failed: ${err.message}`)
|
|
572
|
+
process.exit(1)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
416
576
|
/**
|
|
417
577
|
* Status --missing mode - show detailed missing strings
|
|
418
578
|
*/
|
|
@@ -609,6 +769,569 @@ async function runAudit(siteRoot, config, args) {
|
|
|
609
769
|
}
|
|
610
770
|
}
|
|
611
771
|
|
|
772
|
+
// ─────────────────────────────────────────────────────────────────
|
|
773
|
+
// Free-form Translation Commands
|
|
774
|
+
// ─────────────────────────────────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Initialize a free-form translation file
|
|
778
|
+
*
|
|
779
|
+
* Usage:
|
|
780
|
+
* uniweb i18n init-freeform es pages/about hero
|
|
781
|
+
* uniweb i18n init-freeform es page-ids/installation intro
|
|
782
|
+
* uniweb i18n init-freeform es collections/articles getting-started
|
|
783
|
+
*/
|
|
784
|
+
async function runInitFreeform(siteRoot, config, args) {
|
|
785
|
+
const locale = args[0]
|
|
786
|
+
const pathType = args[1] // pages/about, page-ids/installation, collections/articles
|
|
787
|
+
const sectionId = args[2] // hero, intro, getting-started
|
|
788
|
+
|
|
789
|
+
if (!locale || !pathType || !sectionId) {
|
|
790
|
+
error('Usage: uniweb i18n init-freeform <locale> <path> <section-id>')
|
|
791
|
+
log(`${colors.dim}Examples:`)
|
|
792
|
+
log(' uniweb i18n init-freeform es pages/about hero')
|
|
793
|
+
log(' uniweb i18n init-freeform es page-ids/installation intro')
|
|
794
|
+
log(` uniweb i18n init-freeform es collections/articles getting-started${colors.reset}`)
|
|
795
|
+
process.exit(1)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
799
|
+
const freeformDir = join(localesPath, 'freeform', locale)
|
|
800
|
+
|
|
801
|
+
// Determine target file path
|
|
802
|
+
const relativePath = `${pathType}/${sectionId}.md`
|
|
803
|
+
const targetPath = join(freeformDir, relativePath)
|
|
804
|
+
|
|
805
|
+
// Check if already exists
|
|
806
|
+
if (existsSync(targetPath)) {
|
|
807
|
+
error(`Translation already exists: ${relativePath}`)
|
|
808
|
+
log(`${colors.dim}Edit it directly or use 'update-hash' after changes.${colors.reset}`)
|
|
809
|
+
process.exit(1)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
// Load site content to get source
|
|
814
|
+
const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
|
|
815
|
+
if (!existsSync(siteContentPath)) {
|
|
816
|
+
error('Site content not found. Run "uniweb build" first.')
|
|
817
|
+
process.exit(1)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
821
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
822
|
+
|
|
823
|
+
// Find the source content
|
|
824
|
+
let sourceContent = null
|
|
825
|
+
let sourceHash = null
|
|
826
|
+
|
|
827
|
+
if (pathType.startsWith('pages/') || pathType.startsWith('page-ids/')) {
|
|
828
|
+
// Find section in pages
|
|
829
|
+
const isPageId = pathType.startsWith('page-ids/')
|
|
830
|
+
const pageIdentifier = pathType.replace(/^(pages|page-ids)\//, '')
|
|
831
|
+
|
|
832
|
+
for (const page of siteContent.pages || []) {
|
|
833
|
+
const match = isPageId
|
|
834
|
+
? page.id === pageIdentifier
|
|
835
|
+
: normalizeRoute(page.route) === pageIdentifier
|
|
836
|
+
|
|
837
|
+
if (match) {
|
|
838
|
+
for (const section of page.sections || []) {
|
|
839
|
+
if (section.stableId === sectionId) {
|
|
840
|
+
sourceContent = section.content
|
|
841
|
+
break
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (sourceContent) break
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
} else if (pathType.startsWith('collections/')) {
|
|
848
|
+
// Find item in collection data
|
|
849
|
+
const collectionName = pathType.replace('collections/', '')
|
|
850
|
+
const dataPath = join(siteRoot, 'public', 'data', `${collectionName}.json`)
|
|
851
|
+
|
|
852
|
+
if (existsSync(dataPath)) {
|
|
853
|
+
const dataRaw = await readFile(dataPath, 'utf-8')
|
|
854
|
+
const items = JSON.parse(dataRaw)
|
|
855
|
+
|
|
856
|
+
for (const item of items) {
|
|
857
|
+
if (item.slug === sectionId) {
|
|
858
|
+
sourceContent = item.content
|
|
859
|
+
break
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (!sourceContent) {
|
|
866
|
+
error(`Source content not found for: ${pathType}/${sectionId}`)
|
|
867
|
+
process.exit(1)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Convert ProseMirror to markdown (simplified - just extract text)
|
|
871
|
+
const markdown = proseMirrorToMarkdown(sourceContent)
|
|
872
|
+
|
|
873
|
+
// Create directory structure
|
|
874
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
875
|
+
|
|
876
|
+
// Write translation file
|
|
877
|
+
await writeFile(targetPath, markdown)
|
|
878
|
+
|
|
879
|
+
// Record hash in manifest
|
|
880
|
+
const { computeSourceHash, recordHash } = await import('@uniweb/build/i18n')
|
|
881
|
+
sourceHash = computeSourceHash(sourceContent)
|
|
882
|
+
await recordHash(freeformDir, relativePath, sourceHash)
|
|
883
|
+
|
|
884
|
+
success(`Created free-form translation: ${relativePath}`)
|
|
885
|
+
log(`${colors.dim}Edit the file, then run 'update-hash' when source changes.${colors.reset}`)
|
|
886
|
+
} catch (err) {
|
|
887
|
+
error(`Failed to initialize free-form translation: ${err.message}`)
|
|
888
|
+
process.exit(1)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Normalize route for comparison (remove leading/trailing slashes)
|
|
894
|
+
*/
|
|
895
|
+
function normalizeRoute(route) {
|
|
896
|
+
return route.replace(/^\/|\/$/g, '')
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Convert ProseMirror document to markdown (simplified)
|
|
901
|
+
*/
|
|
902
|
+
function proseMirrorToMarkdown(doc) {
|
|
903
|
+
if (!doc || !doc.content) return ''
|
|
904
|
+
|
|
905
|
+
const lines = []
|
|
906
|
+
|
|
907
|
+
for (const node of doc.content) {
|
|
908
|
+
if (node.type === 'heading') {
|
|
909
|
+
const level = node.attrs?.level || 1
|
|
910
|
+
const prefix = '#'.repeat(level)
|
|
911
|
+
const text = extractText(node)
|
|
912
|
+
lines.push(`${prefix} ${text}`)
|
|
913
|
+
lines.push('')
|
|
914
|
+
} else if (node.type === 'paragraph') {
|
|
915
|
+
const text = extractText(node)
|
|
916
|
+
if (text) lines.push(text)
|
|
917
|
+
lines.push('')
|
|
918
|
+
} else if (node.type === 'bulletList') {
|
|
919
|
+
for (const item of node.content || []) {
|
|
920
|
+
const text = extractText(item)
|
|
921
|
+
if (text) lines.push(`- ${text}`)
|
|
922
|
+
}
|
|
923
|
+
lines.push('')
|
|
924
|
+
} else if (node.type === 'orderedList') {
|
|
925
|
+
let num = 1
|
|
926
|
+
for (const item of node.content || []) {
|
|
927
|
+
const text = extractText(item)
|
|
928
|
+
if (text) lines.push(`${num}. ${text}`)
|
|
929
|
+
num++
|
|
930
|
+
}
|
|
931
|
+
lines.push('')
|
|
932
|
+
} else if (node.type === 'codeBlock') {
|
|
933
|
+
const lang = node.attrs?.language || ''
|
|
934
|
+
const text = extractText(node)
|
|
935
|
+
lines.push('```' + lang)
|
|
936
|
+
lines.push(text)
|
|
937
|
+
lines.push('```')
|
|
938
|
+
lines.push('')
|
|
939
|
+
} else if (node.type === 'blockquote') {
|
|
940
|
+
const text = extractText(node)
|
|
941
|
+
lines.push(`> ${text}`)
|
|
942
|
+
lines.push('')
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return lines.join('\n').trim() + '\n'
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Extract text from a ProseMirror node
|
|
951
|
+
*/
|
|
952
|
+
function extractText(node) {
|
|
953
|
+
if (!node) return ''
|
|
954
|
+
if (node.type === 'text') return node.text || ''
|
|
955
|
+
if (!node.content) return ''
|
|
956
|
+
return node.content.map(extractText).join('')
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Update the hash for a free-form translation
|
|
961
|
+
*
|
|
962
|
+
* Usage:
|
|
963
|
+
* uniweb i18n update-hash es pages/about hero
|
|
964
|
+
* uniweb i18n update-hash es --all-stale
|
|
965
|
+
*/
|
|
966
|
+
async function runUpdateHash(siteRoot, config, args) {
|
|
967
|
+
const locale = args[0]
|
|
968
|
+
const allStale = args.includes('--all-stale')
|
|
969
|
+
|
|
970
|
+
if (!locale) {
|
|
971
|
+
error('Usage: uniweb i18n update-hash <locale> [path] [section-id]')
|
|
972
|
+
log(`${colors.dim}Or: uniweb i18n update-hash <locale> --all-stale${colors.reset}`)
|
|
973
|
+
process.exit(1)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
977
|
+
const freeformDir = join(localesPath, 'freeform', locale)
|
|
978
|
+
|
|
979
|
+
if (!existsSync(freeformDir)) {
|
|
980
|
+
error(`No free-form translations found for locale: ${locale}`)
|
|
981
|
+
process.exit(1)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
// Load site content
|
|
986
|
+
const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
|
|
987
|
+
if (!existsSync(siteContentPath)) {
|
|
988
|
+
error('Site content not found. Run "uniweb build" first.')
|
|
989
|
+
process.exit(1)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
993
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
994
|
+
|
|
995
|
+
const {
|
|
996
|
+
computeSourceHash,
|
|
997
|
+
updateHash,
|
|
998
|
+
buildFreeformPath,
|
|
999
|
+
getStaleTranslations
|
|
1000
|
+
} = await import('@uniweb/build/i18n')
|
|
1001
|
+
|
|
1002
|
+
// Build source hashes map
|
|
1003
|
+
const sourceHashes = buildSourceHashMap(siteContent, buildFreeformPath, computeSourceHash)
|
|
1004
|
+
|
|
1005
|
+
if (allStale) {
|
|
1006
|
+
// Update all stale translations
|
|
1007
|
+
const stale = await getStaleTranslations(freeformDir, sourceHashes)
|
|
1008
|
+
|
|
1009
|
+
if (stale.length === 0) {
|
|
1010
|
+
log(`${colors.dim}No stale translations found.${colors.reset}`)
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for (const item of stale) {
|
|
1015
|
+
await updateHash(freeformDir, item.path, item.currentHash)
|
|
1016
|
+
success(`Updated hash: ${item.path}`)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
log(`\nUpdated ${stale.length} translation hashes.`)
|
|
1020
|
+
} else {
|
|
1021
|
+
// Update specific translation
|
|
1022
|
+
const pathType = args[1]
|
|
1023
|
+
const sectionId = args[2]
|
|
1024
|
+
|
|
1025
|
+
if (!pathType || !sectionId) {
|
|
1026
|
+
error('Usage: uniweb i18n update-hash <locale> <path> <section-id>')
|
|
1027
|
+
process.exit(1)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const relativePath = `${pathType}/${sectionId}.md`
|
|
1031
|
+
const currentHash = sourceHashes[relativePath]
|
|
1032
|
+
|
|
1033
|
+
if (!currentHash) {
|
|
1034
|
+
error(`Source not found for: ${relativePath}`)
|
|
1035
|
+
process.exit(1)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
await updateHash(freeformDir, relativePath, currentHash)
|
|
1039
|
+
success(`Updated hash: ${relativePath}`)
|
|
1040
|
+
}
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
error(`Failed to update hash: ${err.message}`)
|
|
1043
|
+
process.exit(1)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Build a map of relative paths to source hashes
|
|
1049
|
+
*/
|
|
1050
|
+
function buildSourceHashMap(siteContent, buildFreeformPath, computeSourceHash) {
|
|
1051
|
+
const sourceHashes = {}
|
|
1052
|
+
|
|
1053
|
+
for (const page of siteContent.pages || []) {
|
|
1054
|
+
for (const section of page.sections || []) {
|
|
1055
|
+
if (section.stableId && section.content) {
|
|
1056
|
+
const path = buildFreeformPath(section, page)
|
|
1057
|
+
if (path) {
|
|
1058
|
+
sourceHashes[path] = computeSourceHash(section.content)
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return sourceHashes
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Move free-form translations when pages are reorganized
|
|
1069
|
+
*
|
|
1070
|
+
* Usage:
|
|
1071
|
+
* uniweb i18n move pages/docs/setup pages/getting-started
|
|
1072
|
+
*/
|
|
1073
|
+
async function runMove(siteRoot, config, args) {
|
|
1074
|
+
const oldPath = args[0]
|
|
1075
|
+
const newPath = args[1]
|
|
1076
|
+
|
|
1077
|
+
if (!oldPath || !newPath) {
|
|
1078
|
+
error('Usage: uniweb i18n move <old-path> <new-path>')
|
|
1079
|
+
log(`${colors.dim}Example: uniweb i18n move pages/docs/setup pages/getting-started${colors.reset}`)
|
|
1080
|
+
process.exit(1)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
1084
|
+
const freeformPath = join(localesPath, 'freeform')
|
|
1085
|
+
|
|
1086
|
+
if (!existsSync(freeformPath)) {
|
|
1087
|
+
log(`${colors.dim}No free-form translations found.${colors.reset}`)
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
const { renameManifestEntries } = await import('@uniweb/build/i18n')
|
|
1093
|
+
|
|
1094
|
+
// Find all locales with free-form translations
|
|
1095
|
+
const entries = await readdir(freeformPath, { withFileTypes: true })
|
|
1096
|
+
const locales = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
1097
|
+
|
|
1098
|
+
let totalMoved = 0
|
|
1099
|
+
|
|
1100
|
+
for (const locale of locales) {
|
|
1101
|
+
const localeDir = join(freeformPath, locale)
|
|
1102
|
+
const oldDir = join(localeDir, oldPath)
|
|
1103
|
+
|
|
1104
|
+
if (!existsSync(oldDir)) continue
|
|
1105
|
+
|
|
1106
|
+
const newDir = join(localeDir, newPath)
|
|
1107
|
+
|
|
1108
|
+
// Move all files
|
|
1109
|
+
const files = await discoverFiles(oldDir)
|
|
1110
|
+
const oldPaths = []
|
|
1111
|
+
const newPaths = []
|
|
1112
|
+
|
|
1113
|
+
for (const file of files) {
|
|
1114
|
+
const relOld = relative(localeDir, file)
|
|
1115
|
+
const relNew = relOld.replace(oldPath, newPath)
|
|
1116
|
+
oldPaths.push(relOld)
|
|
1117
|
+
newPaths.push(relNew)
|
|
1118
|
+
|
|
1119
|
+
// Create target directory
|
|
1120
|
+
await mkdir(dirname(join(localeDir, relNew)), { recursive: true })
|
|
1121
|
+
|
|
1122
|
+
// Move file
|
|
1123
|
+
await rename(file, join(localeDir, relNew))
|
|
1124
|
+
totalMoved++
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Update manifest
|
|
1128
|
+
if (oldPaths.length > 0) {
|
|
1129
|
+
await renameManifestEntries(localeDir, oldPaths, newPaths)
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (totalMoved > 0) {
|
|
1134
|
+
success(`Moved ${totalMoved} translation file(s) across ${locales.length} locale(s)`)
|
|
1135
|
+
} else {
|
|
1136
|
+
log(`${colors.dim}No translations found at: ${oldPath}${colors.reset}`)
|
|
1137
|
+
}
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
error(`Failed to move translations: ${err.message}`)
|
|
1140
|
+
process.exit(1)
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Rename free-form translation files
|
|
1146
|
+
*
|
|
1147
|
+
* Usage:
|
|
1148
|
+
* uniweb i18n rename pages/about hero welcome
|
|
1149
|
+
*/
|
|
1150
|
+
async function runRename(siteRoot, config, args) {
|
|
1151
|
+
const path = args[0]
|
|
1152
|
+
const oldName = args[1]
|
|
1153
|
+
const newName = args[2]
|
|
1154
|
+
|
|
1155
|
+
if (!path || !oldName || !newName) {
|
|
1156
|
+
error('Usage: uniweb i18n rename <path> <old-name> <new-name>')
|
|
1157
|
+
log(`${colors.dim}Example: uniweb i18n rename pages/about hero welcome${colors.reset}`)
|
|
1158
|
+
process.exit(1)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
1162
|
+
const freeformPath = join(localesPath, 'freeform')
|
|
1163
|
+
|
|
1164
|
+
if (!existsSync(freeformPath)) {
|
|
1165
|
+
log(`${colors.dim}No free-form translations found.${colors.reset}`)
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
try {
|
|
1170
|
+
const { renameManifestEntries } = await import('@uniweb/build/i18n')
|
|
1171
|
+
|
|
1172
|
+
// Find all locales with free-form translations
|
|
1173
|
+
const entries = await readdir(freeformPath, { withFileTypes: true })
|
|
1174
|
+
const locales = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
1175
|
+
|
|
1176
|
+
let totalRenamed = 0
|
|
1177
|
+
|
|
1178
|
+
for (const locale of locales) {
|
|
1179
|
+
const localeDir = join(freeformPath, locale)
|
|
1180
|
+
const oldFile = join(localeDir, path, `${oldName}.md`)
|
|
1181
|
+
const newFile = join(localeDir, path, `${newName}.md`)
|
|
1182
|
+
|
|
1183
|
+
if (!existsSync(oldFile)) continue
|
|
1184
|
+
|
|
1185
|
+
// Rename file
|
|
1186
|
+
await rename(oldFile, newFile)
|
|
1187
|
+
|
|
1188
|
+
// Update manifest
|
|
1189
|
+
const oldRelPath = `${path}/${oldName}.md`
|
|
1190
|
+
const newRelPath = `${path}/${newName}.md`
|
|
1191
|
+
await renameManifestEntries(localeDir, [oldRelPath], [newRelPath])
|
|
1192
|
+
|
|
1193
|
+
totalRenamed++
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (totalRenamed > 0) {
|
|
1197
|
+
success(`Renamed translation in ${totalRenamed} locale(s)`)
|
|
1198
|
+
} else {
|
|
1199
|
+
log(`${colors.dim}No translations found: ${path}/${oldName}.md${colors.reset}`)
|
|
1200
|
+
}
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
error(`Failed to rename translation: ${err.message}`)
|
|
1203
|
+
process.exit(1)
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Prune orphaned free-form translations
|
|
1209
|
+
*
|
|
1210
|
+
* Usage:
|
|
1211
|
+
* uniweb i18n prune --freeform [--dry-run]
|
|
1212
|
+
*/
|
|
1213
|
+
async function runPrune(siteRoot, config, args) {
|
|
1214
|
+
const freeformMode = args.includes('--freeform')
|
|
1215
|
+
const dryRun = args.includes('--dry-run')
|
|
1216
|
+
|
|
1217
|
+
if (!freeformMode) {
|
|
1218
|
+
error('Usage: uniweb i18n prune --freeform [--dry-run]')
|
|
1219
|
+
process.exit(1)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const localesPath = join(siteRoot, config.localesDir)
|
|
1223
|
+
const freeformPath = join(localesPath, 'freeform')
|
|
1224
|
+
|
|
1225
|
+
if (!existsSync(freeformPath)) {
|
|
1226
|
+
log(`${colors.dim}No free-form translations found.${colors.reset}`)
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
log(`\n${colors.cyan}Pruning orphaned free-form translations${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`)
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
// Load site content
|
|
1234
|
+
const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
|
|
1235
|
+
if (!existsSync(siteContentPath)) {
|
|
1236
|
+
error('Site content not found. Run "uniweb build" first.')
|
|
1237
|
+
process.exit(1)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const siteContentRaw = await readFile(siteContentPath, 'utf-8')
|
|
1241
|
+
const siteContent = JSON.parse(siteContentRaw)
|
|
1242
|
+
|
|
1243
|
+
const {
|
|
1244
|
+
buildFreeformPath,
|
|
1245
|
+
getOrphanedTranslations,
|
|
1246
|
+
removeManifestEntries,
|
|
1247
|
+
discoverFreeformTranslations
|
|
1248
|
+
} = await import('@uniweb/build/i18n')
|
|
1249
|
+
|
|
1250
|
+
// Build set of valid paths
|
|
1251
|
+
const validPaths = new Set()
|
|
1252
|
+
for (const page of siteContent.pages || []) {
|
|
1253
|
+
for (const section of page.sections || []) {
|
|
1254
|
+
if (section.stableId) {
|
|
1255
|
+
const path = buildFreeformPath(section, page)
|
|
1256
|
+
if (path) validPaths.add(path)
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Find all locales
|
|
1262
|
+
const entries = await readdir(freeformPath, { withFileTypes: true })
|
|
1263
|
+
const locales = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
1264
|
+
|
|
1265
|
+
let totalPruned = 0
|
|
1266
|
+
|
|
1267
|
+
for (const locale of locales) {
|
|
1268
|
+
const localeDir = join(freeformPath, locale)
|
|
1269
|
+
|
|
1270
|
+
// Get orphaned translations
|
|
1271
|
+
const orphaned = await getOrphanedTranslations(localeDir, validPaths)
|
|
1272
|
+
|
|
1273
|
+
if (orphaned.length === 0) continue
|
|
1274
|
+
|
|
1275
|
+
log(`${locale}:`)
|
|
1276
|
+
|
|
1277
|
+
for (const item of orphaned) {
|
|
1278
|
+
log(` ${colors.red}✗${colors.reset} ${item.path}`)
|
|
1279
|
+
|
|
1280
|
+
if (!dryRun) {
|
|
1281
|
+
// Delete file
|
|
1282
|
+
const filePath = join(localeDir, item.path)
|
|
1283
|
+
if (existsSync(filePath)) {
|
|
1284
|
+
await unlink(filePath)
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
totalPruned++
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Update manifest
|
|
1292
|
+
if (!dryRun && orphaned.length > 0) {
|
|
1293
|
+
const paths = orphaned.map(o => o.path)
|
|
1294
|
+
await removeManifestEntries(localeDir, paths)
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (totalPruned > 0) {
|
|
1299
|
+
if (dryRun) {
|
|
1300
|
+
log(`\n${colors.dim}Would remove ${totalPruned} orphaned translation(s). Run without --dry-run to delete.${colors.reset}`)
|
|
1301
|
+
} else {
|
|
1302
|
+
success(`\nRemoved ${totalPruned} orphaned translation(s)`)
|
|
1303
|
+
}
|
|
1304
|
+
} else {
|
|
1305
|
+
log(`${colors.dim}No orphaned translations found.${colors.reset}`)
|
|
1306
|
+
}
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
error(`Failed to prune translations: ${err.message}`)
|
|
1309
|
+
process.exit(1)
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Recursively discover all files in a directory
|
|
1315
|
+
*/
|
|
1316
|
+
async function discoverFiles(dir) {
|
|
1317
|
+
const files = []
|
|
1318
|
+
|
|
1319
|
+
if (!existsSync(dir)) return files
|
|
1320
|
+
|
|
1321
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
1322
|
+
|
|
1323
|
+
for (const entry of entries) {
|
|
1324
|
+
const fullPath = join(dir, entry.name)
|
|
1325
|
+
if (entry.isDirectory()) {
|
|
1326
|
+
files.push(...await discoverFiles(fullPath))
|
|
1327
|
+
} else {
|
|
1328
|
+
files.push(fullPath)
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return files
|
|
1333
|
+
}
|
|
1334
|
+
|
|
612
1335
|
/**
|
|
613
1336
|
* Show help for i18n commands
|
|
614
1337
|
*/
|
|
@@ -622,22 +1345,32 @@ ${colors.bright}Usage:${colors.reset}
|
|
|
622
1345
|
uniweb i18n [command] [options]
|
|
623
1346
|
|
|
624
1347
|
${colors.bright}Commands:${colors.reset}
|
|
1348
|
+
${colors.dim}# Hash-based (granular) translation${colors.reset}
|
|
625
1349
|
(default) Same as sync - extract/update strings (runs if no command given)
|
|
626
1350
|
extract Extract translatable strings to locales/manifest.json
|
|
627
1351
|
sync Update manifest with content changes (detects moved/changed content)
|
|
628
1352
|
status Show translation coverage per locale
|
|
629
1353
|
audit Find stale translations (no longer in manifest) and missing ones
|
|
630
1354
|
|
|
1355
|
+
${colors.dim}# Free-form (complete replacement) translation${colors.reset}
|
|
1356
|
+
init-freeform <locale> <path> <id> Create free-form translation from source
|
|
1357
|
+
update-hash <locale> [<path> <id>] Update hash after reviewing source changes
|
|
1358
|
+
move <old-path> <new-path> Move translations when pages reorganize
|
|
1359
|
+
rename <path> <old-id> <new-id> Rename translation file
|
|
1360
|
+
prune --freeform Remove orphaned free-form translations
|
|
1361
|
+
|
|
631
1362
|
${colors.bright}Options:${colors.reset}
|
|
632
1363
|
-t, --target <path> Site directory (auto-detected if not specified)
|
|
633
1364
|
--verbose Show detailed output
|
|
634
|
-
--dry-run (sync) Show changes without writing files
|
|
1365
|
+
--dry-run (sync/prune) Show changes without writing files
|
|
635
1366
|
--clean (audit) Remove stale entries from locale files
|
|
636
1367
|
--missing (status) List all missing strings instead of summary
|
|
1368
|
+
--freeform (status/prune) Include free-form translation status
|
|
637
1369
|
--json (status) Output as JSON for translation tools
|
|
638
1370
|
--by-page (status --missing) Group missing strings by page
|
|
639
1371
|
--collections (extract/status/audit) Process only collections
|
|
640
1372
|
--with-collections (extract/status/audit) Include collections with pages
|
|
1373
|
+
--all-stale (update-hash) Update all stale translations at once
|
|
641
1374
|
|
|
642
1375
|
${colors.bright}Configuration:${colors.reset}
|
|
643
1376
|
Optional site.yml settings:
|
|
@@ -658,24 +1391,36 @@ ${colors.bright}Workflow:${colors.reset}
|
|
|
658
1391
|
${colors.bright}File Structure:${colors.reset}
|
|
659
1392
|
locales/
|
|
660
1393
|
manifest.json Auto-generated: source strings + hashes + contexts
|
|
661
|
-
es.json Translations for Spanish
|
|
662
|
-
fr.json Translations for French
|
|
1394
|
+
es.json Translations for Spanish (hash-based)
|
|
1395
|
+
fr.json Translations for French (hash-based)
|
|
663
1396
|
_memory.json Optional: translation memory for reuse
|
|
1397
|
+
freeform/ Free-form translations (complete content replacement)
|
|
1398
|
+
es/
|
|
1399
|
+
.manifest.json Staleness tracking
|
|
1400
|
+
pages/about/hero.md Translated content for /about page, hero section
|
|
1401
|
+
page-ids/install/intro.md Translated content by page ID
|
|
1402
|
+
collections/articles/getting-started.md
|
|
664
1403
|
|
|
665
1404
|
${colors.bright}Examples:${colors.reset}
|
|
1405
|
+
${colors.dim}# Hash-based workflow${colors.reset}
|
|
666
1406
|
uniweb i18n extract # Extract all translatable strings
|
|
667
1407
|
uniweb i18n extract --verbose # Show extracted strings
|
|
668
1408
|
uniweb i18n extract --with-collections # Extract pages + collections
|
|
669
|
-
uniweb i18n extract --collections # Extract collections only
|
|
670
1409
|
uniweb i18n sync # Update manifest after content changes
|
|
671
|
-
uniweb i18n sync --dry-run # Preview changes without writing
|
|
672
1410
|
uniweb i18n status # Show coverage for all locales
|
|
673
1411
|
uniweb i18n status es # Show coverage for Spanish only
|
|
674
|
-
uniweb i18n status --missing # List all missing strings
|
|
675
1412
|
uniweb i18n status es --missing --json # Export missing for AI translation
|
|
676
|
-
uniweb i18n status --missing --by-page # Group missing by page
|
|
677
1413
|
uniweb i18n audit # Find stale and missing translations
|
|
678
1414
|
uniweb i18n audit --clean # Remove stale entries
|
|
1415
|
+
|
|
1416
|
+
${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
|
|
1417
|
+
uniweb i18n init-freeform es pages/about hero
|
|
1418
|
+
uniweb i18n init-freeform es page-ids/installation intro
|
|
1419
|
+
uniweb i18n init-freeform es collections/articles getting-started
|
|
1420
|
+
uniweb i18n status --freeform # Show free-form translation status
|
|
1421
|
+
uniweb i18n update-hash es --all-stale # Update hashes after review
|
|
1422
|
+
uniweb i18n move pages/docs/setup pages/getting-started
|
|
1423
|
+
uniweb i18n prune --freeform --dry-run # Preview orphan cleanup
|
|
679
1424
|
uniweb i18n --target site # Specify site directory explicitly
|
|
680
1425
|
|
|
681
1426
|
${colors.bright}Notes:${colors.reset}
|