uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,1707 @@
1
+ /**
2
+ * i18n CLI Commands
3
+ *
4
+ * Commands for managing site content internationalization.
5
+ *
6
+ * Usage:
7
+ * uniweb i18n extract Extract/update translatable strings (default)
8
+ * uniweb i18n generate [locales] Generate starter locale files from manifest
9
+ * uniweb i18n status Show translation coverage per locale
10
+ * uniweb i18n --target <path> Specify site directory explicitly
11
+ *
12
+ * Aliases: sync → extract, init → generate
13
+ *
14
+ * When run from workspace root, auto-detects sites. If multiple exist,
15
+ * prompts for selection.
16
+ */
17
+
18
+ import { resolve, join, dirname, basename, relative } from 'node:path'
19
+ import { existsSync } from 'node:fs'
20
+ import {
21
+ readFile,
22
+ writeFile,
23
+ mkdir,
24
+ readdir,
25
+ unlink,
26
+ rename
27
+ } from 'fs/promises'
28
+ import yaml from 'js-yaml'
29
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
30
+ import { DATA_DIR } from '@uniweb/core/data-paths'
31
+ import { isWorkspaceRoot, findSites, promptSelect } from '../utils/workspace.js'
32
+
33
+ // Colors for terminal output
34
+ const colors = {
35
+ reset: '\x1b[0m',
36
+ bright: '\x1b[1m',
37
+ dim: '\x1b[2m',
38
+ cyan: '\x1b[36m',
39
+ green: '\x1b[32m',
40
+ yellow: '\x1b[33m',
41
+ red: '\x1b[31m'
42
+ }
43
+
44
+ function log(message) {
45
+ console.log(message)
46
+ }
47
+
48
+ function success(message) {
49
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
50
+ }
51
+
52
+ function warn(message) {
53
+ console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
54
+ }
55
+
56
+ function error(message) {
57
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
58
+ }
59
+
60
+ /**
61
+ * Parse --target option from args
62
+ */
63
+ function parseTargetOption(args) {
64
+ for (let i = 0; i < args.length; i++) {
65
+ if (args[i] === '--target' || args[i] === '-t') {
66
+ return {
67
+ target: args[i + 1],
68
+ remainingArgs: [...args.slice(0, i), ...args.slice(i + 2)]
69
+ }
70
+ }
71
+ }
72
+ return { target: null, remainingArgs: args }
73
+ }
74
+
75
+ /**
76
+ * Main i18n command handler
77
+ * @param {string[]} args - Command arguments
78
+ */
79
+ export async function i18n(args) {
80
+ const subcommand = args[0]
81
+
82
+ if (subcommand === '--help' || subcommand === '-h') {
83
+ showHelp()
84
+ return
85
+ }
86
+
87
+ // Parse --target option
88
+ const { target, remainingArgs } = parseTargetOption(args)
89
+
90
+ // Default to 'extract' if no subcommand (or if first arg is an option)
91
+ const firstArg = remainingArgs[0]
92
+ const effectiveSubcommand =
93
+ !firstArg || firstArg.startsWith('-') ? 'extract' : firstArg
94
+ const effectiveArgs =
95
+ !firstArg || firstArg.startsWith('-')
96
+ ? remainingArgs
97
+ : remainingArgs.slice(1)
98
+
99
+ // Find site root
100
+ const siteRoot = await findSiteRoot(target)
101
+ if (!siteRoot) {
102
+ error(
103
+ 'Could not find site root. Make sure you are in a Uniweb site directory.'
104
+ )
105
+ process.exit(1)
106
+ }
107
+
108
+ // Load site config for locale settings
109
+ const config = await loadSiteConfig(siteRoot)
110
+
111
+ switch (effectiveSubcommand) {
112
+ case 'extract':
113
+ case 'sync':
114
+ await runExtract(siteRoot, config, effectiveArgs)
115
+ break
116
+ case 'generate':
117
+ case 'init':
118
+ await runInit(siteRoot, config, effectiveArgs)
119
+ break
120
+ case 'status':
121
+ await runStatus(siteRoot, config, effectiveArgs)
122
+ break
123
+ case 'audit':
124
+ await runAudit(siteRoot, config, effectiveArgs)
125
+ break
126
+ // Free-form translation commands
127
+ case 'init-freeform':
128
+ await runInitFreeform(siteRoot, config, effectiveArgs)
129
+ break
130
+ case 'update-hash':
131
+ await runUpdateHash(siteRoot, config, effectiveArgs)
132
+ break
133
+ case 'move':
134
+ await runMove(siteRoot, config, effectiveArgs)
135
+ break
136
+ case 'rename':
137
+ await runRename(siteRoot, config, effectiveArgs)
138
+ break
139
+ case 'prune':
140
+ await runPrune(siteRoot, config, effectiveArgs)
141
+ break
142
+ default:
143
+ error(`Unknown subcommand: ${effectiveSubcommand}`)
144
+ showHelp()
145
+ process.exit(1)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Find site root by looking for site.yml
151
+ * Handles: explicit target, workspace root detection, or walking up directories
152
+ *
153
+ * @param {string|null} target - Explicit target path (from --target option)
154
+ */
155
+ async function findSiteRoot(target) {
156
+ const cwd = process.cwd()
157
+
158
+ // If explicit target specified, use it
159
+ if (target) {
160
+ const targetPath = resolve(cwd, target)
161
+ if (existsSync(join(targetPath, 'site.yml'))) {
162
+ return targetPath
163
+ }
164
+ error(`Target directory does not appear to be a site: ${target}`)
165
+ log(`${colors.dim}Sites have a site.yml file.${colors.reset}`)
166
+ process.exit(1)
167
+ }
168
+
169
+ // Check if we're at workspace root
170
+ if (isWorkspaceRoot(cwd)) {
171
+ const sites = await findSites(cwd)
172
+
173
+ if (sites.length === 0) {
174
+ error('No sites found in this workspace.')
175
+ log(
176
+ `${colors.dim}Sites have @uniweb/runtime in dependencies.${colors.reset}`
177
+ )
178
+ process.exit(1)
179
+ }
180
+
181
+ let targetSite
182
+ if (sites.length === 1) {
183
+ targetSite = sites[0]
184
+ log(`${colors.cyan}→${colors.reset} Found site: ${targetSite}`)
185
+ } else {
186
+ log(`${colors.dim}Multiple sites found in workspace.${colors.reset}\n`)
187
+ targetSite = await promptSelect('Select site:', sites)
188
+ if (!targetSite) {
189
+ log('Cancelled.')
190
+ process.exit(0)
191
+ }
192
+ }
193
+
194
+ return resolve(cwd, targetSite)
195
+ }
196
+
197
+ // Walk up directories looking for site.yml
198
+ let dir = cwd
199
+ for (let i = 0; i < 5; i++) {
200
+ if (existsSync(join(dir, 'site.yml'))) {
201
+ return dir
202
+ }
203
+ const parent = resolve(dir, '..')
204
+ if (parent === dir) break
205
+ dir = parent
206
+ }
207
+
208
+ return null
209
+ }
210
+
211
+ /**
212
+ * Load site configuration
213
+ *
214
+ * Resolves locales from config:
215
+ * - undefined → all available locales (from locales/*.json)
216
+ * - '*' → explicitly all available locales
217
+ * - ['es', 'fr'] → only those specific locales
218
+ */
219
+ async function loadSiteConfig(siteRoot) {
220
+ const configPath = join(siteRoot, 'site.yml')
221
+ const content = await readFile(configPath, 'utf-8')
222
+ const config = yaml.load(content) || {}
223
+
224
+ const localesDir = config.i18n?.localesDir || 'locales'
225
+ const localesPath = join(siteRoot, localesDir)
226
+
227
+ // Resolve locales (undefined/'*' → all available, array → specific)
228
+ const { resolveLocales } = await import('@uniweb/build/i18n')
229
+ const locales = await resolveLocales(config.languages, localesPath)
230
+
231
+ return {
232
+ defaultLocale: resolveDefaultLocale(config),
233
+ locales,
234
+ localesDir,
235
+ ...config.i18n
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Extract command - extract translatable strings from site content
241
+ */
242
+ async function runExtract(siteRoot, config, args) {
243
+ const verbose = args.includes('--verbose') || args.includes('-v')
244
+ const dryRun = args.includes('--dry-run')
245
+ const recordsOnly =
246
+ args.includes('--records-only') || args.includes('--records')
247
+ const noRecords = args.includes('--no-records')
248
+ // --with-collections is now a no-op (collections are included by default)
249
+
250
+ // Extract page content (unless --records-only)
251
+ if (!recordsOnly) {
252
+ log(
253
+ `\n${colors.cyan}Extracting translatable content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
254
+ )
255
+
256
+ try {
257
+ // Collect site content directly from source files (no build required)
258
+ const { collectSiteContent } = await import('@uniweb/build/site')
259
+ const { extractManifest, formatSyncReport } =
260
+ await import('@uniweb/build/i18n')
261
+
262
+ log(`${colors.dim}Collecting site content...${colors.reset}`)
263
+ const siteContent = await collectSiteContent(siteRoot)
264
+
265
+ // Check if this is a first-time extract (no previous manifest)
266
+ const manifestPath = join(siteRoot, config.localesDir, 'manifest.json')
267
+ const isUpdate = existsSync(manifestPath)
268
+
269
+ const { manifest, report } = await extractManifest(
270
+ siteRoot,
271
+ siteContent,
272
+ {
273
+ localesDir: config.localesDir,
274
+ verbose,
275
+ dryRun
276
+ }
277
+ )
278
+
279
+ // Show results
280
+ const unitCount = Object.keys(manifest.units).length
281
+ success(`Extracted ${unitCount} translatable strings`)
282
+
283
+ // Show sync report for updates (skip on first extract — everything would be "added")
284
+ if (report && isUpdate) {
285
+ log('')
286
+ log(formatSyncReport(report))
287
+ }
288
+
289
+ if (dryRun) {
290
+ log(`\n${colors.dim}Dry run — no files were modified.${colors.reset}`)
291
+ } else {
292
+ log(
293
+ `\nManifest written to: ${colors.dim}${config.localesDir}/manifest.json${colors.reset}`
294
+ )
295
+ }
296
+
297
+ if (config.locales.length === 0) {
298
+ log(
299
+ `\n${colors.dim}No translation files found in ${config.localesDir}/.`
300
+ )
301
+ log(
302
+ `After translating, create locale files like ${config.localesDir}/es.json${colors.reset}`
303
+ )
304
+ }
305
+ } catch (err) {
306
+ error(`Extraction failed: ${err.message}`)
307
+ if (verbose) console.error(err)
308
+ process.exit(1)
309
+ }
310
+ }
311
+
312
+ // Extract collection content (by default, skip with --no-records)
313
+ if (!noRecords) {
314
+ log(
315
+ `\n${colors.cyan}Extracting record content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
316
+ )
317
+
318
+ // Check if collections exist
319
+ const dataDir = join(siteRoot, 'public', DATA_DIR)
320
+ if (!existsSync(dataDir)) {
321
+ if (recordsOnly) {
322
+ error('No records found. Every file in records/<schema>/ is a record — add some there.')
323
+ process.exit(1)
324
+ }
325
+ log(`${colors.dim}No records found in public/data/.${colors.reset}`)
326
+ return
327
+ }
328
+
329
+ try {
330
+ const { extractRecordManifest, formatSyncReport } =
331
+ await import('@uniweb/build/i18n')
332
+
333
+ const recordManifestPath = join(
334
+ siteRoot,
335
+ config.localesDir,
336
+ 'records',
337
+ 'manifest.json'
338
+ )
339
+ const isUpdate = existsSync(recordManifestPath)
340
+
341
+ const { manifest, report } = await extractRecordManifest(siteRoot, {
342
+ localesDir: config.localesDir,
343
+ dryRun
344
+ })
345
+
346
+ const unitCount = Object.keys(manifest.units).length
347
+ if (unitCount > 0) {
348
+ success(`Extracted ${unitCount} translatable strings from records`)
349
+
350
+ if (report && isUpdate) {
351
+ log('')
352
+ log(formatSyncReport(report))
353
+ }
354
+
355
+ if (dryRun) {
356
+ log(`\n${colors.dim}Dry run — no files were modified.${colors.reset}`)
357
+ } else {
358
+ log(
359
+ `\nManifest written to: ${colors.dim}${config.localesDir}/records/manifest.json${colors.reset}`
360
+ )
361
+ }
362
+ } else {
363
+ log(
364
+ `${colors.dim}No translatable content found in records.${colors.reset}`
365
+ )
366
+ }
367
+ } catch (err) {
368
+ error(`Record extraction failed: ${err.message}`)
369
+ if (verbose) console.error(err)
370
+ if (recordsOnly) process.exit(1)
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * The starter value a translator is handed for one unit.
377
+ *
378
+ * ⭐ `unit.markup` when the element carries inline markdown, `unit.source` otherwise.
379
+ * `source` is the flattened plain text — it is the KEY, and it is what this function
380
+ * used to seed. That meant a paragraph's links, bold and emphasis were already gone
381
+ * from the file a translator opened, and a translator cannot restore what they were
382
+ * never shown: a 100%-covered locale rendered with zero inline links and no check
383
+ * reported it (coverage counts keys, not fidelity). `merge` has always parsed a
384
+ * translation value as inline markdown, so the value channel was lossless the whole
385
+ * time — only the prompt was lossy. Measured 2026-09-18, @uniweb/build 0.44.4.
386
+ *
387
+ * ⚠️ This fills MISSING keys only. A locale file already seeded from the flat source
388
+ * is not repaired by re-running; use `uniweb i18n generate <locale> --force` against
389
+ * a freshly extracted manifest to get the markup-bearing source back.
390
+ */
391
+ function starterValue(unit) {
392
+ return unit.markup || unit.source
393
+ }
394
+
395
+ /**
396
+ * Generate command - generate starter translation files from manifest
397
+ *
398
+ * Usage:
399
+ * uniweb i18n generate es fr Generate specific locales
400
+ * uniweb i18n generate Generate all configured locales
401
+ * uniweb i18n generate --empty Use empty strings instead of source text
402
+ * uniweb i18n generate --force Overwrite existing files entirely
403
+ */
404
+ async function runInit(siteRoot, config, args) {
405
+ const useEmpty = args.includes('--empty')
406
+ const force = args.includes('--force')
407
+
408
+ // Collect locale codes from positional args (skip flags)
409
+ const positionalLocales = args.filter((a) => !a.startsWith('-'))
410
+
411
+ // Read manifest
412
+ const localesPath = join(siteRoot, config.localesDir)
413
+ const manifestPath = join(localesPath, 'manifest.json')
414
+
415
+ if (!existsSync(manifestPath)) {
416
+ error('No manifest found. Run "uniweb i18n extract" first to generate one.')
417
+ process.exit(1)
418
+ }
419
+
420
+ const manifestRaw = await readFile(manifestPath, 'utf-8')
421
+ const manifest = JSON.parse(manifestRaw)
422
+ const units = manifest.units || {}
423
+ const unitCount = Object.keys(units).length
424
+
425
+ if (unitCount === 0) {
426
+ warn('Manifest has no translatable strings. Nothing to initialize.')
427
+ return
428
+ }
429
+
430
+ // Determine target locales
431
+ let targetLocales =
432
+ positionalLocales.length > 0 ? positionalLocales : config.locales
433
+
434
+ if (!targetLocales || targetLocales.length === 0) {
435
+ error('No target locales specified.')
436
+ log(
437
+ `${colors.dim}Specify locales as arguments (e.g., "uniweb i18n generate es fr")`
438
+ )
439
+ log(`or configure them in site.yml under languages.${colors.reset}`)
440
+ process.exit(1)
441
+ }
442
+
443
+ log(`\n${colors.cyan}Initializing translation files...${colors.reset}\n`)
444
+
445
+ await mkdir(localesPath, { recursive: true })
446
+
447
+ for (const locale of targetLocales) {
448
+ // Skip default locale
449
+ if (locale === config.defaultLocale) {
450
+ warn(`Skipped ${locale} (default locale)`)
451
+ continue
452
+ }
453
+
454
+ const localePath = join(localesPath, `${locale}.json`)
455
+
456
+ if (existsSync(localePath) && !force) {
457
+ // Merge mode: add only missing keys
458
+ const existingRaw = await readFile(localePath, 'utf-8')
459
+ let existing
460
+ try {
461
+ existing = JSON.parse(existingRaw)
462
+ } catch {
463
+ warn(
464
+ `${locale}.json has invalid JSON, skipping (use --force to overwrite)`
465
+ )
466
+ continue
467
+ }
468
+
469
+ const existingKeys = new Set(Object.keys(existing))
470
+ let added = 0
471
+
472
+ for (const [hash, unit] of Object.entries(units)) {
473
+ if (!existingKeys.has(hash)) {
474
+ existing[hash] = useEmpty ? '' : starterValue(unit)
475
+ added++
476
+ }
477
+ }
478
+
479
+ if (added > 0) {
480
+ await writeFile(localePath, JSON.stringify(existing, null, 2) + '\n')
481
+ const alreadyCount = existingKeys.size
482
+ success(
483
+ `Updated ${locale}.json (${added} new string${added !== 1 ? 's' : ''} added, ${alreadyCount} already translated)`
484
+ )
485
+ } else {
486
+ success(`${locale}.json already has all ${unitCount} strings`)
487
+ }
488
+ } else {
489
+ // Create new file (or overwrite with --force)
490
+ const localeData = {}
491
+
492
+ for (const [hash, unit] of Object.entries(units)) {
493
+ localeData[hash] = useEmpty ? '' : starterValue(unit)
494
+ }
495
+
496
+ await writeFile(localePath, JSON.stringify(localeData, null, 2) + '\n')
497
+ success(
498
+ `Created ${locale}.json (${unitCount} string${unitCount !== 1 ? 's' : ''})`
499
+ )
500
+ }
501
+ }
502
+
503
+ log(`\n${colors.dim}Next steps:`)
504
+ log(` 1. Edit locale files to add translations`)
505
+ log(` 2. Run 'uniweb build' to build with translations`)
506
+ log(` 3. Run 'uniweb i18n status' to check coverage${colors.reset}`)
507
+ }
508
+
509
+ /**
510
+ * Which translations `status` and `audit` read: the page strings, or — with
511
+ * `--records-only` (or `--records`) — the record strings, in `<localesDir>/records/`,
512
+ * where `extract` writes the record manifest. Both directories hold the same shapes
513
+ * (`manifest.json` and one `<locale>.json` per locale), so every reader takes either.
514
+ *
515
+ * ⛔ Both commands ignored the flag until 2026-09-14 and reported the page strings.
516
+ */
517
+ function translationScope(config, args) {
518
+ const records = args.includes('--records-only') || args.includes('--records')
519
+ return records
520
+ ? { records: true, dir: join(config.localesDir, 'records'), noun: 'record manifest', extract: 'uniweb i18n extract --records-only' }
521
+ : { records: false, dir: config.localesDir, noun: 'manifest', extract: 'uniweb i18n extract' }
522
+ }
523
+
524
+ /**
525
+ * Status command - show translation coverage
526
+ */
527
+ async function runStatus(siteRoot, config, args) {
528
+ const locale = args.find((a) => !a.startsWith('-'))
529
+ const showMissing = args.includes('--missing')
530
+ const showFreeform = args.includes('--freeform')
531
+ const outputJson = args.includes('--json')
532
+ const byPage = args.includes('--by-page')
533
+ const scope = translationScope(config, args)
534
+
535
+ // Check if manifest exists
536
+ const localesPath = join(siteRoot, scope.dir)
537
+ const manifestPath = join(localesPath, 'manifest.json')
538
+ if (!existsSync(manifestPath)) {
539
+ error(`No ${scope.noun} found. Run "${scope.extract}" first.`)
540
+ process.exit(1)
541
+ }
542
+
543
+ // For --missing mode, use auditLocale which returns detailed missing info
544
+ if (showMissing) {
545
+ await runStatusMissing(siteRoot, config, locale, { outputJson, byPage, scope })
546
+ return
547
+ }
548
+
549
+ // For --freeform mode, show free-form translation status
550
+ if (showFreeform) {
551
+ await runStatusFreeform(siteRoot, config, locale, { outputJson })
552
+ return
553
+ }
554
+
555
+ // Standard status mode
556
+ if (!outputJson) {
557
+ log(`\n${colors.cyan}${scope.records ? 'Record ' : ''}Translation Status${colors.reset}\n`)
558
+ }
559
+
560
+ if (config.locales.length === 0) {
561
+ if (outputJson) {
562
+ log(
563
+ JSON.stringify(
564
+ { error: 'No translation files found', locales: [] },
565
+ null,
566
+ 2
567
+ )
568
+ )
569
+ } else {
570
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
571
+ log(
572
+ `Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`
573
+ )
574
+ }
575
+ return
576
+ }
577
+
578
+ try {
579
+ const { getTranslationStatus, formatTranslationStatus } =
580
+ await import('@uniweb/build/i18n')
581
+
582
+ const localesToCheck = locale ? [locale] : config.locales
583
+
584
+ const status = await getTranslationStatus(siteRoot, {
585
+ localesDir: scope.dir,
586
+ locales: localesToCheck
587
+ })
588
+
589
+ if (outputJson) {
590
+ log(JSON.stringify(status, null, 2))
591
+ return
592
+ }
593
+
594
+ log(formatTranslationStatus(status))
595
+
596
+ // Show next steps if there are missing translations
597
+ const hasMissing = Object.values(status.locales).some((l) => l.missing > 0)
598
+ if (hasMissing) {
599
+ log(
600
+ `\n${colors.dim}To translate missing strings, edit the locale files in ${scope.dir}/`
601
+ )
602
+ log(
603
+ `Or use: uniweb i18n status${scope.records ? ' --records-only' : ''} --missing --json > missing.json${colors.reset}`
604
+ )
605
+ }
606
+ } catch (err) {
607
+ error(`Status check failed: ${err.message}`)
608
+ process.exit(1)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Status --freeform mode - show free-form translation status
614
+ */
615
+ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
616
+ const { outputJson = false } = options
617
+ const localesPath = join(siteRoot, config.localesDir)
618
+ const freeformPath = join(localesPath, 'freeform')
619
+
620
+ if (!existsSync(freeformPath)) {
621
+ if (outputJson) {
622
+ log(
623
+ JSON.stringify(
624
+ { error: 'No free-form translations found', locales: {} },
625
+ null,
626
+ 2
627
+ )
628
+ )
629
+ } else {
630
+ log(
631
+ `${colors.dim}No free-form translations found in ${config.localesDir}/freeform/.${colors.reset}`
632
+ )
633
+ }
634
+ return
635
+ }
636
+
637
+ if (!outputJson) {
638
+ log(`\n${colors.cyan}Free-form Translation Status${colors.reset}\n`)
639
+ }
640
+
641
+ try {
642
+ // Load site content for source hashes
643
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
644
+ if (!existsSync(siteContentPath)) {
645
+ error('Site content not found. Run "uniweb build" first.')
646
+ process.exit(1)
647
+ }
648
+
649
+ const siteContentRaw = await readFile(siteContentPath, 'utf-8')
650
+ const siteContent = JSON.parse(siteContentRaw)
651
+
652
+ const {
653
+ discoverFreeformTranslations,
654
+ freeformSourceIndex,
655
+ getStaleTranslations,
656
+ getOrphanedTranslations
657
+ } = await import('@uniweb/build/i18n')
658
+
659
+ // What the built content says about its free-form translations — every path the
660
+ // renderer reads one from, with its source's hash. The build's own check uses this
661
+ // index, so the two cannot disagree about which files are orphaned or stale.
662
+ const { validPaths, sourceHashes, canJudge } = freeformSourceIndex(siteContent)
663
+
664
+ // Find all locales
665
+ const entries = await readdir(freeformPath, { withFileTypes: true })
666
+ const locales = entries.filter((e) => e.isDirectory()).map((e) => e.name)
667
+ const localesToCheck = locale ? [locale] : locales
668
+
669
+ const results = {}
670
+
671
+ for (const loc of localesToCheck) {
672
+ const localeDir = join(freeformPath, loc)
673
+ if (!existsSync(localeDir)) continue
674
+
675
+ // Discover translations
676
+ const discovered = await discoverFreeformTranslations(loc, localesPath)
677
+ const allPaths = [
678
+ ...discovered.pages,
679
+ ...discovered.pageIds,
680
+ ...discovered.records
681
+ ]
682
+
683
+ // Check staleness. Orphaned only when the content can judge it — never a record's
684
+ // translation, nor one for a page whose sections the built content does not carry.
685
+ const stale = await getStaleTranslations(localeDir, sourceHashes)
686
+ const orphaned = (await getOrphanedTranslations(localeDir, validPaths)).filter((o) => canJudge(o.path))
687
+
688
+ const upToDate = allPaths.filter(
689
+ (p) =>
690
+ !stale.some((s) => s.path === p) &&
691
+ !orphaned.some((o) => o.path === p)
692
+ )
693
+
694
+ results[loc] = {
695
+ total: allPaths.length,
696
+ upToDate: upToDate.length,
697
+ stale: stale.map((s) => ({
698
+ path: s.path,
699
+ recordedDate: s.recordedDate
700
+ })),
701
+ orphaned: orphaned.map((o) => ({
702
+ path: o.path,
703
+ recordedDate: o.recordedDate
704
+ }))
705
+ }
706
+ }
707
+
708
+ if (outputJson) {
709
+ log(JSON.stringify({ locales: results }, null, 2))
710
+ return
711
+ }
712
+
713
+ // Human-readable output
714
+ for (const [loc, info] of Object.entries(results)) {
715
+ log(`${colors.bright}${loc}:${colors.reset}`)
716
+
717
+ if (info.total === 0) {
718
+ log(` ${colors.dim}No free-form translations${colors.reset}`)
719
+ log('')
720
+ continue
721
+ }
722
+
723
+ // Group by path prefix (pages, page-ids, collections)
724
+ if (info.stale.length > 0) {
725
+ log(` ${colors.yellow}Stale (source changed):${colors.reset}`)
726
+ for (const item of info.stale) {
727
+ log(
728
+ ` ${colors.yellow}⚠${colors.reset} ${item.path} ${colors.dim}(${item.recordedDate})${colors.reset}`
729
+ )
730
+ }
731
+ }
732
+
733
+ if (info.orphaned.length > 0) {
734
+ log(` ${colors.red}Orphaned (source not found):${colors.reset}`)
735
+ for (const item of info.orphaned) {
736
+ log(` ${colors.red}✗${colors.reset} ${item.path}`)
737
+ }
738
+ }
739
+
740
+ log(
741
+ ` ${colors.dim}Summary: ${info.upToDate} up to date, ${info.stale.length} stale, ${info.orphaned.length} orphaned${colors.reset}`
742
+ )
743
+ log('')
744
+ }
745
+
746
+ // Show next steps
747
+ const hasStale = Object.values(results).some((r) => r.stale.length > 0)
748
+ const hasOrphaned = Object.values(results).some(
749
+ (r) => r.orphaned.length > 0
750
+ )
751
+
752
+ if (hasStale) {
753
+ log(
754
+ `${colors.dim}Run 'uniweb i18n update-hash <locale> --all-stale' to update hashes after reviewing.${colors.reset}`
755
+ )
756
+ }
757
+ if (hasOrphaned) {
758
+ log(
759
+ `${colors.dim}Run 'uniweb i18n prune --freeform' to remove orphaned translations.${colors.reset}`
760
+ )
761
+ }
762
+ } catch (err) {
763
+ error(`Status check failed: ${err.message}`)
764
+ process.exit(1)
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Status --missing mode - show detailed missing strings
770
+ */
771
+ async function runStatusMissing(siteRoot, config, locale, options = {}) {
772
+ const { outputJson = false, byPage = false, scope = translationScope(config, []) } = options
773
+ const localesPath = join(siteRoot, scope.dir)
774
+
775
+ if (config.locales.length === 0) {
776
+ if (outputJson) {
777
+ log(
778
+ JSON.stringify(
779
+ { error: 'No translation files found', missing: [] },
780
+ null,
781
+ 2
782
+ )
783
+ )
784
+ } else {
785
+ log(
786
+ `${colors.dim}No translation files found in ${config.localesDir}/.${colors.reset}`
787
+ )
788
+ }
789
+ return
790
+ }
791
+
792
+ try {
793
+ const { auditLocale } = await import('@uniweb/build/i18n')
794
+
795
+ const localesToCheck = locale ? [locale] : config.locales
796
+
797
+ // Collect missing strings from all requested locales
798
+ const allMissing = []
799
+ const byLocale = {}
800
+
801
+ for (const loc of localesToCheck) {
802
+ const result = await auditLocale(localesPath, loc)
803
+ byLocale[loc] = {
804
+ total: result.total,
805
+ translated: result.valid.length,
806
+ missing: result.missing.length,
807
+ coverage:
808
+ result.total > 0
809
+ ? Math.round((result.valid.length / result.total) * 100)
810
+ : 100
811
+ }
812
+
813
+ // Add locale info to each missing entry
814
+ for (const entry of result.missing) {
815
+ allMissing.push({
816
+ locale: loc,
817
+ ...entry
818
+ })
819
+ }
820
+ }
821
+
822
+ // JSON output
823
+ if (outputJson) {
824
+ const output = {
825
+ locales: byLocale,
826
+ missing: allMissing
827
+ }
828
+ log(JSON.stringify(output, null, 2))
829
+ return
830
+ }
831
+
832
+ // Human-readable output
833
+ log(`\n${colors.cyan}Missing Translations${colors.reset}\n`)
834
+
835
+ // Show summary per locale
836
+ for (const [loc, info] of Object.entries(byLocale)) {
837
+ log(`${loc}: ${info.missing} missing (${info.coverage}% coverage)`)
838
+ }
839
+
840
+ if (allMissing.length === 0) {
841
+ log(`\n${colors.green}All strings are translated!${colors.reset}`)
842
+ return
843
+ }
844
+
845
+ log('')
846
+
847
+ // Group by page if requested
848
+ if (byPage) {
849
+ const grouped = groupByPage(allMissing)
850
+ for (const [page, entries] of Object.entries(grouped)) {
851
+ log(`${colors.bright}${page}${colors.reset}`)
852
+ for (const entry of entries) {
853
+ const preview = truncateString(entry.source, 50)
854
+ log(` ${colors.dim}${entry.hash}${colors.reset} "${preview}"`)
855
+ }
856
+ log('')
857
+ }
858
+ } else {
859
+ // Flat list
860
+ for (const entry of allMissing.slice(0, 20)) {
861
+ const preview = truncateString(entry.source, 60)
862
+ const context = entry.contexts?.[0]
863
+ // A page string's context names its page and section; a record string's, its record
864
+ const location = context ? context.record ?? `${context.page}:${context.section}` : ''
865
+ log(` ${colors.dim}${entry.hash}${colors.reset} "${preview}"`)
866
+ if (location) {
867
+ log(` ${colors.dim}→ ${location}${colors.reset}`)
868
+ }
869
+ }
870
+
871
+ if (allMissing.length > 20) {
872
+ log(
873
+ `\n ${colors.dim}... and ${allMissing.length - 20} more${colors.reset}`
874
+ )
875
+ }
876
+ }
877
+
878
+ log(
879
+ `\n${colors.dim}Use --json to export for translation tools.${colors.reset}`
880
+ )
881
+ } catch (err) {
882
+ error(`Status check failed: ${err.message}`)
883
+ process.exit(1)
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Group missing entries by page
889
+ */
890
+ function groupByPage(entries) {
891
+ const grouped = {}
892
+ for (const entry of entries) {
893
+ const page = entry.contexts?.[0]?.page || entry.contexts?.[0]?.record || 'unknown'
894
+ if (!grouped[page]) grouped[page] = []
895
+ grouped[page].push(entry)
896
+ }
897
+ return grouped
898
+ }
899
+
900
+ /**
901
+ * Truncate string for display
902
+ */
903
+ function truncateString(str, maxLen) {
904
+ if (str.length <= maxLen) return str
905
+ return str.slice(0, maxLen - 3) + '...'
906
+ }
907
+
908
+ /**
909
+ * Audit command - find stale and missing translations
910
+ */
911
+ async function runAudit(siteRoot, config, args) {
912
+ const locale = args.find((a) => !a.startsWith('-'))
913
+ const clean = args.includes('--clean')
914
+ const verbose = args.includes('--verbose') || args.includes('-v')
915
+ const scope = translationScope(config, args)
916
+
917
+ log(`\n${colors.cyan}${scope.records ? 'Record ' : ''}Translation Audit${colors.reset}\n`)
918
+
919
+ // Check if manifest exists
920
+ const localesPath = join(siteRoot, scope.dir)
921
+ const manifestPath = join(localesPath, 'manifest.json')
922
+ if (!existsSync(manifestPath)) {
923
+ error(`No ${scope.noun} found. Run "${scope.extract}" first.`)
924
+ process.exit(1)
925
+ }
926
+
927
+ if (config.locales.length === 0) {
928
+ log(`${colors.dim}No translation files found in ${config.localesDir}/.`)
929
+ log(
930
+ `Create locale files like ${config.localesDir}/es.json to add translations.${colors.reset}`
931
+ )
932
+ return
933
+ }
934
+
935
+ try {
936
+ const { auditLocale, cleanLocale, formatAuditReport } =
937
+ await import('@uniweb/build/i18n')
938
+
939
+ const localesToAudit = locale ? [locale] : config.locales
940
+ const results = []
941
+
942
+ for (const loc of localesToAudit) {
943
+ const result = await auditLocale(localesPath, loc)
944
+ results.push(result)
945
+ }
946
+
947
+ // Show report
948
+ log(formatAuditReport(results, { verbose }))
949
+
950
+ // Clean if requested
951
+ if (clean) {
952
+ log('')
953
+ let totalRemoved = 0
954
+
955
+ for (const result of results) {
956
+ if (result.stale.length > 0) {
957
+ const staleHashes = result.stale.map((s) => s.hash)
958
+ const removed = await cleanLocale(
959
+ localesPath,
960
+ result.locale,
961
+ staleHashes
962
+ )
963
+ if (removed > 0) {
964
+ success(
965
+ `Removed ${removed} stale entries from ${result.locale}.json`
966
+ )
967
+ totalRemoved += removed
968
+ }
969
+ }
970
+ }
971
+
972
+ if (totalRemoved === 0) {
973
+ log(`${colors.dim}No stale entries to remove.${colors.reset}`)
974
+ }
975
+ } else {
976
+ // Suggest --clean if there are stale entries
977
+ const hasStale = results.some((r) => r.stale.length > 0)
978
+ if (hasStale) {
979
+ log(
980
+ `\n${colors.dim}Run with --clean to remove stale entries.${colors.reset}`
981
+ )
982
+ }
983
+ }
984
+
985
+ // ⭐ Report translations that will render as flat prose.
986
+ // Coverage cannot see this class — it counts keys, not fidelity — so a site
987
+ // whose every link has been flattened reports 100%. That is exactly how a
988
+ // fully translated documentation site shipped with zero internal links.
989
+ const losesMarkupTotal = results.reduce(
990
+ (sum, r) => sum + (r.losesMarkup?.length || 0),
991
+ 0
992
+ )
993
+ if (losesMarkupTotal > 0) {
994
+ log(
995
+ `\n${colors.yellow}${losesMarkupTotal} translation(s) drop the inline markup their source carries.`
996
+ )
997
+ log(
998
+ `Links, bold and emphasis will be missing from these — a translation value is`
999
+ )
1000
+ log(
1001
+ `inline markdown, so copy the [text](href) and ** ** across from the source.${colors.reset}`
1002
+ )
1003
+ if (!verbose) {
1004
+ log(
1005
+ `${colors.dim}Run with --verbose to see which entries are affected.${colors.reset}`
1006
+ )
1007
+ }
1008
+ }
1009
+ } catch (err) {
1010
+ error(`Audit failed: ${err.message}`)
1011
+ process.exit(1)
1012
+ }
1013
+ }
1014
+
1015
+ // ─────────────────────────────────────────────────────────────────
1016
+ // Free-form Translation Commands
1017
+ // ─────────────────────────────────────────────────────────────────
1018
+
1019
+ /**
1020
+ * Initialize a free-form translation file
1021
+ *
1022
+ * Usage:
1023
+ * uniweb i18n init-freeform es pages/about hero
1024
+ * uniweb i18n init-freeform es page-ids/installation intro
1025
+ * uniweb i18n init-freeform es records/article getting-started
1026
+ */
1027
+ async function runInitFreeform(siteRoot, config, args) {
1028
+ const locale = args[0]
1029
+ const pathType = args[1] // pages/about, page-ids/installation, records/article
1030
+ const sectionId = args[2] // hero, intro, getting-started
1031
+
1032
+ if (!locale || !pathType || !sectionId) {
1033
+ error('Usage: uniweb i18n init-freeform <locale> <path> <section-id>')
1034
+ log(`${colors.dim}Examples:`)
1035
+ log(' uniweb i18n init-freeform es pages/about hero')
1036
+ log(' uniweb i18n init-freeform es page-ids/installation intro')
1037
+ log(
1038
+ ` uniweb i18n init-freeform es records/article getting-started${colors.reset}`
1039
+ )
1040
+ process.exit(1)
1041
+ }
1042
+
1043
+ // A path names a page by route, a page by id, or a record by its schema folder —
1044
+ // and nothing else. Said up front: past this point an unknown kind would only
1045
+ // surface as "source content not found", which names the wrong cause.
1046
+ if (!/^(pages|page-ids|records)\//.test(pathType)) {
1047
+ error(`Unknown kind of path: ${pathType}`)
1048
+ log(`${colors.dim}A path starts with pages/, page-ids/ or records/ (a record's schema folder).${colors.reset}`)
1049
+ process.exit(1)
1050
+ }
1051
+
1052
+ const localesPath = join(siteRoot, config.localesDir)
1053
+ const freeformDir = join(localesPath, 'freeform', locale)
1054
+
1055
+ // Determine target file path
1056
+ const relativePath = `${pathType}/${sectionId}.md`
1057
+ const targetPath = join(freeformDir, relativePath)
1058
+
1059
+ // Check if already exists
1060
+ if (existsSync(targetPath)) {
1061
+ error(`Translation already exists: ${relativePath}`)
1062
+ log(
1063
+ `${colors.dim}Edit it directly or use 'update-hash' after changes.${colors.reset}`
1064
+ )
1065
+ process.exit(1)
1066
+ }
1067
+
1068
+ try {
1069
+ // Load site content to get source
1070
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
1071
+ if (!existsSync(siteContentPath)) {
1072
+ error('Site content not found. Run "uniweb build" first.')
1073
+ process.exit(1)
1074
+ }
1075
+
1076
+ const siteContentRaw = await readFile(siteContentPath, 'utf-8')
1077
+ const siteContent = JSON.parse(siteContentRaw)
1078
+
1079
+ // Find the source content
1080
+ let sourceContent = null
1081
+ let sourceHash = null
1082
+
1083
+ if (pathType.startsWith('pages/') || pathType.startsWith('page-ids/')) {
1084
+ // Find section in pages
1085
+ const isPageId = pathType.startsWith('page-ids/')
1086
+ const pageIdentifier = pathType.replace(/^(pages|page-ids)\//, '')
1087
+
1088
+ for (const page of siteContent.pages || []) {
1089
+ const match = isPageId
1090
+ ? page.id === pageIdentifier
1091
+ : normalizeRoute(page.route) === pageIdentifier
1092
+
1093
+ if (match) {
1094
+ for (const section of page.sections || []) {
1095
+ if (section.stableId === sectionId) {
1096
+ sourceContent = section.content
1097
+ break
1098
+ }
1099
+ }
1100
+ if (sourceContent) break
1101
+ }
1102
+ }
1103
+ } else if (pathType.startsWith('records/')) {
1104
+ // ⛔ ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
1105
+ // tree is `records/<schema dirs>/<slug>.md`; this used to take
1106
+ // `collections/<query>` and write a path nothing read — the loader moved
1107
+ // and this did not.
1108
+ //
1109
+ // The record's CONTENT still lives in a query's materialization, so the
1110
+ // query that covers this schema is resolved rather than named.
1111
+ const poolDirs = pathType.slice('records/'.length)
1112
+ const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
1113
+ let queryName = null
1114
+ try {
1115
+ const { declarations } = await resolveQueriesConfig(siteRoot)
1116
+ for (const [name, decl] of Object.entries(declarations || {})) {
1117
+ const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
1118
+ if (dirs && dirs.join('/') === poolDirs) { queryName = name; break }
1119
+ }
1120
+ } catch {
1121
+ /* no resolvable config — fall through to the not-found message below */
1122
+ }
1123
+ const dataPath = queryName
1124
+ ? join(siteRoot, 'public', 'data', `${queryName}.json`)
1125
+ : join(siteRoot, 'public', 'data', '__none__.json')
1126
+
1127
+ if (existsSync(dataPath)) {
1128
+ const dataRaw = await readFile(dataPath, 'utf-8')
1129
+ const items = JSON.parse(dataRaw)
1130
+
1131
+ for (const item of items) {
1132
+ // A compiled record is named by `$name` — it carries no `slug` since 2026-09-27.
1133
+ if (item.$name === sectionId) {
1134
+ sourceContent = item.content
1135
+ break
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ if (!sourceContent) {
1142
+ error(`Source content not found for: ${pathType}/${sectionId}`)
1143
+ process.exit(1)
1144
+ }
1145
+
1146
+ // Convert ProseMirror to markdown (simplified - just extract text)
1147
+ const markdown = proseMirrorToMarkdown(sourceContent)
1148
+
1149
+ // Create directory structure
1150
+ await mkdir(dirname(targetPath), { recursive: true })
1151
+
1152
+ // Write translation file
1153
+ await writeFile(targetPath, markdown)
1154
+
1155
+ // Record hash in manifest
1156
+ const { computeSourceHash, recordHash } = await import('@uniweb/build/i18n')
1157
+ sourceHash = computeSourceHash(sourceContent)
1158
+ await recordHash(freeformDir, relativePath, sourceHash)
1159
+
1160
+ success(`Created free-form translation: ${relativePath}`)
1161
+ log(
1162
+ `${colors.dim}Edit the file, then run 'update-hash' when source changes.${colors.reset}`
1163
+ )
1164
+ } catch (err) {
1165
+ error(`Failed to initialize free-form translation: ${err.message}`)
1166
+ process.exit(1)
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Normalize route for comparison (remove leading/trailing slashes)
1172
+ */
1173
+ function normalizeRoute(route) {
1174
+ return route.replace(/^\/|\/$/g, '')
1175
+ }
1176
+
1177
+ /**
1178
+ * Convert ProseMirror document to markdown (simplified)
1179
+ */
1180
+ function proseMirrorToMarkdown(doc) {
1181
+ if (!doc || !doc.content) return ''
1182
+
1183
+ const lines = []
1184
+
1185
+ for (const node of doc.content) {
1186
+ if (node.type === 'heading') {
1187
+ const level = node.attrs?.level || 1
1188
+ const prefix = '#'.repeat(level)
1189
+ const text = extractText(node)
1190
+ lines.push(`${prefix} ${text}`)
1191
+ lines.push('')
1192
+ } else if (node.type === 'paragraph') {
1193
+ const text = extractText(node)
1194
+ if (text) lines.push(text)
1195
+ lines.push('')
1196
+ } else if (node.type === 'bulletList') {
1197
+ for (const item of node.content || []) {
1198
+ const text = extractText(item)
1199
+ if (text) lines.push(`- ${text}`)
1200
+ }
1201
+ lines.push('')
1202
+ } else if (node.type === 'orderedList') {
1203
+ let num = 1
1204
+ for (const item of node.content || []) {
1205
+ const text = extractText(item)
1206
+ if (text) lines.push(`${num}. ${text}`)
1207
+ num++
1208
+ }
1209
+ lines.push('')
1210
+ } else if (node.type === 'codeBlock') {
1211
+ const lang = node.attrs?.language || ''
1212
+ const text = extractText(node)
1213
+ lines.push('```' + lang)
1214
+ lines.push(text)
1215
+ lines.push('```')
1216
+ lines.push('')
1217
+ } else if (node.type === 'blockquote') {
1218
+ const text = extractText(node)
1219
+ lines.push(`> ${text}`)
1220
+ lines.push('')
1221
+ }
1222
+ }
1223
+
1224
+ return lines.join('\n').trim() + '\n'
1225
+ }
1226
+
1227
+ /**
1228
+ * Extract text from a ProseMirror node
1229
+ */
1230
+ function extractText(node) {
1231
+ if (!node) return ''
1232
+ if (node.type === 'text') return node.text || ''
1233
+ if (!node.content) return ''
1234
+ return node.content.map(extractText).join('')
1235
+ }
1236
+
1237
+ /**
1238
+ * Update the hash for a free-form translation
1239
+ *
1240
+ * Usage:
1241
+ * uniweb i18n update-hash es pages/about hero
1242
+ * uniweb i18n update-hash es --all-stale
1243
+ */
1244
+ async function runUpdateHash(siteRoot, config, args) {
1245
+ const locale = args[0]
1246
+ const allStale = args.includes('--all-stale')
1247
+
1248
+ if (!locale) {
1249
+ error('Usage: uniweb i18n update-hash <locale> [path] [section-id]')
1250
+ log(
1251
+ `${colors.dim}Or: uniweb i18n update-hash <locale> --all-stale${colors.reset}`
1252
+ )
1253
+ process.exit(1)
1254
+ }
1255
+
1256
+ const localesPath = join(siteRoot, config.localesDir)
1257
+ const freeformDir = join(localesPath, 'freeform', locale)
1258
+
1259
+ if (!existsSync(freeformDir)) {
1260
+ error(`No free-form translations found for locale: ${locale}`)
1261
+ process.exit(1)
1262
+ }
1263
+
1264
+ try {
1265
+ // Load site content
1266
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
1267
+ if (!existsSync(siteContentPath)) {
1268
+ error('Site content not found. Run "uniweb build" first.')
1269
+ process.exit(1)
1270
+ }
1271
+
1272
+ const siteContentRaw = await readFile(siteContentPath, 'utf-8')
1273
+ const siteContent = JSON.parse(siteContentRaw)
1274
+
1275
+ const {
1276
+ updateHash,
1277
+ freeformSourceIndex,
1278
+ getStaleTranslations
1279
+ } = await import('@uniweb/build/i18n')
1280
+
1281
+ // The hash of each section's source, at every path the renderer reads its translation from
1282
+ const { sourceHashes } = freeformSourceIndex(siteContent)
1283
+
1284
+ if (allStale) {
1285
+ // Update all stale translations
1286
+ const stale = await getStaleTranslations(freeformDir, sourceHashes)
1287
+
1288
+ if (stale.length === 0) {
1289
+ log(`${colors.dim}No stale translations found.${colors.reset}`)
1290
+ return
1291
+ }
1292
+
1293
+ for (const item of stale) {
1294
+ await updateHash(freeformDir, item.path, item.currentHash)
1295
+ success(`Updated hash: ${item.path}`)
1296
+ }
1297
+
1298
+ log(`\nUpdated ${stale.length} translation hashes.`)
1299
+ } else {
1300
+ // Update specific translation
1301
+ const pathType = args[1]
1302
+ const sectionId = args[2]
1303
+
1304
+ if (!pathType || !sectionId) {
1305
+ error('Usage: uniweb i18n update-hash <locale> <path> <section-id>')
1306
+ process.exit(1)
1307
+ }
1308
+
1309
+ const relativePath = `${pathType}/${sectionId}.md`
1310
+ const currentHash = sourceHashes[relativePath]
1311
+
1312
+ if (!currentHash) {
1313
+ error(`Source not found for: ${relativePath}`)
1314
+ process.exit(1)
1315
+ }
1316
+
1317
+ await updateHash(freeformDir, relativePath, currentHash)
1318
+ success(`Updated hash: ${relativePath}`)
1319
+ }
1320
+ } catch (err) {
1321
+ error(`Failed to update hash: ${err.message}`)
1322
+ process.exit(1)
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * Move free-form translations when pages are reorganized
1328
+ *
1329
+ * Usage:
1330
+ * uniweb i18n move pages/docs/setup pages/getting-started
1331
+ */
1332
+ async function runMove(siteRoot, config, args) {
1333
+ const oldPath = args[0]
1334
+ const newPath = args[1]
1335
+
1336
+ if (!oldPath || !newPath) {
1337
+ error('Usage: uniweb i18n move <old-path> <new-path>')
1338
+ log(
1339
+ `${colors.dim}Example: uniweb i18n move pages/docs/setup pages/getting-started${colors.reset}`
1340
+ )
1341
+ process.exit(1)
1342
+ }
1343
+
1344
+ const localesPath = join(siteRoot, config.localesDir)
1345
+ const freeformPath = join(localesPath, 'freeform')
1346
+
1347
+ if (!existsSync(freeformPath)) {
1348
+ log(`${colors.dim}No free-form translations found.${colors.reset}`)
1349
+ return
1350
+ }
1351
+
1352
+ try {
1353
+ const { renameManifestEntries } = await import('@uniweb/build/i18n')
1354
+
1355
+ // Find all locales with free-form translations
1356
+ const entries = await readdir(freeformPath, { withFileTypes: true })
1357
+ const locales = entries.filter((e) => e.isDirectory()).map((e) => e.name)
1358
+
1359
+ let totalMoved = 0
1360
+
1361
+ for (const locale of locales) {
1362
+ const localeDir = join(freeformPath, locale)
1363
+ const oldDir = join(localeDir, oldPath)
1364
+
1365
+ if (!existsSync(oldDir)) continue
1366
+
1367
+ const newDir = join(localeDir, newPath)
1368
+
1369
+ // Move all files
1370
+ const files = await discoverFiles(oldDir)
1371
+ const oldPaths = []
1372
+ const newPaths = []
1373
+
1374
+ for (const file of files) {
1375
+ const relOld = relative(localeDir, file)
1376
+ const relNew = relOld.replace(oldPath, newPath)
1377
+ oldPaths.push(relOld)
1378
+ newPaths.push(relNew)
1379
+
1380
+ // Create target directory
1381
+ await mkdir(dirname(join(localeDir, relNew)), { recursive: true })
1382
+
1383
+ // Move file
1384
+ await rename(file, join(localeDir, relNew))
1385
+ totalMoved++
1386
+ }
1387
+
1388
+ // Update manifest
1389
+ if (oldPaths.length > 0) {
1390
+ await renameManifestEntries(localeDir, oldPaths, newPaths)
1391
+ }
1392
+ }
1393
+
1394
+ if (totalMoved > 0) {
1395
+ success(
1396
+ `Moved ${totalMoved} translation file(s) across ${locales.length} locale(s)`
1397
+ )
1398
+ } else {
1399
+ log(`${colors.dim}No translations found at: ${oldPath}${colors.reset}`)
1400
+ }
1401
+ } catch (err) {
1402
+ error(`Failed to move translations: ${err.message}`)
1403
+ process.exit(1)
1404
+ }
1405
+ }
1406
+
1407
+ /**
1408
+ * Rename free-form translation files
1409
+ *
1410
+ * Usage:
1411
+ * uniweb i18n rename pages/about hero welcome
1412
+ */
1413
+ async function runRename(siteRoot, config, args) {
1414
+ const path = args[0]
1415
+ const oldName = args[1]
1416
+ const newName = args[2]
1417
+
1418
+ if (!path || !oldName || !newName) {
1419
+ error('Usage: uniweb i18n rename <path> <old-name> <new-name>')
1420
+ log(
1421
+ `${colors.dim}Example: uniweb i18n rename pages/about hero welcome${colors.reset}`
1422
+ )
1423
+ process.exit(1)
1424
+ }
1425
+
1426
+ const localesPath = join(siteRoot, config.localesDir)
1427
+ const freeformPath = join(localesPath, 'freeform')
1428
+
1429
+ if (!existsSync(freeformPath)) {
1430
+ log(`${colors.dim}No free-form translations found.${colors.reset}`)
1431
+ return
1432
+ }
1433
+
1434
+ try {
1435
+ const { renameManifestEntries } = await import('@uniweb/build/i18n')
1436
+
1437
+ // Find all locales with free-form translations
1438
+ const entries = await readdir(freeformPath, { withFileTypes: true })
1439
+ const locales = entries.filter((e) => e.isDirectory()).map((e) => e.name)
1440
+
1441
+ let totalRenamed = 0
1442
+
1443
+ for (const locale of locales) {
1444
+ const localeDir = join(freeformPath, locale)
1445
+ const oldFile = join(localeDir, path, `${oldName}.md`)
1446
+ const newFile = join(localeDir, path, `${newName}.md`)
1447
+
1448
+ if (!existsSync(oldFile)) continue
1449
+
1450
+ // Rename file
1451
+ await rename(oldFile, newFile)
1452
+
1453
+ // Update manifest
1454
+ const oldRelPath = `${path}/${oldName}.md`
1455
+ const newRelPath = `${path}/${newName}.md`
1456
+ await renameManifestEntries(localeDir, [oldRelPath], [newRelPath])
1457
+
1458
+ totalRenamed++
1459
+ }
1460
+
1461
+ if (totalRenamed > 0) {
1462
+ success(`Renamed translation in ${totalRenamed} locale(s)`)
1463
+ } else {
1464
+ log(
1465
+ `${colors.dim}No translations found: ${path}/${oldName}.md${colors.reset}`
1466
+ )
1467
+ }
1468
+ } catch (err) {
1469
+ error(`Failed to rename translation: ${err.message}`)
1470
+ process.exit(1)
1471
+ }
1472
+ }
1473
+
1474
+ /**
1475
+ * Prune orphaned free-form translations
1476
+ *
1477
+ * Usage:
1478
+ * uniweb i18n prune --freeform [--dry-run]
1479
+ */
1480
+ async function runPrune(siteRoot, config, args) {
1481
+ const freeformMode = args.includes('--freeform')
1482
+ const dryRun = args.includes('--dry-run')
1483
+
1484
+ if (!freeformMode) {
1485
+ error('Usage: uniweb i18n prune --freeform [--dry-run]')
1486
+ process.exit(1)
1487
+ }
1488
+
1489
+ const localesPath = join(siteRoot, config.localesDir)
1490
+ const freeformPath = join(localesPath, 'freeform')
1491
+
1492
+ if (!existsSync(freeformPath)) {
1493
+ log(`${colors.dim}No free-form translations found.${colors.reset}`)
1494
+ return
1495
+ }
1496
+
1497
+ log(
1498
+ `\n${colors.cyan}Pruning orphaned free-form translations${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
1499
+ )
1500
+
1501
+ try {
1502
+ // Load site content
1503
+ const siteContentPath = join(siteRoot, 'dist', 'site-content.json')
1504
+ if (!existsSync(siteContentPath)) {
1505
+ error('Site content not found. Run "uniweb build" first.')
1506
+ process.exit(1)
1507
+ }
1508
+
1509
+ const siteContentRaw = await readFile(siteContentPath, 'utf-8')
1510
+ const siteContent = JSON.parse(siteContentRaw)
1511
+
1512
+ const {
1513
+ freeformSourceIndex,
1514
+ getOrphanedTranslations,
1515
+ removeManifestEntries
1516
+ } = await import('@uniweb/build/i18n')
1517
+
1518
+ // ⛔ THIS DELETES WHAT IT CALLS ORPHANED, so it judges a file exactly as the renderer
1519
+ // reads one (`freeformSourceIndex`, the build's own index): every path a section's
1520
+ // translation is read from — ⛔ until 2026-09-14 only `page-ids/<id>/…` on a page with
1521
+ // an `id`, so a route-addressed translation the page rendered was deleted — and only
1522
+ // what the built content can see: never a record's translation (`records/…`), and
1523
+ // never one for a page whose sections the content does not carry.
1524
+ const { validPaths, canJudge } = freeformSourceIndex(siteContent)
1525
+
1526
+ // Find all locales
1527
+ const entries = await readdir(freeformPath, { withFileTypes: true })
1528
+ const locales = entries.filter((e) => e.isDirectory()).map((e) => e.name)
1529
+
1530
+ let totalPruned = 0
1531
+
1532
+ for (const locale of locales) {
1533
+ const localeDir = join(freeformPath, locale)
1534
+
1535
+ // Get orphaned translations — those the content can judge
1536
+ const orphaned = (await getOrphanedTranslations(localeDir, validPaths)).filter((o) => canJudge(o.path))
1537
+
1538
+ if (orphaned.length === 0) continue
1539
+
1540
+ log(`${locale}:`)
1541
+
1542
+ for (const item of orphaned) {
1543
+ log(` ${colors.red}✗${colors.reset} ${item.path}`)
1544
+
1545
+ if (!dryRun) {
1546
+ // Delete file
1547
+ const filePath = join(localeDir, item.path)
1548
+ if (existsSync(filePath)) {
1549
+ await unlink(filePath)
1550
+ }
1551
+ }
1552
+
1553
+ totalPruned++
1554
+ }
1555
+
1556
+ // Update manifest
1557
+ if (!dryRun && orphaned.length > 0) {
1558
+ const paths = orphaned.map((o) => o.path)
1559
+ await removeManifestEntries(localeDir, paths)
1560
+ }
1561
+ }
1562
+
1563
+ if (totalPruned > 0) {
1564
+ if (dryRun) {
1565
+ log(
1566
+ `\n${colors.dim}Would remove ${totalPruned} orphaned translation(s). Run without --dry-run to delete.${colors.reset}`
1567
+ )
1568
+ } else {
1569
+ success(`\nRemoved ${totalPruned} orphaned translation(s)`)
1570
+ }
1571
+ } else {
1572
+ log(`${colors.dim}No orphaned translations found.${colors.reset}`)
1573
+ }
1574
+ } catch (err) {
1575
+ error(`Failed to prune translations: ${err.message}`)
1576
+ process.exit(1)
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Recursively discover all files in a directory
1582
+ */
1583
+ async function discoverFiles(dir) {
1584
+ const files = []
1585
+
1586
+ if (!existsSync(dir)) return files
1587
+
1588
+ const entries = await readdir(dir, { withFileTypes: true })
1589
+
1590
+ for (const entry of entries) {
1591
+ const fullPath = join(dir, entry.name)
1592
+ if (entry.isDirectory()) {
1593
+ files.push(...(await discoverFiles(fullPath)))
1594
+ } else {
1595
+ files.push(fullPath)
1596
+ }
1597
+ }
1598
+
1599
+ return files
1600
+ }
1601
+
1602
+ /**
1603
+ * Show help for i18n commands
1604
+ */
1605
+ function showHelp() {
1606
+ log(`
1607
+ ${colors.cyan}${colors.bright}Uniweb i18n${colors.reset}
1608
+
1609
+ Site content internationalization commands.
1610
+
1611
+ ${colors.bright}Usage:${colors.reset}
1612
+ uniweb i18n [command] [options]
1613
+
1614
+ ${colors.bright}Commands:${colors.reset}
1615
+ ${colors.dim}# Hash-based (granular) translation${colors.reset}
1616
+ extract Extract/update translatable strings (default if no command given)
1617
+ generate Generate starter locale files from manifest keys
1618
+ status Show translation coverage per locale
1619
+ audit Find stale translations (no longer in manifest) and missing ones
1620
+
1621
+ ${colors.dim}# Free-form (complete replacement) translation${colors.reset}
1622
+ init-freeform <locale> <path> <id> Create free-form translation from source
1623
+ update-hash <locale> [<path> <id>] Update hash after reviewing source changes
1624
+ move <old-path> <new-path> Move translations when pages reorganize
1625
+ rename <path> <old-id> <new-id> Rename translation file
1626
+ prune --freeform Remove orphaned free-form translations
1627
+
1628
+ ${colors.bright}Options:${colors.reset}
1629
+ -t, --target <path> Site directory (auto-detected if not specified)
1630
+ --verbose Show detailed output
1631
+ --dry-run (extract/prune) Preview changes without writing files
1632
+ --empty (generate) Use empty strings instead of source text
1633
+ --force (generate) Overwrite existing locale files entirely
1634
+ --clean (audit) Remove stale entries from locale files
1635
+ --missing (status) List all missing strings instead of summary
1636
+ --freeform (status/prune) Include free-form translation status
1637
+ --json (status) Output as JSON for translation tools
1638
+ --by-page (status --missing) Group missing strings by page
1639
+ --records-only (extract/status/audit) Process only records
1640
+ --no-records (extract/status/audit) Skip records (pages only)
1641
+ --all-stale (update-hash) Update all stale translations at once
1642
+
1643
+ ${colors.bright}Configuration:${colors.reset}
1644
+ Optional site.yml settings:
1645
+
1646
+ languages: [es, fr] # Languages to build (default: all available)
1647
+
1648
+ i18n:
1649
+ localesDir: locales # Directory for translation files (default: locales)
1650
+
1651
+ By default, all *.json files in locales/ are treated as translation targets.
1652
+
1653
+ ${colors.bright}Workflow:${colors.reset}
1654
+ 1. Extract strings: uniweb i18n extract
1655
+ 2. Generate locale files: uniweb i18n generate es fr
1656
+ 3. Translate locale files: Edit locales/es.json, locales/fr.json, etc.
1657
+ 4. Build with translations: uniweb build (generates locale-specific output)
1658
+
1659
+ ${colors.bright}File Structure:${colors.reset}
1660
+ locales/
1661
+ manifest.json Auto-generated: source strings + hashes + contexts
1662
+ es.json Translations for Spanish (hash-based)
1663
+ fr.json Translations for French (hash-based)
1664
+ _memory.json Optional: translation memory for reuse
1665
+ freeform/ Free-form translations (complete content replacement)
1666
+ es/
1667
+ .manifest.json Staleness tracking
1668
+ pages/about/hero.md Translated content for /about page, hero section
1669
+ page-ids/install/intro.md Translated content by page ID
1670
+ records/article/getting-started.md
1671
+
1672
+ ${colors.bright}Examples:${colors.reset}
1673
+ ${colors.dim}# Hash-based workflow${colors.reset}
1674
+ uniweb i18n extract # Extract all translatable strings
1675
+ uniweb i18n extract --dry-run # Preview without writing
1676
+ uniweb i18n extract --verbose # Show extracted strings
1677
+ uniweb i18n extract --no-records # Pages only (skip records)
1678
+ uniweb i18n generate es fr # Create starter files for Spanish and French
1679
+ uniweb i18n generate --empty # Create files with empty values (for translators)
1680
+ uniweb i18n generate --force # Overwrite existing locale files
1681
+ uniweb i18n status # Show coverage for all locales
1682
+ uniweb i18n status es # Show coverage for Spanish only
1683
+ uniweb i18n status es --missing --json # Export missing for AI translation
1684
+ uniweb i18n audit # Find stale and missing translations
1685
+ uniweb i18n audit --clean # Remove stale entries
1686
+
1687
+ ${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
1688
+ uniweb i18n init-freeform es pages/about hero
1689
+ uniweb i18n init-freeform es page-ids/installation intro
1690
+ uniweb i18n init-freeform es records/article getting-started
1691
+ uniweb i18n status --freeform # Show free-form translation status
1692
+ uniweb i18n update-hash es --all-stale # Update hashes after review
1693
+ uniweb i18n move pages/docs/setup pages/getting-started
1694
+ uniweb i18n prune --freeform --dry-run # Preview orphan cleanup
1695
+ uniweb i18n --target site # Specify site directory explicitly
1696
+
1697
+ ${colors.bright}Aliases:${colors.reset}
1698
+ sync → extract (backward-compatible)
1699
+ init → generate (backward-compatible)
1700
+
1701
+ ${colors.bright}Notes:${colors.reset}
1702
+ Run from a site directory to operate on that site.
1703
+ Run from workspace root to auto-detect sites (prompts if multiple).
1704
+ `)
1705
+ }
1706
+
1707
+ export default i18n