@chesterbrains/translify-cli 0.6.1 → 0.8.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 +40 -59
- package/dist/commands/init.js +82 -74
- package/dist/commands/push.js +10 -10
- package/dist/credentials.js +9 -4
- package/dist/detect/angular.js +80 -0
- package/dist/detect/flutter.js +22 -0
- package/dist/detect/i18next.js +11 -0
- package/dist/detect/index.js +16 -0
- package/dist/detect/locales.js +33 -0
- package/dist/detect/shared.js +8 -0
- package/dist/detect/xcode.js +93 -0
- package/dist/http.js +1 -1
- package/dist/index.js +15 -14
- package/dist/render.js +22 -22
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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`
|
|
@@ -252,34 +257,38 @@ Options:
|
|
|
252
257
|
- **Source locale: your files win.** Existing source translations are overwritten with what is in the file.
|
|
253
258
|
- **Target locales: Translify fills only the gaps.** Keys that already have a translation are left alone and counted as skipped.
|
|
254
259
|
- `--overwrite-targets` also overwrites existing target translations.
|
|
255
|
-
- `--prune`
|
|
260
|
+
- `--prune` moves keys that no longer exist in your source files to the Translify trash, together with their translations. They can be restored for 30 days. See [Prune safety](#prune-safety).
|
|
256
261
|
- `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
|
|
257
262
|
- `--dry-run` shows the effect of any combination and writes nothing.
|
|
258
263
|
|
|
259
264
|
## Prune safety
|
|
260
265
|
|
|
261
|
-
`translify push --prune` is the only command that
|
|
266
|
+
`translify push --prune` is the only command that removes keys. It never deletes on the spot: it archives them. What
|
|
267
|
+
it archives, and what stops it:
|
|
262
268
|
|
|
263
269
|
- **Only the namespaces in this push.** A key is an orphan when its namespace is in the push and its source file no
|
|
264
270
|
longer has it. Namespaces you did not push (another rule, another repository, `--namespace` left them out) are never
|
|
265
271
|
touched.
|
|
266
|
-
- **
|
|
267
|
-
|
|
272
|
+
- **Archived, not deleted.** A pruned key goes to the trash in Translify, with its translations and their history.
|
|
273
|
+
It is deleted for good 30 days later. Until then, restore it from the trash on the project's Translations tab, or
|
|
274
|
+
push a source file that names it again: the push restores it in place and reports it as restored.
|
|
275
|
+
- **Live keys too.** A key that is live in an environment is archived like any other, but the
|
|
276
|
+
environment keeps serving it until its next publish, which drops it. Restore it before then if it should stay.
|
|
268
277
|
- **Never a whole namespace by accident.** If a source file in the push has no entries, the push is refused
|
|
269
|
-
(`VALIDATION_FAILED` with `emptySourcePrune`) instead of
|
|
278
|
+
(`VALIDATION_FAILED` with `emptySourcePrune`) instead of archiving every key in that namespace. Emptying a namespace
|
|
270
279
|
is done in the Translify web app.
|
|
271
|
-
- **Exactly the list you confirmed.** The CLI always runs a dry run first, shows the keys it would
|
|
280
|
+
- **Exactly the list you confirmed.** The CLI always runs a dry run first, shows the keys it would archive and asks
|
|
272
281
|
for confirmation. The real push sends a digest of that list; if the server's list differs by then (keys added,
|
|
273
|
-
removed or
|
|
282
|
+
removed, archived or restored in between), it refuses with `ORPHANS_CHANGED` and writes nothing. A server too old to return
|
|
274
283
|
the digest makes `--prune` refuse.
|
|
275
284
|
- **No silent prune in CI.** Without a terminal, `--prune` is refused unless you pass `--yes`.
|
|
276
|
-
- **Atomic.** The whole push,
|
|
285
|
+
- **Atomic.** The whole push, archives and restores included, is one transaction: if anything fails, nothing is written.
|
|
277
286
|
|
|
278
287
|
`--yes` confirms whatever that run's dry run lists, so in CI review the list before it runs unattended. One way:
|
|
279
288
|
show the orphans on pull requests and prune on `main`.
|
|
280
289
|
|
|
281
290
|
```yaml
|
|
282
|
-
# on pull_request: list what a prune would
|
|
291
|
+
# on pull_request: list what a prune would archive, change nothing
|
|
283
292
|
- run: npx translify push --prune --dry-run
|
|
284
293
|
env:
|
|
285
294
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
@@ -302,48 +311,34 @@ When the project has **Require review** turned on in Translify:
|
|
|
302
311
|
|
|
303
312
|
## CI (GitHub Actions)
|
|
304
313
|
|
|
305
|
-
Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
|
|
314
|
+
Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`. Two ready-made workflows live in
|
|
315
|
+
[`recipes/github-actions/`](https://github.com/chesterbrains/translify-cli/tree/main/recipes/github-actions); copy the
|
|
316
|
+
one you need to `.github/workflows/` as it is.
|
|
306
317
|
|
|
307
|
-
|
|
318
|
+
- [`translify-push.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-push.yml)
|
|
319
|
+
runs `push --dry-run` on every pull request and keeps its output in one comment on the PR, updated on each push to the
|
|
320
|
+
branch (a refused push lands there too and fails the job); on `main` it pushes for real, with an optional `publish`
|
|
321
|
+
step. Key scope: `PUSH` (and `PUBLISH` for the optional step). Pull requests from forks are skipped: they have no
|
|
322
|
+
access to secrets.
|
|
323
|
+
- [`translify-release.yml`](https://github.com/chesterbrains/translify-cli/blob/main/recipes/github-actions/translify-release.yml)
|
|
324
|
+
is for apps that bundle translations at build time: `lint`, then `pull`, your build, then `publish` to the environment
|
|
325
|
+
chosen in the Actions tab (or on a tag). Key scopes: `PULL` and `PUBLISH`.
|
|
308
326
|
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
```
|
|
327
|
+
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
|
|
328
|
+
release must not turn the workflow red on its own.
|
|
325
329
|
|
|
326
330
|
Fail a pull request when local files drift from Translify:
|
|
327
331
|
|
|
328
332
|
```yaml
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 }}
|
|
333
|
+
- run: npx -y translify status
|
|
334
|
+
env:
|
|
335
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
341
336
|
```
|
|
342
337
|
|
|
343
338
|
Fail CI when translations in Translify have placeholder errors:
|
|
344
339
|
|
|
345
340
|
```yaml
|
|
346
|
-
- run: npx translify lint --max-warnings 0
|
|
341
|
+
- run: npx -y translify lint --max-warnings 0
|
|
347
342
|
env:
|
|
348
343
|
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
349
344
|
```
|
|
@@ -352,20 +347,6 @@ Fail CI when translations in Translify have placeholder errors:
|
|
|
352
347
|
It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
|
|
353
348
|
project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
|
|
354
349
|
|
|
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
350
|
## Exit codes
|
|
370
351
|
|
|
371
352
|
| Code | Meaning |
|
|
@@ -387,7 +368,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
|
|
|
387
368
|
| `VALIDATION_FAILED` | 1 | Nothing was written. The output lists `file:line key: reason`; fix those entries and push again. |
|
|
388
369
|
| `LOCALE_NOT_FOUND` | 1 | A locale in your files is not enabled in the project. Enable it in Translify, or map it with `locales` in `translify.json`. |
|
|
389
370
|
| `NAMESPACE_NOT_FOUND` | 1 | The namespace does not exist in the project. Create it in Translify or fix the `--namespace` / pattern. |
|
|
390
|
-
| `VALIDATION_FAILED` with `emptySourcePrune` | 1 | `--prune` was refused because a source file in the push has no entries (it would
|
|
371
|
+
| `VALIDATION_FAILED` with `emptySourcePrune` | 1 | `--prune` was refused because a source file in the push has no entries (it would archive the whole namespace). Push the real source file, or drop `--prune`; emptying a namespace is done in the Translify web app. |
|
|
391
372
|
| `JSON_STYLE_REQUIRES_JSON` | 1 | `jsonStyle` only applies to rules with `"format": "json"`. Remove it from the other rules. |
|
|
392
373
|
| `BAD_REQUEST` | 1 | The request was malformed. Re-run with `--debug` for the server's message. |
|
|
393
374
|
| `ENVIRONMENT_NOT_FOUND` / `ENVIRONMENT_ARCHIVED` | 1 | Check the environment slug passed to `publish` or `--from env:<slug>`, and that it is not archived. |
|
|
@@ -400,7 +381,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
|
|
|
400
381
|
| `QUOTA_EXCEEDED` | 3 | Your plan limit was reached (usually translation keys). Remove keys, or push a smaller set. |
|
|
401
382
|
| `PUSH_TOO_LARGE` | 4 | Split the push by namespace: `translify push --namespace <ns>`. |
|
|
402
383
|
| `CLI_TOO_OLD` | 4 | Upgrade: `npm i -g @chesterbrains/translify-cli@latest` (or use `npx translify@latest`). |
|
|
403
|
-
| `ORPHANS_CHANGED` | 1 | The orphan list changed between the dry run and the real push (keys were added, removed or
|
|
384
|
+
| `ORPHANS_CHANGED` | 1 | The orphan list changed between the dry run and the real push (keys were added, removed, archived or restored). Nothing was written; run `translify push --prune` again to review the new list. |
|
|
404
385
|
| `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. |
|
|
405
386
|
| `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. |
|
|
406
387
|
| `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). |
|
package/dist/commands/init.js
CHANGED
|
@@ -1,58 +1,13 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import
|
|
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
|
|
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.
|
|
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 (
|
|
80
|
-
deps.
|
|
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
|
-
|
|
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
|
|
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.
|
|
93
|
-
files = detected.filter((rule) => rule
|
|
94
|
-
if (files.length === 0)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
deps.out(
|
|
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
|
}
|
package/dist/commands/push.js
CHANGED
|
@@ -42,10 +42,11 @@ const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
|
|
|
42
42
|
*/
|
|
43
43
|
const describeDamage = async (preview, opts, api) => {
|
|
44
44
|
const damage = [];
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
if (opts.prune === true &&
|
|
48
|
-
damage.push(`
|
|
45
|
+
// Every orphan is archived, live ones included: they keep serving until the next publish.
|
|
46
|
+
const archivable = preview.orphans.length;
|
|
47
|
+
if (opts.prune === true && archivable > 0) {
|
|
48
|
+
damage.push(`move ${archivable} key(s) and their translations to the trash (restorable for 30 days in Translify)`);
|
|
49
|
+
}
|
|
49
50
|
if (opts.overwriteTargets === true) {
|
|
50
51
|
const { project } = await api.get('/cli/v1/whoami');
|
|
51
52
|
const overwritten = Object.entries(preview.locales)
|
|
@@ -90,12 +91,12 @@ export async function runPush(opts, deps) {
|
|
|
90
91
|
}
|
|
91
92
|
const preview = await deps.api.post(PUSH, buildForm(uploads, opts, true, opts.prune === true));
|
|
92
93
|
if (opts.prune === true && typeof preview.pruneDigest !== 'string') {
|
|
93
|
-
deps.err('This server cannot confirm a prune (its dry run returned no pruneDigest), so --prune would
|
|
94
|
+
deps.err('This server cannot confirm a prune (its dry run returned no pruneDigest), so --prune would archive without a bound. ' +
|
|
94
95
|
'Nothing was changed. Upgrade the Translify server, or push without --prune.');
|
|
95
96
|
return EXIT.failed;
|
|
96
97
|
}
|
|
97
98
|
const damage = await describeDamage(preview, opts, deps.api);
|
|
98
|
-
// Nothing would be
|
|
99
|
+
// Nothing would be archived or overwritten: no question to ask (F19).
|
|
99
100
|
if (damage.length > 0) {
|
|
100
101
|
// With --json, stdout is reserved for the final document.
|
|
101
102
|
(opts.json === true ? deps.err : deps.out)(renderPush(preview));
|
|
@@ -111,10 +112,9 @@ export async function runPush(opts, deps) {
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
|
-
// With
|
|
115
|
-
//
|
|
116
|
-
const
|
|
117
|
-
const prune = opts.prune === true && deletable > 0;
|
|
115
|
+
// With orphans the real push sends the digest of the list the user confirmed: if the server's orphan
|
|
116
|
+
// set differs it answers 409 ORPHANS_CHANGED and writes nothing. With none, there is nothing to prune.
|
|
117
|
+
const prune = opts.prune === true && preview.orphans.length > 0;
|
|
118
118
|
const res = await deps.api.post(PUSH, buildForm(uploads, opts, false, prune, prune ? preview.pruneDigest : undefined));
|
|
119
119
|
show(res, preview);
|
|
120
120
|
return EXIT.ok;
|
package/dist/credentials.js
CHANGED
|
@@ -25,7 +25,8 @@ export const assertSecret = (key) => {
|
|
|
25
25
|
}
|
|
26
26
|
return key;
|
|
27
27
|
};
|
|
28
|
-
|
|
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
|
-
|
|
38
|
+
return undefined;
|
|
38
39
|
}
|
|
39
|
-
|
|
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
|
|
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/http.js
CHANGED
|
@@ -85,7 +85,7 @@ const describeBody = (status, body) => {
|
|
|
85
85
|
case 'NAMESPACE_NOT_FOUND':
|
|
86
86
|
return `Unknown namespace(s): ${list(body.namespaces)}.`;
|
|
87
87
|
case 'ORPHANS_CHANGED':
|
|
88
|
-
return 'The orphans changed since the dry run (keys were added, removed or
|
|
88
|
+
return 'The orphans changed since the dry run (keys were added, removed, archived or restored meanwhile). Nothing was written. Run translify push --prune again to review the new list.';
|
|
89
89
|
case 'REVIEW_DISABLED':
|
|
90
90
|
return 'Review is off for this project, so --status draft or needs-review cannot be used. Turn on "Require review" in the project settings, or drop --status.';
|
|
91
91
|
case 'APPROVED_ONLY_REQUIRES_WORKING':
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { confirm, input, password
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
@@ -88,7 +89,7 @@ program
|
|
|
88
89
|
.description('Upload source and target files')
|
|
89
90
|
.option('--dry-run', 'show what would change, write nothing')
|
|
90
91
|
.option('--overwrite-targets', 'overwrite existing target-locale translations')
|
|
91
|
-
.option('--prune', '
|
|
92
|
+
.option('--prune', 'move keys missing from your source files to the Translify trash (restorable for 30 days); archives exactly the keys you confirmed, and refuses if anything changed meanwhile (re-run to review)')
|
|
92
93
|
.option('-y, --yes', 'confirm destructive flags without prompting')
|
|
93
94
|
.option('--namespace <ns>', 'only this namespace')
|
|
94
95
|
.addOption(new Option('--status <status>', 'status target-locale cells land in (default: needs-review when the project requires review, approved otherwise)').choices(PUSH_STATUSES))
|
package/dist/render.js
CHANGED
|
@@ -14,8 +14,8 @@ export function summarizePush(res) {
|
|
|
14
14
|
skippedFilled: 0,
|
|
15
15
|
unknownKeys: 0,
|
|
16
16
|
orphans: res.orphans.length,
|
|
17
|
-
publishedOrphans: res.orphans.filter((orphan) => orphan.published).length,
|
|
18
17
|
pruned: res.pruned,
|
|
18
|
+
keysRestored: res.keysRestored ?? 0,
|
|
19
19
|
staleReset: res.staleReset ?? 0,
|
|
20
20
|
};
|
|
21
21
|
for (const counts of Object.values(res.locales)) {
|
|
@@ -47,36 +47,36 @@ export function renderPush(res, view = {}) {
|
|
|
47
47
|
if (stale > 0) {
|
|
48
48
|
lines.push(pc.yellow(`${stale} approved translation(s) ${res.dryRun ? 'would go' : 'went'} back to review because their source text changed.`));
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
lines.push(` …and ${list.length - MAX_ORPHANS} more`);
|
|
59
|
-
};
|
|
50
|
+
const restored = res.keysRestored ?? 0;
|
|
51
|
+
if (restored > 0) {
|
|
52
|
+
lines.push(pc.green(`${res.dryRun ? 'Would restore' : 'Restored'} ${restored} key(s) from the trash.`));
|
|
53
|
+
}
|
|
54
|
+
// A prune archives every orphan (it goes to the trash, purged 30 days later); `pruned` is what a dry
|
|
55
|
+
// run would archive or a real push did. A real prune can archive fewer than it listed when some
|
|
56
|
+
// orphans were archived from the web app meanwhile.
|
|
57
|
+
const listed = res.orphans;
|
|
60
58
|
if (listed.length > 0) {
|
|
59
|
+
const meanwhile = listed.length - res.pruned;
|
|
61
60
|
const header = res.pruned === 0
|
|
62
61
|
? `${listed.length} key(s) in Translify are not in your source files:`
|
|
63
62
|
: res.dryRun
|
|
64
|
-
? `${listed.length} key(s) not in your source files would be
|
|
65
|
-
: `
|
|
63
|
+
? `${listed.length} key(s) not in your source files would be moved to the trash:`
|
|
64
|
+
: `Moved ${res.pruned} key(s) that were not in your source files to the trash (restorable for 30 days${meanwhile > 0 ? `; ${meanwhile} already archived meanwhile` : ''}):`;
|
|
66
65
|
lines.push(pc.yellow(` ${header}`));
|
|
67
|
-
|
|
66
|
+
for (const orphan of listed.slice(0, MAX_ORPHANS))
|
|
67
|
+
lines.push(` ${orphan.namespace}:${orphan.key}`);
|
|
68
|
+
if (listed.length > MAX_ORPHANS)
|
|
69
|
+
lines.push(` …and ${listed.length - MAX_ORPHANS} more`);
|
|
68
70
|
if (res.pruned === 0 && view.prune !== true)
|
|
69
|
-
lines.push(pc.dim(' Use --prune to
|
|
71
|
+
lines.push(pc.dim(' Use --prune to move them to the trash.'));
|
|
70
72
|
}
|
|
71
|
-
if (
|
|
72
|
-
lines.push(pc.yellow(` ${
|
|
73
|
-
names(published);
|
|
73
|
+
if (res.pruned > 0 && res.dryRun) {
|
|
74
|
+
lines.push(pc.yellow(` Would move ${res.pruned} key(s) to the trash (restorable for 30 days).`));
|
|
74
75
|
}
|
|
75
|
-
if (res.pruned > 0 && res.dryRun)
|
|
76
|
-
lines.push(pc.yellow(` Would delete ${res.pruned} key(s).`));
|
|
77
76
|
// A real prune's orphan header already says it; repeat it only when there was no list.
|
|
78
|
-
if (res.pruned > 0 && !res.dryRun && listed.length === 0)
|
|
79
|
-
lines.push(pc.
|
|
77
|
+
if (res.pruned > 0 && !res.dryRun && listed.length === 0) {
|
|
78
|
+
lines.push(pc.yellow(` Moved ${res.pruned} key(s) to the Translify trash (restorable for 30 days).`));
|
|
79
|
+
}
|
|
80
80
|
return lines.join('\n');
|
|
81
81
|
}
|
|
82
82
|
const MAX_VALUE = 80;
|
package/dist/version.js
CHANGED