@localheroai/cli 0.0.1
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/LICENSE +21 -0
- package/package.json +62 -0
- package/src/api/auth.js +15 -0
- package/src/api/client.js +42 -0
- package/src/api/imports.js +20 -0
- package/src/api/projects.js +24 -0
- package/src/api/translations.js +37 -0
- package/src/cli.js +71 -0
- package/src/commands/init.js +285 -0
- package/src/commands/login.js +69 -0
- package/src/commands/translate.js +282 -0
- package/src/utils/auth.js +23 -0
- package/src/utils/config.js +96 -0
- package/src/utils/defaults.js +7 -0
- package/src/utils/files.js +139 -0
- package/src/utils/git.js +16 -0
- package/src/utils/github.js +65 -0
- package/src/utils/helpers.js +3 -0
- package/src/utils/import-service.js +146 -0
- package/src/utils/project-service.js +11 -0
- package/src/utils/prompt-service.js +51 -0
- package/src/utils/translation-updater.js +154 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { createImport, checkImportStatus } from '../api/imports.js';
|
|
5
|
+
|
|
6
|
+
function getFileFormat(filePath) {
|
|
7
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
8
|
+
if (ext === '.json') return 'json';
|
|
9
|
+
if (ext === '.yml' || ext === '.yaml') return 'yaml';
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function readFileContent(filePath) {
|
|
14
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
15
|
+
return Buffer.from(content).toString('base64');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getLanguageFromPath(filePath, sourceLocale) {
|
|
19
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
20
|
+
return fileName === sourceLocale ? sourceLocale : fileName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const importService = {
|
|
24
|
+
async findTranslationFiles(config, basePath = process.cwd()) {
|
|
25
|
+
const { translationFiles, sourceLocale } = config;
|
|
26
|
+
const { paths, ignore = [] } = translationFiles;
|
|
27
|
+
|
|
28
|
+
const allFiles = [];
|
|
29
|
+
for (const translationPath of paths) {
|
|
30
|
+
const fullPath = path.join(basePath, translationPath);
|
|
31
|
+
const pattern = path.join(fullPath, '**/*.{json,yml,yaml}');
|
|
32
|
+
|
|
33
|
+
const files = await glob(pattern, {
|
|
34
|
+
ignore: ignore.map(p => path.join(basePath, p)),
|
|
35
|
+
nodir: true
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
allFiles.push(...files);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return allFiles.map(file => ({
|
|
42
|
+
path: path.relative(basePath, file),
|
|
43
|
+
language: getLanguageFromPath(file, sourceLocale),
|
|
44
|
+
format: getFileFormat(file)
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async importTranslations(config, basePath = process.cwd()) {
|
|
49
|
+
const files = await this.findTranslationFiles(config, basePath);
|
|
50
|
+
|
|
51
|
+
if (!files.length) {
|
|
52
|
+
return { status: 'no_files' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sourceFiles = files.filter(file => file.language === config.sourceLocale);
|
|
56
|
+
const targetFiles = files.filter(file => file.language !== config.sourceLocale);
|
|
57
|
+
const importedFiles = {
|
|
58
|
+
source: sourceFiles,
|
|
59
|
+
target: targetFiles
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (!sourceFiles.length) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'failed',
|
|
65
|
+
error: 'No source language files found. Source language files must be included in the first import.',
|
|
66
|
+
files: importedFiles
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// First import: source language files
|
|
71
|
+
const sourceTranslations = await Promise.all(
|
|
72
|
+
sourceFiles.map(async file => ({
|
|
73
|
+
language: file.language,
|
|
74
|
+
format: file.format,
|
|
75
|
+
filename: file.path,
|
|
76
|
+
content: await readFileContent(path.join(basePath, file.path))
|
|
77
|
+
}))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const sourceImport = await createImport({
|
|
81
|
+
projectId: config.projectId,
|
|
82
|
+
translations: sourceTranslations
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (sourceImport.status === 'failed') {
|
|
86
|
+
return sourceImport;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let finalSourceImport = sourceImport;
|
|
90
|
+
while (finalSourceImport.status === 'processing') {
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, finalSourceImport.poll_interval * 1000));
|
|
92
|
+
finalSourceImport = await checkImportStatus(config.projectId, finalSourceImport.id);
|
|
93
|
+
|
|
94
|
+
if (finalSourceImport.status === 'failed') {
|
|
95
|
+
return finalSourceImport;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!targetFiles.length) {
|
|
100
|
+
return { ...finalSourceImport, files: importedFiles };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Second import: target language files
|
|
104
|
+
const targetTranslations = await Promise.all(
|
|
105
|
+
targetFiles.map(async file => ({
|
|
106
|
+
language: file.language,
|
|
107
|
+
format: file.format,
|
|
108
|
+
filename: file.path,
|
|
109
|
+
content: await readFileContent(path.join(basePath, file.path))
|
|
110
|
+
}))
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const targetImport = await createImport({
|
|
114
|
+
projectId: config.projectId,
|
|
115
|
+
translations: targetTranslations
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (targetImport.status === 'completed') {
|
|
119
|
+
return {
|
|
120
|
+
...targetImport,
|
|
121
|
+
sourceImport: finalSourceImport,
|
|
122
|
+
files: importedFiles
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let finalTargetImport = targetImport;
|
|
127
|
+
while (finalTargetImport.status === 'processing') {
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, finalTargetImport.poll_interval * 1000));
|
|
129
|
+
finalTargetImport = await checkImportStatus(config.projectId, finalTargetImport.id);
|
|
130
|
+
|
|
131
|
+
if (finalTargetImport.status !== 'processing') {
|
|
132
|
+
return {
|
|
133
|
+
...finalTargetImport,
|
|
134
|
+
sourceImport: finalSourceImport,
|
|
135
|
+
files: importedFiles
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...finalTargetImport,
|
|
142
|
+
sourceImport: finalSourceImport,
|
|
143
|
+
files: importedFiles
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createProject as apiCreateProject, listProjects as apiListProjects } from '../api/projects.js';
|
|
2
|
+
|
|
3
|
+
export const defaultProjectService = {
|
|
4
|
+
async createProject({ name, sourceLocale, targetLocales }) {
|
|
5
|
+
return apiCreateProject({ name, sourceLocale, targetLocales });
|
|
6
|
+
},
|
|
7
|
+
|
|
8
|
+
async listProjects() {
|
|
9
|
+
return apiListProjects();
|
|
10
|
+
}
|
|
11
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
export function createPromptService(deps = {}) {
|
|
4
|
+
const { inquirer = null } = deps;
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
async getApiKey() {
|
|
8
|
+
if (!inquirer) return '';
|
|
9
|
+
return inquirer.password({
|
|
10
|
+
message: 'API Key:',
|
|
11
|
+
mask: '*'
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async getProjectSetup() {
|
|
16
|
+
if (!inquirer) return {};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
projectName: '',
|
|
20
|
+
sourceLocale: '',
|
|
21
|
+
outputLocales: [],
|
|
22
|
+
translationPath: '',
|
|
23
|
+
ignorePaths: []
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async confirmLogin() {
|
|
28
|
+
if (!inquirer) return { shouldLogin: false };
|
|
29
|
+
const result = await inquirer.confirm({
|
|
30
|
+
message: 'Would you like to login now?',
|
|
31
|
+
default: true
|
|
32
|
+
});
|
|
33
|
+
return { shouldLogin: result };
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async select(options) {
|
|
37
|
+
if (!inquirer) return 'new';
|
|
38
|
+
return inquirer.select(options);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async input(options) {
|
|
42
|
+
if (!inquirer) return '';
|
|
43
|
+
return inquirer.input(options);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async confirm(options) {
|
|
47
|
+
if (!inquirer) return false;
|
|
48
|
+
return inquirer.confirm(options);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'yaml';
|
|
4
|
+
|
|
5
|
+
function getExistingQuoteStyles(content) {
|
|
6
|
+
const styles = new Map();
|
|
7
|
+
|
|
8
|
+
// Pre-split lines and get non-empty, non-comment lines
|
|
9
|
+
const lines = content.match(/[^\n]+/g) || [];
|
|
10
|
+
let currentPath = new Array(10); // Pre-allocate array with reasonable size
|
|
11
|
+
let pathLength = 0;
|
|
12
|
+
|
|
13
|
+
// Regex patterns - compile once
|
|
14
|
+
const indentRegex = /^\s*/;
|
|
15
|
+
const keyValueRegex = /^([^:]+):\s*(.*)$/;
|
|
16
|
+
const doubleQuoteRegex = /^"(.*)"$/;
|
|
17
|
+
const singleQuoteRegex = /^'(.*)'$/;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < lines.length; i++) {
|
|
20
|
+
const line = lines[i];
|
|
21
|
+
if (!line || line.trim().startsWith('#')) continue;
|
|
22
|
+
|
|
23
|
+
// Calculate indent level
|
|
24
|
+
const indent = line.match(indentRegex)[0].length;
|
|
25
|
+
const level = indent >> 1; // Divide by 2 using bit shift
|
|
26
|
+
|
|
27
|
+
// Adjust current path
|
|
28
|
+
pathLength = level;
|
|
29
|
+
|
|
30
|
+
// Extract key and value
|
|
31
|
+
const match = line.trim().match(keyValueRegex);
|
|
32
|
+
if (match) {
|
|
33
|
+
const key = match[1].trim();
|
|
34
|
+
const value = match[2];
|
|
35
|
+
|
|
36
|
+
currentPath[level] = key;
|
|
37
|
+
|
|
38
|
+
// Only process if there's a value
|
|
39
|
+
if (value) {
|
|
40
|
+
// Build path string only when needed
|
|
41
|
+
const fullPath = currentPath.slice(0, pathLength + 1).join('.');
|
|
42
|
+
|
|
43
|
+
// Check quote style
|
|
44
|
+
const valueTrimed = value.trim();
|
|
45
|
+
const hasDoubleQuotes = doubleQuoteRegex.test(valueTrimed);
|
|
46
|
+
const hasSingleQuotes = !hasDoubleQuotes && singleQuoteRegex.test(valueTrimed);
|
|
47
|
+
|
|
48
|
+
if (hasDoubleQuotes || hasSingleQuotes || valueTrimed) {
|
|
49
|
+
styles.set(fullPath, {
|
|
50
|
+
quoted: hasDoubleQuotes || hasSingleQuotes,
|
|
51
|
+
quoteType: hasDoubleQuotes ? '"' : (hasSingleQuotes ? "'" : ''),
|
|
52
|
+
originalValue: valueTrimed
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return styles;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cache for repeated string operations
|
|
63
|
+
const SPECIAL_CHARS_REGEX = /[:@#,\[\]{}?|>&*!\n]/;
|
|
64
|
+
const INTERPOLATION = '%{';
|
|
65
|
+
const INDENT_CACHE = new Map();
|
|
66
|
+
|
|
67
|
+
function getIndent(level) {
|
|
68
|
+
let indent = INDENT_CACHE.get(level);
|
|
69
|
+
if (!indent) {
|
|
70
|
+
indent = ' '.repeat(level);
|
|
71
|
+
INDENT_CACHE.set(level, indent);
|
|
72
|
+
}
|
|
73
|
+
return indent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stringifyYaml(obj, indent = 0, parentPath = '', result = []) {
|
|
77
|
+
const indentStr = getIndent(indent);
|
|
78
|
+
|
|
79
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
80
|
+
const currentPath = parentPath ? `${parentPath}.${key}` : key;
|
|
81
|
+
|
|
82
|
+
if (value && typeof value === 'object') {
|
|
83
|
+
result.push(`${indentStr}${key}:`);
|
|
84
|
+
stringifyYaml(value, indent + 2, currentPath, result);
|
|
85
|
+
} else {
|
|
86
|
+
let formattedValue = value;
|
|
87
|
+
|
|
88
|
+
if (typeof value === 'string') {
|
|
89
|
+
const existingStyle = existingStyles.get(currentPath);
|
|
90
|
+
|
|
91
|
+
if (existingStyle?.quoted) {
|
|
92
|
+
formattedValue = `${existingStyle.quoteType}${value}${existingStyle.quoteType}`;
|
|
93
|
+
} else if (existingStyle?.originalValue === value) {
|
|
94
|
+
formattedValue = existingStyle.originalValue;
|
|
95
|
+
} else if (value.includes(INTERPOLATION) || SPECIAL_CHARS_REGEX.test(value)) {
|
|
96
|
+
formattedValue = `"${value}"`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
result.push(`${indentStr}${key}: ${formattedValue}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Store styles globally to avoid passing as parameter
|
|
108
|
+
let existingStyles;
|
|
109
|
+
|
|
110
|
+
export async function updateTranslationFile(filePath, translations, languageCode) {
|
|
111
|
+
try {
|
|
112
|
+
if (path.extname(filePath).slice(1) === 'json') {
|
|
113
|
+
// Handle JSON (existing code)
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let existingContent = '';
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
existingContent = await fs.readFile(filePath, 'utf8');
|
|
121
|
+
existingStyles = getExistingQuoteStyles(existingContent);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.warn(`Creating new file: ${filePath}`);
|
|
124
|
+
existingStyles = new Map();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse existing content
|
|
128
|
+
const yamlContent = yaml.parse(existingContent) || {};
|
|
129
|
+
yamlContent[languageCode] = yamlContent[languageCode] || {};
|
|
130
|
+
|
|
131
|
+
// Update translations efficiently
|
|
132
|
+
for (const [keyPath, newValue] of Object.entries(translations)) {
|
|
133
|
+
const keys = keyPath.split('.');
|
|
134
|
+
let current = yamlContent[languageCode];
|
|
135
|
+
const lastIndex = keys.length - 1;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < lastIndex; i++) {
|
|
138
|
+
const key = keys[i];
|
|
139
|
+
current[key] = current[key] || {};
|
|
140
|
+
current = current[key];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
current[keys[lastIndex]] = newValue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const content = stringifyYaml(yamlContent);
|
|
147
|
+
|
|
148
|
+
await fs.writeFile(filePath, content.join('\n'));
|
|
149
|
+
return Object.keys(translations);
|
|
150
|
+
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new Error(`Failed to update translation file ${filePath}: ${error.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|