champollion 0.3.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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/autofix.js ADDED
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Autofix — the `wrap` command.
3
+ *
4
+ * Scans source files for hardcoded user-facing strings and wraps them
5
+ * in the project's i18n translation function (e.g., t("key")).
6
+ *
7
+ * SIX SAFETY GATES:
8
+ * 1. Git-clean gate — refuses unless `git status --porcelain` is empty
9
+ * 2. Dry-run first — `wrap --dry` shows diffs without writing
10
+ * 3. Atomic backup — .champollion-backup/ before any write
11
+ * 4. Conservative matching — fix obvious cases, flag ambiguous for human review
12
+ * 5. One-undo — `wrap --undo` restores from backup
13
+ * 6. Diff output — prints git-style diff of every change
14
+ *
15
+ * KEY GENERATION:
16
+ * "Welcome to my portfolio" → t("general.welcome_to_my_portfolio")
17
+ * "Get in Touch" → t("general.get_in_touch")
18
+ * Alt text stays in its context: alt="My photo" → alt={t("general.my_photo")}
19
+ *
20
+ * Zero external dependencies.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { execSync } from 'node:child_process';
26
+ import { isNonTranslatableString } from './string-classify.js';
27
+
28
+ // -----------------------------------------------------------------
29
+ // Key generation
30
+ // -----------------------------------------------------------------
31
+
32
+ /**
33
+ * Generate a translation key from a hardcoded string.
34
+ *
35
+ * Strategy: prefix with "general." namespace, then snake_case the text.
36
+ * Truncates at 5 words to keep keys manageable.
37
+ *
38
+ * @param {string} text - The hardcoded text
39
+ * @param {string} namespace - Key namespace (default: 'general')
40
+ * @returns {string} Generated key like "general.welcome_to_my"
41
+ */
42
+ function generateKey(text, namespace = 'general') {
43
+ const words = text
44
+ .toLowerCase()
45
+ .replace(/[^\w\s]/g, '') // Remove punctuation
46
+ .trim()
47
+ .split(/\s+/) // Split on whitespace
48
+ .filter(w => w.length > 0)
49
+ .slice(0, 5); // Max 5 words
50
+
51
+ if (words.length === 0) return null;
52
+
53
+ const slug = words.join('_');
54
+ return `${namespace}.${slug}`;
55
+ }
56
+
57
+ // -----------------------------------------------------------------
58
+ // Replacement logic
59
+ // -----------------------------------------------------------------
60
+
61
+ /**
62
+ * Generate a replacement for a JSX text node.
63
+ *
64
+ * "Welcome to my portfolio" → {t("general.welcome_to_my_portfolio")}
65
+ *
66
+ * @param {string} original - The original match (e.g., ">Text<")
67
+ * @param {string} text - The extracted text
68
+ * @param {string} key - The generated translation key
69
+ * @param {string} framework - Framework name for t() syntax
70
+ * @returns {string} The replacement string
71
+ */
72
+ function replaceJsxText(original, text, key, framework) {
73
+ if (framework === 'Hugo') {
74
+ // >Text< → >{{ i18n "key" }}<
75
+ return original.replace(text, `{{ i18n "${key}" }}`);
76
+ }
77
+
78
+ // React/Vue: >Text< → >{t("key")}<
79
+ return original.replace(text, `{t("${key}")}`);
80
+ }
81
+
82
+ /**
83
+ * Generate a replacement for a translatable attribute.
84
+ *
85
+ * placeholder="Search" → placeholder={t("general.search")}
86
+ *
87
+ * @param {string} attr - The attribute name
88
+ * @param {string} value - The current value
89
+ * @param {string} key - The generated translation key
90
+ * @param {string} framework - Framework name
91
+ * @returns {{ from: string, to: string }} Before/after for the replacement
92
+ */
93
+ function replaceAttribute(attr, value, key, framework) {
94
+ if (framework === 'Hugo') {
95
+ return {
96
+ from: `${attr}="${value}"`,
97
+ to: `${attr}="{{ i18n "${key}" }}"`,
98
+ };
99
+ }
100
+
101
+ return {
102
+ from: `${attr}="${value}"`,
103
+ to: `${attr}={t("${key}")}`,
104
+ };
105
+ }
106
+
107
+ // -----------------------------------------------------------------
108
+ // Safety gates
109
+ // -----------------------------------------------------------------
110
+
111
+ /**
112
+ * Gate 1: Check if git working tree is clean.
113
+ *
114
+ * @param {string} cwd - Working directory
115
+ * @returns {{ clean: boolean, status: string }}
116
+ */
117
+ function checkGitClean(cwd) {
118
+ try {
119
+ const status = execSync('git status --porcelain', {
120
+ cwd,
121
+ encoding: 'utf-8',
122
+ timeout: 5000,
123
+ }).trim();
124
+
125
+ return { clean: status.length === 0, status };
126
+ } catch (_) {
127
+ // Not a git repo — allow but warn
128
+ return { clean: true, status: '(not a git repository)' };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Gate 3: Create a backup of files before modification.
134
+ *
135
+ * @param {string[]} filePaths - Absolute paths to back up
136
+ * @param {string} cwd - Working directory
137
+ * @returns {string} Backup directory path
138
+ */
139
+ function createBackup(filePaths, cwd) {
140
+ const backupDir = path.join(cwd, '.champollion-backup');
141
+ fs.mkdirSync(backupDir, { recursive: true });
142
+
143
+ for (const filePath of filePaths) {
144
+ const relPath = path.relative(cwd, filePath);
145
+ const backupPath = path.join(backupDir, relPath);
146
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
147
+ fs.copyFileSync(filePath, backupPath);
148
+ }
149
+
150
+ return backupDir;
151
+ }
152
+
153
+ /**
154
+ * Gate 5: Restore files from backup.
155
+ *
156
+ * @param {string} cwd - Working directory
157
+ * @returns {{ restored: number, errors: string[] }}
158
+ */
159
+ function restoreFromBackup(cwd) {
160
+ const backupDir = path.join(cwd, '.champollion-backup');
161
+ if (!fs.existsSync(backupDir)) {
162
+ return { restored: 0, errors: ['No backup found at .champollion-backup/'] };
163
+ }
164
+
165
+ const errors = [];
166
+ let restored = 0;
167
+
168
+ function walkRestore(dir) {
169
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
170
+ for (const entry of entries) {
171
+ const fullPath = path.join(dir, entry.name);
172
+ if (entry.isDirectory()) {
173
+ walkRestore(fullPath);
174
+ } else {
175
+ const relPath = path.relative(backupDir, fullPath);
176
+ const targetPath = path.join(cwd, relPath);
177
+ try {
178
+ fs.copyFileSync(fullPath, targetPath);
179
+ restored++;
180
+ } catch (err) {
181
+ errors.push(`Failed to restore ${relPath}: ${err.message}`);
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ walkRestore(backupDir);
188
+ return { restored, errors };
189
+ }
190
+
191
+ // -----------------------------------------------------------------
192
+ // Diff generation
193
+ // -----------------------------------------------------------------
194
+
195
+ /**
196
+ * Gate 6: Generate a simple text diff between original and modified content.
197
+ *
198
+ * This is a positional diff (compare lines by index), not a true
199
+ * diff (LCS/Myers). Works correctly for autofix because all
200
+ * replacements are in-place string swaps that preserve line count.
201
+ * Would produce misleading output for insertions/deletions — but
202
+ * autofix never inserts or deletes lines.
203
+ *
204
+ * @param {string} original - Original file content
205
+ * @param {string} modified - Modified file content
206
+ * @param {string} filePath - File path for the header
207
+ * @returns {string} Diff output
208
+ */
209
+ function generateDiff(original, modified, filePath) {
210
+ if (original === modified) return '';
211
+
212
+ const origLines = original.split('\n');
213
+ const modLines = modified.split('\n');
214
+ const lines = [];
215
+
216
+ lines.push(`--- a/${filePath}`);
217
+ lines.push(`+++ b/${filePath}`);
218
+
219
+ for (let i = 0; i < Math.max(origLines.length, modLines.length); i++) {
220
+ const origLine = origLines[i];
221
+ const modLine = modLines[i];
222
+
223
+ if (origLine !== modLine) {
224
+ if (origLine !== undefined) lines.push(`- ${origLine}`);
225
+ if (modLine !== undefined) lines.push(`+ ${modLine}`);
226
+ }
227
+ }
228
+
229
+ return lines.join('\n');
230
+ }
231
+
232
+ // -----------------------------------------------------------------
233
+ // Main autofix runner
234
+ // -----------------------------------------------------------------
235
+
236
+ /**
237
+ * Process a single source file for hardcoded strings.
238
+ *
239
+ * @param {string} content - File content
240
+ * @param {string} frameworkName - Framework name
241
+ * @param {object} framework - Framework config (from lint.js)
242
+ * @param {number} minLength - Minimum string length
243
+ * @param {object} existingKeys - Already-existing locale keys (for dedup)
244
+ * @returns {{ modified: string, fixes: object[], ambiguous: object[] }}
245
+ */
246
+ function processFile(content, frameworkName, framework, minLength, existingKeys = {}) {
247
+ const fixes = []; // Applied fixes
248
+ const ambiguous = []; // Flagged for human review
249
+ let modified = content;
250
+
251
+ const lines = content.split('\n');
252
+
253
+ for (let i = 0; i < lines.length; i++) {
254
+ const line = lines[i];
255
+ const trimmed = line.trim();
256
+
257
+ // Skip non-content lines (same logic as lint.js)
258
+ if (trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;
259
+ if (/^\s*import\s/.test(trimmed)) continue;
260
+ if (/^\s*(?:export\s+)?(?:type|interface|enum)\s/.test(trimmed)) continue;
261
+ if (/^\s*console\.\w+\(/.test(trimmed)) continue;
262
+
263
+ // Check for JSX text nodes: >text here<
264
+ const textPattern = />([^<>{]+)</g;
265
+ let match;
266
+ while ((match = textPattern.exec(line)) !== null) {
267
+ const text = match[1].trim();
268
+ if (text.length < minLength) continue;
269
+ if (!shouldFixText(text)) continue;
270
+
271
+ const key = generateKey(text);
272
+ if (!key) continue;
273
+
274
+ // Gate 4: Conservative — skip ambiguous cases
275
+ if (isAmbiguous(text, line)) {
276
+ ambiguous.push({ line: i + 1, text, reason: 'Complex context — review manually' });
277
+ continue;
278
+ }
279
+
280
+ const fullMatch = match[0]; // >Text<
281
+ const replacement = replaceJsxText(fullMatch, text, key, frameworkName);
282
+ modified = modified.replace(fullMatch, replacement);
283
+ fixes.push({ line: i + 1, text, key, type: 'jsx-text' });
284
+ }
285
+
286
+ // Check for translatable attributes
287
+ for (const attr of (framework.translatableAttrs || [])) {
288
+ const attrPattern = new RegExp(`${attr}\\s*=\\s*"([^"]*)"`, 'g');
289
+ while ((match = attrPattern.exec(line)) !== null) {
290
+ const value = match[1].trim();
291
+ if (value.length < minLength) continue;
292
+ if (!shouldFixText(value)) continue;
293
+
294
+ const key = generateKey(value);
295
+ if (!key) continue;
296
+
297
+ const { from, to } = replaceAttribute(attr, match[1], key, frameworkName);
298
+ modified = modified.replace(from, to);
299
+ fixes.push({ line: i + 1, text: value, key, type: `attr:${attr}` });
300
+ }
301
+ }
302
+ }
303
+
304
+ return { modified, fixes, ambiguous };
305
+ }
306
+
307
+ /**
308
+ * Check if a text string is a good candidate for auto-fix.
309
+ *
310
+ * Conservative: only fix obvious user-facing strings.
311
+ *
312
+ * @param {string} text - The string to evaluate
313
+ * @returns {boolean} true if the string should be wrapped in t()
314
+ */
315
+ function shouldFixText(text) {
316
+ // Delegate to the shared core — same heuristics used by lint.js's
317
+ // shouldFlagString, but without the lint-specific extensions
318
+ // (file paths, emails, HTML entities, TypeScript types).
319
+ // Autofix is more conservative: it only wraps obvious user-facing text.
320
+ return !isNonTranslatableString(text, 2);
321
+ }
322
+
323
+ /**
324
+ * Gate 4: Determine if a string is too ambiguous for auto-fix.
325
+ *
326
+ * Ambiguous cases are flagged for human review rather than auto-fixed.
327
+ *
328
+ * @param {string} text - The extracted string
329
+ * @param {string} lineContext - The full line of source code for context
330
+ * @returns {boolean} true if the string is too ambiguous to auto-fix
331
+ */
332
+ function isAmbiguous(text, lineContext) {
333
+ // String contains template expressions mixed with text
334
+ if (/\{[^}]+\}/.test(text) && /\w{3,}/.test(text)) return true;
335
+ // String contains HTML entities
336
+ if (/&\w+;/.test(text)) return true;
337
+ // String is inside a ternary or conditional
338
+ if (/\?\s*['"`]/.test(lineContext) || /:\s*['"`]/.test(lineContext)) return true;
339
+ // Very short (1-2 words) — might be an intentional label
340
+ if (text.split(/\s+/).length <= 1 && text.length < 8) return true;
341
+
342
+ return false;
343
+ }
344
+
345
+ /**
346
+ * Add generated keys to locale files.
347
+ *
348
+ * @param {object[]} fixes - Array of { key, text } from processFile
349
+ * @param {string} localesDir - Path to locale files directory
350
+ * @param {string} sourceLocale - Source locale code (e.g., 'en')
351
+ * @param {string[]} targetLocales - Target locale codes
352
+ */
353
+ function addKeysToLocales(fixes, localesDir, sourceLocale, targetLocales) {
354
+ if (fixes.length === 0) return;
355
+
356
+ // Build key-value map from fixes
357
+ const newKeys = {};
358
+ for (const fix of fixes) {
359
+ // Expand dot-notation key into nested object
360
+ const parts = fix.key.split('.');
361
+ let current = newKeys;
362
+ for (let i = 0; i < parts.length - 1; i++) {
363
+ if (!current[parts[i]]) current[parts[i]] = {};
364
+ current = current[parts[i]];
365
+ }
366
+ current[parts[parts.length - 1]] = fix.text;
367
+ }
368
+
369
+ // Update source locale file
370
+ const sourcePath = path.join(localesDir, `${sourceLocale}.json`);
371
+ if (fs.existsSync(sourcePath)) {
372
+ const existing = JSON.parse(fs.readFileSync(sourcePath, 'utf-8'));
373
+ const merged = deepMerge(existing, newKeys);
374
+ fs.writeFileSync(sourcePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
375
+ }
376
+
377
+ // Add placeholder entries to target locale files
378
+ for (const locale of targetLocales) {
379
+ const targetPath = path.join(localesDir, `${locale}.json`);
380
+ if (fs.existsSync(targetPath)) {
381
+ const existing = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
382
+ // Use [EN] prefix for target locales as untranslated markers
383
+ const placeholders = {};
384
+ for (const fix of fixes) {
385
+ const parts = fix.key.split('.');
386
+ let current = placeholders;
387
+ for (let i = 0; i < parts.length - 1; i++) {
388
+ if (!current[parts[i]]) current[parts[i]] = {};
389
+ current = current[parts[i]];
390
+ }
391
+ current[parts[parts.length - 1]] = `[EN] ${fix.text}`;
392
+ }
393
+ const merged = deepMerge(existing, placeholders);
394
+ fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
395
+ }
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Deep merge two objects (source into target), without overwriting existing keys.
401
+ *
402
+ * @param {object} target - Base object
403
+ * @param {object} source - Object to merge in (new keys only)
404
+ * @returns {object} New merged object
405
+ */
406
+ function deepMerge(target, source) {
407
+ const result = { ...target };
408
+ for (const [key, value] of Object.entries(source)) {
409
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
410
+ result[key] = deepMerge(result[key] || {}, value);
411
+ } else if (!(key in result)) {
412
+ // Only add new keys, don't overwrite existing
413
+ result[key] = value;
414
+ }
415
+ }
416
+ return result;
417
+ }
418
+
419
+ export {
420
+ generateKey,
421
+ replaceJsxText,
422
+ replaceAttribute,
423
+ checkGitClean,
424
+ createBackup,
425
+ restoreFromBackup,
426
+ generateDiff,
427
+ processFile,
428
+ shouldFixText,
429
+ isAmbiguous,
430
+ addKeysToLocales,
431
+ deepMerge,
432
+ };