@localheroai/cli 0.0.2 → 0.0.3

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,44 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+
4
+ export const SPECIAL_CHARS_REGEX = /[:@#,[\]{}?|>&*!\n]/;
5
+ export const INTERPOLATION = '%{';
6
+ export const MAX_ARRAY_LENGTH = 1000;
7
+
8
+ export async function fileExists(filePath) {
9
+ try {
10
+ await fs.access(filePath);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function ensureDirectoryExists(filePath) {
18
+ const dir = path.dirname(filePath);
19
+ if (dir !== '.') {
20
+ await fs.mkdir(dir, { recursive: true });
21
+ }
22
+ }
23
+
24
+ export function tryParseJsonArray(value) {
25
+ if (typeof value !== 'string' || !value.startsWith('["') || !value.endsWith('"]')) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ const parsed = JSON.parse(value);
31
+ if (!Array.isArray(parsed)) {
32
+ return null;
33
+ }
34
+
35
+ if (parsed.length > MAX_ARRAY_LENGTH) {
36
+ console.warn(`Array length ${parsed.length} exceeds maximum allowed length of ${MAX_ARRAY_LENGTH}`);
37
+ return null;
38
+ }
39
+
40
+ return parsed;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'path';
2
+ import { fileExists, ensureDirectoryExists } from './common.js';
3
+ import { updateYamlFile, deleteKeysFromYamlFile } from './yaml-handler.js';
4
+ import { updateJsonFile, deleteKeysFromJsonFile } from './json-handler.js';
5
+
6
+ export async function updateTranslationFile(filePath, translations, languageCode = 'en') {
7
+ const fileExt = path.extname(filePath).slice(1).toLowerCase();
8
+ const result = {
9
+ updatedKeys: Object.keys(translations),
10
+ created: false
11
+ };
12
+
13
+ await ensureDirectoryExists(filePath);
14
+
15
+ if (fileExt === 'json') {
16
+ const jsonResult = await updateJsonFile(filePath, translations, languageCode);
17
+ return {
18
+ updatedKeys: result.updatedKeys,
19
+ created: jsonResult.created
20
+ };
21
+ }
22
+
23
+ return updateYamlFile(filePath, translations, languageCode);
24
+ }
25
+
26
+ export async function deleteKeysFromTranslationFile(filePath, keysToDelete, languageCode = 'en') {
27
+ const exists = await fileExists(filePath);
28
+ if (!exists) {
29
+ return [];
30
+ }
31
+
32
+ const fileExt = path.extname(filePath).slice(1).toLowerCase();
33
+ return fileExt === 'json'
34
+ ? deleteKeysFromJsonFile(filePath, keysToDelete, languageCode)
35
+ : deleteKeysFromYamlFile(filePath, keysToDelete, languageCode);
36
+ }
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { detectJsonFormat, preserveJsonStructure } from '../files.js';
4
+ import { ensureDirectoryExists } from './common.js';
5
+
6
+ export async function updateJsonFile(filePath, translations, languageCode) {
7
+ try {
8
+ let existingContent = {};
9
+ let jsonFormat = 'nested';
10
+ let hasLanguageWrapper = false;
11
+ const result = {
12
+ updatedKeys: Object.keys(translations),
13
+ created: false
14
+ };
15
+
16
+ try {
17
+ const content = await fs.readFile(filePath, 'utf8');
18
+ existingContent = JSON.parse(content);
19
+ if (existingContent[languageCode] && typeof existingContent[languageCode] === 'object') {
20
+ hasLanguageWrapper = true;
21
+ jsonFormat = detectJsonFormat(existingContent[languageCode]);
22
+ } else {
23
+ jsonFormat = detectJsonFormat(existingContent);
24
+ }
25
+ } catch {
26
+ console.warn(`Creating new JSON file: ${filePath}`);
27
+ result.created = true;
28
+ await ensureDirectoryExists(filePath);
29
+ }
30
+
31
+ let updatedContent;
32
+
33
+ if (result.created) {
34
+ updatedContent = {
35
+ [languageCode]: preserveJsonStructure({}, translations, jsonFormat)
36
+ };
37
+ } else if (hasLanguageWrapper) {
38
+ existingContent[languageCode] = existingContent[languageCode] || {};
39
+ updatedContent = JSON.parse(JSON.stringify(existingContent));
40
+ const mergedContent = preserveJsonStructure(
41
+ existingContent[languageCode],
42
+ translations,
43
+ jsonFormat
44
+ );
45
+ updatedContent[languageCode] = mergedContent;
46
+ } else {
47
+ const existingCopy = JSON.parse(JSON.stringify(existingContent));
48
+ updatedContent = preserveJsonStructure(existingCopy, translations, jsonFormat);
49
+ }
50
+
51
+ await fs.writeFile(filePath, JSON.stringify(updatedContent, null, 2));
52
+ return result;
53
+ } catch (error) {
54
+ throw new Error(`Failed to update JSON file ${filePath}: ${error.message}`);
55
+ }
56
+ }
57
+
58
+ export async function deleteKeysFromJsonFile(filePath, keysToDelete, languageCode) {
59
+ try {
60
+ const content = await fs.readFile(filePath, 'utf8');
61
+ let jsonContent = JSON.parse(content);
62
+ let hasLanguageWrapper = false;
63
+ let rootContent = jsonContent;
64
+ if (jsonContent[languageCode] && typeof jsonContent[languageCode] === 'object') {
65
+ hasLanguageWrapper = true;
66
+ rootContent = jsonContent[languageCode];
67
+ }
68
+
69
+ const deletedKeys = [];
70
+
71
+ for (const keyPath of keysToDelete) {
72
+ const keys = keyPath.split('.');
73
+ const lastIndex = keys.length - 1;
74
+ let current = rootContent;
75
+ let parent = null;
76
+ let keyInParent = '';
77
+ let found = true;
78
+
79
+ for (let i = 0; i < lastIndex; i++) {
80
+ const key = keys[i];
81
+ if (!current[key] || typeof current[key] !== 'object') {
82
+ found = false;
83
+ break;
84
+ }
85
+ parent = current;
86
+ keyInParent = key;
87
+ current = current[key];
88
+ }
89
+
90
+ if (found) {
91
+ const lastKey = keys[lastIndex];
92
+ if (current[lastKey] !== undefined) {
93
+ delete current[lastKey];
94
+ deletedKeys.push(keyPath);
95
+ if (parent && Object.keys(current).length === 0) {
96
+ delete parent[keyInParent];
97
+ }
98
+ }
99
+ }
100
+ }
101
+ if (hasLanguageWrapper) {
102
+ jsonContent[languageCode] = rootContent;
103
+ } else {
104
+ jsonContent = rootContent;
105
+ }
106
+ await fs.writeFile(filePath, JSON.stringify(jsonContent, null, 2));
107
+
108
+ return deletedKeys;
109
+ } catch (error) {
110
+ throw new Error(`Failed to delete keys from JSON file ${filePath}: ${error.message}`);
111
+ }
112
+ }
@@ -0,0 +1,181 @@
1
+ import { promises as fs } from 'fs';
2
+ import yaml from 'yaml';
3
+ import { SPECIAL_CHARS_REGEX, INTERPOLATION, fileExists, tryParseJsonArray } from './common.js';
4
+
5
+ function detectYamlOptions(content) {
6
+ const lines = content
7
+ .split('\n')
8
+ .filter(line => line.trim())
9
+ .slice(0, 10);
10
+
11
+ const options = {
12
+ indent: 2,
13
+ indentSeq: true
14
+ };
15
+
16
+ const indentMatch = lines.find(line => /^\s+\S/.test(line))?.match(/^(\s+)\S/);
17
+ if (indentMatch) {
18
+ options.indent = indentMatch[1].length;
19
+ if (indentMatch[1].includes('\t')) {
20
+ options.indent = 2;
21
+ }
22
+ }
23
+
24
+ const seqMatch = lines.find(line => /^\s*-\s+\S/.test(line));
25
+ if (seqMatch) {
26
+ options.indentSeq = /^\s+-\s+/.test(seqMatch);
27
+ }
28
+
29
+ return options;
30
+ }
31
+
32
+ function processArrayItems(array, yamlDoc) {
33
+ return array.map(item => {
34
+ const itemNode = yamlDoc.createNode(item);
35
+ if (typeof item === 'string') {
36
+ const needsQuotes = item.includes(INTERPOLATION) || SPECIAL_CHARS_REGEX.test(item);
37
+ if (needsQuotes) {
38
+ itemNode.type = 'QUOTE_DOUBLE';
39
+ }
40
+ }
41
+ return itemNode;
42
+ });
43
+ }
44
+
45
+ async function createYamlDocument(filePath) {
46
+ const exists = await fileExists(filePath);
47
+ if (!exists) {
48
+ console.warn(`Creating new file: ${filePath}`);
49
+ const doc = new yaml.Document();
50
+ doc.contents = doc.createNode({});
51
+ return { doc, created: true, options: { indent: 2, indentSeq: true } };
52
+ }
53
+
54
+ const content = await fs.readFile(filePath, 'utf8');
55
+ const options = detectYamlOptions(content);
56
+ return {
57
+ doc: yaml.parseDocument(content),
58
+ created: false,
59
+ options
60
+ };
61
+ }
62
+
63
+ async function updateYamlTranslations(yamlDoc, translations, languageCode) {
64
+ if (!yamlDoc.contents) {
65
+ yamlDoc.contents = yamlDoc.createNode({});
66
+ }
67
+
68
+ const rootNode = yamlDoc.contents;
69
+ if (!rootNode.has(languageCode)) {
70
+ rootNode.set(languageCode, yamlDoc.createNode({}));
71
+ }
72
+
73
+ const langNode = rootNode.get(languageCode);
74
+
75
+ for (const [keyPath, newValue] of Object.entries(translations)) {
76
+ const keys = keyPath.split('.');
77
+ let current = langNode;
78
+
79
+ for (let i = 0; i < keys.length - 1; i++) {
80
+ const key = keys[i];
81
+ if (!current.has(key)) {
82
+ current.set(key, yamlDoc.createNode({}));
83
+ }
84
+ current = current.get(key);
85
+ }
86
+
87
+ const lastKey = keys[keys.length - 1];
88
+
89
+ if (Array.isArray(newValue)) {
90
+ const arrayNode = new yaml.YAMLSeq();
91
+ processArrayItems(newValue, yamlDoc).forEach(item => arrayNode.add(item));
92
+ current.set(lastKey, arrayNode);
93
+ continue;
94
+ }
95
+
96
+ const array = tryParseJsonArray(newValue);
97
+ if (array) {
98
+ const arrayNode = new yaml.YAMLSeq();
99
+ processArrayItems(array, yamlDoc).forEach(item => arrayNode.add(item));
100
+ current.set(lastKey, arrayNode);
101
+ continue;
102
+ }
103
+
104
+ if (typeof newValue === 'string' && newValue.includes('\n')) {
105
+ const scalar = new yaml.Scalar(newValue);
106
+ scalar.type = 'BLOCK_LITERAL';
107
+ current.set(lastKey, scalar);
108
+ continue;
109
+ }
110
+
111
+ const node = yamlDoc.createNode(newValue);
112
+ if (typeof newValue === 'string' && (newValue.includes(INTERPOLATION) || SPECIAL_CHARS_REGEX.test(newValue))) {
113
+ node.type = 'QUOTE_DOUBLE';
114
+ }
115
+ current.set(lastKey, node);
116
+ }
117
+ }
118
+
119
+ export async function updateYamlFile(filePath, translations, languageCode) {
120
+ const { doc: yamlDoc, created, options } = await createYamlDocument(filePath);
121
+
122
+ await updateYamlTranslations(yamlDoc, translations, languageCode);
123
+
124
+ yamlDoc.options.indent = options.indent;
125
+ yamlDoc.options.indentSeq = options.indentSeq;
126
+
127
+ await fs.writeFile(filePath, yamlDoc.toString());
128
+ return {
129
+ updatedKeys: Object.keys(translations),
130
+ created
131
+ };
132
+ }
133
+
134
+ export async function deleteKeysFromYamlFile(filePath, keysToDelete, languageCode) {
135
+ try {
136
+ const content = await fs.readFile(filePath, 'utf8');
137
+ const yamlDoc = yaml.parseDocument(content);
138
+ if (!yamlDoc.contents || !yamlDoc.contents.has(languageCode)) {
139
+ return [];
140
+ }
141
+
142
+ const langNode = yamlDoc.contents.get(languageCode);
143
+ const deletedKeys = [];
144
+
145
+ for (const keyPath of keysToDelete) {
146
+ const keys = keyPath.split('.');
147
+ const lastIndex = keys.length - 1;
148
+ let current = langNode;
149
+ let parent = null;
150
+ let keyInParent = '';
151
+ let found = true;
152
+
153
+ for (let i = 0; i < lastIndex; i++) {
154
+ const key = keys[i];
155
+ if (!current.has(key)) {
156
+ found = false;
157
+ break;
158
+ }
159
+ parent = current;
160
+ keyInParent = key;
161
+ current = current.get(key);
162
+ }
163
+
164
+ if (found) {
165
+ const lastKey = keys[lastIndex];
166
+ if (current.has(lastKey)) {
167
+ current.delete(lastKey);
168
+ deletedKeys.push(keyPath);
169
+ if (parent && current.items.length === 0) {
170
+ parent.delete(keyInParent);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ await fs.writeFile(filePath, yamlDoc.toString());
177
+ return deletedKeys;
178
+ } catch (error) {
179
+ throw new Error(`Failed to delete keys from YAML file ${filePath}: ${error.message}`);
180
+ }
181
+ }
@@ -0,0 +1,237 @@
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
+ return targetFiles.find(f =>
159
+ f.locale === targetLocale &&
160
+ (path.dirname(f.path) === path.dirname(sourceFile.path) ||
161
+ path.basename(f.path, path.extname(f.path)) === path.basename(sourceFile.path, path.extname(sourceFile.path)).replace(sourceLocale, targetLocale))
162
+ );
163
+ }
164
+
165
+ export function generateTargetPath(sourceFile, targetLocale, sourceLocale) {
166
+ const sourceExt = path.extname(sourceFile.path);
167
+ const sourceDir = path.dirname(sourceFile.path);
168
+ const sourceName = path.basename(sourceFile.path, sourceExt);
169
+
170
+ // Case 1: File is named exactly as the source locale (e.g., "en.yml")
171
+ if (sourceName === sourceLocale) {
172
+ return path.join(sourceDir, `${targetLocale}${sourceExt}`);
173
+ }
174
+
175
+ // Case 2: File ends with .locale (e.g., "translations.en.yml")
176
+ if (sourceName.endsWith(`.${sourceLocale}`)) {
177
+ const baseName = sourceName.slice(0, -(sourceLocale.length + 1));
178
+ return path.join(sourceDir, `${baseName}.${targetLocale}${sourceExt}`);
179
+ }
180
+
181
+ // Case 3: File uses hyphen-locale format (e.g., "translations-en.yml")
182
+ if (sourceName.includes(`-${sourceLocale}`)) {
183
+ const baseName = sourceName.slice(0, -(sourceLocale.length + 1));
184
+ return path.join(sourceDir, `${baseName}-${targetLocale}${sourceExt}`);
185
+ }
186
+
187
+ // Case 4: Source locale is a directory name
188
+ const sourceParentDir = path.basename(sourceDir);
189
+ if (sourceParentDir === sourceLocale) {
190
+ const grandParentDir = path.dirname(sourceDir);
191
+ return path.join(grandParentDir, targetLocale, path.basename(sourceFile.path));
192
+ }
193
+
194
+ // Default case: If none of the above patterns match,
195
+ // construct the target path by replacing the locale in the filename only
196
+ const dirPath = path.dirname(sourceFile.path);
197
+ const fileName = path.basename(sourceFile.path);
198
+ // Use regex to match the exact locale string to avoid partial matches
199
+ const localeRegex = new RegExp(`\\b${sourceLocale}\\b`, 'g');
200
+ const newFileName = fileName.replace(localeRegex, targetLocale);
201
+ return path.join(dirPath, newFileName);
202
+ }
203
+
204
+ export function processTargetContent(targetContent, targetLocale) {
205
+ if (targetContent[targetLocale]) {
206
+ return flattenTranslations(targetContent[targetLocale]);
207
+ }
208
+ return flattenTranslations(targetContent);
209
+ }
210
+
211
+ export function processLocaleTranslations(sourceKeys, targetLocale, targetFiles, sourceFile, sourceLocale) {
212
+ try {
213
+ const targetFile = findTargetFile(targetFiles, targetLocale, sourceFile, sourceLocale);
214
+ let targetKeys = {};
215
+ let targetPath = '';
216
+
217
+ if (targetFile) {
218
+ const targetContentRaw = Buffer.from(targetFile.content, 'base64').toString();
219
+ const targetContent = parseFile(targetContentRaw, targetFile.format);
220
+ targetKeys = processTargetContent(targetContent, targetLocale);
221
+ targetPath = targetFile.path;
222
+ } else {
223
+ targetPath = generateTargetPath(sourceFile, targetLocale, sourceLocale);
224
+ }
225
+
226
+ const { missingKeys, skippedKeys } = findMissingTranslations(sourceKeys, targetKeys);
227
+
228
+ return {
229
+ targetPath,
230
+ missingKeys,
231
+ skippedKeys,
232
+ targetFile
233
+ };
234
+ } catch (error) {
235
+ throw new Error(`Failed to process translations for ${targetLocale}: ${error.message}`);
236
+ }
237
+ }
@@ -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
- };