` or JSON `baseValue`
-- Sets status to `translated` for all imports
-- Expands Transloco key references (e.g., `{{t('other.key')}}`) into full values
-- Updates comments and tags from import data by default
+- Sets status to `translated` (use `--preserve-status` with rich JSON to honor source status)
+- Resolves Transloco key references (e.g., `{{t('other.key')}}`, `{{key}}`) by inlining the referenced value; missing references emit a "Missing reference target" warning and circular references emit a "Circular reference detected" warning — both are preserved as literals
+- Normalizes Transloco plural/select syntax to ICU format
- Overwrites existing values and metadata
**Flags Default**:
-- `--create-missing`: `true`
-- `--update-comments`: `true`
-- `--update-tags`: `true`
+- `--create-missing`: `true` (applied automatically for migration strategy)
+- `--update-comments`: `false` (pass explicitly to enable)
+- `--update-tags`: `false` (pass explicitly to enable)
**Example**:
```bash
-lingo-tracker import --source old-system-fr.json --locale fr --strategy migration
+lingo-tracker import --source old-system-fr.json --locale fr --strategy migration \
+ --update-comments --update-tags
```
### update
@@ -100,7 +110,7 @@ lingo-tracker import --source old-system-fr.json --locale fr --strategy migratio
**Use Case**: Bulk updates to existing translations (maintaining existing status)
**Behavior**:
-- Updates only existing resources (skips with warning if resource not found)
+- Updates only existing resources (skips missing resources; counted in Resources Skipped, not an error)
- Preserves existing status (does NOT change to `translated`)
- Does NOT create new resources
- Useful for fixing typos or updating specific translations
@@ -193,7 +203,7 @@ LingoTracker automatically fixes common ICU message format placeholder errors ma
**What Gets Reported**:
- All auto-fixes are logged in the import summary with before/after values
-- Errors for unfixable cases (multiple missing placeholders, malformed syntax)
+- Errors for unfixable cases (multiple missing placeholders, malformed syntax) — the resource import fails and is counted in Resources Failed
**Example**:
```
@@ -205,7 +215,7 @@ This ensures translations work correctly even when translators accidentally modi
## Import Summary
-Every import generates an `import-summary.md` file with detailed change tracking:
+Every completed import writes `import-summary.md` to the translations folder (e.g., `src/assets/i18n/import-summary.md`) with detailed change tracking:
- Import statistics (resources imported, created, updated, skipped, failed)
- Status transitions (e.g., "new → translated", "stale → verified")
@@ -214,6 +224,8 @@ Every import generates an `import-summary.md` file with detailed change tracking
- Warnings and errors
- Detailed change log (first 20 resources)
+> **Exit code:** The command exits with code `1` when any resources fail or errors occur. Skipped resources (missing from storage) do not cause a non-zero exit, so CI pipelines will not fail on skips alone.
+
### Dry-Run Mode
Use `--dry-run` to preview what would be imported without modifying files:
@@ -222,13 +234,13 @@ Use `--dry-run` to preview what would be imported without modifying files:
lingo-tracker import --source translations-es.xliff --locale es --dry-run
```
-The dry-run summary shows exactly what would happen, using "Would" language to indicate no changes were made.
+The dry-run summary shows exactly what would happen, using "Would" language to indicate no changes were made. The summary file is not written in dry-run mode, but the path where it would be written is still printed to the console.
## Interactive Mode
If you run `lingo-tracker import` without required options in a terminal, it will launch an interactive wizard to guide you through:
-- Source file selection (with file picker/autocomplete)
+- Source file path entry (with path validation)
- Format auto-detection
- Target locale selection (from configured locales)
- Collection selection (if multiple exist)
diff --git a/docs/features/validate.md b/docs/features/validate.md
index 544d1230..45015be4 100644
--- a/docs/features/validate.md
+++ b/docs/features/validate.md
@@ -1,3 +1,8 @@
+---
+title: Validate
+sidebar_position: 4
+---
+
# Validate Feature
The Validate feature provides a comprehensive translation validation system designed to ensure translation completeness and quality before production deployment. It serves as a quality gate in CI/CD pipelines by checking the status of all translation resources across all configured locales and collections.
@@ -29,8 +34,8 @@ lingo-tracker validate [options]
### Exit Codes
-- `0` - All validations passed (all resources verified)
-- `1` - Validation failures found (new/stale resources or translated without flag)
+- `0` - All validations passed (all resources verified or only warnings)
+- `1` - Validation failures found, OR configuration errors (config file missing or invalid JSON, no collections configured, no target locales configured)
## Validation Rules
@@ -42,6 +47,7 @@ The validate command categorizes resources based on their translation status:
| `stale` | ❌ Failure | ❌ Failure | Base value changed, translation out of sync |
| `translated` | ❌ Failure | ⚠️ Warning | Has translation but not verified |
| `verified` | ✅ Success | ✅ Success | Reviewed and approved |
+| *(missing)* | ❌ Failure | ❌ Failure | No status entry for this locale — treated as `new` |
### Validation Philosophy
@@ -75,7 +81,8 @@ The validate command categorizes resources based on their translation status:
- Base locale (source translations are authoritative by definition)
- ICU message format syntax (use separate linting tools)
- File structure or JSON validity (handled during resource loading)
-- Missing metadata (resources without metadata are skipped with warnings)
+
+Resources with no status entry for a locale are treated as `new` (failure), not skipped.
## Examples
@@ -96,16 +103,28 @@ lingo-tracker validate --allow-translated
#### All Verified (Success)
```
-✅ Translation Validation PASSED
-
-Validation Summary by Locale:
- es:
- verified: 51
-
- fr-ca:
- verified: 51
-
-Total: 0 failures, 0 warnings
+✅ Validation PASSED
+
+📊 Validation Statistics:
+──────────────────────────────────────────────────
+ Total Resources Validated: 102
+ Unique Resource Keys: 51
+ Locales Validated: 2
+ Collections Validated: 1
+
+📈 Status Breakdown:
+──────────────────────────────────────────────────
+ ✅ Verified: 102
+ ✏️ Translated: 0
+ ⚠️ Stale: 0
+ ❌ New: 0
+
+──────────────────────────────────────────────────
+📋 Summary:
+ Total Failures: 0
+ Total Successes: 102
+
+✅ Validation passed successfully!
```
Exit code: `0`
@@ -113,36 +132,43 @@ Exit code: `0`
#### Mixed Status (Failure)
```
-❌ Translation Validation FAILED
-
-Validation Summary by Locale:
- es:
- new: 3
- stale: 2
- translated: 1
- verified: 45
-
- fr-ca:
- new: 1
- stale: 0
- translated: 2
- verified: 48
-
-Failures (6 resources):
- es:
- - apps.common.buttons.submit (new)
- - apps.common.errors.network (new)
- - apps.features.dashboard.title (new)
- - apps.common.buttons.save (stale)
- - apps.features.settings.label (stale)
- - apps.common.buttons.cancel (translated)
-
- fr-ca:
- - apps.common.buttons.submit (new)
- - apps.features.dashboard.subtitle (translated)
- - apps.features.profile.header (translated)
-
-Total: 6 failures, 0 warnings
+❌ Validation FAILED
+
+📊 Validation Statistics:
+──────────────────────────────────────────────────
+ Total Resources Validated: 102
+ Unique Resource Keys: 51
+ Locales Validated: 2
+ Collections Validated: 1
+
+📈 Status Breakdown:
+──────────────────────────────────────────────────
+ ✅ Verified: 93
+ ✏️ Translated: 3
+ ⚠️ Stale: 2
+ ❌ New: 4
+
+❌ Failures (9):
+──────────────────────────────────────────────────
+ Locale: es (6 failures)
+ ❌ [main] apps.common.buttons.submit (new)
+ ❌ [main] apps.common.errors.network (new)
+ ❌ [main] apps.features.dashboard.title (new)
+ ⚠️ [main] apps.common.buttons.save (stale)
+ ⚠️ [main] apps.features.settings.label (stale)
+ ✏️ [main] apps.common.buttons.cancel (translated)
+
+ Locale: fr-ca (3 failures)
+ ❌ [main] apps.common.buttons.submit (new)
+ ✏️ [main] apps.features.dashboard.subtitle (translated)
+ ✏️ [main] apps.features.profile.header (translated)
+
+──────────────────────────────────────────────────
+📋 Summary:
+ Total Failures: 9
+ Total Successes: 93
+
+❌ Validation failed. Please review the failures above.
```
Exit code: `1`
@@ -150,31 +176,40 @@ Exit code: `1`
#### With --allow-translated Flag (Warnings)
```
-⚠️ Translation Validation PASSED with warnings
-
-Validation Summary by Locale:
- es:
- new: 0
- stale: 0
- translated: 3
- verified: 48
-
- fr-ca:
- new: 0
- stale: 0
- translated: 1
- verified: 50
-
-Warnings (4 resources):
- es:
- - apps.common.buttons.cancel (translated)
- - apps.features.dashboard.subtitle (translated)
- - apps.features.profile.header (translated)
-
- fr-ca:
- - apps.common.buttons.apply (translated)
-
-Total: 0 failures, 4 warnings
+✅ Validation PASSED
+
+📊 Validation Statistics:
+──────────────────────────────────────────────────
+ Total Resources Validated: 102
+ Unique Resource Keys: 51
+ Locales Validated: 2
+ Collections Validated: 1
+
+📈 Status Breakdown:
+──────────────────────────────────────────────────
+ ✅ Verified: 98
+ ✏️ Translated: 4
+ ⚠️ Stale: 0
+ ❌ New: 0
+
+⚠️ Warnings (4):
+──────────────────────────────────────────────────
+ Resources with "translated" status (not yet verified):
+ Locale: es (3 warnings)
+ ✏️ [main] apps.common.buttons.cancel (translated)
+ ✏️ [main] apps.features.dashboard.subtitle (translated)
+ ✏️ [main] apps.features.profile.header (translated)
+
+ Locale: fr-ca (1 warnings)
+ ✏️ [main] apps.common.buttons.apply (translated)
+
+──────────────────────────────────────────────────
+📋 Summary:
+ Total Failures: 0
+ Total Warnings: 4
+ Total Successes: 98
+
+✅ Validation passed with warnings.
```
Exit code: `0`
@@ -247,91 +282,6 @@ jobs:
run: pnpm run deploy:staging
```
-### GitLab CI
-
-```yaml
-stages:
- - validate
- - build
- - deploy
-
-validate-translations:
- stage: validate
- script:
- - lingo-tracker validate
- only:
- - main
-
-validate-translations-staging:
- stage: validate
- script:
- - lingo-tracker validate --allow-translated
- only:
- - develop
-```
-
-### Jenkins
-
-```groovy
-pipeline {
- agent any
-
- stages {
- stage('Validate Translations') {
- steps {
- sh 'lingo-tracker validate'
- }
- }
-
- stage('Build') {
- steps {
- sh 'pnpm run build'
- }
- }
-
- stage('Deploy') {
- steps {
- sh 'pnpm run deploy'
- }
- }
- }
-}
-```
-
-### CircleCI
-
-```yaml
-version: 2.1
-
-jobs:
- validate-and-deploy:
- docker:
- - image: cimg/node:22.16
- steps:
- - checkout
- - run:
- name: Install dependencies
- command: pnpm install
- - run:
- name: Validate translations
- command: lingo-tracker validate
- - run:
- name: Build
- command: pnpm run build
- - run:
- name: Deploy
- command: pnpm run deploy
-
-workflows:
- version: 2
- build-and-deploy:
- jobs:
- - validate-and-deploy:
- filters:
- branches:
- only: main
-```
-
### Custom Script Integration
```bash
@@ -526,20 +476,23 @@ The validate feature is implemented across two layers:
The validate command reuses the existing `loadResourcesFromCollections` utility from the core library, ensuring consistency with export/import operations.
-### Status Determination
+### Translation Status Semantics
-Translation status is determined by examining metadata checksums:
+Validate reads pre-computed status values from resource metadata — it does not re-evaluate checksums. The status for each resource/locale pair is determined during resource loading by `loadResourcesFromCollections`. Validate maps a missing status entry to `new` via a null-coalescing fallback.
-- **new**: No translation or translation checksum matches base checksum
-- **stale**: Base checksum changed since translation was created
-- **translated**: Translation exists with non-matching checksums, not verified
-- **verified**: Translation exists and marked as verified
+The meaning of each status value:
+
+- **new**: Resource has been added but not yet translated (explicitly stored status)
+- **stale**: Base checksum changed since the translation was last updated (source drifted)
+- **translated**: Translation exists and matches the current base checksum, but not yet verified
+- **verified**: Translation exists and has been explicitly marked as verified
+
+A missing metadata entry for a locale is coalesced to `new` at validation time (already covered by the *(missing)* row in the Validation Rules table above).
### Performance
- **Fast**: Validation only reads metadata, no heavy computation
- **Scalable**: Handles projects with 10,000+ resources efficiently
-- **Memory-efficient**: Streams through resources, no large in-memory accumulation
## Limitations
@@ -554,13 +507,21 @@ Validate always checks ALL collections and ALL target locales. There is no optio
### No Verbose Mode
-Unlike export/import commands, validate does not have a `--verbose` flag. Output is always concise, showing only:
-- Summary counts by locale
-- List of failures
-- List of warnings
+Unlike export/import commands, validate does not have a `--verbose` flag. Output always includes:
+- Statistics (total resources, unique keys, locales, collections validated)
+- Status breakdown by count (verified, translated, stale, new)
+- Failures grouped by locale (with collection name and status)
+- Warnings grouped by locale (when `--allow-translated` is used)
+- Summary footer with total counts and final verdict
+
+Verified resources are not listed individually.
**Rationale**: CI logs should be concise. Showing all verified resources adds noise.
+### Output Truncation
+
+Within each locale group, at most 100 failures or warnings are shown. The cap is applied independently per locale — a project with 5 locales could show up to 500 failure lines. When the per-locale limit is exceeded, the output includes `... and N more failures` (or `... and N more warnings` for the warnings section) to indicate additional items. This prevents extremely long CI logs for large projects with many missing translations.
+
### No JSON Output
Validate only outputs human-readable text. There is no `--json` flag for structured output.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 2afff585..2b1b3bdf 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,24 +1,51 @@
+---
+title: Getting Started
+sidebar_position: 2
+---
+
## Getting Started
Lingo Tracker helps you track, validate, and manage translations across projects. This guide shows how to install the CLI, initialize a project, and add collections.
### Install
-Node.js >= 22.16 is required. Install the CLI as a dev dependency in your project:
+Node.js >= 22.16 is required. Install the CLI as a dev dependency in your project.
+
+#### pnpm
```bash
pnpm add -D @simoncodes-ca/lingo-tracker
+```
+
+#### npm
+
+```bash
npm install --save-dev @simoncodes-ca/lingo-tracker
+```
+
+#### yarn
+
+```bash
yarn add -D @simoncodes-ca/lingo-tracker
```
-Once installed, the use `npx lingo-tracker` command to interact with the CLI.
+Once installed, use the `npx lingo-tracker` command to interact with the CLI.
+
+### Install the Lingo Tracker Skill
+
+If you use Claude Code (or a compatible AI assistant), install the Lingo Tracker skill to enable guided i18n workflows — detecting hardcoded strings, creating translation resources, and updating components to use Transloco:
+
+```bash
+npx lingo-tracker install-skill
+```
+
+This writes a skill file to your project that Claude Code picks up automatically. See [`install-skill`](./cli.md#install-skill) in the CLI Reference for details.
### Run the Lingo Tracker App
The `lingo-tracker` package ships a built-in API server that hosts the Lingo Tracker web interface.
-### Then start the app server:
+Then start the app server:
```bash
npx lingo-tracker-app
@@ -39,7 +66,7 @@ Or set the `LINGO_TRACKER_PORT` environment variable. The `--port` flag takes pr
```json
{
"scripts": {
- "lingo-tracker-app": "lingo-tracker-api --port 4000"
+ "lingo-tracker-app": "lingo-tracker-app --port 4000"
}
}
```
@@ -337,4 +364,3 @@ For full details on configuration, ICU handling, and best practices, see the [Au
- For managing translation resources and other CLI commands, see the [CLI Reference](./cli.md)
- For programmatic access via REST API, see the [API Reference](./api.md)
- For setting up machine translation, see the [Auto-Translation Guide](./auto-translation.md)
-- To set up an AI assistant skill for your repo (guides Claude Code and similar tools through the i18n workflow), run `lingo-tracker install-skill` — see [`install-skill`](./cli.md#install-skill) in the CLI Reference
diff --git a/docs/guides/bundling.md b/docs/guides/bundling.md
index e7bf9876..cae0b174 100644
--- a/docs/guides/bundling.md
+++ b/docs/guides/bundling.md
@@ -1,3 +1,8 @@
+---
+title: Bundling
+sidebar_position: 1
+---
+
# Bundle Generation Guide
This guide provides comprehensive examples and best practices for using LingoTracker's bundle generation feature to create optimized translation files for your applications.
@@ -21,7 +26,9 @@ This guide provides comprehensive examples and best practices for using LingoTra
### What Are Bundles?
-Bundles are **deployment-ready translation files** generated from LingoTracker's source translations.
+LingoTracker stores translations in a structured format optimized for management — version control friendly, metadata-rich, and split across many small files. Your application, however, needs deployment-ready JSON files per locale at runtime. Bundles are that output: **one or more domain-specific translation files** assembled from LingoTracker's source translations.
+
+A single app might ship a `core` bundle loaded eagerly, an `admin` bundle loaded only for admin users, and a `reports` bundle lazy-loaded on demand. Each bundle is independently configured — you control which collections and keys go into it using key patterns, tags, or both. Tags in particular enable a high degree of customization: you can slice your translations by any cross-cutting concern (feature, audience, environment) regardless of how keys are structured in the folder hierarchy.
**Source (LingoTracker)**:
```
@@ -52,6 +59,8 @@ translations/
## Quick Start
+> **Prerequisites**: Complete the [Getting Started](../getting-started.md) guide first. You should already have a `.lingo-tracker.json` with at least one collection configured.
+
### 1. Add Bundle Configuration
Edit `.lingo-tracker.json`:
@@ -95,6 +104,68 @@ src/assets/i18n/
Each file contains hierarchical JSON with all translations for that locale.
+> **Want TypeScript type safety?** Add `typeDistFile` to your bundle config and LingoTracker will generate a typed constants file alongside the JSON bundles. See [Bundle Type Generation](../features/bundle-type-generation.md).
+
+### 4. Add Type-Safe Tokens (Optional)
+
+Extend the bundle config with `typeDistFile`:
+
+```json
+{
+ "bundles": {
+ "main": {
+ "bundleName": "{locale}",
+ "dist": "./src/assets/i18n",
+ "collections": "All",
+ "typeDistFile": "./src/generated/main-tokens.ts"
+ }
+ }
+}
+```
+
+Running `lingo-tracker bundle` now also generates `src/generated/main-tokens.ts`:
+
+```typescript
+// src/generated/main-tokens.ts (auto-generated — do not edit)
+export const MAIN_TOKENS = {
+ APPS: {
+ COMMON: {
+ BUTTONS: {
+ OK: 'apps.common.buttons.ok',
+ CANCEL: 'apps.common.buttons.cancel',
+ APPLY: 'apps.common.buttons.apply',
+ },
+ },
+ },
+} as const;
+
+export type MainTokens = typeof MAIN_TOKENS;
+```
+
+Use the constants in your components — no more hardcoded string keys:
+
+```typescript
+import { MAIN_TOKENS } from '@/generated/main-tokens';
+
+@Component({
+ template: `
+ {{ tokens.APPS.COMMON.BUTTONS.OK | transloco }}
+
+ `
+})
+export class AppComponent {
+ readonly tokens = MAIN_TOKENS;
+}
+```
+
+Or pass individual keys directly:
+
+```typescript
+this.translocoService.translate(MAIN_TOKENS.APPS.COMMON.BUTTONS.OK);
+```
+
+Your IDE will autocomplete available keys, and renaming a key in LingoTracker regenerates the constant — any stale references become compile errors.
+
---
## Bundle Configuration
@@ -177,29 +248,15 @@ Generate different bundles for different purposes:
### Filename Patterns
-The `{locale}` placeholder is replaced with each locale:
+The `{locale}` placeholder in `bundleName` is replaced with each locale at generation time:
-```json
-{
- "bundles": {
- "example1": {
- "bundleName": "{locale}", // en.json, fr-ca.json
- "dist": "./dist/i18n",
- "collections": "All"
- },
- "example2": {
- "bundleName": "app.{locale}", // app.en.json, app.fr-ca.json
- "dist": "./dist",
- "collections": "All"
- },
- "example3": {
- "bundleName": "{locale}/main", // en/main.json, fr-ca/main.json
- "dist": "./dist/i18n",
- "collections": "All"
- }
- }
-}
-```
+| `bundleName` | Output files (locales: `en`, `fr-ca`) |
+|---|---|
+| `{locale}` | `dist/i18n/en.json`, `dist/i18n/fr-ca.json` |
+| `app.{locale}` | `dist/app.en.json`, `dist/app.fr-ca.json` |
+| `{locale}/main` | `dist/i18n/en/main.json`, `dist/i18n/fr-ca/main.json` |
+
+The subdirectory form (`{locale}/main`) is useful for frameworks that expect one folder per locale.
---
@@ -220,13 +277,11 @@ When multiple collections contribute the same key, merge strategies determine wh
"collections": [
{
"name": "Common",
- "entriesSelectionRules": "All",
- "mergeStrategy": "merge"
+ "entriesSelectionRules": "All"
},
{
"name": "App",
- "entriesSelectionRules": "All",
- "mergeStrategy": "merge"
+ "entriesSelectionRules": "All"
}
]
}
@@ -234,6 +289,8 @@ When multiple collections contribute the same key, merge strategies determine wh
}
```
+> `merge` is the default strategy, so you don't need to specify it explicitly.
+
**Example**:
Common collection has: `apps.buttons.ok = "OK"`
@@ -526,69 +583,7 @@ export class TranslocoHttpLoader implements TranslocoLoader {
export class AppComponent {}
```
----
-
-### react-i18next
-
-**Bundle Configuration**:
-```json
-{
- "bundles": {
- "react": {
- "bundleName": "{locale}/translation",
- "dist": "./public/locales",
- "collections": "All"
- }
- }
-}
-```
-
-**Generates**:
-```
-public/locales/
-├── en/translation.json
-├── fr-ca/translation.json
-└── es/translation.json
-```
-
-**i18next Setup** (`i18n.js`):
-```javascript
-import i18n from 'i18next';
-import { initReactI18next } from 'react-i18next';
-import HttpBackend from 'i18next-http-backend';
-
-i18n
- .use(HttpBackend)
- .use(initReactI18next)
- .init({
- fallbackLng: 'en',
- supportedLngs: ['en', 'fr-ca', 'es'],
- backend: {
- loadPath: '/locales/{{lng}}/{{ns}}.json'
- },
- interpolation: {
- escapeValue: false
- }
- });
-
-export default i18n;
-```
-
-**Usage in Components**:
-```jsx
-import { useTranslation } from 'react-i18next';
-
-function App() {
- const { t } = useTranslation();
-
- return (
-
-
{t('apps.common.title')}
-
-
- );
-}
-```
+> LingoTracker currently has first-class integration guidance for Angular/Transloco. Other frameworks (React, Vue, etc.) work the same way — configure `bundleName` and `dist` to match where your i18n library expects to load files from.
---
@@ -782,6 +777,73 @@ this.translocoService.setActiveLang('en');
this.translocoService.load('admin').subscribe();
```
+### Tag-Driven Multi-Bundle Split
+
+Tags let you split a single collection into multiple bundles without relying on key structure. This is useful when translations don't follow a clean folder hierarchy, or when the same key might need to appear in more than one bundle.
+
+In this example, a single `App` collection is tagged per feature area. Each bundle selects only the keys tagged for it:
+
+**Tagging your resources** (in LingoTracker UI or via CLI):
+- `apps.common.*` keys → tagged `core`
+- `apps.admin.*` keys → tagged `admin`
+- `apps.reports.*` keys → tagged `reports`
+
+**Bundle configuration**:
+
+```json
+{
+ "bundles": {
+ "core": {
+ "bundleName": "core.{locale}",
+ "dist": "./src/assets/i18n",
+ "collections": [
+ {
+ "name": "App",
+ "entriesSelectionRules": [
+ {
+ "matchingPattern": "*",
+ "matchingTags": ["core"]
+ }
+ ]
+ }
+ ]
+ },
+ "admin": {
+ "bundleName": "admin.{locale}",
+ "dist": "./src/assets/i18n",
+ "collections": [
+ {
+ "name": "App",
+ "entriesSelectionRules": [
+ {
+ "matchingPattern": "*",
+ "matchingTags": ["admin"]
+ }
+ ]
+ }
+ ]
+ },
+ "reports": {
+ "bundleName": "reports.{locale}",
+ "dist": "./src/assets/i18n",
+ "collections": [
+ {
+ "name": "App",
+ "entriesSelectionRules": [
+ {
+ "matchingPattern": "*",
+ "matchingTags": ["reports"]
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
+```
+
+The same approach works for any cross-cutting split: by audience (`public` / `internal`), by feature flag, or by release stage. A key can carry multiple tags, so it can appear in more than one bundle if needed.
+
### Environment-Specific Bundles
Different bundles for different environments:
@@ -814,12 +876,13 @@ Different bundles for different environments:
}
```
-Generate specific bundle:
+By default `lingo-tracker bundle` generates all configured bundles. Use `--name` to target a single bundle by its key in `.lingo-tracker.json`:
+
```bash
lingo-tracker bundle --name production
```
-Override token casing from the CLI:
+Override token casing for a single bundle run:
```bash
lingo-tracker bundle --name core --token-casing camelCase
```
@@ -869,7 +932,11 @@ Different bundles for different tenants:
### Bundled Key Prefixes
-Add prefixes to avoid key conflicts:
+`bundledKeyPrefix` prepends a namespace segment to every key from a collection before merging. This is useful when two collections have overlapping key structures and you need to keep them separate in the output.
+
+For example, if both `AppTranslations` and `LibraryTranslations` have a `buttons.ok` key, including both without a prefix would cause a collision (only one value survives). With `bundledKeyPrefix`, each collection's keys live under their own namespace.
+
+**Example**:
```json
{
@@ -902,6 +969,8 @@ Add prefixes to avoid key conflicts:
## Troubleshooting
+> **Tip**: Run `lingo-tracker bundle --help` for a full list of flags. Add `--verbose` to any bundle command to see detailed output about which keys each collection contributes.
+
### Empty Bundle Warning
**Problem**: "Bundle 'main' for locale 'en' is empty"
@@ -976,17 +1045,19 @@ Add prefixes to avoid key conflicts:
## Summary
-Bundle generation is a powerful feature that separates translation management from application deployment:
+Bundle generation bridges translation management and application deployment: LingoTracker manages your translations in a structured, version-control-friendly format, and `lingo-tracker bundle` produces the runtime files your app actually loads.
-- **Configure once** in `.lingo-tracker.json`
-- **Generate anytime** with `lingo-tracker bundle`
-- **Integrate seamlessly** with any framework or build tool
-- **Filter flexibly** with patterns and tags
-- **Customize output** with merge strategies and prefixes
+**Recommended next step**: Add bundle generation to your `prebuild` npm script so bundles are always up to date before each build:
-For more information:
-- [CLI Reference](../cli.md) - Complete command documentation
-- [Getting Started](../getting-started.md) - Initial setup guide
-- [API Reference](../api.md) - REST API endpoints
+```json
+{
+ "scripts": {
+ "prebuild": "lingo-tracker bundle"
+ }
+}
+```
-**Next Steps**: Add bundle generation to your build pipeline and start deploying optimized translation files!
+**Further reading**:
+- [Bundle Type Generation](../features/bundle-type-generation.md) — generate TypeScript constants for type-safe translation keys
+- [CLI Reference](../cli.md) — full `bundle` command options and flags
+- [Getting Started](../getting-started.md) — initial project setup
diff --git a/docs/guides/migration.md b/docs/guides/migration.md
index f9bd0457..700f10c2 100644
--- a/docs/guides/migration.md
+++ b/docs/guides/migration.md
@@ -1,3 +1,8 @@
+---
+title: Migration
+sidebar_position: 2
+---
+
# Migration Guide
This guide provides step-by-step instructions for migrating translation resources from other translation management systems to LingoTracker.
@@ -78,20 +83,34 @@ Always create a backup of your original translation files before migration:
cp -r ./src/i18n ./src/i18n.backup
```
+If something goes wrong, restore with:
+
+```bash
+cp -r ./src/i18n.backup ./src/i18n
+```
+
### 3. Choose Migration Strategy
LingoTracker's import feature has a dedicated `migration` strategy that:
-- Creates new resources (base + translations)
-- Resolves Transloco-style key references
-- Updates comments and tags by default
+- Creates new resources when `--create-missing` is specified
+- Resolves Transloco-style key references (`{{t('key')}}`)
+- Updates comments and tags when `--update-comments` / `--update-tags` are specified
- Sets all imported translations to `translated` status
+> **Tip**: Always do a dry run first to preview what will be created before committing to the import:
+> ```bash
+> lingo-tracker import --source en.json --locale en --strategy migration \
+> --create-missing --dry-run
+> ```
+
---
## Migrating from Transloco
Transloco is Angular's internationalization library. LingoTracker provides specialized support for Transloco migrations.
+> **After completing the steps below**, follow the [Post-Migration Steps](#post-migration-steps) to normalize resources, configure bundles, and test your application.
+
### Source Format
Transloco typically uses one JSON file per locale:
@@ -120,92 +139,24 @@ src/assets/i18n/
}
```
-### Step 1: Export Base Locale with Rich Metadata
-
-If your Transloco files don't have base values, create a rich JSON export:
-
-```bash
-# Export existing Transloco translations to get structure
-lingo-tracker export --format json --locale en --rich --include-base \
- --output ./migration-prep
-```
-
-This creates `migration-prep/en.json` with rich format that includes base values.
-
-### Step 2: Prepare Translation Files
-
-For each target locale, you need to prepare a JSON file with base values.
-
-**Option A: Transform existing Transloco files manually**
-
-Create a script to add base values to your Transloco files:
-
-```javascript
-// transform-transloco.js
-const fs = require('fs');
-
-const baseTranslations = JSON.parse(fs.readFileSync('./src/assets/i18n/en.json', 'utf8'));
-const targetTranslations = JSON.parse(fs.readFileSync('./src/assets/i18n/es.json', 'utf8'));
-
-function addBaseValues(base, target, prefix = '') {
- const result = {};
-
- for (const [key, value] of Object.entries(target)) {
- const fullKey = prefix ? `${prefix}.${key}` : key;
-
- if (typeof value === 'object' && !Array.isArray(value)) {
- // Recurse for nested objects
- Object.assign(result, addBaseValues(base[key] || {}, value, fullKey));
- } else {
- // Create rich object with base value
- result[fullKey] = {
- value: value,
- baseValue: getNestedValue(base, fullKey.split('.')) || value
- };
- }
- }
-
- return result;
-}
-
-function getNestedValue(obj, path) {
- return path.reduce((current, key) => current?.[key], obj);
-}
-
-const richFormat = addBaseValues(baseTranslations, targetTranslations);
-fs.writeFileSync('./migration/es-rich.json', JSON.stringify(richFormat, null, 2));
-
-console.log('Created migration/es-rich.json');
-```
-
-Run the script:
-```bash
-node transform-transloco.js
-```
-
-**Option B: Use hierarchical format with manual base values**
+### Step 1: Import Base Locale
-Simply use your existing Transloco JSON files and add base values during import:
-
-1. Keep your Transloco files as-is
-2. Import with `--strategy migration`
-3. LingoTracker will use the base locale file for base values
-
-### Step 3: Import Base Locale
-
-Import the base locale first to create the resource structure:
+Import the base locale first to create the resource structure. If you have multiple collections, add `--collection `:
```bash
lingo-tracker import \
--source ./src/assets/i18n/en.json \
--locale en \
--strategy migration \
+ --create-missing \
+ --update-comments \
+ --update-tags \
--verbose
```
-**Note**: When importing into the base locale during migration, LingoTracker creates resources with the base locale value. This is a special case only for the `migration` strategy.
+**Note**: Importing into the base locale is only supported with the `migration` strategy. It creates resources with the base locale value set from the JSON values.
-### Step 4: Import Target Locales
+### Step 2: Import Target Locales
Import each target locale:
@@ -215,6 +166,9 @@ lingo-tracker import \
--source ./src/assets/i18n/es.json \
--locale es \
--strategy migration \
+ --create-missing \
+ --update-comments \
+ --update-tags \
--verbose
# Import French
@@ -222,17 +176,23 @@ lingo-tracker import \
--source ./src/assets/i18n/fr.json \
--locale fr \
--strategy migration \
- --verbose
+ --create-missing \
+ --update-comments \
+ --update-tags
# Import German
lingo-tracker import \
--source ./src/assets/i18n/de.json \
--locale de \
--strategy migration \
- --verbose
+ --create-missing \
+ --update-comments \
+ --update-tags
```
-### Step 5: Handle Transloco References
+> **If your target locale files are missing base values**, you can add them with a transformation script before importing. See [Migrating from Other Systems > Generic JSON](#from-json-files-generic) for an example transform.
+
+### Step 3: Handle Transloco References
If your Transloco files use references like `{{t('other.key')}}`, LingoTracker automatically resolves them during migration:
@@ -254,11 +214,12 @@ If your Transloco files use references like `{{t('other.key')}}`, LingoTracker a
This happens automatically with the `migration` strategy.
-### Step 6: Verify Migration
+### Step 4: Verify Migration
-Check the import summary for statistics and warnings:
+Check the import summary — the exact path is printed at the end of each import command output (typically `{translationsFolder}/import-summary.md`):
```bash
+# Example — adjust path to match your translationsFolder config
cat translations/import-summary.md
```
@@ -268,7 +229,7 @@ Verify a few resources were created correctly:
cat translations/common/buttons/resource_entries.json
```
-### Step 7: Update Transloco Configuration
+### Step 5: Update Transloco Configuration
After migration, update your Transloco loader to use LingoTracker bundles:
@@ -308,6 +269,8 @@ loader: TranslocoHttpLoader // Loads from src/assets/i18n
i18next is a popular internationalization framework for JavaScript. LingoTracker can import i18next JSON files directly.
+> **After completing the steps below**, follow the [Post-Migration Steps](#post-migration-steps) to normalize resources, configure bundles, and test your application.
+
### Source Format
i18next typically organizes translations by namespace:
@@ -417,13 +380,16 @@ node combine-namespaces.js
### Step 2: Import Base Locale
-Import the base locale (usually English):
+Import the base locale (usually English). If you have multiple collections, add `--collection `:
```bash
lingo-tracker import \
--source ./migration/en.json \
--locale en \
--strategy migration \
+ --create-missing \
+ --update-comments \
+ --update-tags \
--verbose
```
@@ -436,13 +402,19 @@ Import each target locale:
lingo-tracker import \
--source ./migration/es.json \
--locale es \
- --strategy migration
+ --strategy migration \
+ --create-missing \
+ --update-comments \
+ --update-tags
# Import French
lingo-tracker import \
--source ./migration/fr.json \
--locale fr \
- --strategy migration
+ --strategy migration \
+ --create-missing \
+ --update-comments \
+ --update-tags
```
### Step 4: Update i18next Configuration
@@ -529,12 +501,14 @@ Create a transformation script to convert your format to LingoTracker's expected
lingo-tracker import \
--source ./transformed-en.json \
--locale en \
- --strategy migration
+ --strategy migration \
+ --create-missing
lingo-tracker import \
--source ./transformed-es.json \
--locale es \
- --strategy migration
+ --strategy migration \
+ --create-missing
```
### From XLIFF Files
@@ -559,6 +533,7 @@ lingo-tracker import \
--source ./es.xliff \
--locale es \
--strategy migration \
+ --create-missing \
--verbose
```
@@ -612,7 +587,8 @@ fs.writeFileSync('./es-transformed.json', JSON.stringify(transformed, null, 2));
lingo-tracker import \
--source ./es-transformed.json \
--locale es \
- --strategy migration
+ --strategy migration \
+ --create-missing
```
### From CSV Files
@@ -667,9 +643,9 @@ buttons.ok,OK,Aceptar,OK,OK
#### Step 2: Import
```bash
-lingo-tracker import --source ./es.json --locale es --strategy migration
-lingo-tracker import --source ./fr.json --locale fr --strategy migration
-lingo-tracker import --source ./de.json --locale de --strategy migration
+lingo-tracker import --source ./es.json --locale es --strategy migration --create-missing
+lingo-tracker import --source ./fr.json --locale fr --strategy migration --create-missing
+lingo-tracker import --source ./de.json --locale de --strategy migration --create-missing
```
---
@@ -678,9 +654,10 @@ lingo-tracker import --source ./de.json --locale de --strategy migration
### 1. Verify Import Summary
-Check the import summary for each locale:
+The import command prints the summary file path at the end of its output. Open it to check each locale import:
```bash
+# Path depends on your translationsFolder config — check the import output
cat translations/import-summary.md
```
@@ -708,6 +685,8 @@ Export resources with `new` or `stale` status to identify missing translations:
lingo-tracker export --format json --status new,stale --output ./review
```
+If you have multiple collections, add `--collection ` to target a specific one.
+
### 4. Configure Bundles
Set up bundle generation for your framework:
@@ -883,12 +862,12 @@ cat translations/path/to/resource/resource_entries.json
### Import Summary Shows Skipped Resources
-**Cause**: Using wrong strategy. Default `translation-service` strategy doesn't create new resources.
+**Cause**: Using wrong strategy or missing `--create-missing`. Default `translation-service` strategy doesn't create new resources.
-**Solution**: Use `--strategy migration` for migration imports:
+**Solution**: Use `--strategy migration --create-missing` for migration imports:
```bash
-lingo-tracker import --source file.json --locale es --strategy migration
+lingo-tracker import --source file.json --locale es --strategy migration --create-missing
```
---
@@ -897,9 +876,9 @@ lingo-tracker import --source file.json --locale es --strategy migration
Migrating to LingoTracker involves:
-1. **Export** from source system (or use existing files)
-2. **Transform** to JSON or XLIFF format
-3. **Import** with `--strategy migration`
+1. **Prepare** source files (transform format if needed)
+2. **Dry run** with `--dry-run` to preview what will be created
+3. **Import** with `--strategy migration --create-missing`
4. **Normalize** to add missing locale entries
5. **Configure** bundles for your framework
6. **Test** thoroughly before removing old files
@@ -907,16 +886,19 @@ Migrating to LingoTracker involves:
### Quick Reference
```bash
-# Migration workflow
-lingo-tracker import --source en.json --locale en --strategy migration
-lingo-tracker import --source es.json --locale es --strategy migration
-lingo-tracker import --source fr.json --locale fr --strategy migration
+# Migration workflow (base locale first, then target locales)
+lingo-tracker import --source en.json --locale en --strategy migration \
+ --create-missing --update-comments --update-tags
+lingo-tracker import --source es.json --locale es --strategy migration \
+ --create-missing --update-comments --update-tags
+lingo-tracker import --source fr.json --locale fr --strategy migration \
+ --create-missing --update-comments --update-tags
# Post-migration
lingo-tracker normalize --all
lingo-tracker bundle
-# Verify
+# Verify (path printed at end of each import command)
cat translations/import-summary.md
```
diff --git a/libs/core/src/collections-manager/add-locale-to-collection.spec.ts b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts
new file mode 100644
index 00000000..6f6fdfb9
--- /dev/null
+++ b/libs/core/src/collections-manager/add-locale-to-collection.spec.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+import { addLocaleToCollection } from './add-locale-to-collection';
+import { calculateChecksum } from '../resource/checksum';
+import type { ResourceEntries } from '../resource/resource-entry';
+import type { TrackerMetadata } from '../resource/tracker-metadata';
+import type { SafeAny } from '../constants';
+import { setupMockFs, makeBaseConfig } from './locale-spec-helpers';
+
+vi.mock('fs');
+
+const CWD = '/test';
+const CONFIG_PATH = path.join(CWD, '.lingo-tracker.json');
+const TRANSLATIONS_FOLDER = path.join(CWD, 'src/i18n');
+
+function makeConfig(overrides: SafeAny = {}) {
+ return makeBaseConfig(overrides);
+}
+
+describe('addLocaleToCollection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('adds locale to config and backfills resource entries', async () => {
+ const entries: ResourceEntries = {
+ ok: { source: 'OK', fr: 'OK' },
+ };
+ const meta: TrackerMetadata = {
+ ok: {
+ en: { checksum: calculateChecksum('OK') },
+ fr: { checksum: calculateChecksum('OK'), baseChecksum: calculateChecksum('OK'), status: 'new' },
+ },
+ };
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['resource_entries.json', 'tracker_meta.json'] },
+ [path.join(TRANSLATIONS_FOLDER, 'resource_entries.json')]: { type: 'file', content: JSON.stringify(entries) },
+ [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: JSON.stringify(meta) },
+ });
+
+ const result = await addLocaleToCollection('main', 'de', { cwd: CWD });
+
+ expect(result.message).toBe('Locale "de" added to collection "main" successfully');
+ expect(result.entriesBackfilled).toBe(1);
+ expect(result.filesUpdated).toBe(1);
+
+ const writeCalls = vi.mocked(fs.writeFileSync).mock.calls;
+ // First call: config file
+ const configWrite = JSON.parse(writeCalls[0][1] as string);
+ expect(configWrite.collections.main.locales).toContain('de');
+
+ // Second call: resource_entries.json — de entry should be source value
+ const entriesWrite = JSON.parse(writeCalls[1][1] as string);
+ expect(entriesWrite.ok.de).toBe('OK');
+
+ // Third call: tracker_meta.json — de metadata should be status 'new'
+ const metaWrite = JSON.parse(writeCalls[2][1] as string);
+ expect(metaWrite.ok.de).toMatchObject({
+ checksum: calculateChecksum('OK'),
+ baseChecksum: calculateChecksum('OK'),
+ status: 'new',
+ });
+ });
+
+ it('copies global locales into collection when collection has no locales override', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ const result = await addLocaleToCollection('main', 'de', { cwd: CWD });
+
+ expect(result.message).toContain('de');
+
+ const configWriteCall = vi.mocked(fs.writeFileSync).mock.calls[0];
+ const writtenConfig = JSON.parse(configWriteCall[1] as string);
+ // Should have copied global locales AND added de
+ expect(writtenConfig.collections.main.locales).toEqual(['en', 'fr', 'de']);
+ });
+
+ it('does not copy global locales when collection already has explicit locales', async () => {
+ const config = makeConfig({
+ locales: ['en', 'fr'],
+ collections: {
+ main: { translationsFolder: 'src/i18n', locales: ['en', 'fr'] },
+ },
+ });
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ await addLocaleToCollection('main', 'de', { cwd: CWD });
+
+ const configWriteCall = vi.mocked(fs.writeFileSync).mock.calls[0];
+ const writtenConfig = JSON.parse(configWriteCall[1] as string);
+ expect(writtenConfig.collections.main.locales).toEqual(['en', 'fr', 'de']);
+ });
+
+ it('handles empty translations folder gracefully (no resource files)', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ const result = await addLocaleToCollection('main', 'de', { cwd: CWD });
+
+ expect(result.entriesBackfilled).toBe(0);
+ expect(result.filesUpdated).toBe(0);
+ });
+
+ it('handles non-existent translations folder gracefully', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ // translations folder not in mockFs
+ });
+
+ const result = await addLocaleToCollection('main', 'de', { cwd: CWD });
+
+ expect(result.entriesBackfilled).toBe(0);
+ expect(result.filesUpdated).toBe(0);
+ });
+
+ it('throws when locale already exists in collection', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(addLocaleToCollection('main', 'fr', { cwd: CWD })).rejects.toThrow(
+ 'Locale "fr" already exists in collection "main"',
+ );
+ });
+
+ it('throws when trying to add the base locale', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(addLocaleToCollection('main', 'en', { cwd: CWD })).rejects.toThrow(
+ 'Cannot add or remove the base locale "en"',
+ );
+ });
+
+ it('throws when collection does not exist', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(addLocaleToCollection('nonexistent', 'de', { cwd: CWD })).rejects.toThrow(
+ 'Collection "nonexistent" not found',
+ );
+ });
+
+ it('throws when locale format is invalid', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(addLocaleToCollection('main', 'not-valid-123', { cwd: CWD })).rejects.toThrow('Invalid locale format');
+ });
+
+ it('uses collection-level baseLocale when blocking base locale add', async () => {
+ const config = makeConfig({
+ collections: {
+ main: { translationsFolder: 'src/i18n', baseLocale: 'fr' },
+ },
+ });
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) },
+ });
+
+ await expect(addLocaleToCollection('main', 'fr', { cwd: CWD })).rejects.toThrow(
+ 'Cannot add or remove the base locale "fr"',
+ );
+ });
+});
diff --git a/libs/core/src/collections-manager/add-locale-to-collection.ts b/libs/core/src/collections-manager/add-locale-to-collection.ts
new file mode 100644
index 00000000..d2e6b8e4
--- /dev/null
+++ b/libs/core/src/collections-manager/add-locale-to-collection.ts
@@ -0,0 +1,124 @@
+import * as path from 'node:path';
+import * as fs from 'node:fs';
+import { validateLocale } from '@simoncodes-ca/domain';
+import { updateConfig } from '../lib/config/config-file-operations';
+import { walkFolders } from '../lib/normalize/iterative-folder-walker';
+import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations';
+import { calculateChecksum } from '../resource/checksum';
+import { ErrorMessages } from '../lib/errors/error-messages';
+import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants';
+
+export interface AddLocaleToCollectionOptions {
+ readonly cwd?: string;
+}
+
+export interface AddLocaleToCollectionResult {
+ readonly message: string;
+ readonly entriesBackfilled: number;
+ readonly filesUpdated: number;
+}
+
+export async function addLocaleToCollection(
+ collectionName: string,
+ locale: string,
+ options: AddLocaleToCollectionOptions = {},
+): Promise {
+ const cwd = options.cwd ?? process.cwd();
+
+ validateLocale(locale);
+
+ const updatedConfig = updateConfig((config) => {
+ if (!config.collections?.[collectionName]) {
+ throw new Error(ErrorMessages.collectionNotFound(collectionName));
+ }
+
+ const collection = config.collections[collectionName];
+ const baseLocale = collection.baseLocale ?? config.baseLocale;
+
+ if (locale === baseLocale) {
+ throw new Error(ErrorMessages.cannotModifyBaseLocale(locale));
+ }
+
+ const effectiveLocales = collection.locales ?? config.locales ?? [];
+
+ if (effectiveLocales.includes(locale)) {
+ throw new Error(ErrorMessages.localeAlreadyExists(locale, collectionName));
+ }
+
+ const newLocales = [...effectiveLocales, locale];
+
+ return {
+ ...config,
+ collections: {
+ ...config.collections,
+ [collectionName]: {
+ ...collection,
+ locales: newLocales,
+ },
+ },
+ };
+ }, cwd);
+
+ const collection = updatedConfig.collections[collectionName];
+ const translationsFolderPath = path.resolve(cwd, collection.translationsFolder);
+
+ let entriesBackfilled = 0;
+ let filesUpdated = 0;
+
+ for (const visit of walkFolders(translationsFolderPath)) {
+ const resourceEntriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME);
+ const trackerMetaPath = path.join(visit.absolutePath, TRACKER_META_FILENAME);
+
+ if (!fs.existsSync(resourceEntriesPath)) continue;
+
+ const resourceEntries = readResourceEntries(resourceEntriesPath);
+
+ const trackerMetadata = readTrackerMetadata(trackerMetaPath, {});
+
+ let folderModified = false;
+
+ for (const entryKey of Object.keys(resourceEntries)) {
+ const entry = resourceEntries[entryKey];
+
+ if (typeof entry !== 'object' || entry === null || typeof entry.source !== 'string') {
+ continue;
+ }
+
+ if (typeof entry[locale] === 'string') {
+ continue;
+ }
+
+ // Seed the new locale with the base (source) value and status 'new' —
+ // this matches the convention used by normalizeEntry/ensureLocaleEntryExists,
+ // which also seeds missing locales with baseValue and marks them 'new'.
+ const baseValue = entry.source;
+ const checksum = calculateChecksum(baseValue);
+
+ entry[locale] = baseValue;
+
+ if (!trackerMetadata[entryKey]) {
+ trackerMetadata[entryKey] = {};
+ }
+ trackerMetadata[entryKey][locale] = {
+ checksum,
+ baseChecksum: checksum,
+ status: 'new',
+ };
+
+ entriesBackfilled++;
+ folderModified = true;
+ }
+
+ if (folderModified) {
+ writeJsonFile({ filePath: resourceEntriesPath, data: resourceEntries });
+ writeJsonFile({ filePath: trackerMetaPath, data: trackerMetadata });
+ filesUpdated++;
+ }
+ }
+
+ return {
+ message: `Locale "${locale}" added to collection "${collectionName}" successfully`,
+ entriesBackfilled,
+ filesUpdated,
+ };
+}
diff --git a/libs/core/src/collections-manager/index.ts b/libs/core/src/collections-manager/index.ts
index ef860e15..676a72e4 100644
--- a/libs/core/src/collections-manager/index.ts
+++ b/libs/core/src/collections-manager/index.ts
@@ -1,3 +1,5 @@
export * from './delete-collection-by-name';
export * from './add-collection';
export * from './update-collection';
+export * from './add-locale-to-collection';
+export * from './remove-locale-from-collection';
diff --git a/libs/core/src/collections-manager/locale-spec-helpers.spec.ts b/libs/core/src/collections-manager/locale-spec-helpers.spec.ts
new file mode 100644
index 00000000..8662e2ef
--- /dev/null
+++ b/libs/core/src/collections-manager/locale-spec-helpers.spec.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest';
+import { makeBaseConfig } from './locale-spec-helpers';
+
+describe('locale-spec-helpers', () => {
+ it('makeBaseConfig returns the expected shape', () => {
+ const config = makeBaseConfig();
+ expect(config).toMatchObject({
+ baseLocale: 'en',
+ locales: ['en', 'fr'],
+ collections: {
+ main: {
+ translationsFolder: 'src/i18n',
+ },
+ },
+ });
+ });
+});
diff --git a/libs/core/src/collections-manager/locale-spec-helpers.ts b/libs/core/src/collections-manager/locale-spec-helpers.ts
new file mode 100644
index 00000000..fb40f4ae
--- /dev/null
+++ b/libs/core/src/collections-manager/locale-spec-helpers.ts
@@ -0,0 +1,58 @@
+import { vi } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { SafeAny } from '../constants';
+
+export interface MockFile {
+ type: 'file' | 'directory';
+ content?: string;
+ children?: string[];
+}
+
+export function setupMockFs(mockFs: Record): void {
+ vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => (p as string) in mockFs);
+
+ vi.mocked(fs.readdirSync).mockImplementation(((p: fs.PathLike, opts?: SafeAny) => {
+ const entry = mockFs[p as string];
+ if (!entry || entry.type !== 'directory') return [];
+ const childNames = entry.children ?? [];
+ if (opts && typeof opts === 'object' && (opts as { withFileTypes?: boolean }).withFileTypes) {
+ return childNames.map((name) => {
+ const childPath = path.join(p as string, name);
+ const isDir = mockFs[childPath]?.type === 'directory';
+ return {
+ name,
+ isDirectory: () => isDir,
+ isFile: () => !isDir,
+ isSymbolicLink: () => false,
+ isBlockDevice: () => false,
+ isCharacterDevice: () => false,
+ isFIFO: () => false,
+ isSocket: () => false,
+ } as unknown as fs.Dirent;
+ });
+ }
+ return childNames as unknown as fs.Dirent[];
+ }) as SafeAny);
+
+ vi.mocked(fs.readFileSync).mockImplementation((p: fs.PathOrFileDescriptor) => {
+ const entry = mockFs[p as string];
+ if (!entry || entry.type !== 'file') throw new Error(`ENOENT: ${p}`);
+ return entry.content ?? '';
+ });
+
+ vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
+}
+
+export function makeBaseConfig(overrides: SafeAny = {}): SafeAny {
+ return {
+ baseLocale: 'en',
+ locales: ['en', 'fr'],
+ collections: {
+ main: {
+ translationsFolder: 'src/i18n',
+ },
+ },
+ ...overrides,
+ };
+}
diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts
new file mode 100644
index 00000000..54efff07
--- /dev/null
+++ b/libs/core/src/collections-manager/remove-locale-from-collection.spec.ts
@@ -0,0 +1,193 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+import { removeLocaleFromCollection } from './remove-locale-from-collection';
+import { calculateChecksum } from '../resource/checksum';
+import type { ResourceEntries } from '../resource/resource-entry';
+import type { TrackerMetadata } from '../resource/tracker-metadata';
+import type { SafeAny } from '../constants';
+import { setupMockFs, makeBaseConfig } from './locale-spec-helpers';
+
+vi.mock('fs');
+
+const CWD = '/test';
+const CONFIG_PATH = path.join(CWD, '.lingo-tracker.json');
+const TRANSLATIONS_FOLDER = path.join(CWD, 'src/i18n');
+
+function makeConfig(overrides: SafeAny = {}) {
+ return makeBaseConfig({
+ locales: ['en', 'fr', 'de'],
+ ...overrides,
+ });
+}
+
+describe('removeLocaleFromCollection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('removes locale from config and purges resource entries', async () => {
+ const entries: ResourceEntries = {
+ ok: { source: 'OK', fr: 'OK FR', de: 'OK DE' },
+ };
+ const meta: TrackerMetadata = {
+ ok: {
+ en: { checksum: calculateChecksum('OK') },
+ fr: { checksum: calculateChecksum('OK FR'), baseChecksum: calculateChecksum('OK'), status: 'translated' },
+ de: { checksum: calculateChecksum('OK DE'), baseChecksum: calculateChecksum('OK'), status: 'translated' },
+ },
+ };
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: ['resource_entries.json', 'tracker_meta.json'] },
+ [path.join(TRANSLATIONS_FOLDER, 'resource_entries.json')]: { type: 'file', content: JSON.stringify(entries) },
+ [path.join(TRANSLATIONS_FOLDER, 'tracker_meta.json')]: { type: 'file', content: JSON.stringify(meta) },
+ });
+
+ const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD });
+
+ expect(result.message).toBe('Locale "fr" removed from collection "main" successfully');
+ expect(result.entriesPurged).toBe(1);
+ expect(result.filesUpdated).toBe(1);
+
+ const writeCalls = vi.mocked(fs.writeFileSync).mock.calls;
+
+ // First write: updated config (fr removed from locales)
+ const configWrite = JSON.parse(writeCalls[0][1] as string);
+ expect(configWrite.collections.main.locales).not.toContain('fr');
+ expect(configWrite.collections.main.locales).toContain('de');
+
+ // Second write: updated resource_entries.json (fr key removed)
+ const entriesWrite = JSON.parse(writeCalls[1][1] as string);
+ expect(entriesWrite.ok.fr).toBeUndefined();
+ expect(entriesWrite.ok.de).toBe('OK DE');
+
+ // Third write: updated tracker_meta.json (fr metadata removed)
+ const metaWrite = JSON.parse(writeCalls[2][1] as string);
+ expect(metaWrite.ok.fr).toBeUndefined();
+ expect(metaWrite.ok.de).toBeDefined();
+ });
+
+ it('copies global locales into collection when collection has no locales override', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD });
+
+ expect(result.message).toContain('fr');
+
+ const configWriteCall = vi.mocked(fs.writeFileSync).mock.calls[0];
+ const writtenConfig = JSON.parse(configWriteCall[1] as string);
+ // Should have copied global locales and removed fr
+ expect(writtenConfig.collections.main.locales).toEqual(['en', 'de']);
+ });
+
+ it('allows removing the last non-base locale (monolingual collection)', async () => {
+ const config = makeConfig({
+ locales: ['en', 'fr'],
+ collections: {
+ main: { translationsFolder: 'src/i18n', locales: ['en', 'fr'] },
+ },
+ });
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD });
+
+ expect(result.message).toContain('fr');
+
+ const configWriteCall = vi.mocked(fs.writeFileSync).mock.calls[0];
+ const writtenConfig = JSON.parse(configWriteCall[1] as string);
+ expect(writtenConfig.collections.main.locales).toEqual(['en']);
+ });
+
+ it('handles empty translations folder gracefully (no resource files)', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ [TRANSLATIONS_FOLDER]: { type: 'directory', children: [] },
+ });
+
+ const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD });
+
+ expect(result.entriesPurged).toBe(0);
+ expect(result.filesUpdated).toBe(0);
+ });
+
+ it('handles non-existent translations folder gracefully', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ // no translations folder
+ });
+
+ const result = await removeLocaleFromCollection('main', 'fr', { cwd: CWD });
+
+ expect(result.entriesPurged).toBe(0);
+ expect(result.filesUpdated).toBe(0);
+ });
+
+ it('throws when locale does not exist in collection', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(removeLocaleFromCollection('main', 'ja', { cwd: CWD })).rejects.toThrow(
+ 'Locale "ja" not found in collection "main"',
+ );
+ });
+
+ it('throws when trying to remove the base locale', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(removeLocaleFromCollection('main', 'en', { cwd: CWD })).rejects.toThrow(
+ 'Cannot add or remove the base locale "en"',
+ );
+ });
+
+ it('throws when collection does not exist', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(removeLocaleFromCollection('nonexistent', 'fr', { cwd: CWD })).rejects.toThrow(
+ 'Collection "nonexistent" not found',
+ );
+ });
+
+ it('throws when locale format is invalid', async () => {
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(makeConfig()) },
+ });
+
+ await expect(removeLocaleFromCollection('main', 'not-valid-123', { cwd: CWD })).rejects.toThrow(
+ 'Invalid locale format',
+ );
+ });
+
+ it('uses collection-level baseLocale when blocking base locale removal', async () => {
+ const config = makeConfig({
+ collections: {
+ main: { translationsFolder: 'src/i18n', baseLocale: 'fr', locales: ['fr', 'de'] },
+ },
+ });
+
+ setupMockFs({
+ [CONFIG_PATH]: { type: 'file', content: JSON.stringify(config) },
+ });
+
+ await expect(removeLocaleFromCollection('main', 'fr', { cwd: CWD })).rejects.toThrow(
+ 'Cannot add or remove the base locale "fr"',
+ );
+ });
+});
diff --git a/libs/core/src/collections-manager/remove-locale-from-collection.ts b/libs/core/src/collections-manager/remove-locale-from-collection.ts
new file mode 100644
index 00000000..288f23fc
--- /dev/null
+++ b/libs/core/src/collections-manager/remove-locale-from-collection.ts
@@ -0,0 +1,116 @@
+import * as path from 'node:path';
+import * as fs from 'node:fs';
+import { validateLocale } from '@simoncodes-ca/domain';
+import { updateConfig } from '../lib/config/config-file-operations';
+import { walkFolders } from '../lib/normalize/iterative-folder-walker';
+import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../lib/file-io/json-file-operations';
+import { ErrorMessages } from '../lib/errors/error-messages';
+import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../constants';
+
+export interface RemoveLocaleFromCollectionOptions {
+ readonly cwd?: string;
+}
+
+export interface RemoveLocaleFromCollectionResult {
+ readonly message: string;
+ readonly entriesPurged: number;
+ readonly filesUpdated: number;
+}
+
+export async function removeLocaleFromCollection(
+ collectionName: string,
+ locale: string,
+ options: RemoveLocaleFromCollectionOptions = {},
+): Promise {
+ const cwd = options.cwd ?? process.cwd();
+
+ validateLocale(locale);
+
+ const updatedConfig = updateConfig((config) => {
+ if (!config.collections?.[collectionName]) {
+ throw new Error(ErrorMessages.collectionNotFound(collectionName));
+ }
+
+ const collection = config.collections[collectionName];
+ const baseLocale = collection.baseLocale ?? config.baseLocale;
+
+ if (locale === baseLocale) {
+ throw new Error(ErrorMessages.cannotModifyBaseLocale(locale));
+ }
+
+ const effectiveLocales = collection.locales ?? config.locales ?? [];
+
+ if (!effectiveLocales.includes(locale)) {
+ throw new Error(ErrorMessages.localeNotFound(locale, collectionName));
+ }
+
+ const newLocales = effectiveLocales.filter((l) => l !== locale);
+
+ return {
+ ...config,
+ collections: {
+ ...config.collections,
+ [collectionName]: {
+ ...collection,
+ locales: newLocales,
+ },
+ },
+ };
+ }, cwd);
+
+ const collection = updatedConfig.collections[collectionName];
+ const translationsFolderPath = path.resolve(cwd, collection.translationsFolder);
+
+ let entriesPurged = 0;
+ let filesUpdated = 0;
+
+ for (const visit of walkFolders(translationsFolderPath)) {
+ const resourceEntriesPath = path.join(visit.absolutePath, RESOURCE_ENTRIES_FILENAME);
+ const trackerMetaPath = path.join(visit.absolutePath, TRACKER_META_FILENAME);
+
+ if (!fs.existsSync(resourceEntriesPath)) continue;
+
+ const resourceEntries = readResourceEntries(resourceEntriesPath);
+
+ const trackerMetadata = readTrackerMetadata(trackerMetaPath, {});
+
+ let folderModified = false;
+
+ for (const entryKey of Object.keys(resourceEntries)) {
+ const entry = resourceEntries[entryKey];
+
+ if (typeof entry !== 'object' || entry === null) {
+ continue;
+ }
+
+ let entryModified = false;
+
+ if (locale in entry) {
+ delete entry[locale];
+ entryModified = true;
+ }
+
+ if (trackerMetadata[entryKey] && locale in trackerMetadata[entryKey]) {
+ delete trackerMetadata[entryKey][locale];
+ entryModified = true;
+ }
+
+ if (entryModified) {
+ entriesPurged++;
+ folderModified = true;
+ }
+ }
+
+ if (folderModified) {
+ writeJsonFile({ filePath: resourceEntriesPath, data: resourceEntries });
+ writeJsonFile({ filePath: trackerMetaPath, data: trackerMetadata });
+ filesUpdated++;
+ }
+ }
+
+ return {
+ message: `Locale "${locale}" removed from collection "${collectionName}" successfully`,
+ entriesPurged,
+ filesUpdated,
+ };
+}
diff --git a/libs/core/src/config/translation-config.ts b/libs/core/src/config/translation-config.ts
index 83369b22..5fb27a52 100644
--- a/libs/core/src/config/translation-config.ts
+++ b/libs/core/src/config/translation-config.ts
@@ -7,4 +7,8 @@ export interface TranslationConfig {
readonly enabled: boolean;
readonly provider: string;
readonly apiKeyEnv: string;
+ /** Number of resources to send per API batch when translating a full locale. Defaults to 5. */
+ readonly batchSize?: number;
+ /** Milliseconds to wait between batches to avoid rate-limit errors. Defaults to 1000. */
+ readonly delayMs?: number;
}
diff --git a/libs/core/src/lib/errors/error-messages.ts b/libs/core/src/lib/errors/error-messages.ts
index b508b171..d3d5019b 100644
--- a/libs/core/src/lib/errors/error-messages.ts
+++ b/libs/core/src/lib/errors/error-messages.ts
@@ -24,5 +24,12 @@ export const ErrorMessages = {
collectionAlreadyExists: (name: string) => `Collection "${name}" already exists`,
+ localeAlreadyExists: (locale: string, collection: string) =>
+ `Locale "${locale}" already exists in collection "${collection}"`,
+
+ localeNotFound: (locale: string, collection: string) => `Locale "${locale}" not found in collection "${collection}"`,
+
+ cannotModifyBaseLocale: (locale: string) => `Cannot add or remove the base locale "${locale}"`,
+
invalidKey: (key: string, reason: string) => `Invalid resource key "${key}": ${reason}`,
} as const;
diff --git a/libs/core/src/lib/translation/index.ts b/libs/core/src/lib/translation/index.ts
index f6ad4b6a..c0065e6c 100644
--- a/libs/core/src/lib/translation/index.ts
+++ b/libs/core/src/lib/translation/index.ts
@@ -17,3 +17,5 @@ export { autoTranslateResource } from './auto-translate-resources';
export type { AutoTranslateParams, AutoTranslatedEntry, AutoTranslateResult } from './auto-translate-resources';
export { translateExistingResource } from './translate-existing-resource';
export type { TranslateExistingResourceOptions, TranslateExistingResourceResult } from './translate-existing-resource';
+export { translateLocale } from './translate-locale';
+export type { TranslateLocaleParams, TranslateLocaleProgress, TranslateLocaleResult } from './translate-locale';
diff --git a/libs/core/src/lib/translation/translate-locale.spec.ts b/libs/core/src/lib/translation/translate-locale.spec.ts
new file mode 100644
index 00000000..c37f1229
--- /dev/null
+++ b/libs/core/src/lib/translation/translate-locale.spec.ts
@@ -0,0 +1,422 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { TranslationError } from './translation-provider';
+import type { TranslationConfig } from '../../config/translation-config';
+import type { TranslateTextResult } from './translation-orchestrator';
+import type { ResourceTreeNode } from '../resource/load-resource-tree';
+
+// ---------------------------------------------------------------------------
+// Module mocks — must be declared before any imports that reference them
+// ---------------------------------------------------------------------------
+
+vi.mock('../resource/load-resource-tree');
+vi.mock('../resource/extract-subtree');
+vi.mock('../file-io/json-file-operations');
+vi.mock('./translation-provider-factory');
+vi.mock('./translation-orchestrator');
+
+import { loadResourceTree } from '../resource/load-resource-tree';
+import { extractResourcesRecursively } from '../resource/extract-subtree';
+import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations';
+import { createTranslationProvider } from './translation-provider-factory';
+import { TranslationOrchestrator } from './translation-orchestrator';
+import { translateLocale } from './translate-locale';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const EMPTY_TREE: ResourceTreeNode = {
+ folderPathSegments: [],
+ resources: [],
+ children: [],
+};
+
+const BASE_CONFIG: TranslationConfig = {
+ enabled: true,
+ provider: 'google-translate',
+ apiKeyEnv: 'GOOGLE_TRANSLATE_API_KEY',
+ batchSize: 5,
+ delayMs: 0, // no delay in tests
+};
+
+function translated(value: string): TranslateTextResult {
+ return { kind: 'translated', value };
+}
+
+function skipped(value: string): TranslateTextResult {
+ return { kind: 'skipped', value };
+}
+
+function makeResource(
+ key: string,
+ source: string,
+ targetLocaleStatus?: 'new' | 'stale' | 'translated' | 'verified',
+ targetLocale = 'fr',
+) {
+ return {
+ key,
+ source,
+ translations: {},
+ metadata: targetLocaleStatus
+ ? {
+ [targetLocale]: { checksum: 'abc', status: targetLocaleStatus },
+ en: { checksum: '123' },
+ }
+ : { en: { checksum: '123' } },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Setup
+// ---------------------------------------------------------------------------
+
+const mockTranslateBatchForLocale = vi.fn();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+
+ process.env.GOOGLE_TRANSLATE_API_KEY = 'test-api-key';
+
+ vi.mocked(loadResourceTree).mockReturnValue(EMPTY_TREE);
+ vi.mocked(extractResourcesRecursively).mockReturnValue([]);
+
+ vi.mocked(createTranslationProvider).mockReturnValue({
+ translate: vi.fn(),
+ getCapabilities: vi.fn(),
+ });
+
+ vi.mocked(TranslationOrchestrator).mockImplementation(
+ () =>
+ ({
+ translateBatchForLocale: mockTranslateBatchForLocale,
+ }) as unknown as TranslationOrchestrator,
+ );
+
+ // Default mocks return generic entries so any entryKey resolves correctly.
+ vi.mocked(readResourceEntries).mockReturnValue({
+ ok: { source: 'OK' },
+ cancel: { source: 'Cancel' },
+ a: { source: 'A' },
+ b: { source: 'B' },
+ c: { source: 'C' },
+ key0: { source: 'Text 0' },
+ key1: { source: 'Text 1' },
+ key2: { source: 'Text 2' },
+ key3: { source: 'Text 3' },
+ key4: { source: 'Text 4' },
+ key5: { source: 'Text 5' },
+ } as ReturnType);
+ vi.mocked(readTrackerMetadata).mockReturnValue({
+ ok: { en: { checksum: '123' } },
+ cancel: { en: { checksum: '456' } },
+ a: { en: { checksum: '111' } },
+ b: { en: { checksum: '222' } },
+ c: { en: { checksum: '333' } },
+ });
+ vi.mocked(writeJsonFile).mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ delete process.env.GOOGLE_TRANSLATE_API_KEY;
+});
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('translateLocale', () => {
+ const defaultParams = {
+ translationsFolder: 'src/i18n',
+ translationConfig: BASE_CONFIG,
+ targetLocale: 'fr',
+ baseLocale: 'en',
+ allLocales: ['en', 'fr'],
+ cwd: '/project',
+ };
+
+ // -------------------------------------------------------------------------
+ // Early exit — nothing to translate
+ // -------------------------------------------------------------------------
+
+ describe('early exit when nothing needs translating', () => {
+ it('returns zeroed result when there are no resources at all', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([]);
+ const onProgress = vi.fn();
+
+ const result = await translateLocale({ ...defaultParams, onProgress });
+
+ expect(result).toEqual({
+ totalResources: 0,
+ translatedCount: 0,
+ failedCount: 0,
+ skippedCount: 0,
+ failures: [],
+ skippedKeys: [],
+ });
+ expect(mockTranslateBatchForLocale).not.toHaveBeenCalled();
+ expect(onProgress).not.toHaveBeenCalled();
+ });
+
+ it('returns zeroed result when all resources are already translated', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([
+ makeResource('apps.ok', 'OK', 'translated'),
+ makeResource('apps.cancel', 'Cancel', 'verified'),
+ ]);
+ const onProgress = vi.fn();
+
+ const result = await translateLocale({ ...defaultParams, onProgress });
+
+ expect(result.totalResources).toBe(0);
+ expect(mockTranslateBatchForLocale).not.toHaveBeenCalled();
+ expect(onProgress).not.toHaveBeenCalled();
+ });
+
+ it('skips resources with verified status', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('apps.ok', 'OK', 'verified')]);
+ const onProgress = vi.fn();
+
+ const result = await translateLocale({ ...defaultParams, onProgress });
+
+ expect(result.totalResources).toBe(0);
+ expect(mockTranslateBatchForLocale).not.toHaveBeenCalled();
+ expect(onProgress).not.toHaveBeenCalled();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Status filtering
+ // -------------------------------------------------------------------------
+
+ describe('status filtering', () => {
+ it('translates resources with new status', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.totalResources).toBe(1);
+ expect(result.translatedCount).toBe(1);
+ });
+
+ it('translates resources with stale status', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'stale')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.totalResources).toBe(1);
+ expect(result.translatedCount).toBe(1);
+ });
+
+ it('translates resources with no metadata for the target locale', async () => {
+ // makeResource without a status means no 'fr' metadata entry.
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.totalResources).toBe(1);
+ expect(result.translatedCount).toBe(1);
+ });
+
+ it('does not translate resources with translated status', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'translated')]);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.totalResources).toBe(0);
+ expect(mockTranslateBatchForLocale).not.toHaveBeenCalled();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Batching
+ // -------------------------------------------------------------------------
+
+ describe('batch processing', () => {
+ it('calls onProgress for each batch', async () => {
+ const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new'), makeResource('c', 'C', 'new')];
+ vi.mocked(extractResourcesRecursively).mockReturnValue(resources);
+
+ mockTranslateBatchForLocale
+ .mockResolvedValueOnce([translated('A-fr'), translated('B-fr')])
+ .mockResolvedValueOnce([translated('C-fr')]);
+
+ const progressEvents: number[] = [];
+ await translateLocale({
+ ...defaultParams,
+ translationConfig: { ...BASE_CONFIG, batchSize: 2, delayMs: 0 },
+ onProgress: (p) => progressEvents.push(p.currentBatch),
+ });
+
+ expect(progressEvents).toEqual([1, 2]);
+ });
+
+ it('respects batchSize when calling the orchestrator', async () => {
+ const resources = Array.from({ length: 6 }, (_, i) => makeResource(`key${i}`, `Text ${i}`, 'new'));
+ vi.mocked(extractResourcesRecursively).mockReturnValue(resources);
+
+ mockTranslateBatchForLocale.mockResolvedValue([translated('t1'), translated('t2'), translated('t3')]);
+
+ await translateLocale({
+ ...defaultParams,
+ translationConfig: { ...BASE_CONFIG, batchSize: 3, delayMs: 0 },
+ });
+
+ expect(mockTranslateBatchForLocale).toHaveBeenCalledTimes(2);
+ });
+
+ it('reports accurate progress counts in onProgress', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]);
+
+ let capturedProgress: Parameters>[0] | undefined;
+
+ await translateLocale({
+ ...defaultParams,
+ onProgress: (p) => {
+ capturedProgress = p;
+ },
+ });
+
+ expect(capturedProgress).toMatchObject({
+ totalResources: 1,
+ translatedCount: 1,
+ failedCount: 0,
+ skippedCount: 0,
+ currentBatch: 1,
+ totalBatches: 1,
+ });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // ICU skipping
+ // -------------------------------------------------------------------------
+
+ describe('ICU skipping', () => {
+ it('adds skipped-ICU resource keys to skippedKeys', async () => {
+ const icuSource = '{count, plural, one {# item} other {# items}}';
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', icuSource, 'new')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([skipped(icuSource)]);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.skippedKeys).toContain('ok');
+ expect(result.skippedCount).toBe(1);
+ expect(result.translatedCount).toBe(0);
+ });
+
+ it('does not call writeJsonFile for skipped resources', async () => {
+ const icuSource = '{count, plural, one {# item} other {# items}}';
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', icuSource, 'new')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([skipped(icuSource)]);
+
+ await translateLocale(defaultParams);
+
+ expect(writeJsonFile).not.toHaveBeenCalled();
+ });
+
+ it('skips writing and increments skippedCount when entryKey is missing from disk entries', async () => {
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr')]);
+
+ // Return an empty object — the 'ok' entry is absent from disk
+ vi.mocked(readResourceEntries).mockReturnValue({} as ReturnType);
+
+ const result = await translateLocale(defaultParams);
+
+ expect(result.skippedCount).toBe(1);
+ expect(result.translatedCount).toBe(0);
+ expect(writeJsonFile).not.toHaveBeenCalled();
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // File I/O
+ // -------------------------------------------------------------------------
+
+ describe('file I/O', () => {
+ it('writes entries and meta files once per unique folder', async () => {
+ // Both resources share the same root folder — grouped write produces 2 files.
+ vi.mocked(extractResourcesRecursively).mockReturnValue([
+ makeResource('ok', 'OK', 'new'),
+ makeResource('cancel', 'Cancel', 'new'),
+ ]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr'), translated('Annuler')]);
+
+ await translateLocale(defaultParams);
+
+ // Two resources in the same folder → one read-modify-write cycle → 2 file writes.
+ expect(writeJsonFile).toHaveBeenCalledTimes(2);
+ });
+
+ it('writes entries and meta files once per unique folder across multiple folders', async () => {
+ // Resources in two different folders each get their own write cycle.
+ vi.mocked(extractResourcesRecursively).mockReturnValue([
+ makeResource('folderA.ok', 'OK', 'new'),
+ makeResource('folderB.cancel', 'Cancel', 'new'),
+ ]);
+ mockTranslateBatchForLocale.mockResolvedValueOnce([translated('OK-fr'), translated('Annuler')]);
+
+ await translateLocale(defaultParams);
+
+ // Two distinct folders → 2 read-modify-write cycles → 4 file writes.
+ expect(writeJsonFile).toHaveBeenCalledTimes(4);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Error handling
+ // -------------------------------------------------------------------------
+
+ describe('error handling', () => {
+ it('throws TranslationError with MISSING_API_KEY when the env var is absent', async () => {
+ delete process.env.GOOGLE_TRANSLATE_API_KEY;
+ vi.mocked(extractResourcesRecursively).mockReturnValue([makeResource('ok', 'OK', 'new')]);
+
+ await expect(translateLocale(defaultParams)).rejects.toMatchObject({
+ code: 'MISSING_API_KEY',
+ retryable: false,
+ });
+ });
+
+ it('records all resources in a failed batch as failures and continues', async () => {
+ const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new'), makeResource('c', 'C', 'new')];
+ vi.mocked(extractResourcesRecursively).mockReturnValue(resources);
+
+ const providerError = new TranslationError('quota exceeded', 'RATE_LIMIT', true);
+
+ mockTranslateBatchForLocale
+ .mockRejectedValueOnce(providerError) // batch 1 fails
+ .mockResolvedValueOnce([translated('C-fr')]); // batch 2 succeeds
+
+ const result = await translateLocale({
+ ...defaultParams,
+ translationConfig: { ...BASE_CONFIG, batchSize: 2, delayMs: 0 },
+ });
+
+ expect(result.failedCount).toBe(2);
+ expect(result.translatedCount).toBe(1);
+ expect(result.failures).toHaveLength(2);
+ expect(result.failures[0].key).toBe('a');
+ expect(result.failures[1].key).toBe('b');
+ });
+
+ it('continues processing subsequent batches after a batch failure', async () => {
+ const resources = [makeResource('a', 'A', 'new'), makeResource('b', 'B', 'new')];
+ vi.mocked(extractResourcesRecursively).mockReturnValue(resources);
+
+ const providerError = new TranslationError('fail', 'SERVER_ERROR', true);
+ mockTranslateBatchForLocale.mockRejectedValueOnce(providerError).mockResolvedValueOnce([translated('B-fr')]);
+
+ const result = await translateLocale({
+ ...defaultParams,
+ translationConfig: { ...BASE_CONFIG, batchSize: 1, delayMs: 0 },
+ });
+
+ expect(result.failedCount).toBe(1);
+ expect(result.translatedCount).toBe(1);
+ });
+ });
+});
diff --git a/libs/core/src/lib/translation/translate-locale.ts b/libs/core/src/lib/translation/translate-locale.ts
new file mode 100644
index 00000000..991a0804
--- /dev/null
+++ b/libs/core/src/lib/translation/translate-locale.ts
@@ -0,0 +1,305 @@
+/**
+ * Bulk locale translation.
+ *
+ * Translates all `new` and `stale` resources in a translations folder for a
+ * single target locale. Resources are processed in batches so that the number
+ * of API calls is bounded regardless of how many resources exist.
+ *
+ * Complex ICU resources (plural, select, etc.) are automatically skipped by
+ * the underlying {@link TranslationOrchestrator} and reported in `skippedKeys`.
+ *
+ * @module translate-locale
+ */
+
+import * as path from 'node:path';
+import { loadResourceTree } from '../resource/load-resource-tree';
+import { extractResourcesRecursively } from '../resource/extract-subtree';
+import { readResourceEntries, readTrackerMetadata, writeJsonFile } from '../file-io/json-file-operations';
+import { calculateChecksum } from '../../resource/checksum';
+import { createTranslationProvider } from './translation-provider-factory';
+import { TranslationOrchestrator } from './translation-orchestrator';
+import { TranslationError } from './translation-provider';
+import { RESOURCE_ENTRIES_FILENAME, TRACKER_META_FILENAME } from '../../constants';
+import type { TranslationConfig } from '../../config/translation-config';
+import type { ResourceTreeEntry } from '../resource/load-resource-tree';
+
+// ---------------------------------------------------------------------------
+// Public interfaces
+// ---------------------------------------------------------------------------
+
+export interface TranslateLocaleParams {
+ readonly translationsFolder: string;
+ readonly translationConfig: TranslationConfig;
+ readonly targetLocale: string;
+ readonly baseLocale: string;
+ readonly allLocales: string[];
+ readonly cwd?: string;
+ readonly onProgress?: (progress: TranslateLocaleProgress) => void;
+}
+
+export interface TranslateLocaleProgress {
+ /**
+ * Number of resources eligible for translation (status `new`, `stale`, or missing metadata
+ * for the target locale). Does NOT represent the total collection size.
+ * Returns 0 when no resources needed translation.
+ */
+ readonly totalResources: number;
+ readonly translatedCount: number;
+ readonly failedCount: number;
+ readonly skippedCount: number;
+ readonly currentBatch: number;
+ readonly totalBatches: number;
+}
+
+export interface TranslateLocaleResult {
+ /**
+ * Number of resources eligible for translation (status `new`, `stale`, or missing metadata
+ * for the target locale). Does NOT represent the total collection size.
+ * Returns 0 when no resources needed translation.
+ */
+ readonly totalResources: number;
+ readonly translatedCount: number;
+ readonly failedCount: number;
+ readonly skippedCount: number;
+ readonly failures: ReadonlyArray<{ key: string; error: string }>;
+ readonly skippedKeys: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Returns true when a resource needs translation for `targetLocale`.
+ * A resource needs translation when its status is `new`, `stale`,
+ * or when there is no metadata at all for the locale.
+ */
+function needsTranslation(resource: ResourceTreeEntry, targetLocale: string): boolean {
+ const meta = resource.metadata[targetLocale];
+ if (!meta) return true;
+ return meta.status === 'new' || meta.status === 'stale';
+}
+
+/**
+ * Converts a dot-delimited composite key (e.g. `apps.common.buttons.ok`)
+ * into the filesystem folder path (e.g. `apps/common/buttons`) and the
+ * entry key (`ok`).
+ */
+function resolveResourcePath(
+ compositeKey: string,
+ absoluteTranslationsFolder: string,
+): { folderPath: string; entryKey: string } {
+ const segments = compositeKey.split('.');
+ const entryKey = segments[segments.length - 1];
+ const folderSegments = segments.slice(0, -1);
+ const folderPath =
+ folderSegments.length > 0 ? path.join(absoluteTranslationsFolder, ...folderSegments) : absoluteTranslationsFolder;
+
+ return { folderPath, entryKey };
+}
+
+// ---------------------------------------------------------------------------
+// Internal types
+// ---------------------------------------------------------------------------
+
+interface FolderWriteEntry {
+ readonly entryKey: string;
+ readonly result: { kind: string; value: string };
+ readonly source: string;
+ readonly resourceKey: string;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers (continued)
+// ---------------------------------------------------------------------------
+
+/**
+ * Reads the entries and metadata files for a folder once, applies all
+ * translated resources for that folder, and writes both files back once.
+ * Resources whose entry key is missing from disk are silently skipped.
+ *
+ * @returns How many entries were actually written (missing entries are 0).
+ */
+function writeTranslatedResources(
+ folderPath: string,
+ entries: readonly FolderWriteEntry[],
+ targetLocale: string,
+ baseLocale: string,
+): {
+ writtenKeys: string[];
+ skippedKeys: string[];
+} {
+ const entriesFilePath = path.join(folderPath, RESOURCE_ENTRIES_FILENAME);
+ const metaFilePath = path.join(folderPath, TRACKER_META_FILENAME);
+
+ const resourceEntries = readResourceEntries(entriesFilePath, {});
+ const trackerMeta = readTrackerMetadata(metaFilePath, {});
+
+ const writtenKeys: string[] = [];
+ const skippedKeys: string[] = [];
+
+ for (const entry of entries) {
+ if (!resourceEntries[entry.entryKey]) {
+ skippedKeys.push(entry.resourceKey);
+ continue;
+ }
+
+ (resourceEntries[entry.entryKey] as Record)[targetLocale] = entry.result.value;
+
+ const baseChecksum = trackerMeta[entry.entryKey]?.[baseLocale]?.checksum ?? calculateChecksum(entry.source);
+ trackerMeta[entry.entryKey] = {
+ ...trackerMeta[entry.entryKey],
+ [targetLocale]: {
+ checksum: calculateChecksum(entry.result.value),
+ baseChecksum,
+ status: 'translated',
+ },
+ };
+
+ writtenKeys.push(entry.resourceKey);
+ }
+
+ if (writtenKeys.length > 0) {
+ writeJsonFile({ filePath: entriesFilePath, data: resourceEntries });
+ writeJsonFile({ filePath: metaFilePath, data: trackerMeta });
+ }
+
+ return { writtenKeys, skippedKeys };
+}
+
+// ---------------------------------------------------------------------------
+// Main export
+// ---------------------------------------------------------------------------
+
+/**
+ * Translates all `new` and `stale` resources in `translationsFolder` for the
+ * given `targetLocale`, writing the results back to disk.
+ *
+ * Resources are processed in batches of `translationConfig.batchSize` (default 5).
+ * A configurable delay (`translationConfig.delayMs`, default 1000 ms) is inserted
+ * between batches to avoid hitting provider rate limits.
+ *
+ * Complex ICU messages (plural, select, etc.) are silently skipped and their keys
+ * are included in `TranslateLocaleResult.skippedKeys`. Provider-level errors mark
+ * all resources in the failing batch as failed but do not abort the run.
+ *
+ * @param params - Translation parameters.
+ * @returns A summary of how many resources were translated, skipped, or failed.
+ * @throws {TranslationError} with code `MISSING_API_KEY` when the env var is absent.
+ */
+export async function translateLocale(params: TranslateLocaleParams): Promise {
+ const { translationConfig, targetLocale, baseLocale, cwd = process.cwd(), onProgress } = params;
+
+ const absoluteFolder = path.resolve(cwd, params.translationsFolder);
+
+ // Load the entire resource tree.
+ const tree = loadResourceTree({ translationsFolder: absoluteFolder, depth: 999, cwd });
+ const allResources = extractResourcesRecursively(tree);
+
+ // Filter to only those that need translating for the target locale.
+ const resourcesToTranslate = allResources.filter((resource) => needsTranslation(resource, targetLocale));
+
+ if (resourcesToTranslate.length === 0) {
+ return {
+ totalResources: 0,
+ translatedCount: 0,
+ failedCount: 0,
+ skippedCount: 0,
+ failures: [],
+ skippedKeys: [],
+ };
+ }
+
+ // Resolve the API key once — fail fast before doing any I/O.
+ const apiKey = process.env[translationConfig.apiKeyEnv];
+ if (!apiKey) {
+ throw new TranslationError(
+ `Translation API key not found. Set the ${translationConfig.apiKeyEnv} environment variable.`,
+ 'MISSING_API_KEY',
+ false,
+ );
+ }
+
+ const provider = createTranslationProvider(translationConfig.provider, apiKey);
+ const orchestrator = new TranslationOrchestrator(provider);
+
+ const batchSize = translationConfig.batchSize ?? 5;
+ const delayMs = translationConfig.delayMs ?? 1000;
+
+ const totalResources = resourcesToTranslate.length;
+ const totalBatches = Math.ceil(totalResources / batchSize);
+
+ let translatedCount = 0;
+ let failedCount = 0;
+ let skippedCount = 0;
+ const failures: Array<{ key: string; error: string }> = [];
+ const skippedKeys: string[] = [];
+
+ for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
+ const batchStart = batchIndex * batchSize;
+ const batch = resourcesToTranslate.slice(batchStart, batchStart + batchSize);
+ const sourceTexts = batch.map((resource) => resource.source);
+
+ try {
+ const batchResults = await orchestrator.translateBatchForLocale(sourceTexts, baseLocale, targetLocale);
+
+ // Group translated results by folder so each folder's files are read and
+ // written only once, even when multiple resources share the same folder.
+ const byFolder = new Map();
+
+ for (let i = 0; i < batch.length; i++) {
+ const resource = batch[i];
+ const result = batchResults[i];
+
+ if (result.kind === 'skipped') {
+ skippedKeys.push(resource.key);
+ skippedCount++;
+ continue;
+ }
+
+ const { folderPath, entryKey } = resolveResourcePath(resource.key, absoluteFolder);
+ const folderEntries = byFolder.get(folderPath) ?? [];
+ folderEntries.push({ entryKey, result, source: resource.source, resourceKey: resource.key });
+ byFolder.set(folderPath, folderEntries);
+ }
+
+ for (const [folderPath, folderEntries] of byFolder) {
+ const { writtenKeys, skippedKeys: folderSkippedKeys } = writeTranslatedResources(
+ folderPath,
+ folderEntries,
+ targetLocale,
+ baseLocale,
+ );
+ translatedCount += writtenKeys.length;
+ skippedCount += folderSkippedKeys.length;
+ skippedKeys.push(...folderSkippedKeys);
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ for (const resource of batch) {
+ failures.push({ key: resource.key, error: errorMessage });
+ failedCount++;
+ }
+ }
+
+ onProgress?.({
+ totalResources,
+ translatedCount,
+ failedCount,
+ skippedCount,
+ currentBatch: batchIndex + 1,
+ totalBatches,
+ });
+
+ // Pause between batches (skip after the last one).
+ if (batchIndex < totalBatches - 1) {
+ await sleep(delayMs);
+ }
+ }
+
+ return { totalResources, translatedCount, failedCount, skippedCount, failures, skippedKeys };
+}
diff --git a/libs/core/src/lib/translation/translation-orchestrator.spec.ts b/libs/core/src/lib/translation/translation-orchestrator.spec.ts
index 5929e79e..e01a8404 100644
--- a/libs/core/src/lib/translation/translation-orchestrator.spec.ts
+++ b/libs/core/src/lib/translation/translation-orchestrator.spec.ts
@@ -306,4 +306,113 @@ describe('TranslationOrchestrator', () => {
expect(provider.translate).toHaveBeenCalledOnce();
});
});
+
+ // -------------------------------------------------------------------------
+ // translateBatchForLocale
+ // -------------------------------------------------------------------------
+
+ describe('translateBatchForLocale', () => {
+ it('returns an empty array for an empty input without calling the provider', async () => {
+ const results = await orchestrator.translateBatchForLocale([], 'en', 'de');
+
+ expect(results).toEqual([]);
+ expect(provider.translate).not.toHaveBeenCalled();
+ });
+
+ it('returns results in the same order as the input texts', async () => {
+ provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Tschüss')]);
+
+ const results = await orchestrator.translateBatchForLocale(['Hello', 'Goodbye'], 'en', 'de');
+
+ expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' });
+ expect(results[1]).toEqual({ kind: 'translated', value: 'Tschüss' });
+ });
+
+ it('skips complex ICU texts immediately without including them in the provider call', async () => {
+ const icuText = '{count, plural, one {# item} other {# items}}';
+
+ const results = await orchestrator.translateBatchForLocale([icuText], 'en', 'de');
+
+ expect(results).toEqual([{ kind: 'skipped', value: icuText }]);
+ expect(provider.translate).not.toHaveBeenCalled();
+ });
+
+ it('sends all plain texts to the provider in a single call', async () => {
+ provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Welt')]);
+
+ await orchestrator.translateBatchForLocale(['Hello', 'World'], 'en', 'de');
+
+ expect(provider.translate).toHaveBeenCalledOnce();
+ expect(provider.translate).toHaveBeenCalledWith([
+ { text: 'Hello', sourceLocale: 'en', targetLocale: 'de' },
+ { text: 'World', sourceLocale: 'en', targetLocale: 'de' },
+ ]);
+ });
+
+ it('protects and restores placeholders for simple-placeholder texts', async () => {
+ provider.translate.mockImplementation(async (requests: TranslateRequest[]) => {
+ // Simulate translation of surrounding text while leaving span markers intact.
+ return requests.map((req) => makeResult(req.text.replace('Hello', 'Hallo')));
+ });
+
+ const results = await orchestrator.translateBatchForLocale(['Hello {name}'], 'en', 'de');
+
+ expect(results[0].kind).toBe('translated-with-placeholders');
+ expect(results[0].value).toBe('Hallo {name}');
+ });
+
+ it('falls back to skipped when the provider drops a placeholder marker', async () => {
+ provider.translate.mockResolvedValueOnce([makeResult('Hallo')]);
+
+ const results = await orchestrator.translateBatchForLocale(['Hello {name}'], 'en', 'de');
+
+ expect(results[0]).toEqual({ kind: 'skipped', value: 'Hello {name}' });
+ });
+
+ it('calls the provider exactly once for a mixed batch of plain, simple-placeholder, and complex ICU texts', async () => {
+ const complexIcu = '{count, plural, one {# item} other {# items}}';
+
+ // Provider receives: plain text + protected simple-placeholder text (complex ICU is excluded).
+ provider.translate.mockImplementation(async (requests: TranslateRequest[]) => {
+ return requests.map((req) => makeResult(req.text.replace('Hello', 'Hallo')));
+ });
+
+ const results = await orchestrator.translateBatchForLocale(['Hello', 'Hello {name}', complexIcu], 'en', 'de');
+
+ expect(provider.translate).toHaveBeenCalledOnce();
+ expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' });
+ expect(results[1].kind).toBe('translated-with-placeholders');
+ expect(results[1].value).toBe('Hallo {name}');
+ expect(results[2]).toEqual({ kind: 'skipped', value: complexIcu });
+ });
+
+ it('returns kind: skipped for all items and never calls the provider when every item is complex ICU', async () => {
+ const icuPlural = '{count, plural, one {# item} other {# items}}';
+ const icuSelect = '{gender, select, male {he} female {she} other {they}}';
+ const icuMixed = 'Hello {name}, {count, plural, one {# item} other {# items}}';
+
+ const results = await orchestrator.translateBatchForLocale([icuPlural, icuSelect, icuMixed], 'en', 'de');
+
+ expect(provider.translate).not.toHaveBeenCalled();
+ expect(results).toHaveLength(3);
+ expect(results[0]).toEqual({ kind: 'skipped', value: icuPlural });
+ expect(results[1]).toEqual({ kind: 'skipped', value: icuSelect });
+ expect(results[2]).toEqual({ kind: 'skipped', value: icuMixed });
+ });
+
+ it('preserves order when complex ICU items are interspersed with translatable items', async () => {
+ const complexIcu = '{gender, select, male {he} female {she} other {they}}';
+
+ provider.translate.mockResolvedValueOnce([makeResult('Hallo'), makeResult('Tschüss')]);
+
+ const results = await orchestrator.translateBatchForLocale(['Hello', complexIcu, 'Goodbye'], 'en', 'de');
+
+ expect(results[0]).toEqual({ kind: 'translated', value: 'Hallo' });
+ expect(results[1]).toEqual({ kind: 'skipped', value: complexIcu });
+ expect(results[2]).toEqual({ kind: 'translated', value: 'Tschüss' });
+ // Only two texts were sent to the provider (Hello and Goodbye, not the ICU).
+ expect(provider.translate).toHaveBeenCalledOnce();
+ expect(provider.translate.mock.calls[0][0]).toHaveLength(2);
+ });
+ });
});
diff --git a/libs/core/src/lib/translation/translation-orchestrator.ts b/libs/core/src/lib/translation/translation-orchestrator.ts
index b7442518..b8593c2d 100644
--- a/libs/core/src/lib/translation/translation-orchestrator.ts
+++ b/libs/core/src/lib/translation/translation-orchestrator.ts
@@ -123,6 +123,101 @@ export class TranslationOrchestrator {
return results;
}
+ /**
+ * Translates multiple texts for a single target locale in one provider call.
+ *
+ * This is the key optimization for bulk locale translation: rather than making
+ * one provider call per resource, all translatable texts are collected and sent
+ * in a single call (the provider handles its own internal chunking at 128 items).
+ *
+ * Complex ICU texts are classified and skipped immediately without any API call.
+ * Simple-placeholder texts have their placeholders protected before sending and
+ * restored from the translated result; a marker mismatch falls back to `kind: 'skipped'`.
+ *
+ * Results are returned in the same order as the input `texts` array.
+ *
+ * @param texts - Array of source strings to translate.
+ * @param sourceLocale - BCP 47 locale code of the source texts.
+ * @param targetLocale - BCP 47 locale code to translate into.
+ * @returns Results in the same order as the input.
+ * @throws {TranslationError} for any provider-level failure.
+ */
+ async translateBatchForLocale(
+ texts: string[],
+ sourceLocale: string,
+ targetLocale: string,
+ ): Promise {
+ if (texts.length === 0) {
+ return [];
+ }
+
+ type TextKind = 'plain' | 'simple-placeholders' | 'complex-icu';
+ const classifications: TextKind[] = texts.map((text) => classifyICUContent(text));
+
+ // Collect translatable texts (plain + protected simple-placeholder) in one flat array,
+ // preserving an index so we can map results back to the original input positions.
+ const providerInputs: Array<{ text: string; originalIndex: number }> = [];
+
+ // For simple-placeholder items we need the protect result to restore later.
+ const placeholderMaps = new Map['placeholders']>();
+
+ for (let i = 0; i < texts.length; i++) {
+ const kind = classifications[i];
+ if (kind === 'complex-icu') continue;
+
+ if (kind === 'plain') {
+ providerInputs.push({ text: texts[i], originalIndex: i });
+ } else {
+ // simple-placeholders
+ const { protectedText, placeholders } = protectPlaceholders(texts[i]);
+ placeholderMaps.set(i, placeholders);
+ providerInputs.push({ text: protectedText, originalIndex: i });
+ }
+ }
+
+ let providerResults: Array<{ translatedText: string }> = [];
+ if (providerInputs.length > 0) {
+ providerResults = await this.#provider.translate(
+ providerInputs.map(({ text }) => ({ text, sourceLocale, targetLocale })),
+ );
+ }
+
+ const results: TranslateTextResult[] = new Array(texts.length);
+
+ // Fill complex-ICU skips first.
+ for (let i = 0; i < texts.length; i++) {
+ if (classifications[i] === 'complex-icu') {
+ results[i] = { kind: 'skipped', value: texts[i] };
+ }
+ }
+
+ // Fill translated results.
+ for (let providerIdx = 0; providerIdx < providerInputs.length; providerIdx++) {
+ const { originalIndex } = providerInputs[providerIdx];
+ const translatedText = providerResults[providerIdx].translatedText;
+ const kind = classifications[originalIndex];
+
+ if (kind === 'plain') {
+ results[originalIndex] = { kind: 'translated', value: translatedText };
+ } else {
+ // simple-placeholders — restore markers
+ const placeholders = placeholderMaps.get(originalIndex);
+ if (!placeholders) {
+ results[originalIndex] = { kind: 'skipped', value: texts[originalIndex] };
+ continue;
+ }
+ const restoreResult = restorePlaceholders(translatedText, placeholders);
+ if (!restoreResult.success) {
+ results[originalIndex] = { kind: 'skipped', value: texts[originalIndex] };
+ } else {
+ results[originalIndex] = { kind: 'translated-with-placeholders', value: restoreResult.value };
+ }
+ }
+ }
+
+ return results;
+ }
+
/**
* Protects simple placeholders with markers, translates the protected text,
* then restores the original placeholder syntax.
diff --git a/libs/data-transfer/src/index.ts b/libs/data-transfer/src/index.ts
index 0593f777..34ca98f2 100644
--- a/libs/data-transfer/src/index.ts
+++ b/libs/data-transfer/src/index.ts
@@ -22,3 +22,7 @@ export * from './lib/tree-status-response.dto';
export * from './lib/translation-config.dto';
export * from './lib/translate-resource.dto';
export * from './lib/translate-resource-response.dto';
+export * from './lib/translate-locale.dto';
+export * from './lib/add-locale.dto';
+export * from './lib/add-locale-response.dto';
+export * from './lib/remove-locale-response.dto';
diff --git a/libs/data-transfer/src/lib/add-locale-response.dto.ts b/libs/data-transfer/src/lib/add-locale-response.dto.ts
new file mode 100644
index 00000000..13d0f19d
--- /dev/null
+++ b/libs/data-transfer/src/lib/add-locale-response.dto.ts
@@ -0,0 +1,5 @@
+export interface AddLocaleResponseDto {
+ message: string;
+ entriesBackfilled: number;
+ filesUpdated: number;
+}
diff --git a/libs/data-transfer/src/lib/add-locale.dto.ts b/libs/data-transfer/src/lib/add-locale.dto.ts
new file mode 100644
index 00000000..41b58d9d
--- /dev/null
+++ b/libs/data-transfer/src/lib/add-locale.dto.ts
@@ -0,0 +1,3 @@
+export interface AddLocaleDto {
+ locale: string;
+}
diff --git a/libs/data-transfer/src/lib/remove-locale-response.dto.ts b/libs/data-transfer/src/lib/remove-locale-response.dto.ts
new file mode 100644
index 00000000..0da1b42a
--- /dev/null
+++ b/libs/data-transfer/src/lib/remove-locale-response.dto.ts
@@ -0,0 +1,5 @@
+export interface RemoveLocaleResponseDto {
+ message: string;
+ entriesPurged: number;
+ filesUpdated: number;
+}
diff --git a/libs/data-transfer/src/lib/translate-locale.dto.ts b/libs/data-transfer/src/lib/translate-locale.dto.ts
new file mode 100644
index 00000000..3bd0dcfc
--- /dev/null
+++ b/libs/data-transfer/src/lib/translate-locale.dto.ts
@@ -0,0 +1,18 @@
+export interface TranslateLocaleRequestDto {
+ readonly locale: string;
+}
+
+export interface TranslateLocaleJobDto {
+ readonly jobId: string;
+ readonly collectionName: string;
+ readonly targetLocale: string;
+ readonly status: 'pending' | 'running' | 'completed' | 'failed';
+ readonly totalResources: number;
+ readonly translatedCount: number;
+ readonly failedCount: number;
+ readonly skippedCount: number;
+ readonly failures?: ReadonlyArray<{ key: string; error: string }>;
+ readonly skippedKeys?: readonly string[];
+ readonly startedAt?: string;
+ readonly completedAt?: string;
+}
diff --git a/libs/data-transfer/src/lib/translation-config.dto.ts b/libs/data-transfer/src/lib/translation-config.dto.ts
index d8b83fb1..43306c1a 100644
--- a/libs/data-transfer/src/lib/translation-config.dto.ts
+++ b/libs/data-transfer/src/lib/translation-config.dto.ts
@@ -2,4 +2,8 @@ export interface TranslationConfigDto {
readonly enabled: boolean;
readonly provider: string;
readonly apiKeyEnv: string;
+ /** Number of resources to send per API batch when translating a full locale. Defaults to 5. */
+ readonly batchSize?: number;
+ /** Milliseconds to wait between batches to avoid rate-limit errors. Defaults to 1000. */
+ readonly delayMs?: number;
}
diff --git a/package.json b/package.json
index cb259dab..df7d05f2 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,9 @@
"format": "pnpm exec biome format --write apps libs scripts types",
"format:check": "pnpm exec biome format apps libs scripts types",
"format:write": "pnpm exec biome format --write apps libs scripts types",
- "lingo-tracker": "node dist/apps/cli/main.cjs"
+ "lingo-tracker": "node dist/apps/cli/main.cjs",
+ "docs:dev": "cd docs-site && pnpm start",
+ "docs:build": "cd docs-site && pnpm build"
},
"keywords": [
"translation",