@chesterbrains/translify-cli 0.6.1 → 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,6 +128,8 @@ server converts both ways:
128
128
  { "files": [{ "pattern": "src/locale/messages.{locale}.xlf", "format": "xliff", "namespace": "web", "xliffDialect": "angular" }] }
129
129
  ```
130
130
 
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
+
131
133
  Give every template string `i18n="@@<key>"` so the unit ids are your keys. A translation file's `trgLang` must be the
132
134
  Translify locale code: with `"locales": { "it": "it-IT" }` the file is still `messages.it.xlf`, but it declares
133
135
  `trgLang="it-IT"`, or `push` is refused with `File targets it but it-IT was requested`. What `pull` writes:
@@ -171,6 +173,8 @@ iOS translations get placeholder validation (Xcode's own import accepts a target
171
173
  "xliffDialect": "xcode", "xliffOriginal": "Sources/App/Localizable.xcstrings" }] }
172
174
  ```
173
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
+
174
178
  Workflow:
175
179
 
176
180
  1. Seed once: `xcodebuild -exportLocalizations -localizationPath out -exportLanguage en`, copy
@@ -228,7 +232,7 @@ otherwise the CLI stops before sending anything and names the files involved.
228
232
 
229
233
  | Command | What it does |
230
234
  |---|---|
231
- | `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`) |
232
236
  | `translify login` | Store a secret key on this machine (interactive) |
233
237
  | `translify push` | Upload source and target files |
234
238
  | `translify pull` | Download translation files |
@@ -238,6 +242,7 @@ otherwise the CLI stops before sending anything and names the files involved.
238
242
 
239
243
  Options:
240
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>`
241
246
  - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
242
247
  - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
243
248
  - `publish`: `--fail-on-withheld`, `--allow-errors`
@@ -302,48 +307,34 @@ When the project has **Require review** turned on in Translify:
302
307
 
303
308
  ## CI (GitHub Actions)
304
309
 
305
- 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.
306
313
 
307
- 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`.
308
322
 
309
- ```yaml
310
- name: Translify push
311
- on:
312
- push:
313
- branches: [main]
314
- jobs:
315
- push:
316
- runs-on: ubuntu-latest
317
- steps:
318
- - uses: actions/checkout@v4
319
- - uses: actions/setup-node@v4
320
- with: { node-version: 22 }
321
- - run: npx translify push
322
- env:
323
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
324
- ```
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.
325
325
 
326
326
  Fail a pull request when local files drift from Translify:
327
327
 
328
328
  ```yaml
329
- name: Translify status
330
- on: pull_request
331
- jobs:
332
- status:
333
- runs-on: ubuntu-latest
334
- steps:
335
- - uses: actions/checkout@v4
336
- - uses: actions/setup-node@v4
337
- with: { node-version: 22 }
338
- - run: npx translify status
339
- env:
340
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
329
+ - run: npx -y translify status
330
+ env:
331
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
341
332
  ```
342
333
 
343
334
  Fail CI when translations in Translify have placeholder errors:
344
335
 
345
336
  ```yaml
346
- - run: npx translify lint --max-warnings 0
337
+ - run: npx -y translify lint --max-warnings 0
347
338
  env:
348
339
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
349
340
  ```
@@ -352,20 +343,6 @@ Fail CI when translations in Translify have placeholder errors:
352
343
  It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
353
344
  project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
354
345
 
355
- Pull and publish on deploy:
356
-
357
- ```yaml
358
- - run: npx translify lint
359
- env:
360
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
361
- - run: npx translify pull
362
- env:
363
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
364
- - run: npx translify publish production
365
- env:
366
- TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
367
- ```
368
-
369
346
  ## Exit codes
370
347
 
371
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
  }
@@ -25,7 +25,8 @@ export const assertSecret = (key) => {
25
25
  }
26
26
  return key;
27
27
  };
28
- 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) {
29
30
  const fromEnv = env.TRANSLIFY_SECRET_KEY?.trim();
30
31
  if (fromEnv)
31
32
  return assertSecret(fromEnv);
@@ -34,11 +35,15 @@ export async function resolveKey(env = process.env) {
34
35
  stored = (await readFile(credentialsPath(env), 'utf8')).trim();
35
36
  }
36
37
  catch {
37
- throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
38
+ return undefined;
38
39
  }
39
- 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)
40
45
  throw new CliError(EXIT.auth, 'No key. Set TRANSLIFY_SECRET_KEY or run `translify login`.');
41
- return assertSecret(stored);
46
+ return key;
42
47
  }
43
48
  export async function saveKey(key, env = process.env) {
44
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.1';
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.1",
3
+ "version": "0.7.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {