@chesterbrains/translify-cli 0.6.0 → 0.7.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
@@ -47,7 +47,7 @@ In Translify, open your project's API keys, create a **secret key** (it starts w
47
47
  Give the key to the CLI in one of two ways:
48
48
 
49
49
  - set `TRANSLIFY_SECRET_KEY` (always wins; use this in CI), or
50
- - run `translify login` and paste it. It is stored in `~/.config/translify/credentials` (or under `$XDG_CONFIG_HOME`).
50
+ - run `translify login` (or let `translify init` ask for it) and paste it. It is stored in `~/.config/translify/credentials` (or under `$XDG_CONFIG_HOME`).
51
51
 
52
52
  Never commit a key. Delivery keys (public keys used by apps to download published translations) do not work here.
53
53
 
@@ -61,7 +61,7 @@ locales/
61
61
 
62
62
  ```bash
63
63
  export TRANSLIFY_SECRET_KEY=sk_...
64
- npx translify init # detects your layout, writes translify.json
64
+ npx translify init # detects your layout, asks for your key, writes translify.json
65
65
  npx translify push --dry-run # show what would change, write nothing
66
66
  npx translify push
67
67
  npx translify pull
@@ -128,7 +128,11 @@ 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
+ `translify init` reads `angular.json` and writes this rule for you (one per distinct translation path, namespace = project name); `ng extract-i18n` must be run with `--format=xlf2 --out-file messages.<sourceLocale>.xlf` so the source file matches it.
132
+
133
+ Give every template string `i18n="@@<key>"` so the unit ids are your keys. A translation file's `trgLang` must be the
134
+ Translify locale code: with `"locales": { "it": "it-IT" }` the file is still `messages.it.xlf`, but it declares
135
+ `trgLang="it-IT"`, or `push` is refused with `File targets it but it-IT was requested`. What `pull` writes:
132
136
 
133
137
  | Translify value | Angular unit |
134
138
  |---|---|
@@ -169,6 +173,8 @@ iOS translations get placeholder validation (Xcode's own import accepts a target
169
173
  "xliffDialect": "xcode", "xliffOriginal": "Sources/App/Localizable.xcstrings" }] }
170
174
  ```
171
175
 
176
+ `translify init` finds every `.xcstrings` and writes this rule for you (namespace = catalog name, `xliffOriginal` = its path; `Localization/{locale}.xliff` unless locale-named `.xliff` files already sit in one folder).
177
+
172
178
  Workflow:
173
179
 
174
180
  1. Seed once: `xcodebuild -exportLocalizations -localizationPath out -exportLanguage en`, copy
@@ -217,7 +223,7 @@ fields are refused, and so is any field whose name contains `key`, `secret` or `
217
223
  | `files[].jsonStyle` | `nested` \| `flat` | no | Only for `json`. `nested` (the default) writes `{ "a": { "b": "…" } }`, `flat` writes `{ "a.b": "…" }`. |
218
224
  | `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
225
  | `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`. |
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. |
226
+ | `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`. |
221
227
 
222
228
  Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
223
229
  otherwise the CLI stops before sending anything and names the files involved.
@@ -226,7 +232,7 @@ otherwise the CLI stops before sending anything and names the files involved.
226
232
 
227
233
  | Command | What it does |
228
234
  |---|---|
229
- | `translify init` | Create `translify.json` (interactive) |
235
+ | `translify init` | Create `translify.json`: detects i18next, Flutter, Angular and Xcode layouts, asks for your key (interactive, or `--yes`) |
230
236
  | `translify login` | Store a secret key on this machine (interactive) |
231
237
  | `translify push` | Upload source and target files |
232
238
  | `translify pull` | Download translation files |
@@ -236,6 +242,7 @@ otherwise the CLI stops before sending anything and names the files involved.
236
242
 
237
243
  Options:
238
244
 
245
+ - `init`: `-y/--yes` (no prompts: every detected rule, the suggested locale map, the key from `TRANSLIFY_SECRET_KEY` or a previous login; fails instead of overwriting), `--api-url <url>`
239
246
  - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
240
247
  - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
241
248
  - `publish`: `--fail-on-withheld`, `--allow-errors`
@@ -300,48 +307,34 @@ When the project has **Require review** turned on in Translify:
300
307
 
301
308
  ## CI (GitHub Actions)
302
309
 
303
- Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
310
+ Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`. Two ready-made workflows live in
311
+ [`recipes/github-actions/`](https://github.com/chesterbrains/translify-cli/tree/main/recipes/github-actions); copy the
312
+ one you need to `.github/workflows/` as it is.
304
313
 
305
- Push on main:
314
+ - [`translify-push.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-push.yml)
315
+ runs `push --dry-run` on every pull request and keeps its output in one comment on the PR, updated on each push to the
316
+ branch (a refused push lands there too and fails the job); on `main` it pushes for real, with an optional `publish`
317
+ step. Key scope: `PUSH` (and `PUBLISH` for the optional step). Pull requests from forks are skipped: they have no
318
+ access to secrets.
319
+ - [`translify-release.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-release.yml)
320
+ is for apps that bundle translations at build time: `lint`, then `pull`, your build, then `publish` to the environment
321
+ chosen in the Actions tab (or on a tag). Key scopes: `PULL` and `PUBLISH`.
306
322
 
307
- ```yaml
308
- name: Translify push
309
- on:
310
- push:
311
- branches: [main]
312
- jobs:
313
- push:
314
- runs-on: ubuntu-latest
315
- steps:
316
- - uses: actions/checkout@v4
317
- - uses: actions/setup-node@v4
318
- with: { node-version: 22 }
319
- - run: npx translify push
320
- env:
321
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
322
- ```
323
+ Both use `npx -y translify`, so they follow every 0.x release; pin a version (`npx -y @chesterbrains/translify-cli@0.6`) if a breaking
324
+ release must not turn the workflow red on its own.
323
325
 
324
326
  Fail a pull request when local files drift from Translify:
325
327
 
326
328
  ```yaml
327
- name: Translify status
328
- on: pull_request
329
- jobs:
330
- status:
331
- runs-on: ubuntu-latest
332
- steps:
333
- - uses: actions/checkout@v4
334
- - uses: actions/setup-node@v4
335
- with: { node-version: 22 }
336
- - run: npx translify status
337
- env:
338
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
329
+ - run: npx -y translify status
330
+ env:
331
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
339
332
  ```
340
333
 
341
334
  Fail CI when translations in Translify have placeholder errors:
342
335
 
343
336
  ```yaml
344
- - run: npx translify lint --max-warnings 0
337
+ - run: npx -y translify lint --max-warnings 0
345
338
  env:
346
339
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
347
340
  ```
@@ -350,20 +343,6 @@ Fail CI when translations in Translify have placeholder errors:
350
343
  It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
351
344
  project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
352
345
 
353
- Pull and publish on deploy:
354
-
355
- ```yaml
356
- - run: npx translify lint
357
- env:
358
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
359
- - run: npx translify pull
360
- env:
361
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
362
- - run: npx translify publish production
363
- env:
364
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
365
- ```
366
-
367
346
  ## Exit codes
368
347
 
369
348
  | Code | Meaning |
@@ -1,58 +1,13 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { assertSecret } from '../credentials.js';
4
+ import { detectLayout } from '../detect/index.js';
5
+ import { suggestLocaleMap } from '../detect/locales.js';
4
6
  import { CliError, EXIT } from '../errors.js';
5
7
  import { findFiles } from '../patterns.js';
6
8
  const SCHEMA_URL = 'https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json';
7
- const I18NEXT_ROOTS = ['locales', 'public/locales', 'src/locales', 'src/i18n'];
8
9
  const EXAMPLE_RULE = { pattern: 'locales/{locale}/{namespace}.json', format: 'json', jsonStyle: 'nested' };
9
- const unquote = (value) => value.replace(/^["']|["']$/g, '').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
10
- async function detectFlutter(cwd) {
11
- let yaml;
12
- try {
13
- yaml = await readFile(join(cwd, 'l10n.yaml'), 'utf8');
14
- }
15
- catch {
16
- yaml = undefined;
17
- }
18
- if (yaml === undefined) {
19
- const arbs = await fg('lib/l10n/*.arb', { cwd, onlyFiles: true });
20
- return arbs.length > 0 ? [{ pattern: 'lib/l10n/app_{locale}.arb', format: 'arb', namespace: 'app' }] : [];
21
- }
22
- const dir = unquote(/^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? 'lib/l10n');
23
- const template = unquote(/^template-arb-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_en.arb');
24
- const prefix = /^(.*?)_[a-z]{2,3}(?:[_-](?:[A-Z]{2}|[0-9]{3}|[A-Z][a-z]{3}))*\.arb$/.exec(template)?.[1] ?? 'app';
25
- return [{ pattern: `${dir}/${prefix}_{locale}.arb`, format: 'arb', namespace: 'app' }];
26
- }
27
- /** Recognises i18next and Flutter layouts; an empty list means "unknown, write an example". */
28
- export async function detectLayout(cwd) {
29
- const rules = [];
30
- for (const root of I18NEXT_ROOTS) {
31
- const found = await fg(`${root}/*/*.json`, { cwd, onlyFiles: true });
32
- if (found.length > 0)
33
- rules.push({ pattern: `${root}/{locale}/{namespace}.json`, format: 'json', jsonStyle: 'nested' });
34
- }
35
- return [...rules, ...(await detectFlutter(cwd))];
36
- }
37
- const normalize = (locale) => locale.toLowerCase().replaceAll('_', '-');
38
- /** Disk locales that differ from a Translify locale only by case or separator (`en_US` → `en-US`). */
39
- export function suggestLocaleMap(diskLocales, translifyLocales) {
40
- const known = new Set(translifyLocales);
41
- const disk = new Set(diskLocales);
42
- const map = {};
43
- const targets = new Set();
44
- for (const locale of diskLocales) {
45
- if (known.has(locale))
46
- continue;
47
- const target = translifyLocales.find((code) => normalize(code) === normalize(locale));
48
- // A bijection that cannot chain: loadConfig rejects anything else.
49
- if (target === undefined || targets.has(target) || disk.has(target))
50
- continue;
51
- map[locale] = target;
52
- targets.add(target);
53
- }
54
- return map;
55
- }
10
+ const NO_KEY_LINE = 'Wrote translify.json. The key is not stored there: run `translify login` or set TRANSLIFY_SECRET_KEY.';
56
11
  const isHttpUrl = (value) => {
57
12
  try {
58
13
  return ['http:', 'https:'].includes(new URL(value).protocol);
@@ -70,35 +25,54 @@ async function exists(path) {
70
25
  return false;
71
26
  }
72
27
  }
28
+ const label = (rule) => (rule.xliffDialect === undefined ? rule.pattern : `${rule.pattern} (${rule.xliffDialect})`);
73
29
  export async function runInit(deps) {
74
- if (!deps.isTty) {
75
- deps.out('`translify init` needs an interactive terminal. Write translify.json by hand in CI.');
30
+ if (!deps.isTty && !deps.yes) {
31
+ deps.out('`translify init` needs an interactive terminal. Pass `--yes` to take the detected defaults, or write translify.json by hand.');
76
32
  return EXIT.failed;
77
33
  }
78
34
  const target = join(deps.cwd, 'translify.json');
79
- if ((await exists(target)) && !(await deps.prompt.confirm('translify.json already exists. Overwrite it?', false))) {
80
- deps.out('Left translify.json unchanged.');
35
+ if (await exists(target)) {
36
+ if (deps.yes) {
37
+ deps.out('translify.json already exists. Delete it first.');
38
+ return EXIT.failed;
39
+ }
40
+ if (!(await deps.prompt.confirm('translify.json already exists. Overwrite it?', false))) {
41
+ deps.out('Left translify.json unchanged.');
42
+ return EXIT.failed;
43
+ }
44
+ }
45
+ const apiUrl = (deps.apiUrl ?? (deps.yes ? deps.apiUrlDefault : await deps.prompt.input('Translify API URL', deps.apiUrlDefault))).trim();
46
+ if (!isHttpUrl(apiUrl)) {
47
+ deps.out('The API URL must start with http:// or https://. Nothing was written.');
81
48
  return EXIT.failed;
82
49
  }
83
- const detected = await detectLayout(deps.cwd);
50
+ // A typed key that is rejected throws here (exit 2): nothing is written and nothing is saved.
51
+ const me = await identify(deps, apiUrl);
52
+ if (me !== undefined) {
53
+ deps.out(`Project: ${me.project.name}, default locale ${me.project.defaultLocale ?? 'none'}; locales: ${me.locales.join(', ')}`);
54
+ }
55
+ const detection = await detectLayout(deps.cwd);
56
+ const detected = detection.rules;
84
57
  if (detected.length === 0) {
85
- deps.out(`No i18next or Flutter layout found in ${deps.cwd} (detection looks only in the current directory). Wrote an example rule (${EXAMPLE_RULE.pattern}): edit files[] to match your project.`);
58
+ deps.out(`No i18next, Flutter, Angular or Xcode layout found in ${deps.cwd} (detection looks only in the current directory). Wrote an example rule (${EXAMPLE_RULE.pattern}): edit files[] to match your project.`);
86
59
  }
87
60
  else {
88
61
  deps.out(`Found: ${detected.map((rule) => `${rule.pattern} (${rule.format})`).join(', ')}`);
89
62
  }
63
+ for (const note of detection.notes)
64
+ deps.out(note);
90
65
  let files = detected.length > 0 ? detected : [EXAMPLE_RULE];
91
- if (detected.length > 1) {
92
- const chosen = await deps.prompt.choose('Several layouts found. Which one holds your translations?', detected.map((rule) => rule.pattern));
93
- files = detected.filter((rule) => rule.pattern === chosen);
94
- if (files.length === 0)
95
- files = [detected[0]];
96
- deps.out(`Dropped: ${detected.filter((rule) => !files.includes(rule)).map((rule) => rule.pattern).join(', ')}`);
97
- }
98
- const apiUrl = (await deps.prompt.input('Translify API URL', deps.apiUrlDefault)).trim();
99
- if (!isHttpUrl(apiUrl)) {
100
- deps.out('The API URL must start with http:// or https://. Nothing was written.');
101
- return EXIT.failed;
66
+ if (detected.length > 1 && !deps.yes) {
67
+ const chosen = new Set(await deps.prompt.checkbox('Which layouts hold your translations?', detected.map(label)));
68
+ files = detected.filter((rule) => chosen.has(label(rule)));
69
+ if (files.length === 0) {
70
+ deps.out('Nothing selected. Nothing was written.');
71
+ return EXIT.failed;
72
+ }
73
+ const dropped = detected.filter((rule) => !files.includes(rule));
74
+ if (dropped.length > 0)
75
+ deps.out(`Dropped: ${dropped.map((rule) => rule.pattern).join(', ')}`);
102
76
  }
103
77
  // A config that cannot resolve its own files would fail on the first push or pull: refuse to write it.
104
78
  let matches;
@@ -111,21 +85,53 @@ export async function runInit(deps) {
111
85
  deps.out(`${error.message} Nothing was written.`);
112
86
  return EXIT.failed;
113
87
  }
114
- let locales = {};
115
- const me = await deps.fetchWhoami(apiUrl);
116
- if (me !== undefined) {
117
- deps.out(`Project: ${me.project.name}, default locale ${me.project.defaultLocale ?? 'none'}; locales: ${me.locales.join(', ')}`);
118
- locales = await suggestMap(deps, matches, me.locales);
119
- }
88
+ const locales = me === undefined ? {} : await suggestMap(deps, matches, me.locales);
120
89
  const config = { $schema: SCHEMA_URL, apiUrl, files, locales };
121
90
  // `locales` is omitted when empty; the schema defaults it.
122
91
  const body = { ...config };
123
92
  if (Object.keys(locales).length === 0)
124
93
  delete body.locales;
125
- await writeFile(target, `${JSON.stringify(body, null, 2)}\n`);
126
- deps.out('Wrote translify.json. The key is not stored there: run `translify login` or set TRANSLIFY_SECRET_KEY.');
94
+ const text = `${JSON.stringify(body, null, 2)}\n`;
95
+ deps.out(text.trimEnd());
96
+ if (!deps.yes && !(await deps.prompt.confirm('Write translify.json?', true))) {
97
+ deps.out('Nothing was written.');
98
+ return EXIT.failed;
99
+ }
100
+ await writeFile(target, text);
101
+ deps.out(me === undefined ? NO_KEY_LINE : 'Wrote translify.json.');
127
102
  return EXIT.ok;
128
103
  }
104
+ /**
105
+ * The project this machine's key belongs to. A stored key that fails only costs the locale hints;
106
+ * a typed key that fails stops init (the error propagates), so a wrong key is never saved.
107
+ */
108
+ async function identify(deps, apiUrl) {
109
+ const stored = await deps.findKey();
110
+ if (stored !== undefined) {
111
+ try {
112
+ return await deps.fetchWhoami(apiUrl, stored);
113
+ }
114
+ catch (error) {
115
+ if (!(error instanceof CliError))
116
+ throw error;
117
+ deps.out(error.exitCode === EXIT.auth
118
+ ? 'Stored key was rejected; run `translify login`. Continuing without project info.'
119
+ : `Could not reach Translify (${error.message}). Continuing without project info.`);
120
+ return undefined;
121
+ }
122
+ }
123
+ if (deps.yes)
124
+ return undefined;
125
+ const typed = (await deps.prompt.secret('Secret key (sk_…), Enter to skip')).trim();
126
+ if (typed === '')
127
+ return undefined;
128
+ // Before any network call, and the error never echoes the value.
129
+ assertSecret(typed);
130
+ const me = await deps.fetchWhoami(apiUrl, typed);
131
+ const path = await deps.saveKey(typed);
132
+ deps.out(`Logged in to ${me.project.name} (${me.project.slug}) with scopes ${me.key.scopes.join(', ')}. Saved to ${path}.`);
133
+ return me;
134
+ }
129
135
  async function suggestMap(deps, matches, translifyLocales) {
130
136
  const diskLocales = [...new Set(matches.map((match) => match.locale))];
131
137
  const missing = diskLocales.filter((locale) => !translifyLocales.includes(locale));
@@ -135,6 +141,8 @@ async function suggestMap(deps, matches, translifyLocales) {
135
141
  }
136
142
  if (Object.keys(map).length === 0)
137
143
  return {};
144
+ if (deps.yes)
145
+ return map;
138
146
  const text = Object.entries(map).map(([from, to]) => `${from} → ${to}`).join(', ');
139
147
  return (await deps.prompt.confirm(`Map disk locales to Translify codes (${text})?`, true)) ? map : {};
140
148
  }
@@ -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,9 +18,15 @@ 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
- export async function resolveKey(env = process.env) {
28
+ /** The key this machine has (env var first, then the credentials file), or undefined. Malformed values throw. */
29
+ export async function findKey(env = process.env) {
23
30
  const fromEnv = env.TRANSLIFY_SECRET_KEY?.trim();
24
31
  if (fromEnv)
25
32
  return assertSecret(fromEnv);
@@ -28,11 +35,15 @@ export async function resolveKey(env = process.env) {
28
35
  stored = (await readFile(credentialsPath(env), 'utf8')).trim();
29
36
  }
30
37
  catch {
31
- throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
38
+ return undefined;
32
39
  }
33
- if (stored === '')
40
+ return stored === '' ? undefined : assertSecret(stored);
41
+ }
42
+ export async function resolveKey(env = process.env) {
43
+ const key = await findKey(env);
44
+ if (key === undefined)
34
45
  throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
35
- return assertSecret(stored);
46
+ return key;
36
47
  }
37
48
  export async function saveKey(key, env = process.env) {
38
49
  const path = credentialsPath(env);
@@ -0,0 +1,80 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { kebab, NONE, posixPath } from './shared.js';
4
+ const DEFAULT_PATTERN = 'src/locale/messages.{locale}.xlf';
5
+ const firstPath = (value) => {
6
+ const raw = typeof value === 'string' || Array.isArray(value) ? value : value.translation;
7
+ const first = Array.isArray(raw) ? raw[0] : raw;
8
+ return typeof first === 'string' ? first : undefined;
9
+ };
10
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+ /**
12
+ * The last occurrence of `code` bounded by non-alphanumerics, so `es` in `resources/` is not it, and
13
+ * not the language part of a regional name (`pt` in `messages.pt-BR.xlf`), which would split the file.
14
+ */
15
+ const toPattern = (path, code) => {
16
+ const bounded = new RegExp(`(?<![A-Za-z0-9])${escape(code)}(?![A-Za-z0-9]|[-_][A-Za-z0-9])`, 'g');
17
+ const matches = [...path.matchAll(bounded)];
18
+ const last = matches.at(-1);
19
+ if (last === undefined)
20
+ return undefined;
21
+ return `${path.slice(0, last.index)}{locale}${path.slice(last.index + code.length)}`;
22
+ };
23
+ const rule = (pattern, namespace) => ({ pattern, format: 'xliff', namespace, xliffDialect: 'angular' });
24
+ /** `angular.json` with an `i18n` block per project; the translation paths give the pattern. */
25
+ export async function detectAngular(cwd) {
26
+ let raw;
27
+ try {
28
+ raw = await readFile(join(cwd, 'angular.json'), 'utf8');
29
+ }
30
+ catch {
31
+ return NONE;
32
+ }
33
+ let json;
34
+ try {
35
+ json = JSON.parse(raw);
36
+ }
37
+ catch {
38
+ return { rules: [], notes: ['angular.json is not valid JSON, skipped.'] };
39
+ }
40
+ const projects = Object.entries(json.projects ?? {});
41
+ const first = projects[0];
42
+ if (first === undefined)
43
+ return NONE;
44
+ const rules = [];
45
+ const notes = [];
46
+ const seen = new Set();
47
+ let sourceLocale;
48
+ for (const [name, project] of projects) {
49
+ const i18n = project.i18n;
50
+ if (i18n === undefined)
51
+ continue;
52
+ const namespace = kebab(name) || 'app';
53
+ const source = typeof i18n.sourceLocale === 'string' ? i18n.sourceLocale : i18n.sourceLocale?.code;
54
+ if (source !== undefined) {
55
+ sourceLocale ??= source;
56
+ notes.push(`Angular source locale for ${name}: ${source}.`);
57
+ }
58
+ for (const [code, value] of Object.entries(i18n.locales ?? {})) {
59
+ const path = firstPath(value);
60
+ if (path === undefined)
61
+ continue;
62
+ const clean = posixPath(path);
63
+ const pattern = toPattern(clean, code);
64
+ if (pattern === undefined) {
65
+ notes.push(`${clean} does not contain its locale code ${code}; add that rule by hand.`);
66
+ continue;
67
+ }
68
+ if (seen.has(pattern))
69
+ continue;
70
+ seen.add(pattern);
71
+ rules.push(rule(pattern, namespace));
72
+ }
73
+ }
74
+ if (rules.length === 0) {
75
+ rules.push(rule(DEFAULT_PATTERN, kebab(first[0]) || 'app'));
76
+ notes.push('angular.json has no usable i18n locales: the rule assumes the default src/locale/messages.<locale>.xlf layout.');
77
+ }
78
+ notes.push(`\`ng extract-i18n\` writes messages.xlf with no locale in its name; run it with --format=xlf2 --out-file messages.${sourceLocale ?? '<sourceLocale>'}.xlf so the source file matches the rule.`);
79
+ return { rules, notes };
80
+ }
@@ -0,0 +1,22 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import fg from 'fast-glob';
4
+ const unquote = (value) => value.replace(/^["']|["']$/g, '').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
5
+ export async function detectFlutter(cwd) {
6
+ let yaml;
7
+ try {
8
+ yaml = await readFile(join(cwd, 'l10n.yaml'), 'utf8');
9
+ }
10
+ catch {
11
+ yaml = undefined;
12
+ }
13
+ if (yaml === undefined) {
14
+ const arbs = await fg('lib/l10n/*.arb', { cwd, onlyFiles: true });
15
+ const rules = arbs.length > 0 ? [{ pattern: 'lib/l10n/app_{locale}.arb', format: 'arb', namespace: 'app' }] : [];
16
+ return { rules, notes: [] };
17
+ }
18
+ const dir = unquote(/^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? 'lib/l10n');
19
+ const template = unquote(/^template-arb-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_en.arb');
20
+ const prefix = /^(.*?)_[a-z]{2,3}(?:[_-](?:[A-Z]{2}|[0-9]{3}|[A-Z][a-z]{3}))*\.arb$/.exec(template)?.[1] ?? 'app';
21
+ return { rules: [{ pattern: `${dir}/${prefix}_{locale}.arb`, format: 'arb', namespace: 'app' }], notes: [] };
22
+ }
@@ -0,0 +1,11 @@
1
+ import fg from 'fast-glob';
2
+ const I18NEXT_ROOTS = ['locales', 'public/locales', 'src/locales', 'src/i18n'];
3
+ export async function detectI18next(cwd) {
4
+ const rules = [];
5
+ for (const root of I18NEXT_ROOTS) {
6
+ const found = await fg(`${root}/*/*.json`, { cwd, onlyFiles: true });
7
+ if (found.length > 0)
8
+ rules.push({ pattern: `${root}/{locale}/{namespace}.json`, format: 'json', jsonStyle: 'nested' });
9
+ }
10
+ return { rules, notes: [] };
11
+ }
@@ -0,0 +1,16 @@
1
+ import { detectAngular } from './angular.js';
2
+ import { detectFlutter } from './flutter.js';
3
+ import { detectI18next } from './i18next.js';
4
+ import { detectXcode } from './xcode.js';
5
+ const DETECTORS = [detectI18next, detectFlutter, detectAngular, detectXcode];
6
+ /** Looks only in `cwd`. An empty `rules` list means "unknown, write an example". */
7
+ export async function detectLayout(cwd) {
8
+ const rules = [];
9
+ const notes = [];
10
+ for (const detect of DETECTORS) {
11
+ const found = await detect(cwd);
12
+ rules.push(...found.rules);
13
+ notes.push(...found.notes);
14
+ }
15
+ return { rules, notes };
16
+ }
@@ -0,0 +1,33 @@
1
+ const normalize = (locale) => locale.toLowerCase().replaceAll('_', '-');
2
+ const LANGUAGE = /^[a-z]{2,3}$/i;
3
+ const language = (code) => normalize(code).split('-')[0] ?? '';
4
+ /**
5
+ * Disk locales that differ from a Translify locale only by case or separator (`en_US` → `en-US`),
6
+ * then bare languages with exactly one regional match (`en` → `en-US`, but not with `en-GB` too).
7
+ */
8
+ export function suggestLocaleMap(diskLocales, translifyLocales) {
9
+ const known = new Set(translifyLocales);
10
+ const disk = new Set(diskLocales);
11
+ const map = {};
12
+ const targets = new Set();
13
+ // A bijection that cannot chain: loadConfig rejects anything else.
14
+ const take = (locale, target) => {
15
+ if (target === undefined || targets.has(target) || disk.has(target))
16
+ return;
17
+ map[locale] = target;
18
+ targets.add(target);
19
+ };
20
+ for (const locale of diskLocales) {
21
+ if (known.has(locale))
22
+ continue;
23
+ take(locale, translifyLocales.find((code) => normalize(code) === normalize(locale)));
24
+ }
25
+ for (const locale of diskLocales) {
26
+ if (known.has(locale) || map[locale] !== undefined || !LANGUAGE.test(locale))
27
+ continue;
28
+ const regional = translifyLocales.filter((code) => code.includes('-') && language(code) === locale.toLowerCase());
29
+ if (regional.length === 1)
30
+ take(locale, regional[0]);
31
+ }
32
+ return map;
33
+ }
@@ -0,0 +1,8 @@
1
+ export const NONE = { rules: [], notes: [] };
2
+ /** `MyApp_Web` → `myapp-web`; an empty result is the caller's problem (it picks a fallback). */
3
+ export const kebab = (name) => name
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, '-')
6
+ .replace(/^-+|-+$/g, '');
7
+ /** Patterns are written with `/`; a Windows-edited path still becomes one. */
8
+ export const posixPath = (value) => value.replaceAll('\\', '/').replace(/^(\.\/)+/, '');
@@ -0,0 +1,93 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join, posix } from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { kebab, NONE } from './shared.js';
5
+ const IGNORE = ['**/.build/**', '**/Pods/**', '**/node_modules/**', '**/DerivedData/**', '**/.git/**', '**/*.xcloc/**'];
6
+ /** `LOCALE` from config.ts, with `_` allowed: that is how the locales map is used. */
7
+ const LOCALE_FILE = /^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$/;
8
+ const glob = async (cwd, pattern) => (await fg(pattern, { cwd, onlyFiles: true, dot: false, ignore: IGNORE })).sort();
9
+ /** The one directory holding every `.xliff`, when each is named after a locale; else undefined. */
10
+ const existingDir = async (cwd) => {
11
+ const xliffs = await glob(cwd, '**/*.xliff');
12
+ if (xliffs.length === 0)
13
+ return undefined;
14
+ const dirs = new Set(xliffs.map((path) => posix.dirname(path)));
15
+ if (dirs.size !== 1 || !xliffs.every((path) => LOCALE_FILE.test(posix.basename(path, '.xliff'))))
16
+ return undefined;
17
+ return [...dirs][0];
18
+ };
19
+ /** `localizable`, then `localizable-2` for a second catalog with the same basename. */
20
+ const namespaces = (catalogs, notes) => {
21
+ const counts = new Map();
22
+ const bases = new Map();
23
+ return catalogs.map((catalog) => {
24
+ const base = kebab(posix.basename(catalog, '.xcstrings')) || 'strings';
25
+ const count = (counts.get(base) ?? 0) + 1;
26
+ counts.set(base, count);
27
+ if (count === 1) {
28
+ bases.set(base, posix.basename(catalog));
29
+ return base;
30
+ }
31
+ const namespace = `${base}-${count}`;
32
+ notes.push(`Two catalogs are named ${bases.get(base) ?? catalog}: the ${count === 2 ? 'second' : `${count}th`} one is namespace ${namespace}.`);
33
+ return namespace;
34
+ });
35
+ };
36
+ const PROJECT_MARKER = /\.(xcodeproj|xcworkspace)$|^Package\.swift$/;
37
+ /**
38
+ * The directory Xcode resolves `<file original>` against: the nearest ancestor of the catalog (at or
39
+ * below `cwd`) holding an `.xcodeproj`, `.xcworkspace` or `Package.swift`; `.` when there is none.
40
+ */
41
+ const projectRoot = async (cwd, catalog) => {
42
+ for (let dir = posix.dirname(catalog);; dir = posix.dirname(dir)) {
43
+ let entries;
44
+ try {
45
+ entries = await readdir(join(cwd, dir));
46
+ }
47
+ catch {
48
+ entries = [];
49
+ }
50
+ if (entries.some((entry) => PROJECT_MARKER.test(entry)))
51
+ return dir;
52
+ if (dir === '.')
53
+ return dir;
54
+ }
55
+ };
56
+ const rule = (pattern, namespace, original) => ({
57
+ pattern,
58
+ format: 'xliff',
59
+ namespace,
60
+ xliffDialect: 'xcode',
61
+ xliffOriginal: original,
62
+ });
63
+ /** Every `*.xcstrings` is a String Catalog; the XLIFF files Xcode imports live next to it, or will. */
64
+ export async function detectXcode(cwd) {
65
+ const catalogs = await glob(cwd, '**/*.xcstrings');
66
+ const only = catalogs[0];
67
+ if (only === undefined)
68
+ return NONE;
69
+ const notes = [];
70
+ const names = namespaces(catalogs, notes);
71
+ const originals = [];
72
+ const roots = new Set();
73
+ for (const catalog of catalogs) {
74
+ const root = await projectRoot(cwd, catalog);
75
+ originals.push(root === '.' ? catalog : posix.relative(root, catalog));
76
+ if (root !== '.')
77
+ roots.add(root);
78
+ }
79
+ for (const root of roots)
80
+ notes.push(`Xcode project in ${root}/: xliffOriginal is relative to it, as Xcode expects.`);
81
+ if (catalogs.length === 1) {
82
+ const dir = await existingDir(cwd);
83
+ const pattern = dir === undefined ? 'Localization/{locale}.xliff' : dir === '.' ? '{locale}.xliff' : `${dir}/{locale}.xliff`;
84
+ if (dir === undefined) {
85
+ notes.push('No XLIFF files yet: `translify pull` creates Localization/<locale>.xliff; `xcodebuild -importLocalizations` reads them.');
86
+ }
87
+ return { rules: [rule(pattern, names[0] ?? 'strings', originals[0] ?? only)], notes };
88
+ }
89
+ return {
90
+ rules: catalogs.map((catalog, index) => rule(`Localization/${names[index] ?? 'strings'}/{locale}.xliff`, names[index] ?? 'strings', originals[index] ?? catalog)),
91
+ notes: ['Several String Catalogs: each gets its own folder under Localization/, created by `translify pull`.', ...notes],
92
+ };
93
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { confirm, input, password, select } from '@inquirer/prompts';
2
+ import { checkbox, confirm, input, password } from '@inquirer/prompts';
3
3
  import { Command, Option } from 'commander';
4
4
  import { runInit } from './commands/init.js';
5
5
  import { LINT_SEVERITIES, parseMaxWarnings, runLint } from './commands/lint.js';
@@ -9,7 +9,7 @@ import { runPull } from './commands/pull.js';
9
9
  import { PUSH_STATUSES, runPush } from './commands/push.js';
10
10
  import { loadConfig } from './config.js';
11
11
  import { askConfirm } from './confirm.js';
12
- import { resolveKey } from './credentials.js';
12
+ import { findKey, resolveKey, saveKey } from './credentials.js';
13
13
  import { CliError, EXIT } from './errors.js';
14
14
  import { Api } from './http.js';
15
15
  import { VERSION } from './version.js';
@@ -44,25 +44,26 @@ const program = new Command('translify')
44
44
  program
45
45
  .command('init')
46
46
  .description('Create translify.json for this repository')
47
- .action(async () => {
47
+ .option('-y, --yes', 'no prompts: take the detected defaults; the key comes from TRANSLIFY_SECRET_KEY or a previous login; never overwrites')
48
+ .option('--api-url <url>', `Translify API URL (default: ${DEFAULT_API_URL})`)
49
+ .action(async (opts) => {
48
50
  process.exitCode = await runInit({
49
51
  cwd: process.cwd(),
50
52
  isTty,
51
- out,
53
+ yes: opts.yes === true,
54
+ apiUrl: opts.apiUrl,
52
55
  apiUrlDefault: DEFAULT_API_URL,
56
+ out,
53
57
  prompt: {
54
58
  input: async (message, defaultValue) => guarded(async () => input({ message, default: defaultValue }, { output: process.stderr }), ''),
55
59
  confirm: async (message, defaultValue) => guarded(async () => confirm({ message, default: defaultValue }, { output: process.stderr }), false),
56
- choose: async (message, options) => guarded(async () => select({ message, choices: options.map((value) => ({ value })) }, { output: process.stderr }), options[0] ?? ''),
57
- },
58
- fetchWhoami: async (apiUrl) => {
59
- try {
60
- return await new Api(apiUrl, await resolveKey()).get('/cli/v1/whoami');
61
- }
62
- catch {
63
- return undefined;
64
- }
60
+ checkbox: async (message, options) => guarded(async () => checkbox({ message, choices: options.map((value) => ({ value, checked: true })) }, { output: process.stderr }), options),
61
+ // Ctrl+C here is "skip": init goes on without project info.
62
+ secret: async (message) => guarded(async () => password({ message, mask: '*' }, { output: process.stderr }), ''),
65
63
  },
64
+ findKey: async () => findKey(),
65
+ saveKey: async (key) => saveKey(key),
66
+ fetchWhoami: async (apiUrl, key) => new Api(apiUrl, key).get('/cli/v1/whoami'),
66
67
  });
67
68
  });
68
69
  program
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.6.0';
2
+ export const VERSION = '0.7.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.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {