@chesterbrains/translify-cli 0.2.0 → 0.4.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 +21 -2
- package/dist/commands/lint.js +56 -0
- package/dist/commands/publish.js +26 -4
- package/dist/errors.js +1 -1
- package/dist/http.js +3 -0
- package/dist/index.js +18 -1
- package/dist/render.js +75 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,14 +126,16 @@ 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:
|
|
132
133
|
|
|
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
|
-
- `publish`: `--fail-on-withheld`
|
|
136
|
-
-
|
|
136
|
+
- `publish`: `--fail-on-withheld`, `--allow-errors`
|
|
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
|
|
@@ -154,6 +156,7 @@ When the project has **Require review** turned on in Translify:
|
|
|
154
156
|
- `push` lands target-locale translations it writes as **needs review**. `--status draft` or `--status needs-review` picks the status explicitly, and `--status approved` approves them as they land. The source file is always approved. Each locale row shows the status its cells landed in, and the output counts approved translations that went back to review because this push changed their source text.
|
|
155
157
|
- Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
|
|
156
158
|
- `publish` to an environment with **Publish approved text only** ships the last approved text of each translation. The output adds how many translations were **carried over** (the approved text went out instead of a newer, unapproved edit) and how many were **withheld** (never approved, so left out). Publishing still succeeds; `--fail-on-withheld` exits 5 when anything was withheld, for pipelines that must ship everything reviewed.
|
|
159
|
+
- `publish` refuses when a translation that would go live has a placeholder error (a missing or extra placeholder, invalid ICU, a printf type mismatch). Nothing is published, the refused translations are listed like `lint` lists them, and the command exits 6. Warnings never block. On an environment with **Publish approved text only**, only approved translations count: one carried over from its last approved text never blocks. `--allow-errors` publishes anyway and reports how many went out with errors; the publish is recorded in the activity log with the override.
|
|
157
160
|
- `pull --only-approved` writes the approved text from the working copy and leaves out translations that were never approved, for building a release bundle without publishing. `status --only-approved` compares against the same. It works with `--from working` only: environments already apply the gate.
|
|
158
161
|
|
|
159
162
|
## CI (GitHub Actions)
|
|
@@ -196,9 +199,24 @@ jobs:
|
|
|
196
199
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
197
200
|
```
|
|
198
201
|
|
|
202
|
+
Fail CI when translations in Translify have placeholder errors:
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
- run: npx translify lint --max-warnings 0
|
|
206
|
+
env:
|
|
207
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`lint` checks what is stored in Translify, not your local files; run it after `push`, or before `pull`/`publish` on deploy.
|
|
211
|
+
It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
|
|
212
|
+
project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
|
|
213
|
+
|
|
199
214
|
Pull and publish on deploy:
|
|
200
215
|
|
|
201
216
|
```yaml
|
|
217
|
+
- run: npx translify lint
|
|
218
|
+
env:
|
|
219
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
202
220
|
- run: npx translify pull
|
|
203
221
|
env:
|
|
204
222
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
@@ -217,6 +235,7 @@ Pull and publish on deploy:
|
|
|
217
235
|
| 3 | Plan quota exceeded |
|
|
218
236
|
| 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
|
|
219
237
|
| 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
|
|
238
|
+
| 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), or `publish` refused because translations with errors would go live |
|
|
220
239
|
|
|
221
240
|
The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
|
|
222
241
|
|
|
@@ -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/commands/publish.js
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
import { EXIT } from '../errors.js';
|
|
1
|
+
import { CliError, EXIT } from '../errors.js';
|
|
2
|
+
import { renderIssueCells } from '../render.js';
|
|
3
|
+
const isInvalidCells = (details) => typeof details === 'object' && details !== null && details.code === 'INVALID_CELLS';
|
|
4
|
+
/** The server's one-line refusal, the cells grouped like `lint`, and the way out. */
|
|
5
|
+
const refusal = (body, config) => {
|
|
6
|
+
const cells = Array.isArray(body.cells) ? body.cells : [];
|
|
7
|
+
const lines = [
|
|
8
|
+
`Publish refused: ${body.total} translation(s) with placeholder errors would go live. Nothing was published.`,
|
|
9
|
+
...renderIssueCells(cells, config),
|
|
10
|
+
];
|
|
11
|
+
if (body.total > cells.length)
|
|
12
|
+
lines.push(`Showing first ${cells.length} of ${body.total}.`);
|
|
13
|
+
lines.push('Fix them (translify lint lists every issue) or re-run with --allow-errors.');
|
|
14
|
+
return new CliError(EXIT.issues, lines.join('\n'), body);
|
|
15
|
+
};
|
|
2
16
|
export async function runPublish(environment, deps) {
|
|
3
|
-
|
|
17
|
+
// Only send the flag when it is set: a server older than the gate rejects unknown fields.
|
|
18
|
+
const request = deps.allowErrors === true ? { environment, allowErrors: true } : { environment };
|
|
19
|
+
const res = await deps.api.post('/cli/v1/publish', request).catch((error) => {
|
|
20
|
+
if (error instanceof CliError && isInvalidCells(error.details))
|
|
21
|
+
throw refusal(error.details, deps.config);
|
|
22
|
+
throw error;
|
|
23
|
+
});
|
|
4
24
|
const carriedOverCount = res.carriedOverCount ?? 0;
|
|
5
25
|
const withheldCount = res.withheldCount ?? 0;
|
|
26
|
+
const errorCount = res.errorCount ?? 0;
|
|
6
27
|
// An ungated environment reports 0 / 0; keep its output as it was before review state.
|
|
7
28
|
const gate = carriedOverCount > 0 || withheldCount > 0 ? ` ${carriedOverCount} carried over, ${withheldCount} withheld.` : '';
|
|
29
|
+
const errors = errorCount > 0 ? ` ${errorCount} with placeholder errors.` : '';
|
|
8
30
|
deps.out(deps.json === true
|
|
9
31
|
? JSON.stringify({
|
|
10
32
|
response: res,
|
|
11
|
-
summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount },
|
|
33
|
+
summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount, errorCount },
|
|
12
34
|
})
|
|
13
|
-
: `Published ${res.publishedCount} translation(s) to ${environment}.${gate}`);
|
|
35
|
+
: `Published ${res.publishedCount} translation(s) to ${environment}.${gate}${errors}`);
|
|
14
36
|
return deps.failOnWithheld === true && withheldCount > 0 ? EXIT.withheld : EXIT.ok;
|
|
15
37
|
}
|
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/http.js
CHANGED
|
@@ -32,6 +32,7 @@ const CODE_EXIT = {
|
|
|
32
32
|
RATE_LIMITED: EXIT.network,
|
|
33
33
|
REVIEW_DISABLED: EXIT.failed,
|
|
34
34
|
APPROVED_ONLY_REQUIRES_WORKING: EXIT.failed,
|
|
35
|
+
INVALID_CELLS: EXIT.issues,
|
|
35
36
|
};
|
|
36
37
|
const exitFor = (status, code) => {
|
|
37
38
|
const byCode = code === undefined ? undefined : CODE_EXIT[code];
|
|
@@ -88,6 +89,8 @@ const describeBody = (status, body) => {
|
|
|
88
89
|
return 'Review is off for this project, so --status draft or needs-review cannot be used. Turn on "Require review" in the project settings, or drop --status.';
|
|
89
90
|
case 'APPROVED_ONLY_REQUIRES_WORKING':
|
|
90
91
|
return ONLY_APPROVED_NEEDS_WORKING;
|
|
92
|
+
case 'INVALID_CELLS':
|
|
93
|
+
return `Publish refused: ${String(body.total)} translation(s) with placeholder errors would go live. Nothing was published.`;
|
|
91
94
|
case 'TRANSACTION_CONFLICT':
|
|
92
95
|
return 'The server hit a transaction conflict. Try again.';
|
|
93
96
|
case 'RATE_LIMITED':
|
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';
|
|
@@ -105,13 +106,17 @@ program
|
|
|
105
106
|
.command('publish <environment>')
|
|
106
107
|
.description('Publish an environment')
|
|
107
108
|
.option('--fail-on-withheld', 'exit 5 when the review gate withheld any translation (the publish still happens)')
|
|
109
|
+
.option('--allow-errors', 'publish even when translations with placeholder errors would go live (otherwise refused, exit 6)')
|
|
108
110
|
.option('--json', 'machine-readable output')
|
|
109
111
|
.action(async (environment, opts) => {
|
|
112
|
+
const { api, config } = await withApi();
|
|
110
113
|
process.exitCode = await runPublish(environment, {
|
|
111
|
-
api
|
|
114
|
+
api,
|
|
115
|
+
config,
|
|
112
116
|
out,
|
|
113
117
|
json: opts.json,
|
|
114
118
|
failOnWithheld: opts.failOnWithheld,
|
|
119
|
+
allowErrors: opts.allowErrors,
|
|
115
120
|
});
|
|
116
121
|
});
|
|
117
122
|
program
|
|
@@ -136,6 +141,18 @@ program
|
|
|
136
141
|
.action(async (opts) => {
|
|
137
142
|
process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
|
|
138
143
|
});
|
|
144
|
+
program
|
|
145
|
+
.command('lint')
|
|
146
|
+
.description('Exit 6 if translations in Translify have placeholder errors (or too many warnings)')
|
|
147
|
+
.option('--namespace <ns>', 'only this namespace')
|
|
148
|
+
.option('--locale <locale>', 'only this locale (as named on disk)')
|
|
149
|
+
.addOption(new Option('--severity <severity>', 'only list cells whose worst issue is this severity; counts and the exit code are unaffected').choices(LINT_SEVERITIES))
|
|
150
|
+
.option('--max-warnings <n>', 'also exit 6 when there are more than n warnings')
|
|
151
|
+
.option('--json', 'machine-readable output')
|
|
152
|
+
.action(async (opts) => {
|
|
153
|
+
parseMaxWarnings(opts.maxWarnings);
|
|
154
|
+
process.exitCode = await runLint(opts, { ...(await withApi()), out });
|
|
155
|
+
});
|
|
139
156
|
try {
|
|
140
157
|
await program.parseAsync(process.argv);
|
|
141
158
|
}
|
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,77 @@ 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 renderIssueCells(cells, config) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
let namespace;
|
|
115
|
+
let key;
|
|
116
|
+
for (const cell of cells) {
|
|
117
|
+
if (cell.namespace !== namespace) {
|
|
118
|
+
namespace = cell.namespace;
|
|
119
|
+
key = undefined;
|
|
120
|
+
lines.push(pc.bold(namespace));
|
|
121
|
+
}
|
|
122
|
+
if (cell.key !== key) {
|
|
123
|
+
key = cell.key;
|
|
124
|
+
lines.push(` ${key}`);
|
|
125
|
+
}
|
|
126
|
+
const locale = (config === undefined ? cell.locale : toFsLocale(cell.locale, config)).padEnd(8);
|
|
127
|
+
for (const issue of cell.issues) {
|
|
128
|
+
const label = issue.severity === 'ERROR' ? pc.red('error'.padEnd(8)) : pc.yellow('warning'.padEnd(8));
|
|
129
|
+
lines.push(` ${locale} ${label} ${issueText(issue)}`);
|
|
130
|
+
if (issue.code === 'ICU_INVALID' && cell.value !== undefined) {
|
|
131
|
+
lines.push(`${VALUE_INDENT}${pc.dim(JSON.stringify(cut(cell.value)))}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
export function renderLint(res, config, view = {}) {
|
|
138
|
+
const { errors, warnings } = res.summary;
|
|
139
|
+
if (errors + warnings === 0)
|
|
140
|
+
return pc.green(`✔ No issues in ${res.summary.locales.length} locale(s).`);
|
|
141
|
+
const lines = renderIssueCells(res.cells, config);
|
|
142
|
+
if (res.truncated) {
|
|
143
|
+
const matched = view.severity === 'error' ? errors : view.severity === 'warning' ? warnings : errors + warnings;
|
|
144
|
+
lines.push(pc.yellow(`Showing first ${res.cells.length} of ${matched} cells. Narrow with --namespace / --locale, or use --json.`));
|
|
145
|
+
}
|
|
146
|
+
const perLocale = res.summary.locales
|
|
147
|
+
.filter((row) => row.errors + row.warnings > 0)
|
|
148
|
+
.map((row) => `${toFsLocale(row.locale, config)} ${row.errors}/${row.warnings}`)
|
|
149
|
+
.join(' · ');
|
|
150
|
+
const totals = `${plural(errors, 'error')}, ${plural(warnings, 'warning')}${perLocale === '' ? '' : ` — ${perLocale}`}`;
|
|
151
|
+
if (lines.length > 0)
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(errors > 0 ? pc.red(`✖ ${totals}`) : pc.yellow(`⚠ ${totals}`));
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
package/dist/version.js
CHANGED