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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|