@chesterbrains/translify-cli 0.5.0 → 0.6.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
@@ -157,6 +157,49 @@ i18next rather than ICU. Give
157
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
158
  Angular app in [translify-examples](https://github.com/chesterbrains/translify-examples).
159
159
 
160
+ ## Xcode (XLIFF)
161
+
162
+ Xcode exports and imports **XLIFF 1.2** (`<trans-unit>`), keeps printf placeholders (`%@`, `%lld`) literal, and spreads one
163
+ String Catalog entry over several units for plurals and substitutions. Set `xliffDialect: "xcode"` and `xliffOriginal` on the
164
+ rule and the server converts both ways; Translify keeps ICU, so the same catalog serves React, Flutter, Angular and iOS, and
165
+ iOS translations get placeholder validation (Xcode's own import accepts a target that lost its `%@` without a word).
166
+
167
+ ```json
168
+ { "files": [{ "pattern": "Localization/{locale}.xliff", "format": "xliff", "namespace": "ios",
169
+ "xliffDialect": "xcode", "xliffOriginal": "Sources/App/Localizable.xcstrings" }] }
170
+ ```
171
+
172
+ Workflow:
173
+
174
+ 1. Seed once: `xcodebuild -exportLocalizations -localizationPath out -exportLanguage en`, copy
175
+ `out/en.xcloc/Localized Contents/en.xliff` to `Localization/en.xliff`, `translify push`.
176
+ 2. Translate in Translify, then `translify pull`.
177
+ 3. `xcodebuild -importLocalizations -localizationPath Localization/it.xliff` (a bare `.xliff` works; `<file original>`
178
+ comes from `xliffOriginal`).
179
+
180
+ What `pull` writes (arguments are positional, numbered by first appearance in the source, so a reordered translation keeps
181
+ its meaning; the printf type comes from the push that created the key, else `%@` for text, `%lld` for a plural's count, `%f`
182
+ for a fraction):
183
+
184
+ | Translify value | Units |
185
+ |---|---|
186
+ | `Hello, {name}` | `key` → `Hello, %1$@` |
187
+ | `{count, plural, one {# item} other {# items}}` | `key\|==\|plural.one` → `%1$lld item`, `key\|==\|plural.other` → `%1$lld items` |
188
+ | `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` |
189
+ | untranslated | the unit has a `<source>` and no `<target>`, which Xcode shows as untranslated |
190
+
191
+ What `push` reads: `%@` / `%1$@` → `{arg1}` (renamed to the key's own argument when the key already exists), `%lld` →
192
+ `{arg2}` or `#` inside a plural, `%.2f` → `{arg, number}`, `%%` → `%`; `|==|plural.*` units → one ICU plural named `count`;
193
+ a `%#@name@` substitution → the plural inside the sentence. The printf types are kept in the key's metadata for the next
194
+ pull. Device variations (`|==|device.*`), unknown unit kinds and legacy `.strings`/`.stringsdict` exports are reported as
195
+ `unsupportedVariation` and skipped.
196
+
197
+ Not supported in this version (the pull is refused with `XLIFF_DIALECT_UNSUPPORTED`): `select` and `selectordinal`, exact
198
+ matches (`=0`) and `offset`, two plurals in one message, nested plurals, number formats without a known printf type (e.g.
199
+ currency), a translation whose shape differs from the source (plain text for a plural), and keys whose
200
+ placeholders are printf or i18next rather than ICU. A complete example is `examples/ios-xcstrings` in
201
+ [translify-examples](https://github.com/chesterbrains/translify-examples).
202
+
160
203
  ## `translify.json` reference
161
204
 
162
205
  `translify.json` lives in the repository root; every command reads it from the current directory. The
@@ -172,7 +215,8 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
172
215
  | `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
173
216
  | `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
217
  | `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). |
218
+ | `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)). |
219
+ | `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`. |
176
220
  | `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. |
177
221
 
178
222
  Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
@@ -358,7 +402,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
358
402
  | `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
403
  | `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
404
  | `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). |
405
+ | `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
406
  | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
363
407
  | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
364
408
  | `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
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.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.5.0",
3
+ "version": "0.6.0",
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
  }