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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.4.4",
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.3.1",
41
- "@uniweb/core": "0.2.4",
42
- "@uniweb/runtime": "0.4.3",
43
- "@uniweb/kit": "0.3.1"
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
  }
@@ -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}