@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
|
@@ -1,331 +1,88 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync
|
|
2
|
-
import { join
|
|
3
|
-
import chalk from 'chalk'
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { flattenObject, unflattenObject, deepMerge, findMissingKeys } from '../utils/incremental.js';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import ora from 'ora'
|
|
5
|
+
import { translateJSON } from '@shipi18n/core'
|
|
6
|
+
|
|
7
|
+
const PROVIDER_ENV = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' }
|
|
9
8
|
|
|
10
9
|
export function translateCommand(program) {
|
|
11
10
|
program
|
|
12
11
|
.command('translate <input>')
|
|
13
|
-
.description('Translate a JSON locale file to
|
|
12
|
+
.description('Translate a JSON locale file to other languages using your own LLM')
|
|
14
13
|
.option('-t, --target <languages>', 'Target languages (comma-separated)', 'es,fr')
|
|
15
14
|
.option('-s, --source <language>', 'Source language', 'en')
|
|
16
15
|
.option('-o, --output <dir>', 'Output directory', './locales')
|
|
17
|
-
.option('
|
|
18
|
-
.option('--
|
|
19
|
-
.option('--
|
|
20
|
-
.option('--
|
|
21
|
-
.option('--no-regional-fallback', 'Disable regional fallback (e.g., pt-BR -> pt)')
|
|
22
|
-
.option('-i, --incremental', 'Only translate new/missing keys (skip existing translations)')
|
|
23
|
-
.option('--skip-keys <keys>', 'Keys to skip from translation (comma-separated exact paths)')
|
|
24
|
-
.option('--skip-paths <patterns>', 'Paths to skip using wildcards (comma-separated, e.g., "states.*,config.*.secret")')
|
|
25
|
-
.option('--context-file <path>', 'JSON file with context annotations for disambiguation (e.g., {"close": "button - dismiss"})')
|
|
26
|
-
.option('--zip [filename]', 'Output translations as ZIP file (default: translations.zip)')
|
|
16
|
+
.option('-p, --provider <name>', 'LLM provider: anthropic | openai', 'anthropic')
|
|
17
|
+
.option('--api-key <key>', 'LLM API key (else read from provider env var)')
|
|
18
|
+
.option('--model <model>', 'Override the provider default model')
|
|
19
|
+
.option('-i, --incremental', 'Only translate new/missing keys (reuse existing output files)')
|
|
27
20
|
.action(async (input, options) => {
|
|
28
|
-
const
|
|
29
|
-
|
|
21
|
+
const provider = options.provider
|
|
22
|
+
if (!PROVIDER_ENV[provider]) {
|
|
23
|
+
console.error(chalk.red(`Unknown provider '${provider}'. Use 'anthropic' or 'openai'.`))
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
const apiKey = options.apiKey || process.env[PROVIDER_ENV[provider]]
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
console.error(chalk.red(`No API key. Set ${chalk.yellow(PROVIDER_ENV[provider])} or pass --api-key.`))
|
|
29
|
+
console.error(chalk.gray(`This tool uses YOUR own ${provider} key ā no account or Shipi18n key needed.`))
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(input)) {
|
|
33
|
+
console.error(chalk.red(`Input file not found: ${input}`))
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
let content
|
|
30
37
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
spinner.fail();
|
|
37
|
-
logger.error('API key not found');
|
|
38
|
-
logger.info('Set your API key:');
|
|
39
|
-
logger.log(` ${chalk.yellow('shipi18n config set apiKey YOUR_KEY')}`);
|
|
40
|
-
logger.log(` ${chalk.gray('Get your free key at https://shipi18n.com')}`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Read input file
|
|
45
|
-
if (!existsSync(input)) {
|
|
46
|
-
spinner.fail();
|
|
47
|
-
logger.error(`Input file not found: ${input}`);
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
38
|
+
content = JSON.parse(readFileSync(input, 'utf8'))
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(chalk.red(`Invalid JSON in ${input}: ${e.message}`))
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
50
43
|
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
const targets = options.target.split(',').map((s) => s.trim()).filter(Boolean)
|
|
45
|
+
mkdirSync(options.output, { recursive: true })
|
|
46
|
+
console.log(chalk.cyan(`\nš Translating ${input} (${options.source} ā ${targets.join(', ')}) via ${provider}\n`))
|
|
47
|
+
|
|
48
|
+
let hadWarnings = false
|
|
49
|
+
for (const to of targets) {
|
|
50
|
+
const spinner = ora(`Translating to ${to}...`).start()
|
|
51
|
+
const outPath = join(options.output, `${to}.json`)
|
|
52
|
+
const existing =
|
|
53
|
+
options.incremental && existsSync(outPath)
|
|
54
|
+
? JSON.parse(readFileSync(outPath, 'utf8'))
|
|
55
|
+
: undefined
|
|
53
56
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
: [];
|
|
74
|
-
|
|
75
|
-
if (skipKeys.length > 0 || skipPaths.length > 0) {
|
|
76
|
-
logger.info(`Skipping ${skipKeys.length + skipPaths.length} key/pattern(s) from translation`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Parse context annotations file
|
|
80
|
-
let contextAnnotations = {};
|
|
81
|
-
if (options.contextFile) {
|
|
82
|
-
if (!existsSync(options.contextFile)) {
|
|
83
|
-
spinner.fail();
|
|
84
|
-
logger.error(`Context file not found: ${options.contextFile}`);
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const contextContent = readFileSync(options.contextFile, 'utf8');
|
|
89
|
-
contextAnnotations = JSON.parse(contextContent);
|
|
90
|
-
const contextCount = Object.keys(contextAnnotations).length;
|
|
91
|
-
logger.info(`Loaded ${contextCount} context annotation(s) from ${options.contextFile}`);
|
|
92
|
-
} catch (error) {
|
|
93
|
-
spinner.fail();
|
|
94
|
-
logger.error(`Invalid JSON in context file: ${error.message}`);
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Incremental mode: load existing translations and find missing keys
|
|
100
|
-
let jsonToTranslate = json;
|
|
101
|
-
const existingTranslations = {};
|
|
102
|
-
let incrementalStats = { total: 0, existing: 0, toTranslate: 0 };
|
|
103
|
-
|
|
104
|
-
if (options.incremental) {
|
|
105
|
-
spinner.text = 'Checking existing translations...';
|
|
106
|
-
|
|
107
|
-
const sourceKeyCount = Object.keys(flattenObject(json)).length;
|
|
108
|
-
incrementalStats.total = sourceKeyCount;
|
|
109
|
-
|
|
110
|
-
// Load existing translations for each target language
|
|
111
|
-
for (const lang of targetLanguages) {
|
|
112
|
-
const targetFile = join(outputDir, lang, `${inputFileName}.json`);
|
|
113
|
-
const altTargetFile = join(outputDir, `${lang}.json`);
|
|
114
|
-
|
|
115
|
-
let existingFile = null;
|
|
116
|
-
if (existsSync(targetFile)) {
|
|
117
|
-
existingFile = targetFile;
|
|
118
|
-
} else if (existsSync(altTargetFile)) {
|
|
119
|
-
existingFile = altTargetFile;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (existingFile) {
|
|
123
|
-
try {
|
|
124
|
-
const existingContent = readFileSync(existingFile, 'utf8');
|
|
125
|
-
existingTranslations[lang] = JSON.parse(existingContent);
|
|
126
|
-
} catch (e) {
|
|
127
|
-
logger.warn(`Could not parse ${existingFile}, will re-translate`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Find keys that need translation (missing from ANY target language)
|
|
133
|
-
const allMissingKeys = {};
|
|
134
|
-
for (const lang of targetLanguages) {
|
|
135
|
-
const existing = existingTranslations[lang] || {};
|
|
136
|
-
const missing = findMissingKeys(json, existing);
|
|
137
|
-
const missingFlat = flattenObject(missing);
|
|
138
|
-
|
|
139
|
-
for (const [key, value] of Object.entries(missingFlat)) {
|
|
140
|
-
if (!(key in allMissingKeys)) {
|
|
141
|
-
allMissingKeys[key] = value;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
57
|
+
const { result, stats } = await translateJSON({
|
|
58
|
+
content,
|
|
59
|
+
from: options.source,
|
|
60
|
+
to,
|
|
61
|
+
provider,
|
|
62
|
+
apiKey,
|
|
63
|
+
model: options.model,
|
|
64
|
+
existing,
|
|
65
|
+
})
|
|
66
|
+
writeFileSync(outPath, JSON.stringify(result, null, 2) + '\n', 'utf8')
|
|
67
|
+
const warn = stats.placeholderWarnings.length
|
|
68
|
+
if (warn) hadWarnings = true
|
|
69
|
+
spinner.succeed(
|
|
70
|
+
`${to} ā ${chalk.green(outPath)} ` +
|
|
71
|
+
chalk.gray(`(${stats.translated} translated, ${stats.reused} reused` +
|
|
72
|
+
(warn ? chalk.yellow(`, ${warn} placeholder warnings`) : '') + ')')
|
|
73
|
+
)
|
|
74
|
+
for (const w of stats.placeholderWarnings) {
|
|
75
|
+
console.log(chalk.yellow(` ā ${w.path}: placeholder drift ā missing ${JSON.stringify(w.missing)}`))
|
|
144
76
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
incrementalStats.toTranslate = missingKeyCount;
|
|
149
|
-
|
|
150
|
-
if (missingKeyCount === 0) {
|
|
151
|
-
spinner.succeed(chalk.green('All translations up to date!'));
|
|
152
|
-
logger.log('');
|
|
153
|
-
logger.log(chalk.gray(` ${sourceKeyCount} key${sourceKeyCount !== 1 ? 's' : ''} already translated`));
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
jsonToTranslate = unflattenObject(allMissingKeys);
|
|
158
|
-
spinner.text = `Translating ${missingKeyCount} new key${missingKeyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
|
|
159
|
-
logger.log('');
|
|
160
|
-
logger.info(`Incremental mode: ${chalk.cyan(missingKeyCount)} new key${missingKeyCount !== 1 ? 's' : ''} to translate (${incrementalStats.existing} already exist)`);
|
|
161
|
-
} else {
|
|
162
|
-
spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Translate with fallback support
|
|
166
|
-
const api = new Shipi18nAPI(apiKey);
|
|
167
|
-
const translations = await api.translateJSON({
|
|
168
|
-
json: jsonToTranslate,
|
|
169
|
-
sourceLanguage,
|
|
170
|
-
targetLanguages,
|
|
171
|
-
preservePlaceholders: options.preservePlaceholders,
|
|
172
|
-
htmlHandling: options.htmlHandling,
|
|
173
|
-
fallback: {
|
|
174
|
-
fallbackToSource: options.fallback !== false,
|
|
175
|
-
regionalFallback: options.regionalFallback !== false,
|
|
176
|
-
},
|
|
177
|
-
skipKeys,
|
|
178
|
-
skipPaths,
|
|
179
|
-
contextAnnotations,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const keyCount = Object.keys(flattenObject(jsonToTranslate)).length;
|
|
183
|
-
spinner.succeed(chalk.green(`Translated ${keyCount} key${keyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}!`));
|
|
184
|
-
|
|
185
|
-
// Save translated files
|
|
186
|
-
if (!existsSync(outputDir)) {
|
|
187
|
-
mkdirSync(outputDir, { recursive: true });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
spinner.fail(`${to}: ${e.message}`)
|
|
79
|
+
process.exitCode = 1
|
|
188
80
|
}
|
|
189
|
-
|
|
190
|
-
let savedCount = 0;
|
|
191
|
-
|
|
192
|
-
// Prepare translations for output (filter metadata, apply merging)
|
|
193
|
-
const outputTranslations = {};
|
|
194
|
-
for (const [langCode, content] of Object.entries(translations)) {
|
|
195
|
-
if (langCode === 'warnings' || langCode === 'fallbackInfo' || langCode === 'namespaceInfo' || langCode === 'skipped' || langCode === 'contextEnhanced') continue;
|
|
196
|
-
|
|
197
|
-
let finalContent = content;
|
|
198
|
-
if (options.incremental && existingTranslations[langCode]) {
|
|
199
|
-
finalContent = deepMerge(existingTranslations[langCode], content);
|
|
200
|
-
}
|
|
201
|
-
outputTranslations[langCode] = finalContent;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (options.zip) {
|
|
205
|
-
// ZIP output mode
|
|
206
|
-
const zipFileName = typeof options.zip === 'string' ? options.zip : 'translations.zip';
|
|
207
|
-
const zipPath = join(outputDir, zipFileName);
|
|
208
|
-
|
|
209
|
-
await new Promise((resolve, reject) => {
|
|
210
|
-
const output = createWriteStream(zipPath);
|
|
211
|
-
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
212
|
-
|
|
213
|
-
output.on('close', resolve);
|
|
214
|
-
archive.on('error', reject);
|
|
215
|
-
|
|
216
|
-
archive.pipe(output);
|
|
217
|
-
|
|
218
|
-
for (const [langCode, content] of Object.entries(outputTranslations)) {
|
|
219
|
-
archive.append(JSON.stringify(content, null, 2), { name: `${langCode}.json` });
|
|
220
|
-
savedCount++;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
archive.finalize();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
logger.success(`Saved: ${chalk.cyan(zipPath)} (${savedCount} file${savedCount !== 1 ? 's' : ''})`);
|
|
227
|
-
} else {
|
|
228
|
-
// Individual files mode
|
|
229
|
-
for (const [langCode, content] of Object.entries(outputTranslations)) {
|
|
230
|
-
const outputFile = join(outputDir, `${langCode}.json`);
|
|
231
|
-
writeFileSync(outputFile, JSON.stringify(content, null, 2), 'utf8');
|
|
232
|
-
logger.success(`Saved: ${chalk.cyan(outputFile)}${options.incremental ? chalk.gray(' (merged)') : ''}`);
|
|
233
|
-
savedCount++;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Show fallback info if any fallbacks were used
|
|
238
|
-
if (translations.fallbackInfo && translations.fallbackInfo.used) {
|
|
239
|
-
const fallbackInfo = translations.fallbackInfo;
|
|
240
|
-
logger.log('');
|
|
241
|
-
logger.info('Fallback information:');
|
|
242
|
-
|
|
243
|
-
// Regional fallbacks
|
|
244
|
-
if (Object.keys(fallbackInfo.regionalFallbacks).length > 0) {
|
|
245
|
-
for (const [lang, baseLang] of Object.entries(fallbackInfo.regionalFallbacks)) {
|
|
246
|
-
logger.log(` ${chalk.blue('ā¢')} ${lang} ā ${baseLang} ${chalk.gray('(regional fallback)')}`);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Languages that fell back to source
|
|
251
|
-
if (fallbackInfo.languagesFallbackToSource.length > 0) {
|
|
252
|
-
for (const lang of fallbackInfo.languagesFallbackToSource) {
|
|
253
|
-
logger.log(` ${chalk.yellow('ā¢')} ${lang} ā ${sourceLanguage} ${chalk.gray('(source fallback)')}`);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Keys that used fallback
|
|
258
|
-
if (Object.keys(fallbackInfo.keysFallback).length > 0) {
|
|
259
|
-
for (const [lang, keys] of Object.entries(fallbackInfo.keysFallback)) {
|
|
260
|
-
logger.log(` ${chalk.yellow('ā¢')} ${lang}: ${keys.length} key${keys.length > 1 ? 's' : ''} used fallback`);
|
|
261
|
-
if (keys.length <= 5) {
|
|
262
|
-
keys.forEach(key => {
|
|
263
|
-
logger.log(` ${chalk.gray('- ' + key)}`);
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Show skipped keys info if any
|
|
271
|
-
if (translations.skipped && translations.skipped.count > 0) {
|
|
272
|
-
logger.log('');
|
|
273
|
-
logger.info(`Skipped ${translations.skipped.count} key${translations.skipped.count > 1 ? 's' : ''} from translation:`);
|
|
274
|
-
const keysToShow = translations.skipped.keys.slice(0, 10);
|
|
275
|
-
keysToShow.forEach(key => {
|
|
276
|
-
logger.log(` ${chalk.gray('ā¢')} ${key}`);
|
|
277
|
-
});
|
|
278
|
-
if (translations.skipped.keys.length > 10) {
|
|
279
|
-
logger.log(` ${chalk.gray(`... and ${translations.skipped.keys.length - 10} more`)}`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Show context-enhanced keys info if any
|
|
284
|
-
if (translations.contextEnhanced && translations.contextEnhanced.count > 0) {
|
|
285
|
-
logger.log('');
|
|
286
|
-
logger.info(`${chalk.cyan('šÆ')} ${translations.contextEnhanced.count} key${translations.contextEnhanced.count > 1 ? 's' : ''} translated with context annotations:`);
|
|
287
|
-
const keysToShow = translations.contextEnhanced.keys.slice(0, 10);
|
|
288
|
-
keysToShow.forEach(key => {
|
|
289
|
-
logger.log(` ${chalk.cyan('ā¢')} ${key}`);
|
|
290
|
-
});
|
|
291
|
-
if (translations.contextEnhanced.keys.length > 10) {
|
|
292
|
-
logger.log(` ${chalk.gray(`... and ${translations.contextEnhanced.keys.length - 10} more`)}`);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Show legal content warning with key details
|
|
297
|
-
const legalWarning = translations.warnings?.find(w => w.type === 'legal_content');
|
|
298
|
-
if (legalWarning?.details?.keys?.length > 0) {
|
|
299
|
-
logger.log('');
|
|
300
|
-
logger.warn(`${chalk.yellow('ā ļø')} Legal content detected - review these keys:`);
|
|
301
|
-
legalWarning.details.keys.forEach(key => {
|
|
302
|
-
logger.log(` ${chalk.yellow('ā¢')} ${key}`);
|
|
303
|
-
});
|
|
304
|
-
if (legalWarning.details.count > 10) {
|
|
305
|
-
logger.log(` ${chalk.gray(`... and ${legalWarning.details.count - 10} more`)}`);
|
|
306
|
-
}
|
|
307
|
-
logger.log(` ${chalk.gray('Machine-translated legal text may not be legally binding.')}`);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Show other warnings if any (exclude legal_content since we showed it above)
|
|
311
|
-
const otherWarnings = translations.warnings?.filter(w => w.type !== 'legal_content') || [];
|
|
312
|
-
if (otherWarnings.length > 0) {
|
|
313
|
-
logger.log('');
|
|
314
|
-
logger.warn('Warnings:');
|
|
315
|
-
otherWarnings.forEach(warning => {
|
|
316
|
-
logger.log(` ${chalk.yellow('ā¢')} ${warning.message}`);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
logger.log('');
|
|
321
|
-
logger.log(chalk.green(`⨠Successfully translated ${savedCount} file${savedCount > 1 ? 's' : ''}!`));
|
|
322
|
-
logger.log(chalk.gray(` Output: ${outputDir}`));
|
|
323
|
-
|
|
324
|
-
} catch (error) {
|
|
325
|
-
spinner.fail();
|
|
326
|
-
logger.log('');
|
|
327
|
-
logger.log(formatError(error));
|
|
328
|
-
process.exit(1);
|
|
329
81
|
}
|
|
330
|
-
|
|
82
|
+
console.log(
|
|
83
|
+
hadWarnings
|
|
84
|
+
? chalk.yellow('\nDone, with placeholder warnings ā review the flagged keys.')
|
|
85
|
+
: chalk.green('\nā Done.')
|
|
86
|
+
)
|
|
87
|
+
})
|
|
331
88
|
}
|
package/src/reporters.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formats for `shipi18n check`.
|
|
3
|
+
*
|
|
4
|
+
* Reporters SERIALIZE a result; they never decide it. Exit codes come from
|
|
5
|
+
* `verdict()` alone, so switching reporter can never change whether CI fails.
|
|
6
|
+
*/
|
|
7
|
+
import chalk from 'chalk'
|
|
8
|
+
|
|
9
|
+
/* ------------------------------------------------------------------ human */
|
|
10
|
+
|
|
11
|
+
export function humanReport(result, verdictResult) {
|
|
12
|
+
const lines = []
|
|
13
|
+
lines.push('')
|
|
14
|
+
lines.push(
|
|
15
|
+
`š shipi18n check ā ${result.layout} layout, source '${result.source}', ${result.languages.length} target language(s)`
|
|
16
|
+
)
|
|
17
|
+
lines.push('')
|
|
18
|
+
for (const l of result.languages) {
|
|
19
|
+
const all = l.namespaces.flatMap((n) => n.findings.map((f) => ({ ...f, ns: n.ns })))
|
|
20
|
+
const mark = l.stats.errors ? chalk.red('ā') : all.length ? chalk.yellow('ā ') : chalk.green('ā')
|
|
21
|
+
lines.push(
|
|
22
|
+
`${mark} ${chalk.bold(l.lang)} coverage ${(l.stats.coverage * 100).toFixed(1)}% ${l.stats.errors} error(s), ${l.stats.warnings} warning(s)`
|
|
23
|
+
)
|
|
24
|
+
for (const f of all.slice(0, 50)) {
|
|
25
|
+
const color = f.severity === 'error' ? chalk.red : chalk.yellow
|
|
26
|
+
const where = result.layout === 'flat' ? f.path : `${f.ns}:${f.path}`
|
|
27
|
+
lines.push(` ${color(f.severity)} ${chalk.cyan(where)} ${f.type} ā ${f.message}`)
|
|
28
|
+
}
|
|
29
|
+
if (all.length > 50) lines.push(chalk.gray(` ⦠and ${all.length - 50} more`))
|
|
30
|
+
}
|
|
31
|
+
lines.push('')
|
|
32
|
+
lines.push(
|
|
33
|
+
verdictResult.ok
|
|
34
|
+
? chalk.green('ā check passed')
|
|
35
|
+
: chalk.red(`ā check failed: ${verdictResult.failures.join('; ')}`)
|
|
36
|
+
)
|
|
37
|
+
return lines.join('\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ------------------------------------------------------------------- json */
|
|
41
|
+
|
|
42
|
+
export function jsonReport(result, verdictResult) {
|
|
43
|
+
return JSON.stringify({ ...result, ok: verdictResult.ok, failures: verdictResult.failures }, null, 2)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ sarif */
|
|
47
|
+
|
|
48
|
+
const RULE_META = {
|
|
49
|
+
'missing-key': 'A key present in the source language is missing from a translation.',
|
|
50
|
+
'orphan-key': 'A key present in a translation does not exist in the source language.',
|
|
51
|
+
'placeholder-missing': 'A placeholder from the source string was dropped in the translation.',
|
|
52
|
+
'placeholder-added': 'The translation contains a placeholder the source does not have.',
|
|
53
|
+
'plural-forms': 'A pipe-separated plural lost one or more of its forms in translation.',
|
|
54
|
+
'empty-value': 'The translation of a non-empty source string is empty.',
|
|
55
|
+
'untranslated': 'The translation is identical to a multi-word source string.',
|
|
56
|
+
'type-mismatch': 'Source and translation values have different JSON types.',
|
|
57
|
+
'invalid-json': 'A locale file could not be parsed as JSON.',
|
|
58
|
+
'missing-file': 'An expected locale file does not exist.',
|
|
59
|
+
'stale-translation': 'The catalog marks this translation as needing review.',
|
|
60
|
+
'glossary-violation': 'A do-not-translate or locked glossary term was not respected.',
|
|
61
|
+
'manual-translation-clobbered': 'A translation locked as hand-edited has been overwritten.',
|
|
62
|
+
'manual-translation-stale': 'The source changed after this translation was locked by hand.',
|
|
63
|
+
'semantic-mistranslation': 'LLM judge (majority vote): the translation states something different from the source.',
|
|
64
|
+
'semantic-omission': 'LLM judge (majority vote): meaningful source content is missing from the translation.',
|
|
65
|
+
'semantic-addition': 'LLM judge (majority vote): the translation contains claims the source does not make.',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SARIF 2.1.0 ā one run, one rule per finding type, one result per finding. */
|
|
69
|
+
export function sarifReport(result, _verdictResult, { toolVersion = '0.0.0' } = {}) {
|
|
70
|
+
const findings = []
|
|
71
|
+
for (const l of result.languages) {
|
|
72
|
+
for (const n of l.namespaces) {
|
|
73
|
+
for (const f of n.findings) findings.push({ lang: l.lang, ns: n.ns, file: n.file, ...f })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Deterministic output: stable ordering makes committed SARIF diffable.
|
|
77
|
+
findings.sort((a, b) =>
|
|
78
|
+
`${a.file}|${a.path}|${a.type}`.localeCompare(`${b.file}|${b.path}|${b.type}`)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const usedTypes = [...new Set(findings.map((f) => f.type))].sort()
|
|
82
|
+
const ruleIndex = Object.fromEntries(usedTypes.map((t, i) => [t, i]))
|
|
83
|
+
|
|
84
|
+
const sarif = {
|
|
85
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
86
|
+
version: '2.1.0',
|
|
87
|
+
runs: [
|
|
88
|
+
{
|
|
89
|
+
tool: {
|
|
90
|
+
driver: {
|
|
91
|
+
name: 'shipi18n-check',
|
|
92
|
+
informationUri: 'https://github.com/Shipi18n/shipi18n',
|
|
93
|
+
version: toolVersion,
|
|
94
|
+
rules: usedTypes.map((t) => ({
|
|
95
|
+
id: t,
|
|
96
|
+
shortDescription: { text: RULE_META[t] || t },
|
|
97
|
+
helpUri: 'https://shipi18n.com/docs/cli/commands',
|
|
98
|
+
})),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
results: findings.map((f) => ({
|
|
102
|
+
ruleId: f.type,
|
|
103
|
+
ruleIndex: ruleIndex[f.type],
|
|
104
|
+
level: f.severity === 'error' ? 'error' : 'warning',
|
|
105
|
+
message: { text: `[${f.lang}] ${f.path}: ${f.message}` },
|
|
106
|
+
locations: [
|
|
107
|
+
{
|
|
108
|
+
physicalLocation: {
|
|
109
|
+
artifactLocation: { uri: (f.file || '').split('\\').join('/') },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
})),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
return JSON.stringify(sarif, null, 2)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ junit */
|
|
121
|
+
|
|
122
|
+
const xmlEscape = (s) =>
|
|
123
|
+
String(s)
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/"/g, '"')
|
|
128
|
+
.replace(/'/g, ''')
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One <testsuite> per language, one <testcase> per namespace.
|
|
132
|
+
* Errors become <failure>; warnings go to <system-out> ā a warning that fails
|
|
133
|
+
* CI gets the tool uninstalled.
|
|
134
|
+
*/
|
|
135
|
+
export function junitReport(result) {
|
|
136
|
+
const suites = []
|
|
137
|
+
let totalTests = 0
|
|
138
|
+
let totalFailures = 0
|
|
139
|
+
|
|
140
|
+
for (const l of result.languages) {
|
|
141
|
+
const cases = []
|
|
142
|
+
let failures = 0
|
|
143
|
+
for (const n of l.namespaces) {
|
|
144
|
+
totalTests++
|
|
145
|
+
const errors = n.findings.filter((f) => f.severity === 'error')
|
|
146
|
+
const warnings = n.findings.filter((f) => f.severity === 'warning')
|
|
147
|
+
const body = []
|
|
148
|
+
if (errors.length) {
|
|
149
|
+
failures++
|
|
150
|
+
totalFailures++
|
|
151
|
+
// Include the offending strings: "dropped {{name}}" is not actionable
|
|
152
|
+
// without seeing WHICH string dropped it.
|
|
153
|
+
const detail = errors
|
|
154
|
+
.map((f) => {
|
|
155
|
+
const lines = [`${f.path}: ${f.type} ā ${f.message}`]
|
|
156
|
+
if (f.source != null) lines.push(` source: ${f.source}`)
|
|
157
|
+
if (f.translation != null) lines.push(` translation: ${f.translation}`)
|
|
158
|
+
return lines.join('\n')
|
|
159
|
+
})
|
|
160
|
+
.join('\n')
|
|
161
|
+
body.push(
|
|
162
|
+
` <failure message="${xmlEscape(`${errors.length} error(s) in ${l.lang}/${n.ns}`)}">${xmlEscape(detail)}</failure>`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
if (warnings.length) {
|
|
166
|
+
const detail = warnings.map((f) => `${f.path}: ${f.type} ā ${f.message}`).join('\n')
|
|
167
|
+
body.push(` <system-out>${xmlEscape(detail)}</system-out>`)
|
|
168
|
+
}
|
|
169
|
+
cases.push(
|
|
170
|
+
body.length
|
|
171
|
+
? ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}">\n${body.join('\n')}\n </testcase>`
|
|
172
|
+
: ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}"/>`
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
suites.push(
|
|
176
|
+
` <testsuite name="${xmlEscape(l.lang)}" tests="${l.namespaces.length}" failures="${failures}">\n${cases.join('\n')}\n </testsuite>`
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
182
|
+
`<testsuites name="shipi18n-check" tests="${totalTests}" failures="${totalFailures}">`,
|
|
183
|
+
...suites,
|
|
184
|
+
'</testsuites>',
|
|
185
|
+
'',
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const REPORTERS = { human: humanReport, json: jsonReport, sarif: sarifReport, junit: junitReport }
|