@chesterbrains/translify-cli 0.3.0 → 0.4.1
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 +60 -6
- package/dist/commands/publish.js +26 -4
- package/dist/http.js +4 -1
- package/dist/index.js +5 -1
- package/dist/render.js +12 -7
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,9 +77,7 @@ npx translify pull
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
- `format` is `json`, `xliff` or `arb`. JSON also takes `"jsonStyle": "nested"` (default) or `"flat"`.
|
|
82
|
-
- `locales` maps a locale name on disk to the Translify locale code, for example `{ "en_US": "en-US" }`. Omit it when the names already match.
|
|
80
|
+
Every field is described in the [`translify.json` reference](#translifyjson-reference).
|
|
83
81
|
|
|
84
82
|
## Quickstart: Flutter
|
|
85
83
|
|
|
@@ -117,6 +115,26 @@ flutter gen-l10n
|
|
|
117
115
|
|
|
118
116
|
`"@@locale"` is optional in ARB files: when it is absent, the file's locale is used. When it is present it must match the file's locale (case and `_`/`-` are ignored). On `pull`, the CLI writes `@@locale` using the locale name on disk (for example `en_US` in `app_en_US.arb`), as `flutter gen-l10n` expects.
|
|
119
117
|
|
|
118
|
+
## `translify.json` reference
|
|
119
|
+
|
|
120
|
+
`translify.json` lives in the repository root; every command reads it from the current directory. The
|
|
121
|
+
[JSON schema](schema/translify.schema.json) gives editors completion and validation through `$schema`. Unknown
|
|
122
|
+
fields are refused, and so is any field whose name contains `key`, `secret` or `token`: keys never go in this file.
|
|
123
|
+
|
|
124
|
+
| Field | Type | Required | Description |
|
|
125
|
+
|---|---|---|---|
|
|
126
|
+
| `$schema` | string | no | Schema URL for editor support. Ignored by the CLI. |
|
|
127
|
+
| `apiUrl` | URL | yes | The Translify API base, ending in `/api`, e.g. `https://translify.tommasofeltrin.work/api`. |
|
|
128
|
+
| `files` | array | yes | One rule per kind of translation file. Each rule has the fields below. |
|
|
129
|
+
| `files[].pattern` | string | yes | Path relative to `translify.json`, with `/` separators. Must contain `{locale}` once and may contain `{namespace}` once, e.g. `locales/{locale}/{namespace}.json`. |
|
|
130
|
+
| `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
|
|
131
|
+
| `files[].namespace` | string | when the pattern has no `{namespace}`, and always for `arb` | Fixed namespace for every file the rule matches. Lowercase kebab-case, at most 64 characters. |
|
|
132
|
+
| `files[].jsonStyle` | `nested` \| `flat` | no | Only for `json`. `nested` (the default) writes `{ "a": { "b": "…" } }`, `flat` writes `{ "a.b": "…" }`. |
|
|
133
|
+
| `locales` | object | no | Maps a locale name on disk to a Translify locale code, e.g. `{ "en_US": "en-US" }`. Unmapped names pass through unchanged. Two names may not map to the same code, and a target may not itself be a mapped name. |
|
|
134
|
+
|
|
135
|
+
Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
|
|
136
|
+
otherwise the CLI stops before sending anything and names the files involved.
|
|
137
|
+
|
|
120
138
|
## Commands
|
|
121
139
|
|
|
122
140
|
| Command | What it does |
|
|
@@ -133,7 +151,7 @@ Options:
|
|
|
133
151
|
|
|
134
152
|
- `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
|
|
135
153
|
- `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
|
|
136
|
-
- `publish`: `--fail-on-withheld`
|
|
154
|
+
- `publish`: `--fail-on-withheld`, `--allow-errors`
|
|
137
155
|
- `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
156
|
- `--json` on `push`, `pull`, `status`, `publish` and `lint`: machine-readable output on stdout
|
|
139
157
|
- `--debug` (global): print error details and stack traces
|
|
@@ -145,10 +163,44 @@ Options:
|
|
|
145
163
|
- **Source locale: your files win.** Existing source translations are overwritten with what is in the file.
|
|
146
164
|
- **Target locales: Translify fills only the gaps.** Keys that already have a translation are left alone and counted as skipped.
|
|
147
165
|
- `--overwrite-targets` also overwrites existing target translations.
|
|
148
|
-
- `--prune` deletes keys that no longer exist in your source files, together with their translations.
|
|
166
|
+
- `--prune` deletes keys that no longer exist in your source files, together with their translations. See [Prune safety](#prune-safety).
|
|
149
167
|
- `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
|
|
150
168
|
- `--dry-run` shows the effect of any combination and writes nothing.
|
|
151
169
|
|
|
170
|
+
## Prune safety
|
|
171
|
+
|
|
172
|
+
`translify push --prune` is the only command that deletes keys. What it deletes, and what stops it:
|
|
173
|
+
|
|
174
|
+
- **Only the namespaces in this push.** A key is an orphan when its namespace is in the push and its source file no
|
|
175
|
+
longer has it. Namespaces you did not push (another rule, another repository, `--namespace` left them out) are never
|
|
176
|
+
touched.
|
|
177
|
+
- **Never published keys.** A key published to any environment is listed as kept and survives. Unpublish it in
|
|
178
|
+
Translify first if it really should go.
|
|
179
|
+
- **Never a whole namespace by accident.** If a source file in the push has no entries, the push is refused
|
|
180
|
+
(`VALIDATION_FAILED` with `emptySourcePrune`) instead of deleting every key in that namespace. Emptying a namespace
|
|
181
|
+
is done in the Translify web app.
|
|
182
|
+
- **Exactly the list you confirmed.** The CLI always runs a dry run first, shows the keys it would delete and asks
|
|
183
|
+
for confirmation. The real push sends a digest of that list; if the server's list differs by then (keys added,
|
|
184
|
+
removed or published in between), it refuses with `ORPHANS_CHANGED` and writes nothing. A server too old to return
|
|
185
|
+
the digest makes `--prune` refuse.
|
|
186
|
+
- **No silent prune in CI.** Without a terminal, `--prune` is refused unless you pass `--yes`.
|
|
187
|
+
- **Atomic.** The whole push, deletions included, is one transaction: if anything fails, nothing is written.
|
|
188
|
+
|
|
189
|
+
`--yes` confirms whatever that run's dry run lists, so in CI review the list before it runs unattended. One way:
|
|
190
|
+
show the orphans on pull requests and prune on `main`.
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
# on pull_request: list what a prune would delete, change nothing
|
|
194
|
+
- run: npx translify push --prune --dry-run
|
|
195
|
+
env:
|
|
196
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
197
|
+
|
|
198
|
+
# on push to main: prune for real
|
|
199
|
+
- run: npx translify push --prune --yes
|
|
200
|
+
env:
|
|
201
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
202
|
+
```
|
|
203
|
+
|
|
152
204
|
## Review
|
|
153
205
|
|
|
154
206
|
When the project has **Require review** turned on in Translify:
|
|
@@ -156,6 +208,7 @@ When the project has **Require review** turned on in Translify:
|
|
|
156
208
|
- `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.
|
|
157
209
|
- Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
|
|
158
210
|
- `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.
|
|
211
|
+
- `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.
|
|
159
212
|
- `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.
|
|
160
213
|
|
|
161
214
|
## CI (GitHub Actions)
|
|
@@ -234,7 +287,7 @@ Pull and publish on deploy:
|
|
|
234
287
|
| 3 | Plan quota exceeded |
|
|
235
288
|
| 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
|
|
236
289
|
| 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
|
|
237
|
-
| 6 | `lint` found
|
|
290
|
+
| 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), or `publish` refused because translations with errors would go live |
|
|
238
291
|
|
|
239
292
|
The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
|
|
240
293
|
|
|
@@ -261,6 +314,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
|
|
|
261
314
|
| `ORPHANS_CHANGED` | 1 | The orphan list changed between the dry run and the real push (keys were added, removed or published). Nothing was written; run `translify push --prune` again to review the new list. |
|
|
262
315
|
| `REVIEW_DISABLED` | 1 | `push --status draft` or `--status needs-review` on a project without review. Turn on **Require review** in the project settings, or drop `--status`. Nothing was written. |
|
|
263
316
|
| `APPROVED_ONLY_REQUIRES_WORKING` | 1 | `--only-approved` was combined with `--from env:<slug>`. Pull an environment without it; it already holds only what its gate allowed. |
|
|
317
|
+
| `INVALID_CELLS` | 6 | `publish` refused: translations that would go live have placeholder errors. Nothing was published; the output lists them like `lint` does. Fix them in Translify, or re-run with `--allow-errors` to publish anyway (recorded in the activity log). |
|
|
264
318
|
| `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
|
|
265
319
|
| `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
|
|
266
320
|
| `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
|
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/http.js
CHANGED
|
@@ -8,7 +8,7 @@ const REQUEST_TIMEOUT_MS = 180_000;
|
|
|
8
8
|
const UPGRADE = 'npm i -g @chesterbrains/translify-cli@latest, or for the standalone binary: curl -fsSL https://github.com/chesterbrains/translify-cli/releases/latest/download/install.sh | sh';
|
|
9
9
|
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
10
|
/** Exit code by the body's `code` first; the status only decides for a code we do not know. */
|
|
11
|
-
const CODE_EXIT = {
|
|
11
|
+
export const CODE_EXIT = {
|
|
12
12
|
VALIDATION_FAILED: EXIT.failed,
|
|
13
13
|
LOCALE_NOT_FOUND: EXIT.failed,
|
|
14
14
|
NAMESPACE_NOT_FOUND: EXIT.failed,
|
|
@@ -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
|
@@ -106,13 +106,17 @@ program
|
|
|
106
106
|
.command('publish <environment>')
|
|
107
107
|
.description('Publish an environment')
|
|
108
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)')
|
|
109
110
|
.option('--json', 'machine-readable output')
|
|
110
111
|
.action(async (environment, opts) => {
|
|
112
|
+
const { api, config } = await withApi();
|
|
111
113
|
process.exitCode = await runPublish(environment, {
|
|
112
|
-
api
|
|
114
|
+
api,
|
|
115
|
+
config,
|
|
113
116
|
out,
|
|
114
117
|
json: opts.json,
|
|
115
118
|
failOnWithheld: opts.failOnWithheld,
|
|
119
|
+
allowErrors: opts.allowErrors,
|
|
116
120
|
});
|
|
117
121
|
});
|
|
118
122
|
program
|
package/dist/render.js
CHANGED
|
@@ -109,14 +109,11 @@ const cut = (value) => (value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE -
|
|
|
109
109
|
* ESLint-style: namespace, then key, then one line per issue with the locale as named on disk.
|
|
110
110
|
* Rows arrive sorted by the server; only the grouping headers are added here.
|
|
111
111
|
*/
|
|
112
|
-
export function
|
|
113
|
-
const { errors, warnings } = res.summary;
|
|
114
|
-
if (errors + warnings === 0)
|
|
115
|
-
return pc.green(`✔ No issues in ${res.summary.locales.length} locale(s).`);
|
|
112
|
+
export function renderIssueCells(cells, config) {
|
|
116
113
|
const lines = [];
|
|
117
114
|
let namespace;
|
|
118
115
|
let key;
|
|
119
|
-
for (const cell of
|
|
116
|
+
for (const cell of cells) {
|
|
120
117
|
if (cell.namespace !== namespace) {
|
|
121
118
|
namespace = cell.namespace;
|
|
122
119
|
key = undefined;
|
|
@@ -126,14 +123,22 @@ export function renderLint(res, config, view = {}) {
|
|
|
126
123
|
key = cell.key;
|
|
127
124
|
lines.push(` ${key}`);
|
|
128
125
|
}
|
|
129
|
-
const locale = toFsLocale(cell.locale, config).padEnd(8);
|
|
126
|
+
const locale = (config === undefined ? cell.locale : toFsLocale(cell.locale, config)).padEnd(8);
|
|
130
127
|
for (const issue of cell.issues) {
|
|
131
128
|
const label = issue.severity === 'ERROR' ? pc.red('error'.padEnd(8)) : pc.yellow('warning'.padEnd(8));
|
|
132
129
|
lines.push(` ${locale} ${label} ${issueText(issue)}`);
|
|
133
|
-
if (issue.code === 'ICU_INVALID')
|
|
130
|
+
if (issue.code === 'ICU_INVALID' && cell.value !== undefined) {
|
|
134
131
|
lines.push(`${VALUE_INDENT}${pc.dim(JSON.stringify(cut(cell.value)))}`);
|
|
132
|
+
}
|
|
135
133
|
}
|
|
136
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);
|
|
137
142
|
if (res.truncated) {
|
|
138
143
|
const matched = view.severity === 'error' ? errors : view.severity === 'warning' ? warnings : errors + warnings;
|
|
139
144
|
lines.push(pc.yellow(`Showing first ${res.cells.length} of ${matched} cells. Narrow with --namespace / --locale, or use --json.`));
|
package/dist/version.js
CHANGED