langctl 0.2.0 → 0.4.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 +53 -0
- package/README.md +158 -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 +86 -129
- package/dist/commands/push.js +125 -0
- package/dist/commands/review.js +65 -0
- package/dist/commands/team.js +68 -257
- package/dist/commands/translate.js +99 -0
- package/dist/core/cache.js +48 -0
- 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 +156 -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 +277 -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,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
|
+
}
|