@localheroai/cli 0.0.2 → 0.0.5

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.
@@ -0,0 +1,278 @@
1
+ import path from 'path';
2
+ import { flattenTranslations, parseFile } from './files.js';
3
+
4
+ export function findMissingTranslations(sourceKeys, targetKeys) {
5
+ const missingKeys = {};
6
+ const skippedKeys = {};
7
+
8
+ for (const [key, details] of Object.entries(sourceKeys)) {
9
+ if (typeof details === 'string') {
10
+ if (
11
+ details.toLowerCase().includes('wip_') ||
12
+ details.toLowerCase().includes('_wip') ||
13
+ details.toLowerCase().includes('__skip_translation__')
14
+ ) {
15
+ skippedKeys[key] = {
16
+ value: details,
17
+ reason: 'wip'
18
+ };
19
+ continue;
20
+ }
21
+
22
+ if (!targetKeys[key]) {
23
+ missingKeys[key] = {
24
+ value: details,
25
+ sourceKey: key
26
+ };
27
+ }
28
+ continue;
29
+ }
30
+
31
+ if (typeof details === 'boolean') {
32
+ if (!targetKeys[key]) {
33
+ missingKeys[key] = {
34
+ value: details,
35
+ sourceKey: key
36
+ };
37
+ }
38
+ continue;
39
+ }
40
+
41
+ if (
42
+ typeof details === 'object' && details !== null &&
43
+ typeof details.value === 'string' &&
44
+ (details.value.toLowerCase().includes('wip_') || details.value.toLowerCase().includes('_wip') ||
45
+ details.value.toLowerCase().includes('__skip_translation__'))
46
+ ) {
47
+ skippedKeys[key] = {
48
+ ...details,
49
+ reason: 'wip'
50
+ };
51
+ continue;
52
+ }
53
+
54
+ if (!targetKeys[key]) {
55
+ if (typeof details === 'object' && details !== null && 'value' in details) {
56
+ missingKeys[key] = {
57
+ ...details,
58
+ sourceKey: key
59
+ };
60
+ } else {
61
+ missingKeys[key] = {
62
+ value: details,
63
+ sourceKey: key
64
+ };
65
+ }
66
+ }
67
+ }
68
+
69
+ return { missingKeys, skippedKeys };
70
+ }
71
+
72
+
73
+ export function batchKeysWithMissing(sourceFiles, missingByLocale, batchSize = 100) {
74
+ const batches = [];
75
+ const errors = [];
76
+ const sourceFileEntries = new Map();
77
+
78
+ for (const [locale, localeData] of Object.entries(missingByLocale)) {
79
+ const sourceFile = sourceFiles.find(f => f.path === localeData.path);
80
+ if (!sourceFile) {
81
+ errors.push({
82
+ type: 'missing_source_file',
83
+ message: `No source file found for path: ${localeData.path}`,
84
+ locale,
85
+ path: localeData.path
86
+ });
87
+ continue;
88
+ }
89
+
90
+ if (!sourceFileEntries.has(sourceFile.path)) {
91
+ sourceFileEntries.set(sourceFile.path, {
92
+ path: sourceFile.path,
93
+ format: sourceFile.format || 'json',
94
+ keys: {},
95
+ locales: new Set()
96
+ });
97
+ }
98
+
99
+ const entry = sourceFileEntries.get(sourceFile.path);
100
+
101
+ const formattedKeys = {};
102
+ for (const [key, value] of Object.entries(localeData.keys)) {
103
+ let extractedValue;
104
+
105
+ if (Array.isArray(value)) {
106
+ extractedValue = value;
107
+ } else if (typeof value === 'boolean') {
108
+ extractedValue = value;
109
+ } else if (typeof value === 'string') {
110
+ extractedValue = value;
111
+ } else if (typeof value === 'object' && value !== null) {
112
+ if ('value' in value) {
113
+ extractedValue = value.value;
114
+ } else if (Object.keys(value).some(k => !isNaN(parseInt(k, 10)))) {
115
+ extractedValue = Object.values(value).join('');
116
+ } else {
117
+ extractedValue = JSON.stringify(value);
118
+ }
119
+ } else {
120
+ extractedValue = String(value);
121
+ }
122
+
123
+ formattedKeys[key] = extractedValue;
124
+ }
125
+
126
+ entry.keys = { ...entry.keys, ...formattedKeys };
127
+ entry.locales.add(locale);
128
+ }
129
+
130
+ for (const entry of sourceFileEntries.values()) {
131
+ const keyEntries = Object.entries(entry.keys);
132
+
133
+ for (let i = 0; i < keyEntries.length; i += batchSize) {
134
+ const batchKeys = Object.fromEntries(keyEntries.slice(i, i + batchSize));
135
+
136
+ const contentObj = { keys: {} };
137
+ for (const [key, value] of Object.entries(batchKeys)) {
138
+ contentObj.keys[key] = {
139
+ value
140
+ };
141
+ }
142
+
143
+ batches.push({
144
+ files: [{
145
+ path: entry.path,
146
+ format: entry.format,
147
+ content: Buffer.from(JSON.stringify(contentObj)).toString('base64')
148
+ }],
149
+ locales: Array.from(entry.locales)
150
+ });
151
+ }
152
+ }
153
+
154
+ return { batches, errors };
155
+ }
156
+
157
+ export function findTargetFile(targetFiles, targetLocale, sourceFile, sourceLocale) {
158
+ // First try exact directory matching (existing logic)
159
+ let found = targetFiles.find(f =>
160
+ f.locale === targetLocale &&
161
+ path.dirname(f.path) === path.dirname(sourceFile.path) &&
162
+ path.basename(f.path, path.extname(f.path)) === path.basename(sourceFile.path, path.extname(sourceFile.path)).replace(sourceLocale, targetLocale)
163
+ );
164
+
165
+ if (found) return found;
166
+
167
+ // Then try filename-based matching regardless of directory (existing logic)
168
+ found = targetFiles.find(f =>
169
+ f.locale === targetLocale &&
170
+ path.basename(f.path, path.extname(f.path)) === path.basename(sourceFile.path, path.extname(sourceFile.path)).replace(sourceLocale, targetLocale)
171
+ );
172
+
173
+ if (found) return found;
174
+
175
+ const sourceDirParts = path.dirname(sourceFile.path).split(path.sep);
176
+ const sourceFileBaseName = path.basename(sourceFile.path, path.extname(sourceFile.path));
177
+
178
+ // Check for corresponding file in subdirectories or parent directories
179
+ return targetFiles.find(f => {
180
+ if (f.locale !== targetLocale) return false;
181
+
182
+ // Handle cases where files are in different subdirectories
183
+ const targetDirParts = path.dirname(f.path).split(path.sep);
184
+ const targetFileBaseName = path.basename(f.path, path.extname(f.path));
185
+
186
+ if (
187
+ sourceFileBaseName === sourceLocale &&
188
+ targetFileBaseName === targetLocale &&
189
+ sourceDirParts.length === targetDirParts.length
190
+ ) {
191
+ return true;
192
+ }
193
+
194
+ // Nested directory structure
195
+ if (sourceDirParts.includes(sourceLocale) && targetDirParts.includes(targetLocale)) {
196
+ const sourceBasePath = sourceDirParts.slice(0, sourceDirParts.indexOf(sourceLocale)).join(path.sep);
197
+ const targetBasePath = targetDirParts.slice(0, targetDirParts.indexOf(targetLocale)).join(path.sep);
198
+
199
+ return sourceBasePath === targetBasePath &&
200
+ sourceFileBaseName === targetFileBaseName;
201
+ }
202
+
203
+ return false;
204
+ });
205
+ }
206
+
207
+ export function generateTargetPath(sourceFile, targetLocale, sourceLocale) {
208
+ const sourceExt = path.extname(sourceFile.path);
209
+ const sourceDir = path.dirname(sourceFile.path);
210
+ const sourceName = path.basename(sourceFile.path, sourceExt);
211
+
212
+ // Case 1: File is named exactly as the source locale (e.g., "en.yml")
213
+ if (sourceName === sourceLocale) {
214
+ return path.join(sourceDir, `${targetLocale}${sourceExt}`);
215
+ }
216
+
217
+ // Case 2: File ends with .locale (e.g., "translations.en.yml")
218
+ if (sourceName.endsWith(`.${sourceLocale}`)) {
219
+ const baseName = sourceName.slice(0, -(sourceLocale.length + 1));
220
+ return path.join(sourceDir, `${baseName}.${targetLocale}${sourceExt}`);
221
+ }
222
+
223
+ // Case 3: File uses hyphen-locale format (e.g., "translations-en.yml")
224
+ if (sourceName.includes(`-${sourceLocale}`)) {
225
+ const baseName = sourceName.slice(0, -(sourceLocale.length + 1));
226
+ return path.join(sourceDir, `${baseName}-${targetLocale}${sourceExt}`);
227
+ }
228
+
229
+ // Case 4: Source locale is a directory name
230
+ const sourceParentDir = path.basename(sourceDir);
231
+ if (sourceParentDir === sourceLocale) {
232
+ const grandParentDir = path.dirname(sourceDir);
233
+ return path.join(grandParentDir, targetLocale, path.basename(sourceFile.path));
234
+ }
235
+
236
+ // Default case: If none of the above patterns match,
237
+ // construct the target path by replacing the locale in the filename only
238
+ const dirPath = path.dirname(sourceFile.path);
239
+ const fileName = path.basename(sourceFile.path);
240
+ const localeRegex = new RegExp(`\\b${sourceLocale}\\b`, 'g');
241
+ const newFileName = fileName.replace(localeRegex, targetLocale);
242
+ return path.join(dirPath, newFileName);
243
+ }
244
+
245
+ export function processTargetContent(targetContent, targetLocale) {
246
+ if (targetContent[targetLocale]) {
247
+ return flattenTranslations(targetContent[targetLocale]);
248
+ }
249
+ return flattenTranslations(targetContent);
250
+ }
251
+
252
+ export function processLocaleTranslations(sourceKeys, targetLocale, targetFiles, sourceFile, sourceLocale) {
253
+ try {
254
+ const targetFile = findTargetFile(targetFiles, targetLocale, sourceFile, sourceLocale);
255
+ let targetKeys = {};
256
+ let targetPath = '';
257
+
258
+ if (targetFile) {
259
+ const targetContentRaw = Buffer.from(targetFile.content, 'base64').toString();
260
+ const targetContent = parseFile(targetContentRaw, targetFile.format);
261
+ targetKeys = processTargetContent(targetContent, targetLocale);
262
+ targetPath = targetFile.path;
263
+ } else {
264
+ targetPath = generateTargetPath(sourceFile, targetLocale, sourceLocale);
265
+ }
266
+
267
+ const { missingKeys, skippedKeys } = findMissingTranslations(sourceKeys, targetKeys);
268
+
269
+ return {
270
+ targetPath,
271
+ missingKeys,
272
+ skippedKeys,
273
+ targetFile
274
+ };
275
+ } catch (error) {
276
+ throw new Error(`Failed to process translations for ${targetLocale}: ${error.message}`);
277
+ }
278
+ }
@@ -1,7 +0,0 @@
1
- import inquirer from 'inquirer';
2
-
3
- export const defaultDependencies = {
4
- inquirer,
5
- console: global.console,
6
- basePath: process.cwd(),
7
- };
@@ -1,3 +0,0 @@
1
- export function sleep(ms) {
2
- return new Promise(resolve => setTimeout(resolve, ms));
3
- }
@@ -1,11 +0,0 @@
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
- };
@@ -1,154 +0,0 @@
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
- }