@chesterbrains/translify-cli 0.1.2 → 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 CHANGED
@@ -126,13 +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
- - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`
134
- - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`
135
- - `--json` on `push`, `pull`, `status` and `publish`: machine-readable output on stdout
134
+ - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
135
+ - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
136
+ - `publish`: `--fail-on-withheld`
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
136
139
  - `--debug` (global): print error details and stack traces
137
140
 
138
141
  ## Conflict rules
@@ -146,6 +149,15 @@ Options:
146
149
  - `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
147
150
  - `--dry-run` shows the effect of any combination and writes nothing.
148
151
 
152
+ ## Review
153
+
154
+ When the project has **Require review** turned on in Translify:
155
+
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.
157
+ - Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
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
+ - `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
+
149
161
  ## CI (GitHub Actions)
150
162
 
151
163
  Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
@@ -186,9 +198,24 @@ jobs:
186
198
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
187
199
  ```
188
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
+
189
213
  Pull and publish on deploy:
190
214
 
191
215
  ```yaml
216
+ - run: npx translify lint
217
+ env:
218
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
192
219
  - run: npx translify pull
193
220
  env:
194
221
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
@@ -206,6 +233,8 @@ Pull and publish on deploy:
206
233
  | 2 | Key problem: invalid, revoked, wrong kind of key, or a missing scope |
207
234
  | 3 | Plan quota exceeded |
208
235
  | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
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` |
209
238
 
210
239
  The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
211
240
 
@@ -230,6 +259,8 @@ The server's error `code` decides the exit code first; the HTTP status is only u
230
259
  | `PUSH_TOO_LARGE` | 4 | Split the push by namespace: `translify push --namespace <ns>`. |
231
260
  | `CLI_TOO_OLD` | 4 | Upgrade: `npm i -g @chesterbrains/translify-cli@latest` (or use `npx translify@latest`). |
232
261
  | `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
+ | `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
+ | `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. |
233
264
  | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
234
265
  | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
