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
package/dist/commands/pull.js
CHANGED
|
@@ -1,139 +1,89 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
2
|
+
import { relative, resolve } from 'path';
|
|
3
|
+
import { loadProjectConfig } from '../core/config.js';
|
|
4
|
+
import { CliError, ExitCode, usageError } from '../core/errors.js';
|
|
5
|
+
import { syncFile } from '../core/files.js';
|
|
6
|
+
import { getSession } from '../core/http.js';
|
|
7
|
+
import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
8
|
+
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
9
|
+
import { expandTemplate, getFormat, templateHasLanguage } from '../formats/index.js';
|
|
10
|
+
export async function pullCommand(projectArg, opts) {
|
|
11
|
+
const loaded = loadProjectConfig();
|
|
12
|
+
const cfg = loaded?.config ?? {};
|
|
13
|
+
const slug = projectSlugFrom(projectArg);
|
|
14
|
+
const format = getFormat(opts.format ?? cfg.format);
|
|
15
|
+
// Paths from flags are relative to the cwd; paths from langctl.json are relative to that file
|
|
16
|
+
let template;
|
|
17
|
+
if (opts.output)
|
|
18
|
+
template = resolve(opts.output);
|
|
19
|
+
else if (opts.dir)
|
|
20
|
+
template = resolve(opts.dir, format.defaultOutput);
|
|
21
|
+
else if (cfg.output && loaded)
|
|
22
|
+
template = resolve(loaded.root, cfg.output);
|
|
23
|
+
else
|
|
24
|
+
template = resolve(format.defaultOutput);
|
|
25
|
+
const includeDrafts = Boolean(opts.includeDrafts || opts.publishedOnly === false || cfg.includeDrafts);
|
|
26
|
+
const module = opts.module ?? cfg.module;
|
|
27
|
+
const dryRun = Boolean(opts.check || opts.dryRun);
|
|
28
|
+
const session = await getSession();
|
|
29
|
+
const spin = spinner(`Fetching ${slug}…`);
|
|
30
|
+
let project, snapshot;
|
|
31
|
+
try {
|
|
32
|
+
project = await getProject(session, slug);
|
|
33
|
+
snapshot = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, {
|
|
34
|
+
publishedOnly: includeDrafts ? 'false' : 'true',
|
|
35
|
+
module,
|
|
36
|
+
});
|
|
28
37
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
|
|
32
|
-
process.exitCode = 1;
|
|
33
|
-
return;
|
|
38
|
+
finally {
|
|
39
|
+
spin.stop();
|
|
34
40
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const baseDir = options.dir || './translations';
|
|
40
|
-
// Validate format
|
|
41
|
-
const validFormats = ['json', 'json-nested', 'ios', 'android', 'flutter'];
|
|
42
|
-
if (!validFormats.includes(format)) {
|
|
43
|
-
console.log(chalk.red(`✗ Invalid format: ${format}`));
|
|
44
|
-
console.log(chalk.yellow(`Valid formats: ${validFormats.join(', ')}\n`));
|
|
45
|
-
return;
|
|
41
|
+
const languages = opts.languages ? splitList(opts.languages) : (cfg.languages ?? project.languages);
|
|
42
|
+
assertLanguages(project, languages);
|
|
43
|
+
if (languages.length > 1 && !templateHasLanguage(template)) {
|
|
44
|
+
throw usageError(`Output "${relative(process.cwd(), template)}" has no {lang} placeholder, so ${languages.length} languages would overwrite each other.`, 'Use a template like "locales/{lang}.json", or pick one language with --languages.');
|
|
46
45
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
46
|
+
const files = [];
|
|
47
|
+
for (const lang of languages) {
|
|
48
|
+
const entries = snapshot.keys
|
|
49
|
+
.filter(k => typeof k.translations[lang] === 'string' && k.translations[lang] !== '')
|
|
50
|
+
.map(k => ({ key: k.key, value: k.translations[lang], description: k.description }));
|
|
51
|
+
const path = expandTemplate(template, lang, project.defaultLanguage);
|
|
52
|
+
const status = syncFile(path, format.serialize(entries, lang), dryRun, text => format.parse(text, lang));
|
|
53
|
+
files.push({ path, language: lang, status, keys: entries.length, translated: entries.length, total: snapshot.keys.length });
|
|
54
|
+
}
|
|
55
|
+
const changed = files.filter(f => f.status !== 'unchanged');
|
|
56
|
+
const incomplete = files.filter(f => f.translated < f.total);
|
|
57
|
+
if (runtime.json) {
|
|
58
|
+
printJson({
|
|
59
|
+
project: project.slug,
|
|
60
|
+
format: format.id,
|
|
61
|
+
publishedOnly: !includeDrafts,
|
|
62
|
+
keys: snapshot.keys.length,
|
|
63
|
+
dryRun,
|
|
64
|
+
files: files.map(f => ({ ...f, path: relative(process.cwd(), f.path) })),
|
|
65
|
+
changed: changed.length,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const icon = f.status === 'unchanged' ? chalk.dim('=') : f.status === 'created' ? chalk.green('+') : chalk.yellow('~');
|
|
71
|
+
const verb = dryRun && f.status !== 'unchanged' ? `would be ${f.status}` : f.status;
|
|
72
|
+
const coverage = f.translated < f.total ? chalk.yellow(` ${f.translated}/${f.total} translated`) : '';
|
|
73
|
+
log.out(`${icon} ${relative(process.cwd(), f.path)} ${chalk.dim(verb)}${coverage}`);
|
|
69
74
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Export translations for each language
|
|
74
|
-
const exportedFiles = [];
|
|
75
|
-
let totalKeyCount = 0;
|
|
76
|
-
for (const language of languagesToPull) {
|
|
77
|
-
spinner.text = `Exporting ${language}...`;
|
|
78
|
-
// Fetch flat translations from the API
|
|
79
|
-
const result = await api.get(`/orgs/${orgId}/projects/${project.id}/export`, {
|
|
80
|
-
language,
|
|
81
|
-
publishedOnly: publishedOnly ? 'true' : 'false'
|
|
82
|
-
});
|
|
83
|
-
// Convert flat translations to TranslationKey array for the exporter
|
|
84
|
-
const flatTranslations = result.translations || {};
|
|
85
|
-
const translationKeys = Object.entries(flatTranslations).map(([key, value]) => ({
|
|
86
|
-
key,
|
|
87
|
-
translations: { [language]: value }
|
|
88
|
-
}));
|
|
89
|
-
if (totalKeyCount === 0) {
|
|
90
|
-
totalKeyCount = translationKeys.length;
|
|
91
|
-
}
|
|
92
|
-
const exportResult = exportTranslations(translationKeys, language, format);
|
|
93
|
-
// Determine output path
|
|
94
|
-
let outputPath;
|
|
95
|
-
if (options.output) {
|
|
96
|
-
// If custom output is specified and multiple languages, append language code
|
|
97
|
-
if (languagesToPull.length > 1) {
|
|
98
|
-
const ext = exportResult.filename.split('.').pop();
|
|
99
|
-
const base = options.output.replace(/\.[^.]+$/, '');
|
|
100
|
-
outputPath = `${base}-${language}.${ext}`;
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
outputPath = options.output;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
// Default output path: <baseDir>/<platform>/<language>.<ext>
|
|
108
|
-
const ext = exportResult.filename.split('.').pop();
|
|
109
|
-
outputPath = join(baseDir, platformDir, `${language}.${ext}`);
|
|
110
|
-
}
|
|
111
|
-
// Create directory if it doesn't exist
|
|
112
|
-
const dir = dirname(outputPath);
|
|
113
|
-
mkdirSync(dir, { recursive: true });
|
|
114
|
-
writeFileSync(outputPath, exportResult.content, 'utf-8');
|
|
115
|
-
exportedFiles.push(outputPath);
|
|
116
|
-
spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
|
|
117
|
-
// Restart spinner if more languages remain
|
|
118
|
-
if (languagesToPull.indexOf(language) < languagesToPull.length - 1) {
|
|
119
|
-
spinner.start();
|
|
120
|
-
}
|
|
75
|
+
log.info(chalk.dim(`${project.slug}: ${snapshot.keys.length} ${includeDrafts ? '' : 'published '}keys, ${languages.length} language(s), format ${format.id}`));
|
|
76
|
+
if (snapshot.keys.length === 0 && !includeDrafts) {
|
|
77
|
+
log.warn('No published keys — drafts are excluded by default. Publish keys, or pass --include-drafts.');
|
|
121
78
|
}
|
|
122
|
-
// Summary
|
|
123
|
-
console.log(chalk.hex('#10b981').bold('\n📦 Export Summary:'));
|
|
124
|
-
console.log(chalk.white(` Project: ${project.name}`));
|
|
125
|
-
console.log(chalk.white(` Format: ${format}`));
|
|
126
|
-
console.log(chalk.white(` Platform: ${platformDir}`));
|
|
127
|
-
console.log(chalk.white(` Output: ${baseDir}`));
|
|
128
|
-
console.log(chalk.white(` Keys: ${totalKeyCount}`));
|
|
129
|
-
console.log(chalk.white(` Languages: ${languagesToPull.join(', ')}`));
|
|
130
|
-
console.log(chalk.white(` Files: ${exportedFiles.length}`));
|
|
131
|
-
console.log('');
|
|
132
79
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
console.error(chalk.red(`Error: ${error.message}\n`));
|
|
136
|
-
process.exitCode = 1;
|
|
80
|
+
if (opts.requireComplete && incomplete.length) {
|
|
81
|
+
throw new CliError(`Missing translations: ${incomplete.map(f => `${f.language} (${f.total - f.translated} missing)`).join(', ')}`, ExitCode.Error);
|
|
137
82
|
}
|
|
83
|
+
if (opts.check && changed.length) {
|
|
84
|
+
throw new CliError(`${changed.length} translation file(s) are out of date.`, ExitCode.Drift, 'Run "langctl pull" and commit the result.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function splitList(value) {
|
|
88
|
+
return value.split(',').map(s => s.trim()).filter(Boolean);
|
|
138
89
|
}
|
|
139
|
-
//# sourceMappingURL=pull.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { relative, resolve } from 'path';
|
|
4
|
+
import { loadProjectConfig } from '../core/config.js';
|
|
5
|
+
import { notFoundError, usageError } from '../core/errors.js';
|
|
6
|
+
import { getSession } from '../core/http.js';
|
|
7
|
+
import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
8
|
+
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
9
|
+
import { expandTemplate, formatFromPath, getFormat, templateHasLanguage } from '../formats/index.js';
|
|
10
|
+
import { splitList } from './pull.js';
|
|
11
|
+
const CHUNK = 1000; // stay well under the API's 1 MB request body limit
|
|
12
|
+
/** Upload one language's translations. Shared by `push` and the single-file `import`. */
|
|
13
|
+
export async function uploadTranslations(session, project, language, translations, opts) {
|
|
14
|
+
const entries = Object.entries(translations);
|
|
15
|
+
const result = { language, keys: entries.length, created: 0, updated: 0, unchanged: 0, published: undefined };
|
|
16
|
+
if (entries.length === 0)
|
|
17
|
+
return result;
|
|
18
|
+
if (opts.dryRun) {
|
|
19
|
+
// Compare against the server, including drafts, without writing anything
|
|
20
|
+
const snap = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { language, publishedOnly: 'false' });
|
|
21
|
+
const all = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { publishedOnly: 'false' });
|
|
22
|
+
const existingKeys = new Set(all.keys.map(k => k.key));
|
|
23
|
+
for (const [key, value] of entries) {
|
|
24
|
+
if (!existingKeys.has(key))
|
|
25
|
+
result.created++;
|
|
26
|
+
else if (snap.translations[key] === value)
|
|
27
|
+
result.unchanged++;
|
|
28
|
+
else if (opts.overwrite || snap.translations[key] === undefined)
|
|
29
|
+
result.updated++;
|
|
30
|
+
else
|
|
31
|
+
result.unchanged++;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < entries.length; i += CHUNK) {
|
|
36
|
+
const chunk = Object.fromEntries(entries.slice(i, i + CHUNK));
|
|
37
|
+
// Import is an upsert, so retrying after a dropped connection is safe
|
|
38
|
+
const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/import`, { language, translations: chunk, overwriteExisting: Boolean(opts.overwrite), module: opts.module }, true);
|
|
39
|
+
result.created += res.created;
|
|
40
|
+
result.updated += res.updated;
|
|
41
|
+
result.unchanged += res.skipped;
|
|
42
|
+
}
|
|
43
|
+
if (opts.publish) {
|
|
44
|
+
let published = 0;
|
|
45
|
+
const names = entries.map(([k]) => k);
|
|
46
|
+
for (let i = 0; i < names.length; i += 5000) {
|
|
47
|
+
const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/bulk-publish`, { keys: names.slice(i, i + 5000), published: true }, true);
|
|
48
|
+
published += res.count;
|
|
49
|
+
}
|
|
50
|
+
result.published = published;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function readTranslationFile(path, format, lang) {
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
throw notFoundError(`File not found: ${relative(process.cwd(), path) || path}`);
|
|
57
|
+
const fmt = format ?? formatFromPath(path);
|
|
58
|
+
const translations = fmt.parse(readFileSync(path, 'utf-8'), lang);
|
|
59
|
+
if ((fmt.id === 'android' || fmt.id === 'ios') && Object.values(translations).some(v => /\{\{\d+\}\}/.test(v))) {
|
|
60
|
+
log.warn(`${relative(process.cwd(), path)}: positional placeholders (%1$s / %1$@) were imported as {{1}}, {{2}}… — named placeholders can't be recovered from ${fmt.id} files.`);
|
|
61
|
+
}
|
|
62
|
+
return translations;
|
|
63
|
+
}
|
|
64
|
+
export async function pushCommand(projectArg, opts) {
|
|
65
|
+
const loaded = loadProjectConfig();
|
|
66
|
+
const cfg = loaded?.config ?? {};
|
|
67
|
+
const slug = projectSlugFrom(projectArg);
|
|
68
|
+
const format = opts.format || cfg.format ? getFormat(opts.format ?? cfg.format) : undefined;
|
|
69
|
+
let template;
|
|
70
|
+
if (opts.input)
|
|
71
|
+
template = resolve(opts.input);
|
|
72
|
+
else if (cfg.output && loaded)
|
|
73
|
+
template = resolve(loaded.root, cfg.output);
|
|
74
|
+
else
|
|
75
|
+
template = resolve((format ?? getFormat('json')).defaultOutput);
|
|
76
|
+
const session = await getSession();
|
|
77
|
+
const spin = spinner(`Fetching ${slug}…`);
|
|
78
|
+
let project;
|
|
79
|
+
try {
|
|
80
|
+
project = await getProject(session, slug);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
spin.stop();
|
|
84
|
+
}
|
|
85
|
+
// Default: push only the source language, so translators' work in other languages isn't overwritten
|
|
86
|
+
const explicit = Boolean(opts.languages);
|
|
87
|
+
const languages = opts.languages === 'all' ? project.languages
|
|
88
|
+
: opts.languages ? splitList(opts.languages)
|
|
89
|
+
: [cfg.sourceLanguage ?? project.defaultLanguage];
|
|
90
|
+
assertLanguages(project, languages);
|
|
91
|
+
if (languages.length > 1 && !templateHasLanguage(template)) {
|
|
92
|
+
throw usageError(`Input "${relative(process.cwd(), template)}" has no {lang} placeholder but ${languages.length} languages were requested.`);
|
|
93
|
+
}
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const lang of languages) {
|
|
96
|
+
const path = expandTemplate(template, lang, project.defaultLanguage);
|
|
97
|
+
if (!existsSync(path)) {
|
|
98
|
+
if (explicit)
|
|
99
|
+
throw notFoundError(`File not found for ${lang}: ${relative(process.cwd(), path)}`);
|
|
100
|
+
throw notFoundError(`Source file not found: ${relative(process.cwd(), path)}`, 'Run "langctl pull" first, or set "output" in langctl.json / pass --input.');
|
|
101
|
+
}
|
|
102
|
+
const translations = readTranslationFile(path, format, lang);
|
|
103
|
+
const s = spinner(`${opts.dryRun ? 'Checking' : 'Uploading'} ${lang} (${Object.keys(translations).length} keys)…`);
|
|
104
|
+
try {
|
|
105
|
+
const r = await uploadTranslations(session, project, lang, translations, { ...opts, module: opts.module ?? cfg.module });
|
|
106
|
+
results.push({ ...r, file: relative(process.cwd(), path) });
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
s.stop();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (runtime.json) {
|
|
113
|
+
printJson({ project: project.slug, dryRun: Boolean(opts.dryRun), overwrite: Boolean(opts.overwrite), results });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const r of results) {
|
|
117
|
+
const parts = [`${chalk.green(`${r.created} new`)}`, `${chalk.yellow(`${r.updated} updated`)}`, chalk.dim(`${r.unchanged} unchanged`)];
|
|
118
|
+
if (r.published !== undefined)
|
|
119
|
+
parts.push(chalk.cyan(`${r.published} published`));
|
|
120
|
+
log.out(`${r.language} ${r.file} ${parts.join(', ')}${opts.dryRun ? chalk.dim(' (dry run)') : ''}`);
|
|
121
|
+
}
|
|
122
|
+
if (!opts.overwrite && results.some(r => r.unchanged > 0)) {
|
|
123
|
+
log.info(chalk.dim('Existing translations are never overwritten unless you pass --overwrite.'));
|
|
124
|
+
}
|
|
125
|
+
}
|