langctl 0.2.0 → 0.3.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/CHANGELOG.md +42 -0
- package/README.md +155 -1188
- package/dist/commands/auth.js +85 -19
- package/dist/commands/config.js +36 -26
- package/dist/commands/export.js +11 -122
- package/dist/commands/import.js +32 -143
- package/dist/commands/init.js +61 -72
- package/dist/commands/keys.js +145 -279
- package/dist/commands/org.js +45 -111
- package/dist/commands/projects.js +124 -312
- package/dist/commands/pull.js +80 -130
- package/dist/commands/push.js +125 -0
- package/dist/commands/team.js +68 -257
- package/dist/core/config.js +130 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/files.js +38 -0
- package/dist/core/http.js +121 -0
- package/dist/core/output.js +96 -0
- package/dist/core/project.js +58 -0
- package/dist/core/prompts.js +56 -0
- package/dist/formats/index.js +285 -0
- package/dist/index.js +261 -508
- package/dist/version.js +4 -0
- package/package.json +22 -15
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/CONTRIBUTING.md +0 -33
- package/dist/api.d.ts +0 -20
- package/dist/api.js +0 -75
- package/dist/auth.d.ts +0 -38
- package/dist/auth.js +0 -106
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -61
- package/dist/commands/export.d.ts +0 -13
- package/dist/commands/import.d.ts +0 -12
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/keys.d.ts +0 -25
- package/dist/commands/org.d.ts +0 -13
- package/dist/commands/projects.d.ts +0 -33
- package/dist/commands/pull.d.ts +0 -9
- package/dist/commands/team.d.ts +0 -29
- package/dist/config.d.ts +0 -48
- package/dist/config.js +0 -69
- package/dist/exporters/index.d.ts +0 -36
- package/dist/exporters/index.js +0 -214
- package/dist/index.d.ts +0 -3
- package/dist/utils/banner.d.ts +0 -9
- package/dist/utils/banner.js +0 -24
- package/translations/en.json +0 -5
- package/translations/en.xml +0 -9
- package/translations/fr.json +0 -4
- package/translations/fr.xml +0 -7
- package/translations/hi.json +0 -4
- package/translations/hi.xml +0 -7
- package/translations/ios/en.strings +0 -9
- package/translations/ios/fr.strings +0 -6
- package/translations/ios/hi.strings +0 -6
- package/translations/ios/ja.strings +0 -6
- package/translations/ja.json +0 -4
- package/translations/ja.xml +0 -7
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { CliError, ExitCode, httpError, networkError } from './errors.js';
|
|
2
|
+
import { requireCredentials } from './config.js';
|
|
3
|
+
import { detectCi, log } from './output.js';
|
|
4
|
+
import { VERSION } from '../version.js';
|
|
5
|
+
export const httpSettings = {
|
|
6
|
+
timeoutMs: Number(process.env.LANGCTL_TIMEOUT || 30) * 1000,
|
|
7
|
+
retries: 3,
|
|
8
|
+
};
|
|
9
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
10
|
+
function userAgent() {
|
|
11
|
+
const ci = detectCi();
|
|
12
|
+
return `langctl/${VERSION} (node ${process.versions.node}; ${process.platform}${ci ? `; ci=${ci}` : ''})`;
|
|
13
|
+
}
|
|
14
|
+
export class ApiClient {
|
|
15
|
+
constructor(creds) {
|
|
16
|
+
this.creds = creds;
|
|
17
|
+
}
|
|
18
|
+
get baseUrl() {
|
|
19
|
+
return this.creds.apiUrl;
|
|
20
|
+
}
|
|
21
|
+
async request(method, path, opts = {}) {
|
|
22
|
+
const url = new URL(this.creds.apiUrl + path);
|
|
23
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
24
|
+
if (v !== undefined && v !== '')
|
|
25
|
+
url.searchParams.set(k, String(v));
|
|
26
|
+
}
|
|
27
|
+
const headers = {
|
|
28
|
+
'X-API-Key': this.creds.apiKey,
|
|
29
|
+
'User-Agent': userAgent(),
|
|
30
|
+
Accept: 'application/json',
|
|
31
|
+
};
|
|
32
|
+
// Only declare a JSON body when there is one — Fastify rejects an empty body with this header
|
|
33
|
+
if (opts.body !== undefined)
|
|
34
|
+
headers['Content-Type'] = 'application/json';
|
|
35
|
+
const idempotent = opts.idempotent ?? (method === 'GET');
|
|
36
|
+
const attempts = idempotent ? httpSettings.retries : 1;
|
|
37
|
+
for (let attempt = 1;; attempt++) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
let res;
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(url, {
|
|
42
|
+
method,
|
|
43
|
+
headers,
|
|
44
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
45
|
+
signal: AbortSignal.timeout(httpSettings.timeoutMs),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const mapped = networkError(err, this.creds.apiUrl, httpSettings.timeoutMs);
|
|
50
|
+
log.debug(`${method} ${url.pathname} → ${mapped.message} (${Date.now() - started}ms, attempt ${attempt}/${attempts})`);
|
|
51
|
+
if (attempt < attempts) {
|
|
52
|
+
await sleep(backoff(attempt));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw mapped;
|
|
56
|
+
}
|
|
57
|
+
log.debug(`${method} ${url.pathname}${url.search} → ${res.status} (${Date.now() - started}ms)`);
|
|
58
|
+
const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
|
|
59
|
+
if (retryable && attempt < attempts) {
|
|
60
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
61
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 30) * 1000 : backoff(attempt));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let data = undefined;
|
|
66
|
+
if (text) {
|
|
67
|
+
try {
|
|
68
|
+
data = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw httpError(res.status, undefined, method, url.pathname);
|
|
73
|
+
throw new CliError(`Unexpected non-JSON response from ${url.host} (${res.status}).`, ExitCode.Network, 'Is LANGCTL_API_URL pointing at the Langctl API (…/api/v1)?');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const d = data;
|
|
78
|
+
throw httpError(res.status, d?.error || d?.message, method, url.pathname);
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
get(path, query) {
|
|
84
|
+
return this.request('GET', path, { query });
|
|
85
|
+
}
|
|
86
|
+
post(path, body, idempotent = false) {
|
|
87
|
+
return this.request('POST', path, { body, idempotent });
|
|
88
|
+
}
|
|
89
|
+
patch(path, body) {
|
|
90
|
+
return this.request('PATCH', path, { body, idempotent: true });
|
|
91
|
+
}
|
|
92
|
+
delete(path) {
|
|
93
|
+
return this.request('DELETE', path, { idempotent: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function backoff(attempt) {
|
|
97
|
+
return Math.min(8000, 500 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250);
|
|
98
|
+
}
|
|
99
|
+
/** Look up which org a key belongs to (and its scopes). Returns null if the key is invalid/revoked. */
|
|
100
|
+
export async function validateKey(api, apiKey) {
|
|
101
|
+
const res = await api.request('POST', '/api-keys/validate', { body: { apiKey }, idempotent: true });
|
|
102
|
+
return res.valid && res.organizationId ? { organizationId: res.organizationId, scopes: res.scopes ?? [] } : null;
|
|
103
|
+
}
|
|
104
|
+
let cached = null;
|
|
105
|
+
export async function getSession() {
|
|
106
|
+
if (cached)
|
|
107
|
+
return cached;
|
|
108
|
+
const creds = requireCredentials();
|
|
109
|
+
const api = new ApiClient(creds);
|
|
110
|
+
if (creds.organizationId) {
|
|
111
|
+
cached = { api, orgId: creds.organizationId, creds };
|
|
112
|
+
return cached;
|
|
113
|
+
}
|
|
114
|
+
// Env/flag key: resolve the org from the key itself, so CI needs no config file
|
|
115
|
+
const info = await validateKey(api, creds.apiKey);
|
|
116
|
+
if (!info) {
|
|
117
|
+
throw new CliError(`The API key from ${creds.source === 'env' ? 'LANGCTL_API_KEY' : '--api-key'} is invalid or revoked.`, ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys.');
|
|
118
|
+
}
|
|
119
|
+
cached = { api, orgId: info.organizationId, creds, scopes: info.scopes };
|
|
120
|
+
return cached;
|
|
121
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* Process-wide output settings, set once from global flags in index.ts.
|
|
4
|
+
*
|
|
5
|
+
* Contract: command *results* go to stdout (and only JSON when --json is set);
|
|
6
|
+
* progress, warnings and errors go to stderr. That keeps `langctl ... --json | jq`
|
|
7
|
+
* and `$(langctl ...)` reliable in CI.
|
|
8
|
+
*/
|
|
9
|
+
export const runtime = {
|
|
10
|
+
json: false,
|
|
11
|
+
quiet: false,
|
|
12
|
+
verbose: false,
|
|
13
|
+
yes: false,
|
|
14
|
+
};
|
|
15
|
+
const ciEnv = ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'JENKINS_URL', 'TF_BUILD', 'BITBUCKET_BUILD_NUMBER'];
|
|
16
|
+
export function detectCi() {
|
|
17
|
+
for (const name of ciEnv) {
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value && value !== 'false' && value !== '0')
|
|
20
|
+
return name === 'CI' ? 'ci' : name.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** True only when a human is at an interactive terminal (prompts and spinners allowed). */
|
|
25
|
+
export function isInteractive() {
|
|
26
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY) && !detectCi() && !runtime.json;
|
|
27
|
+
}
|
|
28
|
+
const write = (stream, line) => stream.write(line + '\n');
|
|
29
|
+
export const log = {
|
|
30
|
+
/** Human-readable result line (stdout). Suppressed in --json mode — print JSON with `printJson` instead. */
|
|
31
|
+
out(line = '') {
|
|
32
|
+
if (!runtime.json)
|
|
33
|
+
write(process.stdout, line);
|
|
34
|
+
},
|
|
35
|
+
info(line) {
|
|
36
|
+
if (!runtime.quiet && !runtime.json)
|
|
37
|
+
write(process.stderr, line);
|
|
38
|
+
},
|
|
39
|
+
success(line) {
|
|
40
|
+
if (!runtime.quiet && !runtime.json)
|
|
41
|
+
write(process.stderr, `${chalk.green('✔')} ${line}`);
|
|
42
|
+
},
|
|
43
|
+
warn(line) {
|
|
44
|
+
write(process.stderr, `${chalk.yellow('warning:')} ${line}`);
|
|
45
|
+
},
|
|
46
|
+
error(line, hint) {
|
|
47
|
+
write(process.stderr, `${chalk.red('error:')} ${line}`);
|
|
48
|
+
if (hint)
|
|
49
|
+
write(process.stderr, `${chalk.dim('hint:')} ${hint}`);
|
|
50
|
+
},
|
|
51
|
+
debug(line) {
|
|
52
|
+
if (runtime.verbose)
|
|
53
|
+
write(process.stderr, chalk.dim(`[debug] ${line}`));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export function printJson(data) {
|
|
57
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
58
|
+
}
|
|
59
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
60
|
+
/**
|
|
61
|
+
* Progress indicator that only animates for humans at a terminal; elsewhere (CI, pipes,
|
|
62
|
+
* --json, --quiet) it is a no-op. Deliberately not `ora`: ora touches stdin/cursor state and
|
|
63
|
+
* froze the CLI under emulated terminals (`script`, `docker -t`, some CI runners). This
|
|
64
|
+
* writes to stderr only, and its timer is unref'd so it can never keep the process alive.
|
|
65
|
+
*/
|
|
66
|
+
export function spinner(text) {
|
|
67
|
+
if (!isInteractive() || runtime.quiet || runtime.verbose)
|
|
68
|
+
return { text, stop() { } };
|
|
69
|
+
let i = 0;
|
|
70
|
+
const state = { text };
|
|
71
|
+
const render = () => process.stderr.write(`\r\x1b[2K${chalk.cyan(FRAMES[i++ % FRAMES.length])} ${state.text}`);
|
|
72
|
+
render();
|
|
73
|
+
const timer = setInterval(render, 80);
|
|
74
|
+
timer.unref();
|
|
75
|
+
return {
|
|
76
|
+
get text() { return state.text; },
|
|
77
|
+
set text(t) { state.text = t; },
|
|
78
|
+
stop() {
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
process.stderr.write('\r\x1b[2K');
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Minimal aligned table for human output. */
|
|
85
|
+
export function table(rows, header) {
|
|
86
|
+
const all = header ? [header, ...rows] : rows;
|
|
87
|
+
const widths = all[0]?.map((_, i) => Math.max(...all.map(r => stripAnsi(r[i] ?? '').length))) ?? [];
|
|
88
|
+
const fmt = (r) => r.map((c, i) => (i === r.length - 1 ? c : c + ' '.repeat(widths[i] - stripAnsi(c).length))).join(' ');
|
|
89
|
+
if (header)
|
|
90
|
+
log.out(chalk.bold(fmt(header)));
|
|
91
|
+
rows.forEach(r => log.out(fmt(r)));
|
|
92
|
+
}
|
|
93
|
+
function stripAnsi(s) {
|
|
94
|
+
// eslint-disable-next-line no-control-regex
|
|
95
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CliError, ExitCode, notFoundError, usageError } from './errors.js';
|
|
2
|
+
import { loadProjectConfig } from './config.js';
|
|
3
|
+
/** Project slug from the argument, else langctl.json, else a usage error. */
|
|
4
|
+
export function projectSlugFrom(arg) {
|
|
5
|
+
if (arg)
|
|
6
|
+
return arg;
|
|
7
|
+
const loaded = loadProjectConfig();
|
|
8
|
+
if (loaded?.config.project)
|
|
9
|
+
return loaded.config.project;
|
|
10
|
+
throw usageError('No project given.', 'Pass a project slug, or add "project" to langctl.json (see "langctl init"). List projects with "langctl projects list".');
|
|
11
|
+
}
|
|
12
|
+
export async function getProject(session, slug) {
|
|
13
|
+
try {
|
|
14
|
+
return await session.api.get(`/orgs/${session.orgId}/projects/by-slug/${encodeURIComponent(slug)}`);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err instanceof CliError && err.exitCode === ExitCode.NotFound) {
|
|
18
|
+
const projects = await session.api.get(`/orgs/${session.orgId}/projects`).catch(() => []);
|
|
19
|
+
const available = projects.map(p => p.slug);
|
|
20
|
+
throw notFoundError(`Project "${slug}" not found.`, available.length ? `Available projects: ${available.join(', ')}` : 'This organization has no projects yet — create one with "langctl projects create".');
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertLanguages(project, languages) {
|
|
26
|
+
const missing = languages.filter(l => !project.languages.includes(l));
|
|
27
|
+
if (missing.length) {
|
|
28
|
+
throw usageError(`Language${missing.length > 1 ? 's' : ''} ${missing.join(', ')} not in project "${project.slug}".`, `Project languages: ${project.languages.join(', ')}. Add one with "langctl projects add-language ${project.slug} <code>".`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function findKey(session, project, keyName) {
|
|
32
|
+
const res = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/keys`, { key: keyName, pageSize: 1 });
|
|
33
|
+
return res.data.find(k => k.key === keyName) ?? null;
|
|
34
|
+
}
|
|
35
|
+
export async function requireKey(session, project, keyName) {
|
|
36
|
+
const key = await findKey(session, project, keyName);
|
|
37
|
+
if (!key)
|
|
38
|
+
throw notFoundError(`Key "${keyName}" not found in project "${project.slug}".`, `Search with "langctl keys list ${project.slug} --search <text>".`);
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
/** Fetch every key page by page (the API caps pages at 100). */
|
|
42
|
+
export async function listAllKeys(session, project, filters = {}) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (let page = 1;; page++) {
|
|
45
|
+
const res = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/keys`, {
|
|
46
|
+
page,
|
|
47
|
+
pageSize: 100,
|
|
48
|
+
sortBy: 'key',
|
|
49
|
+
sortOrder: 'asc',
|
|
50
|
+
module: filters.module,
|
|
51
|
+
search: filters.search,
|
|
52
|
+
published: filters.published === undefined ? undefined : String(filters.published),
|
|
53
|
+
});
|
|
54
|
+
out.push(...res.data);
|
|
55
|
+
if (page >= res.pagination.totalPages)
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { Writable } from 'stream';
|
|
3
|
+
import { isInteractive, runtime } from './output.js';
|
|
4
|
+
import { usageError } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Tiny prompts on node:readline (replaces inquirer). They are only ever shown at an
|
|
7
|
+
* interactive terminal; in CI a missing answer is a usage error, never a hang.
|
|
8
|
+
*/
|
|
9
|
+
function ask(question, { hidden = false } = {}) {
|
|
10
|
+
let muted = false;
|
|
11
|
+
const output = new Writable({
|
|
12
|
+
write(chunk, _enc, cb) {
|
|
13
|
+
if (!muted)
|
|
14
|
+
process.stderr.write(chunk);
|
|
15
|
+
cb();
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
rl.question(question, answer => {
|
|
21
|
+
rl.close();
|
|
22
|
+
if (hidden)
|
|
23
|
+
process.stderr.write('\n');
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
rl.on('SIGINT', () => { rl.close(); process.stderr.write('\n'); reject(usageError('Cancelled.')); });
|
|
27
|
+
muted = hidden;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function input(question, fallback) {
|
|
31
|
+
const answer = await ask(`${question}${fallback ? ` (${fallback})` : ''}: `);
|
|
32
|
+
return answer || fallback || '';
|
|
33
|
+
}
|
|
34
|
+
export async function password(question) {
|
|
35
|
+
return ask(`${question}: `, { hidden: true });
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ask before something destructive. `--yes` skips the question; without a TTY and
|
|
39
|
+
* without `--yes` we refuse instead of guessing.
|
|
40
|
+
*/
|
|
41
|
+
export async function confirm(question, flagName = '--yes') {
|
|
42
|
+
if (runtime.yes)
|
|
43
|
+
return true;
|
|
44
|
+
if (!isInteractive()) {
|
|
45
|
+
throw usageError(`Refusing to ${question.replace(/\?$/, '').toLowerCase()} without confirmation.`, `Re-run with ${flagName} to confirm in non-interactive environments.`);
|
|
46
|
+
}
|
|
47
|
+
const answer = (await ask(`${question} [y/N] `)).toLowerCase();
|
|
48
|
+
return answer === 'y' || answer === 'yes';
|
|
49
|
+
}
|
|
50
|
+
/** Read all of stdin (for `auth --stdin`). */
|
|
51
|
+
export async function readStdin() {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const chunk of process.stdin)
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
56
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { usageError } from '../core/errors.js';
|
|
2
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
|
3
|
+
/** Unique placeholder names in order of first appearance — the same name always gets the same position. */
|
|
4
|
+
function placeholderOrder(text) {
|
|
5
|
+
const names = [];
|
|
6
|
+
for (const m of text.matchAll(PLACEHOLDER))
|
|
7
|
+
if (!names.includes(m[1]))
|
|
8
|
+
names.push(m[1]);
|
|
9
|
+
return names;
|
|
10
|
+
}
|
|
11
|
+
function toPositional(text, suffix) {
|
|
12
|
+
const names = placeholderOrder(text);
|
|
13
|
+
if (names.length === 0)
|
|
14
|
+
return text;
|
|
15
|
+
// A literal % must be doubled once the string is used as a format string
|
|
16
|
+
const escaped = text.replace(/%/g, '%%');
|
|
17
|
+
return escaped.replace(PLACEHOLDER, (_m, name) => `%${names.indexOf(name) + 1}$${suffix}`);
|
|
18
|
+
}
|
|
19
|
+
function fromPositional(text, suffix) {
|
|
20
|
+
const re = new RegExp(`%(\\d+)\\$${suffix.replace('@', '@')}`, 'g');
|
|
21
|
+
if (!re.test(text))
|
|
22
|
+
return text;
|
|
23
|
+
return text.replace(re, (_m, n) => `{{${n}}}`).replace(/%%/g, '%');
|
|
24
|
+
}
|
|
25
|
+
function sortEntries(entries) {
|
|
26
|
+
return [...entries].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
27
|
+
}
|
|
28
|
+
/** Rename keys for platforms with stricter identifier rules; fail loudly on collisions instead of silently dropping strings. */
|
|
29
|
+
function renameKeys(entries, rename, platform) {
|
|
30
|
+
const seen = new Map();
|
|
31
|
+
const collisions = [];
|
|
32
|
+
const out = entries.map(e => {
|
|
33
|
+
const id = rename(e.key);
|
|
34
|
+
const prev = seen.get(id);
|
|
35
|
+
if (prev !== undefined && prev !== e.key)
|
|
36
|
+
collisions.push(`"${prev}" and "${e.key}" both become "${id}"`);
|
|
37
|
+
seen.set(id, e.key);
|
|
38
|
+
return { ...e, id };
|
|
39
|
+
});
|
|
40
|
+
if (collisions.length) {
|
|
41
|
+
throw usageError(`Keys collide when converted to ${platform} names:\n ${collisions.join('\n ')}`, 'Rename one of the keys, or export with a JSON format.');
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
// ── Flat JSON ───────────────────────────────────────────────────
|
|
46
|
+
function flatten(obj, prefix = '', out = {}, bad = []) {
|
|
47
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
48
|
+
throw usageError('Expected a JSON object of translations.');
|
|
49
|
+
}
|
|
50
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
51
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
52
|
+
if (typeof v === 'string')
|
|
53
|
+
out[key] = v;
|
|
54
|
+
else if (v && typeof v === 'object' && !Array.isArray(v))
|
|
55
|
+
flatten(v, key, out, bad);
|
|
56
|
+
else
|
|
57
|
+
bad.push(`${key} (${Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v})`);
|
|
58
|
+
}
|
|
59
|
+
if (!prefix && bad.length) {
|
|
60
|
+
throw usageError(`Only string values can be imported. Not strings:\n ${bad.slice(0, 10).join('\n ')}${bad.length > 10 ? `\n …and ${bad.length - 10} more` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function parseJson(content) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(content.replace(/^\uFEFF/, ''));
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
throw usageError(`Invalid JSON: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const flatJson = {
|
|
73
|
+
id: 'json',
|
|
74
|
+
aliases: ['flat-json', 'i18n-json', 'i18next'],
|
|
75
|
+
label: 'Flat JSON { "home.title": "Welcome" }',
|
|
76
|
+
defaultOutput: 'locales/{lang}.json',
|
|
77
|
+
serialize(entries) {
|
|
78
|
+
const obj = {};
|
|
79
|
+
for (const e of sortEntries(entries))
|
|
80
|
+
obj[e.key] = e.value;
|
|
81
|
+
return JSON.stringify(obj, null, 2) + '\n';
|
|
82
|
+
},
|
|
83
|
+
parse: content => flatten(parseJson(content)),
|
|
84
|
+
};
|
|
85
|
+
// ── Nested JSON ─────────────────────────────────────────────────
|
|
86
|
+
const nestedJson = {
|
|
87
|
+
id: 'nested-json',
|
|
88
|
+
aliases: ['json-nested'],
|
|
89
|
+
label: 'Nested JSON { "home": { "title": "Welcome" } }',
|
|
90
|
+
defaultOutput: 'locales/{lang}.json',
|
|
91
|
+
serialize(entries) {
|
|
92
|
+
const root = {};
|
|
93
|
+
const conflicts = [];
|
|
94
|
+
for (const e of sortEntries(entries)) {
|
|
95
|
+
const parts = e.key.split('.');
|
|
96
|
+
let node = root;
|
|
97
|
+
let ok = true;
|
|
98
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
99
|
+
const next = node[parts[i]];
|
|
100
|
+
if (typeof next === 'string') {
|
|
101
|
+
conflicts.push(`"${e.key}" is nested under "${parts.slice(0, i + 1).join('.')}", which is also a string`);
|
|
102
|
+
ok = false;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
node = next ?? (node[parts[i]] = {});
|
|
106
|
+
}
|
|
107
|
+
const leaf = parts[parts.length - 1];
|
|
108
|
+
if (!ok)
|
|
109
|
+
continue;
|
|
110
|
+
if (node[leaf] !== undefined && typeof node[leaf] === 'object') {
|
|
111
|
+
conflicts.push(`"${e.key}" is a string but also has nested keys`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
node[leaf] = e.value;
|
|
115
|
+
}
|
|
116
|
+
if (conflicts.length) {
|
|
117
|
+
throw usageError(`These keys can't be represented as nested JSON:\n ${conflicts.join('\n ')}`, 'Use --format json (flat) or rename the keys.');
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(root, null, 2) + '\n';
|
|
120
|
+
},
|
|
121
|
+
parse: content => flatten(parseJson(content)),
|
|
122
|
+
};
|
|
123
|
+
// ── Android strings.xml ─────────────────────────────────────────
|
|
124
|
+
const androidName = (key) => {
|
|
125
|
+
const n = key.replace(/[^A-Za-z0-9_]/g, '_');
|
|
126
|
+
return /^[0-9]/.test(n) ? `_${n}` : n;
|
|
127
|
+
};
|
|
128
|
+
function escapeAndroid(text) {
|
|
129
|
+
let s = text
|
|
130
|
+
.replace(/\\/g, '\\\\')
|
|
131
|
+
.replace(/&/g, '&')
|
|
132
|
+
.replace(/</g, '<')
|
|
133
|
+
.replace(/>/g, '>')
|
|
134
|
+
.replace(/'/g, "\\'")
|
|
135
|
+
.replace(/"/g, '\\"')
|
|
136
|
+
.replace(/\n/g, '\\n')
|
|
137
|
+
.replace(/\t/g, '\\t');
|
|
138
|
+
if (/^[@?]/.test(s))
|
|
139
|
+
s = `\\${s}`;
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
function unescapeAndroid(text) {
|
|
143
|
+
return text
|
|
144
|
+
.replace(/^\\([@?])/, '$1')
|
|
145
|
+
.replace(/\\n/g, '\n')
|
|
146
|
+
.replace(/\\t/g, '\t')
|
|
147
|
+
.replace(/\\'/g, "'")
|
|
148
|
+
.replace(/\\"/g, '"')
|
|
149
|
+
.replace(/</g, '<')
|
|
150
|
+
.replace(/>/g, '>')
|
|
151
|
+
.replace(/"/g, '"')
|
|
152
|
+
.replace(/'/g, "'")
|
|
153
|
+
.replace(/&/g, '&')
|
|
154
|
+
.replace(/\\\\/g, '\\');
|
|
155
|
+
}
|
|
156
|
+
const escapeXmlComment = (s) => s.replace(/--/g, '- -');
|
|
157
|
+
const android = {
|
|
158
|
+
id: 'android',
|
|
159
|
+
aliases: ['android-xml', 'xml'],
|
|
160
|
+
label: 'Android strings.xml (res/values-xx/)',
|
|
161
|
+
defaultOutput: 'res/{android}/strings.xml',
|
|
162
|
+
serialize(entries) {
|
|
163
|
+
const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>'];
|
|
164
|
+
for (const e of renameKeys(sortEntries(entries), androidName, 'Android resource')) {
|
|
165
|
+
if (e.description)
|
|
166
|
+
lines.push(` <!-- ${escapeXmlComment(e.description)} -->`);
|
|
167
|
+
lines.push(` <string name="${e.id}">${escapeAndroid(toPositional(e.value, 's'))}</string>`);
|
|
168
|
+
}
|
|
169
|
+
lines.push('</resources>');
|
|
170
|
+
return lines.join('\n') + '\n';
|
|
171
|
+
},
|
|
172
|
+
parse(content) {
|
|
173
|
+
const out = {};
|
|
174
|
+
const re = /<string\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/string>/g;
|
|
175
|
+
for (const m of content.matchAll(re)) {
|
|
176
|
+
const raw = m[2].replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1');
|
|
177
|
+
out[m[1]] = fromPositional(unescapeAndroid(raw), 's');
|
|
178
|
+
}
|
|
179
|
+
if (!/<resources/.test(content))
|
|
180
|
+
throw usageError('Not an Android strings.xml file (no <resources> element).');
|
|
181
|
+
return out;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
// ── iOS Localizable.strings ─────────────────────────────────────
|
|
185
|
+
const escapeStrings = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
186
|
+
const unescapeStrings = (s) => s.replace(/\\(["\\nrt])/g, (_m, c) => ({ n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' }[c] ?? c));
|
|
187
|
+
const ios = {
|
|
188
|
+
id: 'ios',
|
|
189
|
+
aliases: ['ios-strings', 'strings'],
|
|
190
|
+
label: 'iOS Localizable.strings (xx.lproj/)',
|
|
191
|
+
defaultOutput: '{lang}.lproj/Localizable.strings',
|
|
192
|
+
serialize(entries) {
|
|
193
|
+
const blocks = sortEntries(entries).map(e => `${e.description ? `/* ${e.description.replace(/\*\//g, '* /')} */\n` : ''}"${escapeStrings(e.key)}" = "${escapeStrings(toPositional(e.value, '@'))}";`);
|
|
194
|
+
return blocks.join('\n') + (blocks.length ? '\n' : '');
|
|
195
|
+
},
|
|
196
|
+
parse(content) {
|
|
197
|
+
const out = {};
|
|
198
|
+
const body = content.replace(/^\uFEFF/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
199
|
+
const re = /"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"\s*;/g;
|
|
200
|
+
for (const m of body.matchAll(re))
|
|
201
|
+
out[unescapeStrings(m[1])] = fromPositional(unescapeStrings(m[2]), '@');
|
|
202
|
+
return out;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
// ── Flutter ARB ─────────────────────────────────────────────────
|
|
206
|
+
/** ARB message ids must be valid Dart identifiers: "auth.login.title" → "authLoginTitle". */
|
|
207
|
+
function arbName(key) {
|
|
208
|
+
const parts = key.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
209
|
+
const id = parts.map((p, i) => (i === 0 ? p.charAt(0).toLowerCase() + p.slice(1) : p.charAt(0).toUpperCase() + p.slice(1))).join('');
|
|
210
|
+
return /^[A-Za-z]/.test(id) ? id : `k${id}`;
|
|
211
|
+
}
|
|
212
|
+
const arb = {
|
|
213
|
+
id: 'arb',
|
|
214
|
+
aliases: ['flutter', 'flutter-arb'],
|
|
215
|
+
label: 'Flutter ARB (lib/l10n/app_xx.arb)',
|
|
216
|
+
defaultOutput: 'lib/l10n/app_{lang_}.arb',
|
|
217
|
+
serialize(entries, lang) {
|
|
218
|
+
const obj = { '@@locale': lang.replace('-', '_') };
|
|
219
|
+
for (const e of renameKeys(sortEntries(entries), arbName, 'Flutter ARB')) {
|
|
220
|
+
const names = placeholderOrder(e.value);
|
|
221
|
+
obj[e.id] = e.value.replace(PLACEHOLDER, '{$1}');
|
|
222
|
+
const meta = {};
|
|
223
|
+
if (e.description)
|
|
224
|
+
meta.description = e.description;
|
|
225
|
+
if (names.length)
|
|
226
|
+
meta.placeholders = Object.fromEntries(names.map(n => [n, { type: 'String' }]));
|
|
227
|
+
if (Object.keys(meta).length)
|
|
228
|
+
obj[`@${e.id}`] = meta;
|
|
229
|
+
}
|
|
230
|
+
return JSON.stringify(obj, null, 2) + '\n';
|
|
231
|
+
},
|
|
232
|
+
parse(content) {
|
|
233
|
+
const obj = parseJson(content);
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
236
|
+
if (k.startsWith('@'))
|
|
237
|
+
continue;
|
|
238
|
+
if (typeof v !== 'string')
|
|
239
|
+
throw usageError(`ARB entry "${k}" is not a string.`);
|
|
240
|
+
// Simple {name} placeholders → {{name}}; ICU blocks like {count, plural, …} are left as-is
|
|
241
|
+
out[k] = v.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, '{{$1}}');
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
// ── Registry ────────────────────────────────────────────────────
|
|
247
|
+
export const FORMATS = [flatJson, nestedJson, android, ios, arb];
|
|
248
|
+
export function getFormat(name) {
|
|
249
|
+
const wanted = (name || 'json').toLowerCase();
|
|
250
|
+
const found = FORMATS.find(f => f.id === wanted || f.aliases.includes(wanted));
|
|
251
|
+
if (!found) {
|
|
252
|
+
throw usageError(`Unknown format "${name}".`, `Supported: ${FORMATS.map(f => f.id).join(', ')} (see "langctl formats").`);
|
|
253
|
+
}
|
|
254
|
+
return found;
|
|
255
|
+
}
|
|
256
|
+
/** Guess a format from a file name (used by import/push when --format is omitted). */
|
|
257
|
+
export function formatFromPath(path) {
|
|
258
|
+
const lower = path.toLowerCase();
|
|
259
|
+
if (lower.endsWith('.arb'))
|
|
260
|
+
return arb;
|
|
261
|
+
if (lower.endsWith('.xml'))
|
|
262
|
+
return android;
|
|
263
|
+
if (lower.endsWith('.strings'))
|
|
264
|
+
return ios;
|
|
265
|
+
if (lower.endsWith('.json'))
|
|
266
|
+
return flatJson; // flat and nested JSON both parse to the same thing
|
|
267
|
+
throw usageError(`Can't tell the format of "${path}" from its extension.`, 'Pass --format.');
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Expand a path template for one language.
|
|
271
|
+
* {lang} pt-BR
|
|
272
|
+
* {lang_} pt_BR (Flutter / gettext style)
|
|
273
|
+
* {android} values / values-pt-rBR (default language → "values")
|
|
274
|
+
*/
|
|
275
|
+
export function expandTemplate(template, lang, defaultLang) {
|
|
276
|
+
const [base, region] = lang.split('-');
|
|
277
|
+
const androidDir = lang === defaultLang ? 'values' : `values-${base}${region ? `-r${region.toUpperCase()}` : ''}`;
|
|
278
|
+
return template
|
|
279
|
+
.replace(/\{lang_\}/g, lang.replace(/-/g, '_'))
|
|
280
|
+
.replace(/\{lang\}/g, lang)
|
|
281
|
+
.replace(/\{android\}/g, androidDir);
|
|
282
|
+
}
|
|
283
|
+
export function templateHasLanguage(template) {
|
|
284
|
+
return /\{(lang|lang_|android)\}/.test(template);
|
|
285
|
+
}
|