@chesterbrains/translify-cli 0.5.0 → 0.6.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 +50 -4
- package/dist/commands/pull.js +7 -4
- package/dist/commands/push.js +4 -0
- package/dist/config.js +8 -1
- package/dist/credentials.js +6 -0
- package/dist/render.js +3 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/schema/translify.schema.json +13 -2
package/README.md
CHANGED
|
@@ -128,7 +128,9 @@ server converts both ways:
|
|
|
128
128
|
{ "files": [{ "pattern": "src/locale/messages.{locale}.xlf", "format": "xliff", "namespace": "web", "xliffDialect": "angular" }] }
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Give every template string `i18n="@@<key>"` so the unit ids are your keys.
|
|
131
|
+
Give every template string `i18n="@@<key>"` so the unit ids are your keys. A translation file's `trgLang` must be the
|
|
132
|
+
Translify locale code: with `"locales": { "it": "it-IT" }` the file is still `messages.it.xlf`, but it declares
|
|
133
|
+
`trgLang="it-IT"`, or `push` is refused with `File targets it but it-IT was requested`. What `pull` writes:
|
|
132
134
|
|
|
133
135
|
| Translify value | Angular unit |
|
|
134
136
|
|---|---|
|
|
@@ -157,6 +159,49 @@ i18next rather than ICU. Give
|
|
|
157
159
|
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
160
|
Angular app in [translify-examples](https://github.com/chesterbrains/translify-examples).
|
|
159
161
|
|
|
162
|
+
## Xcode (XLIFF)
|
|
163
|
+
|
|
164
|
+
Xcode exports and imports **XLIFF 1.2** (`<trans-unit>`), keeps printf placeholders (`%@`, `%lld`) literal, and spreads one
|
|
165
|
+
String Catalog entry over several units for plurals and substitutions. Set `xliffDialect: "xcode"` and `xliffOriginal` on the
|
|
166
|
+
rule and the server converts both ways; Translify keeps ICU, so the same catalog serves React, Flutter, Angular and iOS, and
|
|
167
|
+
iOS translations get placeholder validation (Xcode's own import accepts a target that lost its `%@` without a word).
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{ "files": [{ "pattern": "Localization/{locale}.xliff", "format": "xliff", "namespace": "ios",
|
|
171
|
+
"xliffDialect": "xcode", "xliffOriginal": "Sources/App/Localizable.xcstrings" }] }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Workflow:
|
|
175
|
+
|
|
176
|
+
1. Seed once: `xcodebuild -exportLocalizations -localizationPath out -exportLanguage en`, copy
|
|
177
|
+
`out/en.xcloc/Localized Contents/en.xliff` to `Localization/en.xliff`, `translify push`.
|
|
178
|
+
2. Translate in Translify, then `translify pull`.
|
|
179
|
+
3. `xcodebuild -importLocalizations -localizationPath Localization/it.xliff` (a bare `.xliff` works; `<file original>`
|
|
180
|
+
comes from `xliffOriginal`).
|
|
181
|
+
|
|
182
|
+
What `pull` writes (arguments are positional, numbered by first appearance in the source, so a reordered translation keeps
|
|
183
|
+
its meaning; the printf type comes from the push that created the key, else `%@` for text, `%lld` for a plural's count, `%f`
|
|
184
|
+
for a fraction):
|
|
185
|
+
|
|
186
|
+
| Translify value | Units |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `Hello, {name}` | `key` → `Hello, %1$@` |
|
|
189
|
+
| `{count, plural, one {# item} other {# items}}` | `key\|==\|plural.one` → `%1$lld item`, `key\|==\|plural.other` → `%1$lld items` |
|
|
190
|
+
| `You have {count, plural, one {# item} other {# items}} in your cart, {name}` | `key` → `You have %1$#@count@ in your cart, %2$@`, plus `key\|==\|substitutions.count.plural.one` / `.other` |
|
|
191
|
+
| untranslated | the unit has a `<source>` and no `<target>`, which Xcode shows as untranslated |
|
|
192
|
+
|
|
193
|
+
What `push` reads: `%@` / `%1$@` → `{arg1}` (renamed to the key's own argument when the key already exists), `%lld` →
|
|
194
|
+
`{arg2}` or `#` inside a plural, `%.2f` → `{arg, number}`, `%%` → `%`; `|==|plural.*` units → one ICU plural named `count`;
|
|
195
|
+
a `%#@name@` substitution → the plural inside the sentence. The printf types are kept in the key's metadata for the next
|
|
196
|
+
pull. Device variations (`|==|device.*`), unknown unit kinds and legacy `.strings`/`.stringsdict` exports are reported as
|
|
197
|
+
`unsupportedVariation` and skipped.
|
|
198
|
+
|
|
199
|
+
Not supported in this version (the pull is refused with `XLIFF_DIALECT_UNSUPPORTED`): `select` and `selectordinal`, exact
|
|
200
|
+
matches (`=0`) and `offset`, two plurals in one message, nested plurals, number formats without a known printf type (e.g.
|
|
201
|
+
currency), a translation whose shape differs from the source (plain text for a plural), and keys whose
|
|
202
|
+
placeholders are printf or i18next rather than ICU. A complete example is `examples/ios-xcstrings` in
|
|
203
|
+
[translify-examples](https://github.com/chesterbrains/translify-examples).
|
|
204
|
+
|
|
160
205
|
## `translify.json` reference
|
|
161
206
|
|
|
162
207
|
`translify.json` lives in the repository root; every command reads it from the current directory. The
|
|
@@ -172,8 +217,9 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
|
|
|
172
217
|
| `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
|
|
173
218
|
| `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. |
|
|
174
219
|
| `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`.
|
|
176
|
-
| `
|
|
220
|
+
| `files[].xliffDialect` | `angular` \| `xcode` | no | Only for `xliff`. `angular` writes `@angular/localize` placeholders on pull and reads `ng extract-i18n` output on push ([Angular (XLIFF)](#angular-xliff)); `xcode` writes XLIFF 1.2 for String Catalogs and reads `xcodebuild -exportLocalizations` output ([Xcode (XLIFF)](#xcode-xliff)). |
|
|
221
|
+
| `files[].xliffOriginal` | string | with `xliffDialect: "xcode"` | The String Catalog path Xcode knows, e.g. `Sources/App/Localizable.xcstrings`; written to `<file original>`. Xcode imports nothing when it does not match the project. Only with `xcode`. |
|
|
222
|
+
| `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. The map renames files only: an XLIFF's `trgLang` (2.0) or `target-language` (1.2), when present, must be the Translify code itself, or `push` is refused with `File targets it but it-IT was requested`. |
|
|
177
223
|
|
|
178
224
|
Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
|
|
179
225
|
otherwise the CLI stops before sending anything and names the files involved.
|
|
@@ -358,7 +404,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
|
|
|
358
404
|
| `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. |
|
|
359
405
|
| `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. |
|
|
360
406
|
| `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
|
|
407
|
+
| `XLIFF_DIALECT_UNSUPPORTED` | 6 | `pull` refused: a translation cannot be written in the rule's XLIFF dialect (Angular: text beside a plural, nested plurals, number/date formats; Xcode: `select`, `=0`/`offset`, a translation of a different shape; both: printf/i18next 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) and [Xcode (XLIFF)](#xcode-xliff). |
|
|
362
408
|
| `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
|
|
363
409
|
| `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
|
|
364
410
|
| `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
|
@@ -7,10 +7,10 @@ const readOrUndefined = async (path) => readFile(path, 'utf8').catch(() => undef
|
|
|
7
7
|
const NESTED_CONFLICT = /nested JSON cannot hold both/;
|
|
8
8
|
const isDialectUnsupported = (details) => typeof details === 'object' && details !== null && details.code === 'XLIFF_DIALECT_UNSUPPORTED';
|
|
9
9
|
/** The server's one-line refusal, the cells grouped like `lint`, and the way out. */
|
|
10
|
-
const dialectRefusal = (body, config) => {
|
|
10
|
+
const dialectRefusal = (body, config, dialect) => {
|
|
11
11
|
const cells = Array.isArray(body.cells) ? body.cells : [];
|
|
12
12
|
const lines = [
|
|
13
|
-
`Pull refused: ${body.total} translation(s) cannot be written as
|
|
13
|
+
`Pull refused: ${body.total} translation(s) cannot be written as XLIFF for ${dialect}. Nothing was written.`,
|
|
14
14
|
...renderReasonCells(cells, config),
|
|
15
15
|
];
|
|
16
16
|
if (body.total > cells.length)
|
|
@@ -67,6 +67,8 @@ export async function runPull(opts, deps) {
|
|
|
67
67
|
query.jsonStyle = rule.jsonStyle;
|
|
68
68
|
if (rule.format === 'xliff' && rule.xliffDialect !== undefined)
|
|
69
69
|
query.dialect = rule.xliffDialect;
|
|
70
|
+
if (rule.format === 'xliff' && rule.xliffOriginal !== undefined)
|
|
71
|
+
query.original = rule.xliffOriginal;
|
|
70
72
|
if (opts.from !== undefined)
|
|
71
73
|
query.from = opts.from;
|
|
72
74
|
if (opts.onlyApproved)
|
|
@@ -77,8 +79,9 @@ export async function runPull(opts, deps) {
|
|
|
77
79
|
if (namespace !== undefined)
|
|
78
80
|
query.namespaces = namespace;
|
|
79
81
|
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
|
+
if (error instanceof CliError && isDialectUnsupported(error.details)) {
|
|
83
|
+
throw dialectRefusal(error.details, deps.config, rule.xliffDialect ?? 'angular');
|
|
84
|
+
}
|
|
82
85
|
throw error;
|
|
83
86
|
});
|
|
84
87
|
responses.push(res);
|
package/dist/commands/push.js
CHANGED
|
@@ -21,6 +21,7 @@ const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
|
|
|
21
21
|
locale: match.locale,
|
|
22
22
|
namespace: match.namespace,
|
|
23
23
|
...(match.rule.format === 'xliff' && match.rule.xliffDialect !== undefined ? { dialect: match.rule.xliffDialect } : {}),
|
|
24
|
+
...(match.rule.format === 'xliff' && match.rule.xliffOriginal !== undefined ? { original: match.rule.xliffOriginal } : {}),
|
|
24
25
|
}))));
|
|
25
26
|
form.set('dryRun', String(dryRun));
|
|
26
27
|
form.set('overwriteTargets', String(opts.overwriteTargets === true));
|
|
@@ -74,6 +75,9 @@ export async function runPush(opts, deps) {
|
|
|
74
75
|
bytes: new Uint8Array(await readFile(join(deps.cwd, match.path))),
|
|
75
76
|
})));
|
|
76
77
|
const show = (res, preview) => {
|
|
78
|
+
if (opts.json !== true)
|
|
79
|
+
for (const warning of res.warnings ?? [])
|
|
80
|
+
deps.out(`warning: ${warning}`);
|
|
77
81
|
deps.out(opts.json === true
|
|
78
82
|
? JSON.stringify({ preview, response: res, summary: summarizePush(res) })
|
|
79
83
|
: renderPush(res, { prune: opts.prune === true }));
|
package/dist/config.js
CHANGED
|
@@ -17,7 +17,8 @@ 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
|
+
xliffDialect: z.enum(['angular', 'xcode']).optional(),
|
|
21
|
+
xliffOriginal: z.string().min(1).max(512).optional(),
|
|
21
22
|
})
|
|
22
23
|
.strict()
|
|
23
24
|
.superRefine((rule, ctx) => {
|
|
@@ -34,6 +35,12 @@ const fileRule = z
|
|
|
34
35
|
if (rule.xliffDialect !== undefined && rule.format !== 'xliff') {
|
|
35
36
|
ctx.addIssue({ code: 'custom', path: ['xliffDialect'], message: 'only applies to format "xliff"' });
|
|
36
37
|
}
|
|
38
|
+
if (rule.xliffOriginal !== undefined && rule.xliffDialect !== 'xcode') {
|
|
39
|
+
ctx.addIssue({ code: 'custom', path: ['xliffOriginal'], message: 'only applies to xliffDialect "xcode"' });
|
|
40
|
+
}
|
|
41
|
+
if (rule.xliffDialect === 'xcode' && rule.xliffOriginal === undefined) {
|
|
42
|
+
ctx.addIssue({ code: 'custom', path: ['xliffOriginal'], message: 'required with xliffDialect "xcode"' });
|
|
43
|
+
}
|
|
37
44
|
})
|
|
38
45
|
.transform((rule) => (rule.format === 'json' ? { ...rule, jsonStyle: rule.jsonStyle ?? 'nested' } : rule));
|
|
39
46
|
const configSchema = z
|
package/dist/credentials.js
CHANGED
|
@@ -10,6 +10,7 @@ export function credentialsPath(env = process.env) {
|
|
|
10
10
|
return join(appData, 'translify', 'credentials');
|
|
11
11
|
return join(absolute(env.XDG_CONFIG_HOME) ?? join(homedir(), '.config'), 'translify', 'credentials');
|
|
12
12
|
}
|
|
13
|
+
const SECRET_KEY_SHAPE = /^sk_[A-Za-z0-9_-]+$/;
|
|
13
14
|
// Messages never echo the value: a mistyped key is still a secret.
|
|
14
15
|
export const assertSecret = (key) => {
|
|
15
16
|
if (key.startsWith('pk_')) {
|
|
@@ -17,6 +18,11 @@ export const assertSecret = (key) => {
|
|
|
17
18
|
}
|
|
18
19
|
if (!key.startsWith('sk_'))
|
|
19
20
|
throw new CliError(EXIT.auth, 'Not a Translify secret key (expected sk_...).');
|
|
21
|
+
// A key is `sk_` plus base64url. Anything else (a pasted `…`, a space, a `/`) is caught here: fetch
|
|
22
|
+
// would refuse it as a header value, which reads as a connection error (TRLIFY-128).
|
|
23
|
+
if (!SECRET_KEY_SHAPE.test(key)) {
|
|
24
|
+
throw new CliError(EXIT.auth, 'The key has characters a secret key never has (only letters, digits, - and _ after sk_). Check TRANSLIFY_SECRET_KEY.');
|
|
25
|
+
}
|
|
20
26
|
return key;
|
|
21
27
|
};
|
|
22
28
|
export async function resolveKey(env = process.env) {
|
package/dist/render.js
CHANGED
|
@@ -143,6 +143,9 @@ const REASON_TEXT = {
|
|
|
143
143
|
formattedArg: 'a number, date or time format has no Angular placeholder',
|
|
144
144
|
literalBrace: "a quoted literal brace ('{') cannot be loaded by Angular",
|
|
145
145
|
syntax: 'the key uses printf or i18next placeholders, not ICU',
|
|
146
|
+
exactMatch: 'an exact match (=0, =1) or offset has no Xcode plural category',
|
|
147
|
+
shapeMismatch: 'the translation is not the same shape as the source (plural vs plain text, or a different plural argument)',
|
|
148
|
+
unsupportedVariation: 'a device variation or an unknown |==| unit (not supported)',
|
|
146
149
|
};
|
|
147
150
|
/** Like renderIssueCells: namespace, key, then one line per cell with the locale as named on disk. */
|
|
148
151
|
export function renderReasonCells(cells, config) {
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -30,8 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"jsonStyle": { "enum": ["nested", "flat"], "description": "Only for format json. Defaults to nested." },
|
|
32
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."
|
|
33
|
+
"enum": ["angular", "xcode"],
|
|
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. xcode writes XLIFF 1.2 for String Catalogs (positional printf, |==| plural and substitution units) and reads xcodebuild -exportLocalizations output."
|
|
35
|
+
},
|
|
36
|
+
"xliffOriginal": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"minLength": 1,
|
|
39
|
+
"maxLength": 512,
|
|
40
|
+
"description": "Only with xliffDialect xcode, and required with it: the String Catalog path Xcode knows (e.g. Sources/App/Localizable.xcstrings), written to <file original>. Xcode imports nothing when it does not match."
|
|
35
41
|
}
|
|
36
42
|
},
|
|
37
43
|
"allOf": [
|
|
@@ -50,6 +56,11 @@
|
|
|
50
56
|
{
|
|
51
57
|
"if": { "properties": { "format": { "enum": ["json", "arb"] } }, "required": ["format"] },
|
|
52
58
|
"then": { "not": { "required": ["xliffDialect"] } }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"if": { "properties": { "xliffDialect": { "const": "xcode" } }, "required": ["xliffDialect"] },
|
|
62
|
+
"then": { "required": ["xliffOriginal"] },
|
|
63
|
+
"else": { "not": { "required": ["xliffOriginal"] } }
|
|
53
64
|
}
|
|
54
65
|
]
|
|
55
66
|
}
|