@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 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. What `pull` writes:
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`. Writes `@angular/localize` placeholders on pull (`<ph equiv="INTERPOLATION"/>`, `{VAR_PLURAL, …}`) and reads `ng extract-i18n` output on push. See [Angular (XLIFF)](#angular-xliff). |
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. |
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 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). |
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. |
@@ -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 Angular XLIFF. Nothing was written.`,
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);
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  // Must equal package.json "version"; src/version.test.ts fails when they drift.
2
- export const VERSION = '0.5.0';
2
+ export const VERSION = '0.6.1';
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.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }