@shipi18n/cli 1.1.5 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/NOTICE +7 -1
- package/README.md +101 -526
- package/bin/shipi18n.js +36 -50
- package/package.json +43 -50
- package/src/commands/check.js +160 -0
- package/src/commands/lock.js +131 -0
- package/src/commands/translate.js +73 -316
- package/src/reporters.js +189 -0
- package/src/commands/config.js +0 -94
- package/src/commands/init.js +0 -443
- package/src/commands/keys.js +0 -128
- package/src/lib/api.js +0 -342
- package/src/lib/config.js +0 -74
- package/src/utils/incremental.js +0 -90
- package/src/utils/logger.js +0 -46
package/bin/shipi18n.js
CHANGED
|
@@ -1,55 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
// Read package.json for version
|
|
17
|
-
const packageJson = JSON.parse(
|
|
18
|
-
readFileSync(join(__dirname, '../package.json'), 'utf8')
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const program = new Command();
|
|
22
|
-
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { translateCommand } from '../src/commands/translate.js'
|
|
8
|
+
import { checkCommand } from '../src/commands/check.js'
|
|
9
|
+
import { lockCommand } from '../src/commands/lock.js'
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
13
|
+
|
|
14
|
+
const program = new Command()
|
|
23
15
|
program
|
|
24
16
|
.name('shipi18n')
|
|
25
|
-
.description('🌍
|
|
26
|
-
.version(
|
|
27
|
-
.addHelpText(
|
|
17
|
+
.description('🌍 Open-source i18n translation — bring your own LLM key')
|
|
18
|
+
.version(pkg.version, '-v, --version')
|
|
19
|
+
.addHelpText(
|
|
20
|
+
'after',
|
|
21
|
+
`
|
|
28
22
|
${chalk.cyan('Examples:')}
|
|
29
|
-
$
|
|
23
|
+
$ export ANTHROPIC_API_KEY=sk-ant-...
|
|
30
24
|
$ shipi18n translate en.json --target es,fr,de
|
|
31
|
-
$ shipi18n
|
|
32
|
-
$ shipi18n
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Parse arguments
|
|
50
|
-
program.parse(process.argv);
|
|
51
|
-
|
|
52
|
-
// Show help if no command provided
|
|
53
|
-
if (!process.argv.slice(2).length) {
|
|
54
|
-
program.outputHelp();
|
|
55
|
-
}
|
|
25
|
+
$ shipi18n translate en.json -p openai --target ja --incremental
|
|
26
|
+
$ shipi18n check ./locales --source en --min-coverage 95
|
|
27
|
+
$ shipi18n lock ./locales --keys 'legal.*' # protect hand-edited strings
|
|
28
|
+
|
|
29
|
+
${chalk.cyan('Bring your own LLM:')}
|
|
30
|
+
Set ${chalk.yellow('ANTHROPIC_API_KEY')} (default) or use ${chalk.yellow('-p openai')} with ${chalk.yellow('OPENAI_API_KEY')}.
|
|
31
|
+
No Shipi18n account or hosted API — your keys, your models. Apache-2.0.
|
|
32
|
+
|
|
33
|
+
${chalk.gray('https://github.com/Shipi18n/shipi18n')}
|
|
34
|
+
`
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
translateCommand(program)
|
|
38
|
+
checkCommand(program)
|
|
39
|
+
lockCommand(program)
|
|
40
|
+
program.parse(process.argv)
|
|
41
|
+
if (!process.argv.slice(2).length) program.outputHelp()
|
package/package.json
CHANGED
|
@@ -1,71 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "src/index.js",
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Open-source, bring-your-own-LLM i18n translation CLI. Translate locale files with your own OpenAI/Anthropic key.",
|
|
6
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shipi18n": "./bin/shipi18n.js"
|
|
8
|
+
},
|
|
7
9
|
"files": [
|
|
8
10
|
"bin",
|
|
9
|
-
"src
|
|
10
|
-
"src/
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"src",
|
|
12
|
+
"!src/__tests__",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"NOTICE",
|
|
13
15
|
"README.md",
|
|
14
|
-
"
|
|
16
|
+
"CHANGELOG.md"
|
|
15
17
|
],
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"dev": "node bin/shipi18n.js",
|
|
21
|
-
"build": "echo 'No build step needed for now'",
|
|
22
|
-
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
23
|
-
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
|
|
24
|
-
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage"
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
25
20
|
},
|
|
26
21
|
"keywords": [
|
|
27
22
|
"i18n",
|
|
23
|
+
"cli",
|
|
28
24
|
"translation",
|
|
25
|
+
"llm",
|
|
26
|
+
"openai",
|
|
27
|
+
"anthropic",
|
|
29
28
|
"localization",
|
|
30
|
-
"
|
|
31
|
-
"locale",
|
|
32
|
-
"translate",
|
|
33
|
-
"cli",
|
|
34
|
-
"i18next",
|
|
35
|
-
"react-intl",
|
|
36
|
-
"json-translation",
|
|
37
|
-
"locale-files",
|
|
38
|
-
"multilingual",
|
|
39
|
-
"l10n",
|
|
40
|
-
"ai-translation",
|
|
41
|
-
"shipi18n"
|
|
29
|
+
"bring-your-own-key"
|
|
42
30
|
],
|
|
43
|
-
"author": "Shipi18n",
|
|
44
31
|
"license": "Apache-2.0",
|
|
32
|
+
"author": "Shipi18n",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/Shipi18n/shipi18n.git",
|
|
36
|
+
"directory": "packages/cli"
|
|
37
|
+
},
|
|
45
38
|
"dependencies": {
|
|
46
|
-
"archiver": "^7.0.1",
|
|
47
39
|
"chalk": "^5.3.0",
|
|
48
|
-
"commander": "^
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"ora": "^7.0.1",
|
|
52
|
-
"yaml": "^2.3.4"
|
|
40
|
+
"commander": "^12.0.0",
|
|
41
|
+
"ora": "^8.0.1",
|
|
42
|
+
"@shipi18n/core": "^2.3.0"
|
|
53
43
|
},
|
|
54
|
-
"
|
|
55
|
-
"
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": ">=0.30.0",
|
|
46
|
+
"openai": ">=4.0.0"
|
|
56
47
|
},
|
|
57
|
-
"
|
|
58
|
-
"
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@anthropic-ai/sdk": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"openai": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
59
55
|
},
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
"bugs": {
|
|
65
|
-
"url": "https://github.com/Shipi18n/shipi18n-cli/issues"
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"ajv": "^8.20.0",
|
|
58
|
+
"fast-xml-parser": "^5.10.1",
|
|
59
|
+
"jest": "^29.7.0"
|
|
66
60
|
},
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
"access": "public"
|
|
61
|
+
"scripts": {
|
|
62
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests"
|
|
70
63
|
}
|
|
71
|
-
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipi18n check` — structural QA for locale files, designed to live in CI.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic and offline: no LLM, no API key, no network. Reports the
|
|
5
|
+
* failure modes machine translation actually produces — dropped placeholders,
|
|
6
|
+
* collapsed plurals, missing keys, untranslated copy — and exits non-zero so a
|
|
7
|
+
* pipeline can gate on it.
|
|
8
|
+
*
|
|
9
|
+
* Formats: plain JSON locale trees (flat `locales/<lang>.json` or nested
|
|
10
|
+
* `locales/<lang>/<ns>.json`), Flutter ARB directories, and Apple String
|
|
11
|
+
* Catalogs (`.xcstrings`). Reporters: human, json, sarif, junit.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
+
import { resolve, dirname, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import chalk from 'chalk'
|
|
17
|
+
import {
|
|
18
|
+
runCheck,
|
|
19
|
+
runSemantic,
|
|
20
|
+
discoverLayout,
|
|
21
|
+
compileIgnores,
|
|
22
|
+
statsFrom,
|
|
23
|
+
aggregateLanguage,
|
|
24
|
+
SEP,
|
|
25
|
+
} from '@shipi18n/core'
|
|
26
|
+
import { REPORTERS } from '../reporters.js'
|
|
27
|
+
import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
|
|
28
|
+
|
|
29
|
+
const pkg = JSON.parse(
|
|
30
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/* ------------------------------------------------------------------ engine */
|
|
34
|
+
|
|
35
|
+
// Layout discovery and tree walking live in @shipi18n/core so the CLI and the
|
|
36
|
+
// MCP validator tools cannot drift apart. Re-exported here because the CLI's
|
|
37
|
+
// tests and semantic pass are written against these names.
|
|
38
|
+
export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP }
|
|
39
|
+
|
|
40
|
+
/* ---------------------------------------------------------------- verdict */
|
|
41
|
+
|
|
42
|
+
/** Decide the exit code from findings and flags. Reporters never influence this. */
|
|
43
|
+
export function verdict(result, { failOn = 'error', minCoverage } = {}) {
|
|
44
|
+
const failures = []
|
|
45
|
+
if (failOn === 'error' && result.totals.errors > 0) failures.push(`${result.totals.errors} error(s)`)
|
|
46
|
+
if (failOn === 'warning' && result.totals.errors + result.totals.warnings > 0)
|
|
47
|
+
failures.push(`${result.totals.errors} error(s), ${result.totals.warnings} warning(s)`)
|
|
48
|
+
if (minCoverage != null) {
|
|
49
|
+
for (const l of result.languages) {
|
|
50
|
+
if (l.stats.coverage * 100 < minCoverage)
|
|
51
|
+
failures.push(`${l.lang} coverage ${(l.stats.coverage * 100).toFixed(1)}% < ${minCoverage}%`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ok: failures.length === 0, failures }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------------------------------------------------------------- command */
|
|
58
|
+
|
|
59
|
+
export function checkCommand(program) {
|
|
60
|
+
program
|
|
61
|
+
.command('check [input]')
|
|
62
|
+
.description('Validate translated locale files against the source language (no LLM, no key)')
|
|
63
|
+
.option('-s, --source <language>', 'Source language', 'en')
|
|
64
|
+
.option('-r, --reporter <name>', 'Output format: human | json | sarif | junit', 'human')
|
|
65
|
+
.option('-o, --output <file>', 'Write the report to a file instead of stdout')
|
|
66
|
+
.option('--json', 'Shorthand for --reporter json')
|
|
67
|
+
.option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
|
|
68
|
+
.option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
|
|
69
|
+
.option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
|
|
70
|
+
.option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
|
|
71
|
+
.option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
|
|
72
|
+
.option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
|
|
73
|
+
.option('-p, --provider <name>', 'LLM provider for --semantic: anthropic | openai', 'anthropic')
|
|
74
|
+
.option('--api-key <key>', 'LLM API key for --semantic (else provider env var)')
|
|
75
|
+
.option('--semantic-model <model>', 'Judge model override')
|
|
76
|
+
.option('--semantic-passes <n>', 'Judge passes for the majority vote', (v) => parseInt(v, 10), 3)
|
|
77
|
+
.option('--semantic-cache <file>', 'Verdict cache path', '.shipi18n/semantic-cache.json')
|
|
78
|
+
.option('--locks <file>', 'Manual-translation lock file', DEFAULT_LOCKS_PATH)
|
|
79
|
+
.option('--no-locks', 'Ignore manual-translation locks')
|
|
80
|
+
.action(async (input = './locales', opts) => {
|
|
81
|
+
let glossary
|
|
82
|
+
if (opts.glossary) {
|
|
83
|
+
try {
|
|
84
|
+
glossary = JSON.parse(readFileSync(opts.glossary, 'utf8'))
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(chalk.red(`Error: cannot read glossary ${opts.glossary}: ${err.message}`))
|
|
87
|
+
process.exitCode = 2
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result
|
|
93
|
+
try {
|
|
94
|
+
result = runCheck({
|
|
95
|
+
input,
|
|
96
|
+
source: opts.source,
|
|
97
|
+
ignoreKeys: opts.ignoreKeys,
|
|
98
|
+
glossary,
|
|
99
|
+
locks: locksFor(opts),
|
|
100
|
+
})
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
103
|
+
process.exitCode = 2
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (opts.semantic) {
|
|
108
|
+
const cachePath = resolve(opts.semanticCache)
|
|
109
|
+
let cache = {}
|
|
110
|
+
if (existsSync(cachePath)) {
|
|
111
|
+
try {
|
|
112
|
+
cache = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
113
|
+
} catch {
|
|
114
|
+
cache = {} // a corrupt cache is just a cold cache
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const judge = await runSemantic(result, {
|
|
119
|
+
provider: opts.provider,
|
|
120
|
+
apiKey: opts.apiKey,
|
|
121
|
+
model: opts.semanticModel,
|
|
122
|
+
passes: opts.semanticPasses,
|
|
123
|
+
glossary,
|
|
124
|
+
cache,
|
|
125
|
+
fail: Boolean(opts.semanticFail),
|
|
126
|
+
})
|
|
127
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
128
|
+
writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n')
|
|
129
|
+
console.error(
|
|
130
|
+
chalk.gray(
|
|
131
|
+
`semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
|
|
132
|
+
`${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)`
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(chalk.red(`Semantic pass failed: ${err.message}`))
|
|
137
|
+
process.exitCode = 2
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
|
|
143
|
+
|
|
144
|
+
const name = opts.json ? 'json' : opts.reporter
|
|
145
|
+
const reporter = REPORTERS[name]
|
|
146
|
+
if (!reporter) {
|
|
147
|
+
console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
|
|
148
|
+
process.exitCode = 2
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const report = reporter(result, verdictResult, { toolVersion: pkg.version })
|
|
152
|
+
if (opts.output) {
|
|
153
|
+
writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
|
|
154
|
+
if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
|
|
155
|
+
} else {
|
|
156
|
+
console.log(report)
|
|
157
|
+
}
|
|
158
|
+
if (!verdictResult.ok) process.exitCode = 1
|
|
159
|
+
})
|
|
160
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipi18n lock` — mark translations as hand-edited so `check` can tell you
|
|
3
|
+
* when something overwrites them, or when their source moves underneath.
|
|
4
|
+
*
|
|
5
|
+
* Writes `.shipi18n/locks.json` (safe to commit — it is the record of which
|
|
6
|
+
* translations a human has blessed).
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
9
|
+
import { resolve, dirname } from 'node:path'
|
|
10
|
+
import chalk from 'chalk'
|
|
11
|
+
import {
|
|
12
|
+
runCheck,
|
|
13
|
+
discoverLayout,
|
|
14
|
+
compileIgnores,
|
|
15
|
+
lockId,
|
|
16
|
+
lockEntry,
|
|
17
|
+
emptyLocks,
|
|
18
|
+
normalizeLocks,
|
|
19
|
+
flatten,
|
|
20
|
+
} from '@shipi18n/core'
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LOCKS_PATH = '.shipi18n/locks.json'
|
|
23
|
+
|
|
24
|
+
/** Tolerant read — a missing or corrupt file is simply "no locks yet". */
|
|
25
|
+
export function readLocks(path) {
|
|
26
|
+
if (!existsSync(path)) return emptyLocks()
|
|
27
|
+
try {
|
|
28
|
+
return normalizeLocks(JSON.parse(readFileSync(path, 'utf8')))
|
|
29
|
+
} catch {
|
|
30
|
+
return emptyLocks()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeLocks(path, locks) {
|
|
35
|
+
mkdirSync(dirname(resolve(path)), { recursive: true })
|
|
36
|
+
writeFileSync(resolve(path), JSON.stringify(locks, null, 2) + '\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the lock set for a tree. Only pairs that actually exist in both source
|
|
41
|
+
* and target are lockable — you cannot bless a translation that isn't there.
|
|
42
|
+
*/
|
|
43
|
+
export function buildLocks({ input, source = 'en', keys, existing = emptyLocks(), langs }) {
|
|
44
|
+
const isSelected = compileIgnores(keys) // same glob syntax as --ignore-keys
|
|
45
|
+
const selectAll = !keys
|
|
46
|
+
const layout = discoverLayout(input, source)
|
|
47
|
+
const locked = { ...existing.locked }
|
|
48
|
+
let added = 0
|
|
49
|
+
|
|
50
|
+
const sourceData = {}
|
|
51
|
+
for (const [ns, file] of Object.entries(layout.source)) {
|
|
52
|
+
sourceData[ns] = JSON.parse(readFileSync(file, 'utf8'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const { lang, files } of layout.targets) {
|
|
56
|
+
if (langs && !langs.includes(lang)) continue
|
|
57
|
+
for (const [ns, srcObj] of Object.entries(sourceData)) {
|
|
58
|
+
const file = files[ns]
|
|
59
|
+
if (!file || !existsSync(file)) continue
|
|
60
|
+
let targetObj
|
|
61
|
+
try {
|
|
62
|
+
targetObj = JSON.parse(readFileSync(file, 'utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
continue // an unparseable file has nothing lockable in it
|
|
65
|
+
}
|
|
66
|
+
const src = flatten(srcObj)
|
|
67
|
+
const tgt = flatten(targetObj)
|
|
68
|
+
for (const [path, value] of Object.entries(src)) {
|
|
69
|
+
if (typeof value !== 'string' || typeof tgt[path] !== 'string') continue
|
|
70
|
+
if (!selectAll && !isSelected(ns, path)) continue
|
|
71
|
+
const id = lockId(lang, ns, path)
|
|
72
|
+
if (!(id in locked)) added++
|
|
73
|
+
locked[id] = lockEntry(value, tgt[path])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { locks: { ...emptyLocks(), locked }, added, total: Object.keys(locked).length }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function lockCommand(program) {
|
|
81
|
+
program
|
|
82
|
+
.command('lock [input]')
|
|
83
|
+
.description('Record translations as hand-edited, so check warns when they are overwritten')
|
|
84
|
+
.option('-s, --source <language>', 'Source language', 'en')
|
|
85
|
+
.option('-k, --keys <patterns>', "Only lock keys matching these '*' globs (default: all)")
|
|
86
|
+
.option('-l, --lang <languages>', 'Only lock these languages (comma-separated)')
|
|
87
|
+
.option('--locks <file>', 'Lock file path', DEFAULT_LOCKS_PATH)
|
|
88
|
+
.option('--relock', 'Update hashes for keys already locked (accept current state as blessed)')
|
|
89
|
+
.action((input = './locales', opts) => {
|
|
90
|
+
try {
|
|
91
|
+
const path = resolve(opts.locks)
|
|
92
|
+
const existing = opts.relock ? emptyLocks() : readLocks(path)
|
|
93
|
+
const langs = opts.lang ? opts.lang.split(',').map((l) => l.trim()) : undefined
|
|
94
|
+
const { locks, added, total } = buildLocks({
|
|
95
|
+
input,
|
|
96
|
+
source: opts.source,
|
|
97
|
+
keys: opts.keys,
|
|
98
|
+
existing,
|
|
99
|
+
langs,
|
|
100
|
+
})
|
|
101
|
+
writeLocks(path, locks)
|
|
102
|
+
console.log(
|
|
103
|
+
chalk.green(`✓ locked ${added} new translation(s); ${total} total in ${opts.locks}`)
|
|
104
|
+
)
|
|
105
|
+
if (!opts.keys) {
|
|
106
|
+
console.log(
|
|
107
|
+
chalk.gray(' Tip: --keys narrows this to the strings you actually hand-edited.')
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
112
|
+
process.exitCode = 2
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Used by `check` to load locks unless disabled.
|
|
119
|
+
*
|
|
120
|
+
* Commander pairs `--no-locks` with `--locks <file>`, so the negation arrives as
|
|
121
|
+
* `opts.locks === false` rather than `opts.noLocks` — checking only the latter
|
|
122
|
+
* made --no-locks silently do nothing (caught by an end-to-end run, not by a
|
|
123
|
+
* unit test that hand-built the options object).
|
|
124
|
+
*/
|
|
125
|
+
export function locksFor(opts = {}) {
|
|
126
|
+
if (opts.locks === false || opts.noLocks) return undefined
|
|
127
|
+
const path = resolve(typeof opts.locks === 'string' ? opts.locks : DEFAULT_LOCKS_PATH)
|
|
128
|
+
if (!existsSync(path)) return undefined
|
|
129
|
+
const locks = readLocks(path)
|
|
130
|
+
return Object.keys(locks.locked).length ? locks : undefined
|
|
131
|
+
}
|