@chesterbrains/translify-cli 0.4.2 → 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 +43 -1
- package/dist/commands/pull.js +21 -1
- package/dist/commands/push.js +1 -0
- package/dist/config.js +4 -0
- package/dist/http.js +3 -0
- package/dist/render.js +30 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schema/translify.schema.json +9 -1
package/README.md
CHANGED
|
@@ -117,6 +117,46 @@ flutter gen-l10n
|
|
|
117
117
|
|
|
118
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.
|
|
119
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
|
+
|
|
120
160
|
## `translify.json` reference
|
|
121
161
|
|
|
122
162
|
`translify.json` lives in the repository root; every command reads it from the current directory. The
|
|
@@ -132,6 +172,7 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
|
|
|
132
172
|
| `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
|
|
133
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. |
|
|
134
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). |
|
|
135
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. |
|
|
136
177
|
|
|
137
178
|
Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
|
|
@@ -289,7 +330,7 @@ Pull and publish on deploy:
|
|
|
289
330
|
| 3 | Plan quota exceeded |
|
|
290
331
|
| 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
|
|
291
332
|
| 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
|
|
292
|
-
| 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`),
|
|
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 |
|
|
293
334
|
|
|
294
335
|
The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
|
|
295
336
|
|
|
@@ -317,6 +358,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
|
|
|
317
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. |
|
|
318
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. |
|
|
319
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). |
|
|
320
362
|
| `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
|
|
321
363
|
| `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
|
|
322
364
|
| `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
|
package/dist/commands/pull.js
CHANGED
|
@@ -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}`);
|
package/dist/commands/push.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -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
|
}
|