@chesterbrains/translify-cli 0.2.0 → 0.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/README.md +19 -1
- package/dist/commands/lint.js +56 -0
- package/dist/errors.js +1 -1
- package/dist/index.js +13 -0
- package/dist/render.js +70 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ flutter gen-l10n
|
|
|
126
126
|
| `translify push` | Upload source and target files |
|
|
127
127
|
| `translify pull` | Download translation files |
|
|
128
128
|
| `translify status` | Exit 1 if local files differ from Translify |
|
|
129
|
+
| `translify lint` | Exit 6 if translations in Translify have placeholder errors (or too many warnings) |
|
|
129
130
|
| `translify publish <env>` | Publish an environment, e.g. `production` |
|
|
130
131
|
|
|
131
132
|
Options:
|
|
@@ -133,7 +134,8 @@ Options:
|
|
|
133
134
|
- `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
|
|
134
135
|
- `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
|
|
135
136
|
- `publish`: `--fail-on-withheld`
|
|
136
|
-
-
|
|
137
|
+
- `lint`: `--namespace <ns>`, `--locale <locale>` (as named on disk), `--severity <error|warning>` (only list cells whose worst issue is this severity; counts and the exit code are unaffected), `--max-warnings <n>`
|
|
138
|
+
- `--json` on `push`, `pull`, `status`, `publish` and `lint`: machine-readable output on stdout
|
|
137
139
|
- `--debug` (global): print error details and stack traces
|
|
138
140
|
|
|
139
141
|
## Conflict rules
|
|
@@ -196,9 +198,24 @@ jobs:
|
|
|
196
198
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
197
199
|
```
|
|
198
200
|
|
|
201
|
+
Fail CI when translations in Translify have placeholder errors:
|
|
202
|
+
|
|
203
|
+
```yaml
|
|
204
|
+
- run: npx translify lint --max-warnings 0
|
|
205
|
+
env:
|
|
206
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`lint` checks what is stored in Translify, not your local files; run it after `push`, or before `pull`/`publish` on deploy.
|
|
210
|
+
It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
|
|
211
|
+
project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
|
|
212
|
+
|
|
199
213
|
Pull and publish on deploy:
|
|
200
214
|
|
|
201
215
|
```yaml
|
|
216
|
+
- run: npx translify lint
|
|
217
|
+
env:
|
|
218
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
202
219
|
- run: npx translify pull
|
|
203
220
|
env:
|
|
204
221
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
@@ -217,6 +234,7 @@ Pull and publish on deploy:
|
|
|
217
234
|
| 3 | Plan quota exceeded |
|
|
218
235
|
| 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
|
|
219
236
|
| 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
|
|
237
|
+
| 6 | `lint` found placeholder errors, or more warnings than `--max-warnings` |
|
|
220
238
|
|
|
221
239
|
The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
|
|
222
240
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { CliError, EXIT } from '../errors.js';
|
|
2
|
+
import { toTranslifyLocale } from '../patterns.js';
|
|
3
|
+
import { renderLint } from '../render.js';
|
|
4
|
+
export const LINT_SEVERITIES = ['error', 'warning'];
|
|
5
|
+
export const LINT_UNSUPPORTED = 'This Translify server does not support lint yet.';
|
|
6
|
+
/**
|
|
7
|
+
* What this repo would pull: the flag, else the whole project when any rule is a
|
|
8
|
+
* `{namespace}` wildcard, else the fixed namespaces. Undefined means no filter.
|
|
9
|
+
*/
|
|
10
|
+
export const lintNamespaces = (config, flag) => {
|
|
11
|
+
if (flag !== undefined)
|
|
12
|
+
return flag;
|
|
13
|
+
if (config.files.some((rule) => rule.namespace === undefined))
|
|
14
|
+
return undefined;
|
|
15
|
+
const fixed = [...new Set(config.files.flatMap((rule) => (rule.namespace === undefined ? [] : [rule.namespace])))].sort();
|
|
16
|
+
return fixed.length === 0 ? undefined : fixed.join(',');
|
|
17
|
+
};
|
|
18
|
+
export const parseMaxWarnings = (raw) => {
|
|
19
|
+
if (raw === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (!/^\d+$/.test(raw))
|
|
22
|
+
throw new CliError(EXIT.failed, '--max-warnings must be a whole number, 0 or more.');
|
|
23
|
+
return Number(raw);
|
|
24
|
+
};
|
|
25
|
+
/** Nest's own 404 for an unknown route has no `code`; every CLI error the server sends has one. */
|
|
26
|
+
const isMissingRoute = (error) => {
|
|
27
|
+
if (!(error instanceof CliError) || typeof error.details !== 'object' || error.details === null)
|
|
28
|
+
return false;
|
|
29
|
+
const body = error.details;
|
|
30
|
+
return body.statusCode === 404 && body.code === undefined;
|
|
31
|
+
};
|
|
32
|
+
export async function runLint(opts, deps) {
|
|
33
|
+
const maxWarnings = parseMaxWarnings(opts.maxWarnings);
|
|
34
|
+
const query = {};
|
|
35
|
+
const namespaces = lintNamespaces(deps.config, opts.namespace);
|
|
36
|
+
if (namespaces !== undefined)
|
|
37
|
+
query.namespaces = namespaces;
|
|
38
|
+
if (opts.locale !== undefined)
|
|
39
|
+
query.locales = toTranslifyLocale(opts.locale, deps.config);
|
|
40
|
+
if (opts.severity !== undefined)
|
|
41
|
+
query.severity = opts.severity.toUpperCase();
|
|
42
|
+
let res;
|
|
43
|
+
try {
|
|
44
|
+
res = await deps.api.get('/cli/v1/lint', query);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (isMissingRoute(error))
|
|
48
|
+
throw new CliError(EXIT.failed, LINT_UNSUPPORTED);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const { errors, warnings } = res.summary;
|
|
52
|
+
deps.out(opts.json === true
|
|
53
|
+
? JSON.stringify({ response: res, summary: { errors, warnings, truncated: res.truncated } })
|
|
54
|
+
: renderLint(res, deps.config, { severity: opts.severity }));
|
|
55
|
+
return errors > 0 || (maxWarnings !== undefined && warnings > maxWarnings) ? EXIT.issues : EXIT.ok;
|
|
56
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Minimal for Task 6.2; Task 6.3 owns this file and extends it.
|
|
2
|
-
export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4, withheld: 5 };
|
|
2
|
+
export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4, withheld: 5, issues: 6 };
|
|
3
3
|
/** Every expected failure. `index.ts` prints `message` and exits with `exitCode`; anything else is a bug. */
|
|
4
4
|
export class CliError extends Error {
|
|
5
5
|
exitCode;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
3
3
|
import { Command, Option } from 'commander';
|
|
4
4
|
import { runInit } from './commands/init.js';
|
|
5
|
+
import { LINT_SEVERITIES, parseMaxWarnings, runLint } from './commands/lint.js';
|
|
5
6
|
import { runLogin } from './commands/login.js';
|
|
6
7
|
import { runPublish } from './commands/publish.js';
|
|
7
8
|
import { runPull } from './commands/pull.js';
|
|
@@ -136,6 +137,18 @@ program
|
|
|
136
137
|
.action(async (opts) => {
|
|
137
138
|
process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
|
|
138
139
|
});
|
|
140
|
+
program
|
|
141
|
+
.command('lint')
|
|
142
|
+
.description('Exit 6 if translations in Translify have placeholder errors (or too many warnings)')
|
|
143
|
+
.option('--namespace <ns>', 'only this namespace')
|
|
144
|
+
.option('--locale <locale>', 'only this locale (as named on disk)')
|
|
145
|
+
.addOption(new Option('--severity <severity>', 'only list cells whose worst issue is this severity; counts and the exit code are unaffected').choices(LINT_SEVERITIES))
|
|
146
|
+
.option('--max-warnings <n>', 'also exit 6 when there are more than n warnings')
|
|
147
|
+
.option('--json', 'machine-readable output')
|
|
148
|
+
.action(async (opts) => {
|
|
149
|
+
parseMaxWarnings(opts.maxWarnings);
|
|
150
|
+
process.exitCode = await runLint(opts, { ...(await withApi()), out });
|
|
151
|
+
});
|
|
139
152
|
try {
|
|
140
153
|
await program.parseAsync(process.argv);
|
|
141
154
|
}
|
package/dist/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
|
+
import { toFsLocale } from './patterns.js';
|
|
2
3
|
const MAX_ORPHANS = 20;
|
|
3
4
|
const MAX_UNKNOWN = 5;
|
|
4
5
|
/** APPROVED gets no label: it is all a project without review ever sees. */
|
|
@@ -78,3 +79,72 @@ export function renderPush(res, view = {}) {
|
|
|
78
79
|
lines.push(pc.red(` Deleted ${res.pruned} key(s).`));
|
|
79
80
|
return lines.join('\n');
|
|
80
81
|
}
|
|
82
|
+
const MAX_VALUE = 80;
|
|
83
|
+
/** Where the issue text starts: 4 indent + locale 8 + 1 + severity 8 + 1. */
|
|
84
|
+
const VALUE_INDENT = ' '.repeat(22);
|
|
85
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
86
|
+
const braces = (arg) => `{${arg ?? '?'}}`;
|
|
87
|
+
/** One line of plain English per issue; a code this CLI does not know prints as-is. */
|
|
88
|
+
const issueText = (issue) => {
|
|
89
|
+
const categories = (issue.categories ?? []).join(', ');
|
|
90
|
+
switch (issue.code) {
|
|
91
|
+
case 'ICU_INVALID':
|
|
92
|
+
return `invalid ICU: ${issue.message ?? 'parse error'}${issue.offset === undefined || issue.offset === null ? '' : ` at ${issue.offset}`}`;
|
|
93
|
+
case 'PLACEHOLDER_MISSING':
|
|
94
|
+
return `missing placeholder ${braces(issue.arg)}`;
|
|
95
|
+
case 'PLACEHOLDER_EXTRA':
|
|
96
|
+
return `unexpected placeholder ${braces(issue.arg)}`;
|
|
97
|
+
case 'PLACEHOLDER_KIND_MISMATCH':
|
|
98
|
+
return `placeholder ${braces(issue.arg)} is used differently than in the source`;
|
|
99
|
+
case 'PLURAL_CATEGORY_MISSING':
|
|
100
|
+
return `plural ${braces(issue.arg)} is missing ${categories}`;
|
|
101
|
+
case 'PLURAL_CATEGORY_UNKNOWN':
|
|
102
|
+
return `plural ${braces(issue.arg)} has categories this locale does not use: ${categories}`;
|
|
103
|
+
default:
|
|
104
|
+
return issue.code;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const cut = (value) => (value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE - 1)}…` : value);
|
|
108
|
+
/**
|
|
109
|
+
* ESLint-style: namespace, then key, then one line per issue with the locale as named on disk.
|
|
110
|
+
* Rows arrive sorted by the server; only the grouping headers are added here.
|
|
111
|
+
*/
|
|
112
|
+
export function renderLint(res, config, view = {}) {
|
|
113
|
+
const { errors, warnings } = res.summary;
|
|
114
|
+
if (errors + warnings === 0)
|
|
115
|
+
return pc.green(`✔ No issues in ${res.summary.locales.length} locale(s).`);
|
|
116
|
+
const lines = [];
|
|
117
|
+
let namespace;
|
|
118
|
+
let key;
|
|
119
|
+
for (const cell of res.cells) {
|
|
120
|
+
if (cell.namespace !== namespace) {
|
|
121
|
+
namespace = cell.namespace;
|
|
122
|
+
key = undefined;
|
|
123
|
+
lines.push(pc.bold(namespace));
|
|
124
|
+
}
|
|
125
|
+
if (cell.key !== key) {
|
|
126
|
+
key = cell.key;
|
|
127
|
+
lines.push(` ${key}`);
|
|
128
|
+
}
|
|
129
|
+
const locale = toFsLocale(cell.locale, config).padEnd(8);
|
|
130
|
+
for (const issue of cell.issues) {
|
|
131
|
+
const label = issue.severity === 'ERROR' ? pc.red('error'.padEnd(8)) : pc.yellow('warning'.padEnd(8));
|
|
132
|
+
lines.push(` ${locale} ${label} ${issueText(issue)}`);
|
|
133
|
+
if (issue.code === 'ICU_INVALID')
|
|
134
|
+
lines.push(`${VALUE_INDENT}${pc.dim(JSON.stringify(cut(cell.value)))}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (res.truncated) {
|
|
138
|
+
const matched = view.severity === 'error' ? errors : view.severity === 'warning' ? warnings : errors + warnings;
|
|
139
|
+
lines.push(pc.yellow(`Showing first ${res.cells.length} of ${matched} cells. Narrow with --namespace / --locale, or use --json.`));
|
|
140
|
+
}
|
|
141
|
+
const perLocale = res.summary.locales
|
|
142
|
+
.filter((row) => row.errors + row.warnings > 0)
|
|
143
|
+
.map((row) => `${toFsLocale(row.locale, config)} ${row.errors}/${row.warnings}`)
|
|
144
|
+
.join(' · ');
|
|
145
|
+
const totals = `${plural(errors, 'error')}, ${plural(warnings, 'warning')}${perLocale === '' ? '' : ` — ${perLocale}`}`;
|
|
146
|
+
if (lines.length > 0)
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push(errors > 0 ? pc.red(`✖ ${totals}`) : pc.yellow(`⚠ ${totals}`));
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
package/dist/version.js
CHANGED