235
266
  | `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
@@ -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
+ }
@@ -1,8 +1,15 @@
1
1
  import { EXIT } from '../errors.js';
2
2
  export async function runPublish(environment, deps) {
3
3
  const res = await deps.api.post('/cli/v1/publish', { environment });
4
+ const carriedOverCount = res.carriedOverCount ?? 0;
5
+ const withheldCount = res.withheldCount ?? 0;
6
+ // An ungated environment reports 0 / 0; keep its output as it was before review state.
7
+ const gate = carriedOverCount > 0 || withheldCount > 0 ? ` ${carriedOverCount} carried over, ${withheldCount} withheld.` : '';
4
8
  deps.out(deps.json === true
5
- ? JSON.stringify({ response: res, summary: { environment, publishedCount: res.publishedCount } })
6
- : `Published ${res.publishedCount} translation(s) to ${environment}.`);
7
- return EXIT.ok;
9
+ ? JSON.stringify({
10
+ response: res,
11
+ summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount },
12
+ })
13
+ : `Published ${res.publishedCount} translation(s) to ${environment}.${gate}`);
14
+ return deps.failOnWithheld === true && withheldCount > 0 ? EXIT.withheld : EXIT.ok;
8
15
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
- import { CliError, EXIT } from '../errors.js';
3
+ import { CliError, EXIT, ONLY_APPROVED_NEEDS_WORKING } from '../errors.js';
4
4
  import { pathFor, toFsLocale, toTranslifyLocale } from '../patterns.js';
5
5
  const readOrUndefined = async (path) => readFile(path, 'utf8').catch(() => undefined);
6
6
  const NESTED_CONFLICT = /nested JSON cannot hold both/;
@@ -28,6 +28,10 @@ const withDiskLocale = (content, translifyLocale, config) => {
28
28
  /** A Windows checkout (autocrlf, BOM) must not count as drift. */
29
29
  const normalizeEol = (text) => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
30
30
  export async function runPull(opts, deps) {
31
+ // Environments already apply the gate; the server would 400 this, fail before any request.
32
+ if (opts.onlyApproved && opts.from !== undefined && opts.from !== 'working') {
33
+ throw new CliError(EXIT.failed, ONLY_APPROVED_NEEDS_WORKING);
34
+ }
31
35
  const changed = [];
32
36
  const created = [];
33
37
  const responses = [];
@@ -49,6 +53,8 @@ export async function runPull(opts, deps) {
49
53
  query.jsonStyle = rule.jsonStyle;
50
54
  if (opts.from !== undefined)
51
55
  query.from = opts.from;
56
+ if (opts.onlyApproved)
57
+ query.approvedOnly = 'true';
52
58
  if (opts.locale !== undefined)
53
59
  query.locales = toTranslifyLocale(opts.locale, deps.config);
54
60
  const namespace = rule.namespace ?? opts.namespace;
@@ -3,6 +3,12 @@ import { join } from 'node:path';
3
3
  import { CliError, EXIT } from '../errors.js';
4
4
  import { findFiles } from '../patterns.js';
5
5
  import { renderPush, summarizePush } from '../render.js';
6
+ export const PUSH_STATUSES = ['draft', 'needs-review', 'approved'];
7
+ const WIRE_STATUS = {
8
+ draft: 'DRAFT',
9
+ 'needs-review': 'NEEDS_REVIEW',
10
+ approved: 'APPROVED',
11
+ };
6
12
  const PUSH = '/cli/v1/push';
7
13
  const MAX_FILES = 500;
8
14
  /** One form field per file (`file0`, `file1`, …): the server matches by field, never by filename. */
@@ -20,6 +26,9 @@ const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
20
26
  form.set('prune', String(prune));
21
27
  if (pruneDigest !== undefined)
22
28
  form.set('pruneDigest', pruneDigest);
29
+ // No default on the wire: the server resolves an absent status from the project's review setting.
30
+ if (opts.status !== undefined)
31
+ form.set('status', WIRE_STATUS[opts.status]);
23
32
  for (const { field, match, bytes } of uploads)
24
33
  form.append(field, new File([bytes], match.path));
25
34
  return form;
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 };
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;
@@ -10,3 +10,5 @@ export class CliError extends Error {
10
10
  this.details = details;
11
11
  }
12
12
  }
13
+ /** Shared by the local check in `pull` and the server's `APPROVED_ONLY_REQUIRES_WORKING`. */
14
+ export const ONLY_APPROVED_NEEDS_WORKING = '--only-approved works with --from working only; environments already apply the review gate.';
package/dist/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CliError, EXIT } from './errors.js';
1
+ import { CliError, EXIT, ONLY_APPROVED_NEEDS_WORKING } from './errors.js';
2
2
  import { USER_AGENT } from './version.js';
3
3
  const NOT_JSON = Symbol('notJson');
4
4
  const MAX_RETRIES = 3;
@@ -30,6 +30,8 @@ const CODE_EXIT = {
30
30
  ORPHANS_CHANGED: EXIT.failed,
31
31
  TRANSACTION_CONFLICT: EXIT.network,
32
32
  RATE_LIMITED: EXIT.network,
33
+ REVIEW_DISABLED: EXIT.failed,
34
+ APPROVED_ONLY_REQUIRES_WORKING: EXIT.failed,
33
35
  };
34
36
  const exitFor = (status, code) => {
35
37
  const byCode = code === undefined ? undefined : CODE_EXIT[code];
@@ -82,6 +84,10 @@ const describeBody = (status, body) => {
82
84
  return `Unknown namespace(s): ${list(body.namespaces)}.`;
83
85
  case 'ORPHANS_CHANGED':
84
86
  return 'The orphans changed since the dry run (keys were added, removed or published meanwhile). Nothing was written. Run translify push --prune again to review the new list.';
87
+ case 'REVIEW_DISABLED':
88
+ 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
+ case 'APPROVED_ONLY_REQUIRES_WORKING':
90
+ return ONLY_APPROVED_NEEDS_WORKING;
85
91
  case 'TRANSACTION_CONFLICT':
86
92
  return 'The server hit a transaction conflict. Try again.';
87
93
  case 'RATE_LIMITED':
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { confirm, input, password, select } from '@inquirer/prompts';
3
- import { Command } from 'commander';
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';
8
- import { runPush } from './commands/push.js';
9
+ import { PUSH_STATUSES, runPush } from './commands/push.js';
9
10
  import { loadConfig } from './config.js';
10
11
  import { askConfirm } from './confirm.js';
11
12
  import { resolveKey } from './credentials.js';
@@ -90,6 +91,7 @@ program
90
91
  .option('--prune', 'delete keys missing from your source files; published keys are kept; deletes exactly the keys you confirmed, and refuses if anything changed meanwhile (re-run to review)')
91
92
  .option('-y, --yes', 'confirm destructive flags without prompting')
92
93
  .option('--namespace <ns>', 'only this namespace')
94
+ .addOption(new Option('--status <status>', 'status target-locale cells land in (default: needs-review when the project requires review, approved otherwise)').choices(PUSH_STATUSES))
93
95
  .option('--json', 'machine-readable output')
94
96
  .action(async (opts) => {
95
97
  process.exitCode = await runPush(opts, {
@@ -103,9 +105,15 @@ program
103
105
  program
104
106
  .command('publish <environment>')
105
107
  .description('Publish an environment')
108
+ .option('--fail-on-withheld', 'exit 5 when the review gate withheld any translation (the publish still happens)')
106
109
  .option('--json', 'machine-readable output')
107
110
  .action(async (environment, opts) => {
108
- process.exitCode = await runPublish(environment, { api: (await withApi()).api, out, json: opts.json });
111
+ process.exitCode = await runPublish(environment, {
112
+ api: (await withApi()).api,
113
+ out,
114
+ json: opts.json,
115
+ failOnWithheld: opts.failOnWithheld,
116
+ });
109
117
  });
110
118
  program
111
119
  .command('pull')
@@ -113,6 +121,7 @@ program
113
121
  .option('--from <source>', 'working (default) or env:<slug>')
114
122
  .option('--locale <locale>', 'only this locale (as named on disk)')
115
123
  .option('--namespace <ns>', 'only this namespace')
124
+ .option('--only-approved', 'approved text only; translations never approved are left out (working copy only)')
116
125
  .option('--json', 'machine-readable output')
117
126
  .action(async (opts) => {
118
127
  process.exitCode = await runPull(opts, { ...(await withApi()), out });
@@ -123,10 +132,23 @@ program
123
132
  .option('--from <source>', 'working (default) or env:<slug>')
124
133
  .option('--locale <locale>', 'only this locale (as named on disk)')
125
134
  .option('--namespace <ns>', 'only this namespace')
135
+ .option('--only-approved', 'approved text only; translations never approved are left out (working copy only)')
126
136
  .option('--json', 'machine-readable output')
127
137
  .action(async (opts) => {
128
138
  process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
129
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
+ });
130
152
  try {
131
153
  await program.parseAsync(process.argv);
132
154
  }
package/dist/render.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import pc from 'picocolors';
2
+ import { toFsLocale } from './patterns.js';
2
3
  const MAX_ORPHANS = 20;
3
4
  const MAX_UNKNOWN = 5;
5
+ /** APPROVED gets no label: it is all a project without review ever sees. */
6
+ const STATUS_LABEL = { DRAFT: 'draft', NEEDS_REVIEW: 'needs review' };
4
7
  /** Totals across locales, for `--json`. */
5
8
  export function summarizePush(res) {
6
9
  const summary = {
@@ -13,6 +16,7 @@ export function summarizePush(res) {
13
16
  orphans: res.orphans.length,
14
17
  publishedOrphans: res.orphans.filter((orphan) => orphan.published).length,
15
18
  pruned: res.pruned,
19
+ staleReset: res.staleReset ?? 0,
16
20
  };
17
21
  for (const counts of Object.values(res.locales)) {
18
22
  summary.created += counts.created;
@@ -30,12 +34,19 @@ export function renderPush(res, view = {}) {
30
34
  pc.dim(`${c.unchanged} unchanged`);
31
35
  if (c.skippedFilled > 0)
32
36
  line += pc.dim(`, ${c.skippedFilled} kept (already translated; --overwrite-targets replaces them)`);
37
+ const label = c.status === undefined ? undefined : STATUS_LABEL[c.status];
38
+ if (label !== undefined && c.created + c.updated > 0)
39
+ line += pc.magenta(` · ${label}`);
33
40
  lines.push(line);
34
41
  if (c.unknownKeys.length > 0) {
35
42
  const more = c.unknownKeys.length > MAX_UNKNOWN ? ', …' : '';
36
43
  lines.push(pc.yellow(` ${c.unknownKeys.length} unknown key(s) skipped: ${c.unknownKeys.slice(0, MAX_UNKNOWN).join(', ')}${more}`));
37
44
  }
38
45
  }
46
+ const stale = res.staleReset ?? 0;
47
+ if (stale > 0) {
48
+ lines.push(pc.yellow(`${stale} approved translation(s) ${res.dryRun ? 'would go' : 'went'} back to review because their source text changed.`));
49
+ }
39
50
  // `pruned` is what a dry run would delete or a real push did delete: unpublished orphans only.
40
51
  const unpublished = res.orphans.filter((orphan) => !orphan.published);
41
52
  const published = res.orphans.filter((orphan) => orphan.published);
@@ -68,3 +79,72 @@ export function renderPush(res, view = {}) {
68
79
  lines.push(pc.red(` Deleted ${res.pruned} key(s).`));
69
80
  return lines.join('\n');
70
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
@@ -1,3 +1,3 @@
1
1
  // Must equal package.json "version"; src/version.test.ts fails when they drift.
2
- export const VERSION = '0.1.2';
2
+ export const VERSION = '0.3.0';
3
3
  export const USER_AGENT = `translify-cli/${VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chesterbrains/translify-cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {