Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/docs/en/cli/fill.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
createdAt: 2024-08-11
updatedAt: 2026-09-27
updatedAt: 2026-10-06
priority: 5
title: "intlayer fill: Translate Dictionaries with AI"
description: "Fill missing translations, audit existing ones and translate your Intlayer dictionaries with AI from the CLI, locally or in CI."
Expand Down Expand Up @@ -117,7 +117,9 @@ Affected dictionary keys for processing: app, comp-test, hello-world, lang-switc

- **`--output-locales [outputLocales...]`**: Target locales to translate to. If not specified, all locales from your configuration will be used except the source locale.

- **`--mode [mode]`**: Translation mode: `complete`, `review`. Default is `complete`. `complete` will fill all missing content, `review` will fill missing content and review existing keys.
- **`--mode [mode]`**: Translation mode: `complete`, `review`. Default is `complete`. `complete` will fill all missing content and re-translate values whose source changed in git, `review` will fill missing content and review existing keys.

> In `complete` mode, source values are compared with the last commit (`HEAD`), or with the base branch when `--git-diff` is set (with `--unpushed`, with the upstream branch). A translation is re-translated when its source value changed and the translation itself did not. No extra file is written. This applies to JSON, JS and TS content declaration files tracked by git. If that git ref can't be read (no git repository, no commit, or a shallow clone without the base branch), `fill` logs why and only fills missing translations. In CI, check out the full history (`fetch-depth: 0` with `actions/checkout`) to use `--git-diff`.

**Git options:**

Expand Down
34 changes: 32 additions & 2 deletions packages/@intlayer/cli/src/fill/fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import {
listTranslationsTasks,
type TranslationTask,
} from './listTranslationsTasks';
import {
getSourceChangesRef,
loadPreviousDictionaries,
type PreviousDictionaries,
} from './sourceChanges';
import { translateDictionary } from './translateDictionary';
import { writeFill } from './writeFill';

Expand Down Expand Up @@ -177,14 +182,39 @@ export const fill = async (options?: FillOptions): Promise<FillResult> => {
*
* Create a list of per-locale dictionaries to translate
*
* In 'complete' mode, filter only the missing locales to translate
* In 'complete' mode, filter only the locales with missing translations or
* with source values changed in git (compared to the last commit, or to the
* base branch with `--git-diff`)
*/
let previousDictionaries: PreviousDictionaries = {};

if (mode === 'complete') {
const { ref, skipReason } = await getSourceChangesRef(
options?.gitOptions,
configuration.system.baseDir
);

if (ref) {
previousDictionaries = await loadPreviousDictionaries(
targetUnmergedDictionaries,
configuration,
ref
);
} else {
appLogger(
`Source changes not checked: ${skipReason}. Only missing translations are filled.`,
{ level: options?.gitOptions ? 'warn' : 'info' }
);
}
}

const translationTasks: TranslationTask[] = listTranslationsTasks(
targetUnmergedDictionaries.map((dictionary) => dictionary.localId!),
outputLocales,
mode,
baseLocale,
configuration
configuration,
previousDictionaries
);

// AI calls in flight at once (translateJSON + metadata audit)
Expand Down
122 changes: 122 additions & 0 deletions packages/@intlayer/cli/src/fill/listTranslationsTasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { getDictionaries } from '@intlayer/dictionaries-entry';
import { getUnmergedDictionaries } from '@intlayer/dictionaries-entry/unmerged';
import type { IntlayerConfig } from '@intlayer/types/config';
import type { Dictionary, LocalDictionaryId } from '@intlayer/types/dictionary';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { listMissingTranslationsWithConfig } from '../test';
import { listTranslationsTasks } from './listTranslationsTasks';
import type { PreviousDictionaries } from './sourceChanges';

vi.mock('@intlayer/dictionaries-entry', () => ({ getDictionaries: vi.fn() }));
vi.mock('@intlayer/dictionaries-entry/unmerged', () => ({
getUnmergedDictionaries: vi.fn(),
}));
vi.mock('../test', () => ({ listMissingTranslationsWithConfig: vi.fn() }));
vi.mock('@intlayer/engine/build', () => ({}));
vi.mock('@intlayer/engine/cli', () => ({}));
vi.mock('@intlayer/config/logger', async (importOriginal) => ({
...(await importOriginal<typeof import('@intlayer/config/logger')>()),
getAppLogger: () => () => undefined,
}));

const dictionaryLocalId: LocalDictionaryId = 'home::local::src/home.content.ts';

/** Fully translated dictionary, whose English title was edited to `Hello`. */
const dictionary: Dictionary = {
key: 'home',
localId: dictionaryLocalId,
filePath: 'src/home.content.ts',
content: {
title: {
nodeType: 'translation',
translation: { en: 'Hello', fr: 'Salut', es: 'Hola' },
},
subtitle: {
nodeType: 'translation',
translation: { en: 'Welcome', fr: 'Bienvenue', es: 'Bienvenido' },
},
},
};

const configuration = {
dictionary: { fill: true },
} as IntlayerConfig;

/** `dictionary` as it was in git, with the title and translations given. */
const getPreviousDictionary = (title: Record<string, string>): Dictionary => ({
...dictionary,
content: {
...dictionary.content,
title: { nodeType: 'translation', translation: title },
},
});

const listTasks = (previousDictionaries?: PreviousDictionaries) =>
listTranslationsTasks(
[dictionaryLocalId],
['en', 'fr', 'es'],
'complete',
'en',
configuration,
previousDictionaries
);

describe('listTranslationsTasks', () => {
beforeEach(() => {
vi.mocked(getDictionaries).mockReturnValue({ home: dictionary });
vi.mocked(getUnmergedDictionaries).mockReturnValue({ home: [dictionary] });
vi.mocked(listMissingTranslationsWithConfig).mockReturnValue({
missingTranslations: [],
} as unknown as ReturnType<typeof listMissingTranslationsWithConfig>);
});

it('skips a fully translated dictionary whose source did not change', () => {
expect(listTasks()).toEqual([]);
expect(
listTasks({
[dictionaryLocalId]: getPreviousDictionary({
en: 'Hello',
fr: 'Salut',
es: 'Hola',
}),
})
).toEqual([]);
});

it('re-translates locales whose source value changed in git', () => {
const tasks = listTasks({
[dictionaryLocalId]: getPreviousDictionary({
en: 'Hi',
fr: 'Salut',
es: 'Hola',
}),
});

expect(tasks).toHaveLength(1);
expect(tasks[0]).toMatchObject({
dictionaryKey: 'home',
sourceLocale: 'en',
targetLocales: ['fr', 'es'],
changedSourceContent: {
fr: { title: 'Hello' },
es: { title: 'Hello' },
},
});
});

it('keeps a translation edited in the same change as its source', () => {
const tasks = listTasks({
[dictionaryLocalId]: getPreviousDictionary({
en: 'Hi',
fr: 'Salut',
es: 'Holi',
}),
});

expect(tasks).toHaveLength(1);
expect(tasks[0]).toMatchObject({
targetLocales: ['fr'],
changedSourceContent: { fr: { title: 'Hello' } },
});
});
});
57 changes: 47 additions & 10 deletions packages/@intlayer/cli/src/fill/listTranslationsTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import {
import { getFilterTranslationsOnlyDictionary } from '@intlayer/core/plugins';
import { getDictionaries } from '@intlayer/dictionaries-entry';
import { getUnmergedDictionaries } from '@intlayer/dictionaries-entry/unmerged';
import { formatLocale } from '@intlayer/engine/utils';
import { formatLocale, type Recursive } from '@intlayer/engine/utils';
import type { Locale } from '@intlayer/types/allLocales';
import type { IntlayerConfig } from '@intlayer/types/config';
import type { Dictionary, LocalDictionaryId } from '@intlayer/types/dictionary';
import { listMissingTranslationsWithConfig } from '../test';
import {
getStaleSourceContent,
type PreviousDictionaries,
} from './sourceChanges';

export type TranslationTask = {
dictionaryKey: string;
Expand All @@ -23,14 +27,17 @@ export type TranslationTask = {
targetLocales: Locale[];
dictionaryPreset: string;
dictionaryFilePath: string;
/** Per target locale, source values changed since the git ref. */
changedSourceContent: Partial<Record<Locale, Recursive>>;
};

export const listTranslationsTasks = (
localIds: LocalDictionaryId[],
outputLocales: Locale[],
mode: 'complete' | 'review',
baseLocale: Locale,
configuration: IntlayerConfig
configuration: IntlayerConfig,
previousDictionaries: PreviousDictionaries = {}
): TranslationTask[] => {
const appLogger = getAppLogger(configuration);

Expand Down Expand Up @@ -115,19 +122,48 @@ export const listTranslationsTasks = (
}

/**
* In 'complete' mode, filter only the missing locales to translate
* Source values edited since the git ref, with a translation that was not
* updated. Only the base locale is a source of truth: other per-locale
* files are fill outputs.
*/
const changedSourceContent: Partial<Record<Locale, Recursive>> = {};

if (sourceLocale === baseLocale) {
for (const locale of outputLocales) {
if (locale === sourceLocale) continue;

const changedContent = getStaleSourceContent(
previousDictionaries[dictionaryLocalId],
targetUnmergedDictionary,
sourceLocale,
locale
);

if (changedContent !== undefined) {
changedSourceContent[locale] = changedContent;
}
}
}

/**
* In 'complete' mode, filter only the locales with missing translations
* or with source values changed since the git ref
*
* Skip the dictionary if there are no missing locales to translate
* Skip the dictionary if there are no locales to translate
*/
let outputLocalesList: Locale[] = outputLocales as Locale[];

if (mode === 'complete') {
outputLocalesList =
missingTranslations
.find(
(missingTranslation) => missingTranslation.key === dictionaryKey
)
?.locales.filter((locale) => outputLocales.includes(locale)) ?? [];
const missingLocales =
missingTranslations.find(
(missingTranslation) => missingTranslation.key === dictionaryKey
)?.locales ?? [];

outputLocalesList = outputLocales.filter(
(locale) =>
missingLocales.includes(locale) ||
Object.hasOwn(changedSourceContent, locale)
);
}

if (outputLocalesList.length === 0) {
Expand All @@ -147,6 +183,7 @@ export const listTranslationsTasks = (
targetLocales: outputLocalesList,
dictionaryPreset,
dictionaryFilePath: targetUnmergedDictionary.filePath,
changedSourceContent,
});
}

Expand Down
Loading
Loading