@chesterbrains/translify-cli 0.4.1 → 0.5.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
@@ -9,6 +9,8 @@ The CLI reads a `translify.json` that maps your translation files (i18next JSON,
9
9
  - `status` to fail CI when local files differ from Translify,
10
10
  - `publish` an environment.
11
11
 
12
+ Complete example apps — React + i18next at runtime, Flutter (ARB) and Angular (XLIFF) at build time — live in [translify-examples](https://github.com/chesterbrains/translify-examples).
13
+
12
14
  ## Install
13
15
 
14
16
  ### Standalone binary (no Node needed)
@@ -115,6 +117,46 @@ flutter gen-l10n
115
117
 
116
118
  `"@@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.
117
119
 
120
+ ## Angular (XLIFF)
121
+
122
+ Angular's `@angular/localize` writes a template interpolation as an inline `<ph>` element and a template ICU as
123
+ `{VAR_PLURAL, …}` / `{VAR_SELECT, …}`, and matches a translation's placeholders by the `equiv` name. Plain ICU text
124
+ (`Hello, {name}`) in an Angular XLIFF is therefore literal text at build time. Set `xliffDialect` on the rule and the
125
+ server converts both ways:
126
+
127
+ ```json
128
+ { "files": [{ "pattern": "src/locale/messages.{locale}.xlf", "format": "xliff", "namespace": "web", "xliffDialect": "angular" }] }
129
+ ```
130
+
131
+ Give every template string `i18n="@@<key>"` so the unit ids are your keys. What `pull` writes:
132
+
133
+ | Translify value | Angular unit |
134
+ |---|---|
135
+ | `Hello, {name}` | `Hello, <ph id="0" equiv="INTERPOLATION" disp="{{ name }}"/>` |
136
+ | `{a} {b} {a}` | ids `0`, `1`, `2`; `equiv` `INTERPOLATION`, `INTERPOLATION_1`, `INTERPOLATION` (one name per distinct argument, in order of first appearance in the source; a translation reuses the source's names however it reorders them) |
137
+ | `{count, plural, one {{count} item} other {# items}}` (the whole message) | `{VAR_PLURAL, plural, one {<ph id="0" equiv="INTERPOLATION" disp="{{ count }}"/> item} other {<ph id="1" equiv="INTERPOLATION" disp="{{ count }}"/> items}}` |
138
+ | `{g, select, male {he} other {they}}` (the whole message) | `{VAR_SELECT, select, male {he} other {they}}` |
139
+
140
+ What `push` reads from `ng extract-i18n --format=xlf2` output:
141
+
142
+ | Angular | Translify argument |
143
+ |---|---|
144
+ | `<ph equiv="INTERPOLATION" disp="{{ name }}"/>` | `{name}` |
145
+ | `disp="{{ count() }}"`, `disp="{{ user.name }}"`, `disp="{{ price | currency }}"` | the head identifier: `{count}`, `{user}`, `{price}` (also through `!.` and `[…]`); two that collide become `{user}` and `{user_2}` |
146
+ | no usable `disp` | `{p0}`, `{p1}`, … from the `equiv` number |
147
+ | `VAR_PLURAL` / `VAR_SELECT` | named after the first interpolation inside its branches, else `count` / `value` |
148
+
149
+ Units Angular hashes for a message that mixes text with an ICU are dropped, and `<note category="location">` is not a
150
+ description. After a round trip `#` comes back as `{count}`, and a plural or select with no interpolation inside keeps
151
+ `count` / `value` as its argument.
152
+
153
+ Not supported in this version (the pull is refused with `XLIFF_DIALECT_UNSUPPORTED`, the push reports the unit): text
154
+ beside a plural or select, two ICUs in one message, a plural or select inside another, `selectordinal`, `number` /
155
+ `date` / `time` formats, a quoted literal brace (`'{'`), HTML inside a message, and keys whose placeholders are printf or
156
+ i18next rather than ICU. Give
157
+ such a key its own element so the ICU is the whole message, or move it to a JSON rule. A complete example is the
158
+ Angular app in [translify-examples](https://github.com/chesterbrains/translify-examples).
159
+
118
160
  ## `translify.json` reference
119
161
 
120
162
  `translify.json` lives in the repository root; every command reads it from the current directory. The
@@ -130,6 +172,7 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
130
172
  | `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
131
173
  | `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
174
  | `files[].jsonStyle` | `nested` \| `flat` | no | Only for `json`. `nested` (the default) writes `{ "a": { "b": "…" } }`, `flat` writes `{ "a.b": "…" }`. |
175
+ | `files[].xliffDialect` | `angular` | no | Only for `xliff`. Writes `@angular/localize` placeholders on pull (`<ph equiv="INTERPOLATION"/>`, `{VAR_PLURAL, …}`) and reads `ng extract-i18n` output on push. See [Angular (XLIFF)](#angular-xliff). |
133
176
  | `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
177
 
135
178
  Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
@@ -287,7 +330,7 @@ Pull and publish on deploy:
287
330
  | 3 | Plan quota exceeded |
288
331
  | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
289
332
  | 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
290
- | 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), or `publish` refused because translations with errors would go live |
333
+ | 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), `publish` refused because translations with errors would go live, or `pull` refused an Angular XLIFF rule |
291
334
 
292
335
  The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
293
336
 
@@ -315,6 +358,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
315
358
  | `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. |
316
359
  | `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
360
  | `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). |
361
+ | `XLIFF_DIALECT_UNSUPPORTED` | 6 | `pull` refused: a translation cannot be written as Angular XLIFF (text beside a plural, nested plurals, number/date formats, printf keys). Nothing was written; the output lists the cells and why. Split the text, or move the key to a JSON rule. See [Angular (XLIFF)](#angular-xliff). |
318
362
  | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
319
363
  | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
320
364
  | `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
@@ -2,8 +2,22 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
3
  import { CliError, EXIT, ONLY_APPROVED_NEEDS_WORKING } from '../errors.js';
4
4
  import { pathFor, toFsLocale, toTranslifyLocale } from '../patterns.js';
5
+ import { renderReasonCells } from '../render.js';
5
6
  const readOrUndefined = async (path) => readFile(path, 'utf8').catch(() => undefined);
6
7
  const NESTED_CONFLICT = /nested JSON cannot hold both/;
8
+ const isDialectUnsupported = (details) => typeof details === 'object' && details !== null && details.code === 'XLIFF_DIALECT_UNSUPPORTED';
9
+ /** The server's one-line refusal, the cells grouped like `lint`, and the way out. */
10
+ const dialectRefusal = (body, config) => {
11
+ const cells = Array.isArray(body.cells) ? body.cells : [];
12
+ const lines = [
13
+ `Pull refused: ${body.total} translation(s) cannot be written as Angular XLIFF. Nothing was written.`,
14
+ ...renderReasonCells(cells, config),
15
+ ];
16
+ if (body.total > cells.length)
17
+ lines.push(`Showing first ${cells.length} of ${body.total}.`);
18
+ lines.push('Split the text so each key is plain text with placeholders or one plural/select, or move the key to a JSON rule.');
19
+ return new CliError(EXIT.issues, lines.join('\n'), body);
20
+ };
7
21
  /**
8
22
  * Flutter's gen_l10n requires `@@locale` to match the file name, and only the CLI knows the disk
9
23
  * mapping. Rewrites the value in place (key order kept) with the server's own formatting so the
@@ -51,6 +65,8 @@ export async function runPull(opts, deps) {
51
65
  const query = { format: rule.format };
52
66
  if (rule.format === 'json' && rule.jsonStyle !== undefined)
53
67
  query.jsonStyle = rule.jsonStyle;
68
+ if (rule.format === 'xliff' && rule.xliffDialect !== undefined)
69
+ query.dialect = rule.xliffDialect;
54
70
  if (opts.from !== undefined)
55
71
  query.from = opts.from;
56
72
  if (opts.onlyApproved)
@@ -60,7 +76,11 @@ export async function runPull(opts, deps) {
60
76
  const namespace = rule.namespace ?? opts.namespace;
61
77
  if (namespace !== undefined)
62
78
  query.namespaces = namespace;
63
- const res = await deps.api.get('/cli/v1/pull', query);
79
+ const res = await deps.api.get('/cli/v1/pull', query).catch((error) => {
80
+ if (error instanceof CliError && isDialectUnsupported(error.details))
81
+ throw dialectRefusal(error.details, deps.config);
82
+ throw error;
83
+ });
64
84
  responses.push(res);
65
85
  for (const warning of res.warnings) {
66
86
  lines.push(`warning: ${warning}`);
@@ -20,6 +20,7 @@ const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
20
20
  format: match.rule.format,
21
21
  locale: match.locale,
22
22
  namespace: match.namespace,
23
+ ...(match.rule.format === 'xliff' && match.rule.xliffDialect !== undefined ? { dialect: match.rule.xliffDialect } : {}),
23
24
  }))));
24
25
  form.set('dryRun', String(dryRun));
25
26
  form.set('overwriteTargets', String(opts.overwriteTargets === true));
package/dist/config.js CHANGED
@@ -17,6 +17,7 @@ const fileRule = z
17
17
  format: z.enum(['json', 'xliff', 'arb']),
18
18
  namespace: z.string().regex(NAMESPACE, 'must be lowercase kebab-case').max(64).optional(),
19
19
  jsonStyle: z.enum(['nested', 'flat']).optional(),
20
+ xliffDialect: z.enum(['angular']).optional(),
20
21
  })
21
22
  .strict()
22
23
  .superRefine((rule, ctx) => {
@@ -30,6 +31,9 @@ const fileRule = z
30
31
  if (rule.jsonStyle !== undefined && rule.format !== 'json') {
31
32
  ctx.addIssue({ code: 'custom', path: ['jsonStyle'], message: 'only applies to format "json"' });
32
33
  }
34
+ if (rule.xliffDialect !== undefined && rule.format !== 'xliff') {
35
+ ctx.addIssue({ code: 'custom', path: ['xliffDialect'], message: 'only applies to format "xliff"' });
36
+ }
33
37
  })
34
38
  .transform((rule) => (rule.format === 'json' ? { ...rule, jsonStyle: rule.jsonStyle ?? 'nested' } : rule));
35
39
  const configSchema = z
package/dist/http.js CHANGED
@@ -33,6 +33,7 @@ export const CODE_EXIT = {
33
33
  REVIEW_DISABLED: EXIT.failed,
34
34
  APPROVED_ONLY_REQUIRES_WORKING: EXIT.failed,
35
35
  INVALID_CELLS: EXIT.issues,
36
+ XLIFF_DIALECT_UNSUPPORTED: EXIT.issues,
36
37
  };
37
38
  const exitFor = (status, code) => {
38
39
  const byCode = code === undefined ? undefined : CODE_EXIT[code];
@@ -91,6 +92,8 @@ const describeBody = (status, body) => {
91
92
  return ONLY_APPROVED_NEEDS_WORKING;
92
93
  case 'INVALID_CELLS':
93
94
  return `Publish refused: ${String(body.total)} translation(s) with placeholder errors would go live. Nothing was published.`;
95
+ case 'XLIFF_DIALECT_UNSUPPORTED':
96
+ return `Pull refused: ${String(body.total)} translation(s) cannot be written as Angular XLIFF. Nothing was written.`;
94
97
  case 'TRANSACTION_CONFLICT':
95
98
  return 'The server hit a transaction conflict. Try again.';
96
99
  case 'RATE_LIMITED':
package/dist/render.js CHANGED
@@ -134,6 +134,36 @@ export function renderIssueCells(cells, config) {
134
134
  }
135
135
  return lines;
136
136
  }
137
+ const REASON_TEXT = {
138
+ mixedIcu: 'text next to a plural or select (Angular needs the whole message to be the ICU)',
139
+ multipleIcu: 'more than one plural or select in one message',
140
+ nestedIcu: 'a plural or select inside another one',
141
+ ordinal: 'selectordinal has no Angular equivalent',
142
+ invalidIcu: 'does not parse as ICU',
143
+ formattedArg: 'a number, date or time format has no Angular placeholder',
144
+ literalBrace: "a quoted literal brace ('{') cannot be loaded by Angular",
145
+ syntax: 'the key uses printf or i18next placeholders, not ICU',
146
+ };
147
+ /** Like renderIssueCells: namespace, key, then one line per cell with the locale as named on disk. */
148
+ export function renderReasonCells(cells, config) {
149
+ const lines = [];
150
+ let namespace;
151
+ let key;
152
+ for (const cell of cells) {
153
+ if (cell.namespace !== namespace) {
154
+ namespace = cell.namespace;
155
+ key = undefined;
156
+ lines.push(pc.bold(namespace));
157
+ }
158
+ if (cell.key !== key) {
159
+ key = cell.key;
160
+ lines.push(` ${key}`);
161
+ }
162
+ const locale = (config === undefined ? cell.locale : toFsLocale(cell.locale, config)).padEnd(8);
163
+ lines.push(` ${locale} ${pc.red('error'.padEnd(8))} ${REASON_TEXT[cell.reason] ?? cell.reason}`);
164
+ }
165
+ return lines;
166
+ }
137
167
  export function renderLint(res, config, view = {}) {
138
168
  const { errors, warnings } = res.summary;
139
169
  if (errors + warnings === 0)
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.4.1';
2
+ export const VERSION = '0.5.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.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,11 @@
28
28
  "maxLength": 64,
29
29
  "description": "Fixed namespace. Required when the pattern has no {namespace}, and always for arb."
30
30
  },
31
- "jsonStyle": { "enum": ["nested", "flat"], "description": "Only for format json. Defaults to nested." }
31
+ "jsonStyle": { "enum": ["nested", "flat"], "description": "Only for format json. Defaults to nested." },
32
+ "xliffDialect": {
33
+ "enum": ["angular"],
34
+ "description": "Only for format xliff. angular writes @angular/localize placeholders (<ph equiv=\"INTERPOLATION\"/>, VAR_PLURAL) on pull and reads ng extract-i18n output on push."
35
+ }
32
36
  },
33
37
  "allOf": [
34
38
  {
@@ -42,6 +46,10 @@
42
46
  {
43
47
  "if": { "properties": { "format": { "enum": ["xliff", "arb"] } }, "required": ["format"] },
44
48
  "then": { "not": { "required": ["jsonStyle"] } }
49
+ },
50
+ {
51
+ "if": { "properties": { "format": { "enum": ["json", "arb"] } }, "required": ["format"] },
52
+ "then": { "not": { "required": ["xliffDialect"] } }
45
53
  }
46
54
  ]
47
55
  }