diff --git a/README.md b/README.md index a4676ff..3689486 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Lara Cli automates translation of your i18n files with a single command, preserv Supports multiple file formats including JSON, PO (gettext), TypeScript, Vue I18n single-file components, Markdown and MDX files, Android XML string resource files, Xcode localization files (.strings, .stringsdict, .xcstrings), and plain text (.txt) files. See [Supported Formats](docs/config/formats.md) for details. -[![Version](https://img.shields.io/badge/version-1.5.0-blue.svg)](https://github.com/translated/lara-cli) +[![Version](https://img.shields.io/badge/version-1.6.0-blue.svg)](https://github.com/translated/lara-cli) diff --git a/docs/commands/translate.md b/docs/commands/translate.md index 2400979..770a06b 100644 --- a/docs/commands/translate.md +++ b/docs/commands/translate.md @@ -16,6 +16,7 @@ lara-cli translate [options] | `-p, --paths ` | Comma-separated list of specific file paths to translate (overrides config) | | `-f, --force` | Force retranslation of all content, even if unchanged | | `--no-trace` | Prevent server-side storage of translated content | +| `--orphan-keys ` | `keep` or `delete`. What to do with keys that exist only in target files. Overrides `translation.orphanKeys` in `lara.yaml` for this run. Cannot be combined with `--file` or `--text` | | `-h, --help` | Display help information | ## Examples @@ -123,7 +124,20 @@ When you modify source locale files, the tool automatically detects changes and ### Keys Only Present in a Target File -Keys that exist in a target locale file but not in the source (for example, translations added manually or entries specific to one locale) are **preserved** and kept at their original position. They are never overwritten or removed by translation. A key is only removed from a target when it was previously translated from the source and is then deleted from the source file. +Keys that exist in a target locale file but not in the source (for example, translations added manually or entries specific to one locale) are called **orphan keys**. By default they are **preserved** and kept at their original position — never overwritten or removed by translation. A key is only removed from a target when it was previously translated from the source and is then deleted from the source file. + +You can change this with `translation.orphanKeys` in `lara.yaml`, or per run with the `--orphan-keys` flag: + +```yaml +translation: + orphanKeys: delete # keep (default) | delete +``` + +```bash +lara-cli translate --orphan-keys delete +``` + +With `delete`, orphan keys are removed so each target file mirrors the source exactly. Keys deleted from the source are still removed in both modes, and non-translatable entries (Android `translatable="false"`, `.xcstrings` `shouldTranslate: false`) are never deleted. See [Orphan Keys](../config/structure.md#orphan-keys) for the full behaviour. > Notes: for Gettext PO, orphan messages are preserved but their exact interleaving with source messages is approximate. For Xcode `.stringsdict`, an orphan plural entry keeps its full original structure. diff --git a/docs/config/README.md b/docs/config/README.md index 31276d0..886fe74 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -22,6 +22,9 @@ memories: glossaries: - gls_xyz789 noTrace: false +translation: + batchSize: 50 + orphanKeys: keep files: json: include: @@ -39,7 +42,7 @@ Lara CLI supports multiple file formats. See [Supported Formats](./formats.md) f The configuration is divided into several sections: -- **[Configuration Schema](./structure.md)** - Schema structure and organization of the configuration file +- **[Configuration Schema](./structure.md)** - Schema structure and organization of the configuration file, including [translation batching](./structure.md#translation-batching) and [orphan key handling](./structure.md#orphan-keys) - **[Supported Formats](./formats.md)** - List of supported file formats - **[Locales](./locales.md)** - Source and target language configuration - **[Files](./files.md)** - File paths and exclusion patterns diff --git a/docs/config/structure.md b/docs/config/structure.md index 20e4715..183dc03 100644 --- a/docs/config/structure.md +++ b/docs/config/structure.md @@ -14,7 +14,7 @@ lara.yaml ├── memories # Translation memory settings ├── glossaries # Terminology settings ├── noTrace # No-trace mode (prevents server-side storage) -├── translation # Translation tuning (batch size) +├── translation # Translation tuning (batch size, orphan key handling) └── files # File path and processing rules ``` @@ -53,7 +53,8 @@ noTrace: false # Translation tuning translation: - batchSize: 50 # Max keys sent per translation request (default: 50) + batchSize: 50 # Max keys sent per translation request (default: 50) + orphanKeys: keep # What to do with target-only keys: keep | delete (default: keep) # File path and processing rules files: @@ -130,6 +131,47 @@ translation: - If a batch request fails after retries, the engine automatically falls back to translating each key in that batch one by one, so a single problematic string cannot block the rest of the file. - `batchSize` defaults to `50` when the `translation` section is omitted. Existing `lara.yaml` files continue to work without changes. +## Orphan Keys + +An **orphan key** is a key that exists in a target locale file but not in the source file. They usually appear when a translator adds a locale-specific entry by hand, or when a key is renamed in one place only. + +```yaml +translation: + orphanKeys: keep # keep | delete +``` + +- **`keep`** (default) — orphan keys are preserved untouched, at their original position in the file. Nothing you add to a target file by hand is ever silently lost. +- **`delete`** — orphan keys are removed, so each target file mirrors the source exactly. Use this when the source is the single point of truth and target-only entries are considered leftovers. + +Given this source and target: + +```json +// en.json (source) // it.json (target) +{ { + "one": "One", "one": "Uno", + "two": "Two" "only_it": "Solo italiano", +} "two": "Due" + } +``` + +`only_it` is an orphan. With `keep` it stays between `one` and `two`; with `delete` it is removed and the file ends up with just `one` and `two`. + +### Important distinctions + +- **Keys deleted from the source are always removed**, in both modes. Lara CLI tracks the source with a checksum file, so a key that *used to* exist and was then removed is recognised as deleted rather than as an orphan. +- **Non-translatable entries are never deleted**, even with `orphanKeys: delete`. These are not orphans — they are entries the parser deliberately skips: + - Android XML resources marked `translatable="false"` + - Xcode `.xcstrings` entries marked `shouldTranslate: false` +- **Gettext PO files** cannot always restore an orphan's original position; preserved orphan messages are appended at the end of the file. + +### Overriding per run + +The [`translate`](../commands/translate.md) command accepts `--orphan-keys `, which overrides the config file for a single run — handy for a one-off cleanup without editing `lara.yaml`: + +```bash +lara-cli translate --orphan-keys delete +``` + ## Related Topics Each section of the configuration has its own detailed documentation: diff --git a/package.json b/package.json index d3a04cb..e69fea5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@translated/lara-cli", "type": "module", - "version": "1.5.0", + "version": "1.6.0", "description": "CLI tool for automated i18n file translation using Lara Translate", "repository": { "type": "git", diff --git a/src/__tests__/integration/direct-translate.integration.test.ts b/src/__tests__/integration/direct-translate.integration.test.ts index e013aa0..0f07397 100644 --- a/src/__tests__/integration/direct-translate.integration.test.ts +++ b/src/__tests__/integration/direct-translate.integration.test.ts @@ -181,6 +181,23 @@ describe('Direct Translation Integration Tests', () => { ]) ).rejects.toThrow(); }); + + it('should error when --orphan-keys is used with --text', async () => { + // Orphan handling only applies to the config-driven flow, which builds a + // TranslationEngine; direct mode never touches existing target files. + await expect( + executeCommand(translateCommand, [ + '--text', + 'Hello', + '--source', + 'en', + '--target', + 'fr', + '--orphan-keys', + 'delete', + ]) + ).rejects.toThrow(); + }); }); describe('file mode - txt files', () => { diff --git a/src/__tests__/integration/orphan-keys.integration.test.ts b/src/__tests__/integration/orphan-keys.integration.test.ts index 00b3cbf..716315f 100644 --- a/src/__tests__/integration/orphan-keys.integration.test.ts +++ b/src/__tests__/integration/orphan-keys.integration.test.ts @@ -4,25 +4,35 @@ import { existsSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import yaml from 'yaml'; + import { executeCommand } from './test-helpers.js'; import initCommand from '../../cli/cmd/init/init.js'; import translateCommand from '../../cli/cmd/translate/translate.js'; import { ConfigProvider } from '#modules/config/config.provider.js'; +import { OrphanKeysMode } from '#modules/config/config.types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Regression tests for "orphan" keys: keys that exist in a TARGET locale file but - * NOT in the SOURCE file. They must be PRESERVED (never removed) and kept at their - * original position when the file is translated again. This is distinct from keys - * that were removed from the source (DELETED), which must still be removed. + * NOT in the SOURCE file. By default they must be PRESERVED (never removed) and + * kept at their original position when the file is translated again. This is + * distinct from keys that were removed from the source (DELETED), which must be + * removed whatever the setting says. * * Each test seeds a source plus a target that already contains an orphan (and a * source key missing from the target, to force a real translation pass), runs * `translate`, and asserts the orphan survives untouched while everything else * behaves normally. + * + * The second half of the file covers `translation.orphanKeys: delete` and the + * `--orphan-keys` flag, which opt out of that preservation. Two carve-outs must + * survive even in delete mode, because they are not orphans but entries the + * parsers deliberately skip: Android `translatable="false"` resources and + * .xcstrings entries marked `shouldTranslate: false`. */ -describe('Orphan key preservation (all keyed formats)', () => { +describe('Orphan keys (all keyed formats)', () => { let testDir: string; let originalCwd: string; let originalEnv: NodeJS.ProcessEnv; @@ -77,46 +87,61 @@ describe('Orphan key preservation (all keyed formats)', () => { paths, ]); - const translate = async () => { + const translate = async (args: string[] = []) => { + (ConfigProvider as any).instance = null; + await executeCommand(translateCommand, args); + }; + + /** Structurally edits the config `init` just wrote, then reloads it. */ + const patchConfig = async (mutate: (cfg: any) => void) => { + const configPath = path.join(testDir, 'lara.yaml'); + const cfg = yaml.parse(await readFile(configPath, 'utf-8')); + mutate(cfg); + await writeFile(configPath, yaml.stringify(cfg)); (ConfigProvider as any).instance = null; - await executeCommand(translateCommand, []); }; + const setOrphanKeys = (mode: OrphanKeysMode) => + patchConfig((cfg) => { + // init must emit the field explicitly, not rely on the schema default. + expect(cfg.translation.orphanKeys).toBeDefined(); + cfg.translation.orphanKeys = mode; + }); + + /** Drops the whole `translation` section, so the Zod defaults apply. */ + const removeTranslationSection = () => + patchConfig((cfg) => { + delete cfg.translation; + }); + // --------------------------------------------------------------------------- - // JSON + // Fixtures + // + // One seed per format, shared by the `keep` and `delete` cases so the two can + // never drift apart. Each writes a source, a target that already contains an + // orphan, and runs `init` — leaving the caller to set the mode and translate. + // Every source carries a key missing from the target (`three` / `World` / + // `item_count` / `shared`) so a real translation pass is forced. // --------------------------------------------------------------------------- - it('JSON: keeps orphan key at its position and still translates new source keys', async () => { + + const read = (...segments: string[]) => readFile(path.join(testDir, ...segments), 'utf-8'); + const readJson = async (...segments: string[]) => JSON.parse(await read(...segments)); + + const seedJson = async () => { await mkdir(path.join(testDir, 'i18n', 'locales'), { recursive: true }); - // Source has one, two, three (three is missing from the target -> forces translation) await writeFile( path.join(testDir, 'i18n', 'locales', 'en.json'), JSON.stringify({ one: 'One', two: 'Two', three: 'Three' }, null, 2) ); - // Target has one, orphan, two — orphan sits between two source keys. + // The orphan sits between two source keys, so position can be asserted. await writeFile( path.join(testDir, 'i18n', 'locales', 'it.json'), JSON.stringify({ one: '[it] One', orphan: 'SOLO IT', two: '[it] Two' }, null, 2) ); - await init('i18n/locales/[locale].json'); - await translate(); - - const result = JSON.parse( - await readFile(path.join(testDir, 'i18n', 'locales', 'it.json'), 'utf-8') - ); - - expect(result.orphan).toBe('SOLO IT'); // preserved, untouched - expect(result.one).toBe('[it] One'); // shared, kept - expect(result.two).toBe('[it] Two'); - expect(result.three).toBe('[it] Three'); // new source key translated - // Orphan keeps its position (anchored to the preceding shared key `one`). - expect(Object.keys(result)).toEqual(['one', 'orphan', 'two', 'three']); - }); + }; - // --------------------------------------------------------------------------- - // PO - // --------------------------------------------------------------------------- - it('PO: keeps orphan message that is absent from the source', async () => { + const seedPo = async () => { await mkdir(path.join(testDir, 'locales', 'en'), { recursive: true }); await mkdir(path.join(testDir, 'locales', 'it'), { recursive: true }); await writeFile( @@ -145,20 +170,11 @@ msgid "OnlyItalian" msgstr "Solo italiano" ` ); - await init('locales/[locale]/messages.po'); - await translate(); - - const content = await readFile(path.join(testDir, 'locales', 'it', 'messages.po'), 'utf-8'); - expect(content).toContain('msgid "OnlyItalian"'); // orphan preserved - expect(content).toContain('Solo italiano'); - expect(content).toContain('[it] World'); // new source key translated - }); + }; - // --------------------------------------------------------------------------- - // TypeScript (i18n.ts) — single multi-locale file - // --------------------------------------------------------------------------- - it('TS: keeps orphan key inside the target locale subtree', async () => { + // Single multi-locale file: source and target live in the same document. + const seedTs = async () => { await mkdir(path.join(testDir, 'src'), { recursive: true }); await writeFile( path.join(testDir, 'src', 'i18n.ts'), @@ -177,19 +193,10 @@ msgstr "Solo italiano" export default messages;` ); - await init('src/i18n.ts'); - await translate(); - - const content = await readFile(path.join(testDir, 'src', 'i18n.ts'), 'utf-8'); - expect(content).toContain('Solo italiano'); // orphan preserved - expect(content).toContain('[it] Three'); // new source key translated into `it` - }); + }; - // --------------------------------------------------------------------------- - // Vue SFC — single multi-locale block - // --------------------------------------------------------------------------- - it('Vue: keeps orphan key inside the target locale block', async () => { + const seedVue = async () => { await mkdir(path.join(testDir, 'src', 'components'), { recursive: true }); await writeFile( path.join(testDir, 'src', 'components', 'Hello.vue'), @@ -201,19 +208,15 @@ export default messages;` } ` ); - await init('src/components/*.vue'); - await translate(); - - const content = await readFile(path.join(testDir, 'src', 'components', 'Hello.vue'), 'utf-8'); - expect(content).toContain('Solo italiano'); // orphan preserved - expect(content).toContain('[it] Three'); // new source key translated - }); + }; - // --------------------------------------------------------------------------- - // Android XML - // --------------------------------------------------------------------------- - it('Android XML: keeps orphan string/plural at their position', async () => { + /** + * `nonTranslatable` adds a target-only `translatable="false"` resource. parse() + * skips those on purpose, so they never reach the engine — they are not orphans + * and must survive even in delete mode. + */ + const seedAndroid = async ({ nonTranslatable = false } = {}) => { await mkdir(path.join(testDir, 'res', 'en'), { recursive: true }); await mkdir(path.join(testDir, 'res', 'it'), { recursive: true }); await writeFile( @@ -231,25 +234,13 @@ export default messages;` [it] One Solo italiano - [it] Two +${nonTranslatable ? ' https://example.org\n' : ''} [it] Two ` ); - await init('res/[locale]/strings.xml'); - await translate(); - - const content = await readFile(path.join(testDir, 'res', 'it', 'strings.xml'), 'utf-8'); - expect(content).toContain('Solo italiano'); // orphan preserved - expect(content).toContain('[it] Three'); // new source key translated - // Orphan keeps its position between `one` and `two`. - expect(content.indexOf('one')).toBeLessThan(content.indexOf('only_it')); - expect(content.indexOf('only_it')).toBeLessThan(content.indexOf('name="two"')); - }); + }; - // --------------------------------------------------------------------------- - // Xcode .strings - // --------------------------------------------------------------------------- - it('Xcode .strings: keeps orphan key at its position', async () => { + const seedStrings = async () => { await mkdir(path.join(testDir, 'en.lproj'), { recursive: true }); await mkdir(path.join(testDir, 'it.lproj'), { recursive: true }); await writeFile( @@ -266,22 +257,10 @@ export default messages;` "two" = "[it] Two"; ` ); - await init('[locale].lproj/Localizable.strings'); - await translate(); - - const content = await readFile(path.join(testDir, 'it.lproj', 'Localizable.strings'), 'utf-8'); - expect(content).toContain('"only_it" = "Solo italiano";'); // orphan preserved - expect(content).toContain('[it] Three'); // new source key translated - expect(content.indexOf('"one"')).toBeLessThan(content.indexOf('only_it')); - expect(content.indexOf('only_it')).toBeLessThan(content.indexOf('"two"')); - }); + }; - // --------------------------------------------------------------------------- - // Xcode .stringsdict - // --------------------------------------------------------------------------- - it('Xcode .stringsdict: keeps orphan plural entry with its real structure', async () => { - const pluralEntry = (key: string, one: string, other: string) => ` ${key} + const pluralEntry = (key: string, one: string, other: string) => ` ${key} NSStringLocalizedFormatKey %#@items@ @@ -298,49 +277,29 @@ export default messages;` `; - await mkdir(path.join(testDir, 'en.lproj'), { recursive: true }); - await mkdir(path.join(testDir, 'it.lproj'), { recursive: true }); - await writeFile( - path.join(testDir, 'en.lproj', 'Localizable.stringsdict'), - ` + const plist = (entry: string) => ` -${pluralEntry('item_count', '%d item', '%d items')} +${entry} -` +`; + + const seedStringsdict = async () => { + await mkdir(path.join(testDir, 'en.lproj'), { recursive: true }); + await mkdir(path.join(testDir, 'it.lproj'), { recursive: true }); + await writeFile( + path.join(testDir, 'en.lproj', 'Localizable.stringsdict'), + plist(pluralEntry('item_count', '%d item', '%d items')) ); await writeFile( path.join(testDir, 'it.lproj', 'Localizable.stringsdict'), - ` - - - -${pluralEntry('only_it_count', 'solo %d', 'solo %d')} - -` + plist(pluralEntry('only_it_count', 'solo %d', 'solo %d')) ); - await init('[locale].lproj/Localizable.stringsdict'); - await translate(); - - const content = await readFile( - path.join(testDir, 'it.lproj', 'Localizable.stringsdict'), - 'utf-8' - ); - // Orphan entry preserved with its plural structure and value untouched. - expect(content).toContain('only_it_count'); - expect(content).toContain('solo %d'); - expect(content).toContain('%#@items@'); - // New source key translated. - expect(content).toContain('item_count'); - expect(content).toContain('[it] %d item'); - }); + }; - // --------------------------------------------------------------------------- - // Xcode .xcstrings — single multi-locale file - // --------------------------------------------------------------------------- - it('Xcode .xcstrings: keeps orphan entry that only has a target localization', async () => { + const seedXcstrings = async () => { await writeFile( path.join(testDir, 'Localizable.xcstrings'), JSON.stringify( @@ -364,22 +323,102 @@ ${pluralEntry('only_it_count', 'solo %d', 'solo %d')} 2 ) ); - await init('Localizable.xcstrings'); + }; + + // =========================================================================== + // Default mode: orphans are preserved + // =========================================================================== + + it('JSON: keeps orphan key at its position and still translates new source keys', async () => { + await seedJson(); await translate(); - const content = JSON.parse( - await readFile(path.join(testDir, 'Localizable.xcstrings'), 'utf-8') - ); + const result = await readJson('i18n', 'locales', 'it.json'); + expect(result.orphan).toBe('SOLO IT'); // preserved, untouched + expect(result.one).toBe('[it] One'); // shared, kept + expect(result.two).toBe('[it] Two'); + expect(result.three).toBe('[it] Three'); // new source key translated + // Orphan keeps its position (anchored to the preceding shared key `one`). + expect(Object.keys(result)).toEqual(['one', 'orphan', 'two', 'three']); + }); + + it('PO: keeps orphan message that is absent from the source', async () => { + await seedPo(); + await translate(); + + const content = await read('locales', 'it', 'messages.po'); + expect(content).toContain('msgid "OnlyItalian"'); // orphan preserved + expect(content).toContain('Solo italiano'); + expect(content).toContain('[it] World'); // new source key translated + }); + + it('TS: keeps orphan key inside the target locale subtree', async () => { + await seedTs(); + await translate(); + + const content = await read('src', 'i18n.ts'); + expect(content).toContain('Solo italiano'); // orphan preserved + expect(content).toContain('[it] Three'); // new source key translated into `it` + }); + + it('Vue: keeps orphan key inside the target locale block', async () => { + await seedVue(); + await translate(); + + const content = await read('src', 'components', 'Hello.vue'); + expect(content).toContain('Solo italiano'); // orphan preserved + expect(content).toContain('[it] Three'); // new source key translated + }); + + it('Android XML: keeps orphan string/plural at their position', async () => { + await seedAndroid(); + await translate(); + + const content = await read('res', 'it', 'strings.xml'); + expect(content).toContain('Solo italiano'); // orphan preserved + expect(content).toContain('[it] Three'); // new source key translated + // Orphan keeps its position between `one` and `two`. + expect(content.indexOf('one')).toBeLessThan(content.indexOf('only_it')); + expect(content.indexOf('only_it')).toBeLessThan(content.indexOf('name="two"')); + }); + + it('Xcode .strings: keeps orphan key at its position', async () => { + await seedStrings(); + await translate(); + + const content = await read('it.lproj', 'Localizable.strings'); + expect(content).toContain('"only_it" = "Solo italiano";'); // orphan preserved + expect(content).toContain('[it] Three'); // new source key translated + expect(content.indexOf('"one"')).toBeLessThan(content.indexOf('only_it')); + expect(content.indexOf('only_it')).toBeLessThan(content.indexOf('"two"')); + }); + + it('Xcode .stringsdict: keeps orphan plural entry with its real structure', async () => { + await seedStringsdict(); + await translate(); + + const content = await read('it.lproj', 'Localizable.stringsdict'); + // Orphan entry preserved with its plural structure and value untouched. + expect(content).toContain('only_it_count'); + expect(content).toContain('solo %d'); + expect(content).toContain('%#@items@'); + // New source key translated. + expect(content).toContain('item_count'); + expect(content).toContain('[it] %d item'); + }); + + it('Xcode .xcstrings: keeps orphan entry that only has a target localization', async () => { + await seedXcstrings(); + await translate(); + + const content = await readJson('Localizable.xcstrings'); // Orphan entry's target localization preserved untouched. expect(content.strings.only_it.localizations.it.stringUnit.value).toBe('Solo italiano'); // Shared entry translated into `it`. expect(content.strings.shared.localizations.it.stringUnit.value).toBe('[it] Shared'); }); - // --------------------------------------------------------------------------- - // init must never touch target files - // --------------------------------------------------------------------------- it('init does not read, modify or create target translation files', async () => { await mkdir(path.join(testDir, 'i18n', 'locales'), { recursive: true }); await writeFile( @@ -396,4 +435,174 @@ ${pluralEntry('only_it_count', 'solo %d', 'solo %d')} expect(await readFile(itPath, 'utf-8')).toBe(original); expect(existsSync(path.join(testDir, 'lara.yaml'))).toBe(true); }); + + // =========================================================================== + // translation.orphanKeys: delete + // + // Opting out of preservation: orphans are dropped so the target mirrors the + // source. Shared keys must be untouched, and the format-specific carve-outs + // for non-translatable entries must still survive. + // =========================================================================== + + it('delete: JSON drops the orphan and keeps everything else', async () => { + await seedJson(); + await setOrphanKeys('delete'); + await translate(); + + const result = await readJson('i18n', 'locales', 'it.json'); + expect(result.orphan).toBeUndefined(); + expect(Object.keys(result)).toEqual(['one', 'two', 'three']); + expect(result.one).toBe('[it] One'); + expect(result.three).toBe('[it] Three'); + }); + + it('delete: PO drops the orphan message', async () => { + await seedPo(); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('locales', 'it', 'messages.po'); + expect(content).not.toContain('OnlyItalian'); + expect(content).not.toContain('Solo italiano'); + expect(content).toContain('[it] Hello'); + expect(content).toContain('[it] World'); + }); + + it('delete: TS drops the orphan inside the target locale subtree', async () => { + await seedTs(); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('src', 'i18n.ts'); + expect(content).not.toContain('Solo italiano'); + expect(content).toContain('[it] Three'); + }); + + it('delete: Vue drops the orphan inside the target locale block', async () => { + await seedVue(); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('src', 'components', 'Hello.vue'); + expect(content).not.toContain('Solo italiano'); + expect(content).toContain('[it] Three'); + }); + + it('delete: Android XML drops the orphan but KEEPS translatable="false" resources', async () => { + await seedAndroid({ nonTranslatable: true }); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('res', 'it', 'strings.xml'); + expect(content).not.toContain('only_it'); + expect(content).not.toContain('Solo italiano'); + // The non-translatable resource survives with its value intact. + expect(content).toContain('name="api_url"'); + expect(content).toContain('https://example.org'); + expect(content).toContain('[it] Three'); + }); + + it('delete: Xcode .strings drops the orphan key', async () => { + await seedStrings(); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('it.lproj', 'Localizable.strings'); + expect(content).not.toContain('only_it'); + expect(content).not.toContain('Solo italiano'); + expect(content).toContain('"one" = "[it] One";'); + expect(content).toContain('[it] Three'); + }); + + it('delete: Xcode .stringsdict drops the orphan plural entry', async () => { + await seedStringsdict(); + await setOrphanKeys('delete'); + await translate(); + + const content = await read('it.lproj', 'Localizable.stringsdict'); + expect(content).not.toContain('only_it_count'); + expect(content).not.toContain('solo %d'); + expect(content).toContain('item_count'); + expect(content).toContain('[it] %d item'); + }); + + it('delete: Xcode .xcstrings drops the orphan target localization', async () => { + await seedXcstrings(); + await setOrphanKeys('delete'); + await translate(); + + const content = await readJson('Localizable.xcstrings'); + // The orphan's target localization is pruned; the shared key is translated. + expect(content.strings.only_it.localizations?.it).toBeUndefined(); + expect(content.strings.shared.localizations.it.stringUnit.value).toBe('[it] Shared'); + }); + + // --------------------------------------------------------------------------- + // Defaults and CLI precedence + // --------------------------------------------------------------------------- + + it('defaults to keep when the config has no translation section at all', async () => { + await seedJson(); + await removeTranslationSection(); + await translate(); + + const result = await readJson('i18n', 'locales', 'it.json'); + expect(result.orphan).toBe('SOLO IT'); + expect(result.three).toBe('[it] Three'); + }); + + it('--orphan-keys delete overrides a config set to keep', async () => { + await seedJson(); + await setOrphanKeys('keep'); + await translate(['--orphan-keys', 'delete']); + + const result = await readJson('i18n', 'locales', 'it.json'); + expect(result.orphan).toBeUndefined(); + expect(result.three).toBe('[it] Three'); + }); + + it('--orphan-keys keep overrides a config set to delete', async () => { + await seedJson(); + await setOrphanKeys('delete'); + await translate(['--orphan-keys', 'keep']); + + const result = await readJson('i18n', 'locales', 'it.json'); + expect(result.orphan).toBe('SOLO IT'); + expect(result.three).toBe('[it] Three'); + }); + + it('source-deleted keys are removed in BOTH modes', async () => { + // `gone` exists in the target and was removed from the source. Unlike an + // orphan it is recorded in the changelog as deleted, so it must disappear + // regardless of the orphanKeys setting. + await mkdir(path.join(testDir, 'i18n', 'locales'), { recursive: true }); + await writeFile( + path.join(testDir, 'i18n', 'locales', 'en.json'), + JSON.stringify({ one: 'One', gone: 'Gone' }, null, 2) + ); + await writeFile( + path.join(testDir, 'i18n', 'locales', 'it.json'), + JSON.stringify({ one: '[it] One', gone: '[it] Gone' }, null, 2) + ); + await init('i18n/locales/[locale].json'); + // First pass records the checksum so the next one sees `gone` as deleted. + await translate(); + await writeFile( + path.join(testDir, 'i18n', 'locales', 'en.json'), + JSON.stringify({ one: 'One', two: 'Two' }, null, 2) + ); + + await setOrphanKeys('keep'); + await translate(); + + let result = await readJson('i18n', 'locales', 'it.json'); + expect(result.gone).toBeUndefined(); + expect(result.two).toBe('[it] Two'); + + await setOrphanKeys('delete'); + await translate(); + + result = await readJson('i18n', 'locales', 'it.json'); + expect(result.gone).toBeUndefined(); + }); }); diff --git a/src/__tests__/utils/config-orphan-keys.test.ts b/src/__tests__/utils/config-orphan-keys.test.ts new file mode 100644 index 0000000..ec7e187 --- /dev/null +++ b/src/__tests__/utils/config-orphan-keys.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +import { Config, DEFAULT_BATCH_SIZE, DEFAULT_ORPHAN_KEYS } from '#modules/config/config.types.js'; + +/** + * Schema-level coverage for `translation.orphanKeys`. The behaviour it drives is + * tested end to end in `integration/orphan-keys.integration.test.ts`; here we + * only pin down parsing, the default, and rejection of bad values — the default + * in particular, because it is what keeps existing projects on the old + * preserve-orphans behaviour after upgrading. + */ +const baseConfig = { + version: '1.0.0', + locales: { source: 'en', target: ['it'] }, + files: { + json: { include: ['src/i18n/[locale].json'] }, + }, +}; + +describe('config schema: translation.orphanKeys', () => { + it('defaults to keep when the translation section is absent', () => { + const parsed = Config.parse(baseConfig); + + expect(parsed.translation.orphanKeys).toBe('keep'); + expect(DEFAULT_ORPHAN_KEYS).toBe('keep'); + expect(parsed.translation.batchSize).toBe(DEFAULT_BATCH_SIZE); + }); + + it('defaults to keep when the translation section omits the field', () => { + const parsed = Config.parse({ ...baseConfig, translation: { batchSize: 10 } }); + + expect(parsed.translation.orphanKeys).toBe('keep'); + expect(parsed.translation.batchSize).toBe(10); + }); + + it('accepts both modes', () => { + for (const mode of ['keep', 'delete'] as const) { + const parsed = Config.parse({ ...baseConfig, translation: { orphanKeys: mode } }); + expect(parsed.translation.orphanKeys).toBe(mode); + // Setting one field must not clobber the other's default. + expect(parsed.translation.batchSize).toBe(DEFAULT_BATCH_SIZE); + } + }); + + it('rejects an unknown mode', () => { + const result = Config.safeParse({ ...baseConfig, translation: { orphanKeys: 'remove' } }); + + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('orphanKeys'); + }); +}); diff --git a/src/cli/cmd/init/init.ts b/src/cli/cmd/init/init.ts index 103af7b..266b3fc 100644 --- a/src/cli/cmd/init/init.ts +++ b/src/cli/cmd/init/init.ts @@ -10,7 +10,11 @@ import { isRunningInInteractiveMode } from '#utils/cli.js'; import { COMMA_AND_SPACE_REGEX } from '#modules/common/common.const.js'; import { pathsInput, sourceInput, targetInput } from './init.input.js'; import { InitOptions } from './init.types.js'; -import { ConfigType, DEFAULT_BATCH_SIZE } from '#modules/config/config.types.js'; +import { + ConfigType, + DEFAULT_BATCH_SIZE, + DEFAULT_ORPHAN_KEYS, +} from '#modules/config/config.types.js'; import { Messages } from '#messages/messages.js'; import { setCredentials, resolveProjectInstruction } from './init.utils.js'; import { getFileType } from '#utils/path.js'; @@ -154,7 +158,7 @@ function handleNonInteractiveMode(options: InitOptions): ConfigType { memories: options.translationMemories, glossaries: options.glossaries, noTrace: !options.trace, - translation: { batchSize: DEFAULT_BATCH_SIZE }, + translation: { batchSize: DEFAULT_BATCH_SIZE, orphanKeys: DEFAULT_ORPHAN_KEYS }, files: groupPathsByExtension(options.paths), }; } @@ -217,7 +221,7 @@ async function handleInteractiveMode(options: InitOptions): Promise memories: [], glossaries: [], noTrace: !options.trace, - translation: { batchSize: DEFAULT_BATCH_SIZE }, + translation: { batchSize: DEFAULT_BATCH_SIZE, orphanKeys: DEFAULT_ORPHAN_KEYS }, files: groupPathsByExtension(inputPaths), }; } diff --git a/src/cli/cmd/translate/translate.ts b/src/cli/cmd/translate/translate.ts index d4ac27c..6b0dff0 100644 --- a/src/cli/cmd/translate/translate.ts +++ b/src/cli/cmd/translate/translate.ts @@ -9,7 +9,7 @@ import { } from '#modules/common/common.const.js'; import { LocalesEnum } from '#modules/common/common.types.js'; import { ConfigProvider } from '#modules/config/config.provider.js'; -import { ConfigType } from '#modules/config/config.types.js'; +import { ConfigType, ORPHAN_KEYS_MODES, OrphanKeysMode } from '#modules/config/config.types.js'; import { TranslationEngine } from '#modules/translation/translation.engine.js'; import { TranslationService, TextBlock } from '#modules/translation/translation.service.js'; import { searchLocalePathsByPattern, ensureDirectoryExists, getFileType } from '#utils/path.js'; @@ -35,6 +35,8 @@ type TranslateOptions = { output?: string; translationMemories?: string[]; glossaries?: string[]; + // Undefined means "use the config file value". + orphanKeys?: OrphanKeysMode; }; type TranslateMode = 'text' | 'file' | 'config'; @@ -104,6 +106,12 @@ export default new Command() ).argParser((value) => value.split(COMMA_AND_SPACE_REGEX)) ) .addOption(new Option('--no-trace', 'Prevent server-side storage of translated content')) + .addOption( + new Option( + '--orphan-keys ', + 'How to handle keys that exist only in target files: keep or delete them. Overrides the config file. Cannot be used with --file or --text.' + ).choices([...ORPHAN_KEYS_MODES]) + ) .action(async (options: TranslateOptions) => { try { const mode = validateAndDetectMode(options); @@ -203,6 +211,10 @@ function validateAndDetectMode(options: TranslateOptions): TranslateMode { throw new Error(Messages.errors.pathsNotAllowedWithDirect); } + if (options.orphanKeys) { + throw new Error(Messages.errors.orphanKeysNotAllowedWithDirect); + } + if (options.output && !options.file) { throw new Error(Messages.errors.outputOnlyWithFile); } @@ -364,6 +376,9 @@ async function handleFileType( glossaryIds: config.glossaries, noTrace: !options.trace || config.noTrace, batchSize: config.translation.batchSize, + // `??`, not `||`: the flag is absent (undefined) or a valid mode, and an + // explicit --orphan-keys keep must win over a config set to delete. + orphanKeys: options.orphanKeys ?? config.translation.orphanKeys, }); try { diff --git a/src/messages/messages.ts b/src/messages/messages.ts index d56a10c..012baef 100644 --- a/src/messages/messages.ts +++ b/src/messages/messages.ts @@ -51,6 +51,7 @@ export const Messages = { targetRequiredForDirect: '--target is required when using --file or --text', forceNotAllowedWithDirect: '--force cannot be used with --file or --text', pathsNotAllowedWithDirect: '--paths cannot be used with --file or --text', + orphanKeysNotAllowedWithDirect: '--orphan-keys cannot be used with --file or --text', outputOnlyWithFile: '--output can only be used with --file', sourceEqualsTarget: '--source and --target cannot be the same locale', emptyText: '--text cannot be empty', diff --git a/src/modules/config/config.types.ts b/src/modules/config/config.types.ts index 697201f..1846a37 100644 --- a/src/modules/config/config.types.ts +++ b/src/modules/config/config.types.ts @@ -12,6 +12,14 @@ import { getFileType, isRelative } from '#utils/path.js'; export const DEFAULT_BATCH_SIZE = 50; +/** + * How to handle "orphan" keys: keys present in a target locale file but absent + * from the source. Kept by default, so translators can add target-only entries + * without them being wiped on the next run. + */ +export const ORPHAN_KEYS_MODES = ['keep', 'delete'] as const; +export const DEFAULT_ORPHAN_KEYS = 'keep'; + const LOCALE_FILENAME_PATTERN = new RegExp( `[^/]*\\[locale\\][^/]*\\.(${SEARCHABLE_EXTENSIONS.join('|')})$` ); @@ -88,8 +96,11 @@ const Config = z translation: z .object({ batchSize: z.number().int().positive().default(DEFAULT_BATCH_SIZE), + orphanKeys: z.enum(ORPHAN_KEYS_MODES).default(DEFAULT_ORPHAN_KEYS), }) - .default({ batchSize: DEFAULT_BATCH_SIZE }), + // `{}` and not a literal repeating both values: each field already carries + // its own default, so this cannot fall out of sync with them. + .prefault({}), files: z.partialRecord( SupportedFileTypesEnum, @@ -154,4 +165,6 @@ const Config = z type ConfigType = z.infer; -export { IncludeFilePath as FilePath, KeyPath, Config, type ConfigType }; +type OrphanKeysMode = (typeof ORPHAN_KEYS_MODES)[number]; + +export { IncludeFilePath as FilePath, KeyPath, Config, type ConfigType, type OrphanKeysMode }; diff --git a/src/modules/translation/translation.engine.ts b/src/modules/translation/translation.engine.ts index c7d8174..11ea916 100644 --- a/src/modules/translation/translation.engine.ts +++ b/src/modules/translation/translation.engine.ts @@ -13,6 +13,7 @@ import { TextBlock } from './translation.service.js'; import { Memory, TranslateOptions } from '@translated/lara'; import { Messages } from '#messages/messages.js'; import { ParserFactory } from '../../parsers/parser.factory.js'; +import { OrphanKeysMode } from '#modules/config/config.types.js'; export type TranslationEngineOptions = { sourceLocale: string; @@ -37,6 +38,8 @@ export type TranslationEngineOptions = { noTrace: boolean; batchSize: number; + + orphanKeys: OrphanKeysMode; }; type OutputSlot = { kind: 'omit' } | { kind: 'keep'; value: unknown } | { kind: 'translate' }; @@ -81,6 +84,8 @@ export class TranslationEngine { private readonly batchSize: number; + private readonly orphanKeys: OrphanKeysMode; + private readonly translatorService: TranslationService; // Parser instance used to parse and serialize translation files. @@ -123,6 +128,8 @@ export class TranslationEngine { this.batchSize = options.batchSize; + this.orphanKeys = options.orphanKeys; + this.translatorService = TranslationService.getInstance(); this.parser = new ParserFactory(this.inputPath); @@ -280,6 +287,16 @@ export class TranslationEngine { // original target position, anchored to the nearest preceding shared key. // Note: source-deleted keys DO appear in the changelog (state 'deleted'), // so they are correctly excluded here and still get removed. + // + // Stopping here is the whole of `orphanKeys: delete`: the merge-based + // parsers filter their own graft on the keys we emit, so they follow suit. + // Android's `translatable="false"` resources survive regardless — + // android-xml.parser.ts keeps them unconditionally because parse() skips + // them, making them non-translatable entries rather than orphans. + if (this.orphanKeys === 'delete') { + return { ordered, solo, batch }; + } + const targetEntries: Array<[string, OutputSlot]> = Object.keys(target).map((key) => [ key, { kind: 'keep', value: target[key] },