Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8a33786
ENG-219: Add i18n command
d4mation Feb 11, 2026
0f35576
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 11, 2026
177b84d
ENG-219: Add i18n command tests
d4mation Feb 11, 2026
f66dc8a
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 11, 2026
851c557
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 11, 2026
73869d5
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 11, 2026
07f5ceb
ENG-219: Use createTempProject in i18n tests for isolation
d4mation Feb 11, 2026
5b16d1f
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 11, 2026
010a0aa
ENG-219: Align i18n command behavior with PHP implementation
d4mation Feb 12, 2026
5636ad2
ENG-219: Fix --root flag to only affect download directory, not confi…
d4mation Feb 12, 2026
2dae484
ENG-219: Add tests for --retries and --root flags
d4mation Feb 12, 2026
3fee633
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 12, 2026
77decf7
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 13, 2026
e4f2d8b
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 13, 2026
30055fc
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 13, 2026
6a15f85
ENG-219: Merge app-bootstrap and update imports to .ts
d4mation Feb 13, 2026
37a4854
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-i18n
d4mation Feb 23, 2026
167f40d
ENG-219: Port i18n 429 retry logic from PR #53
d4mation Feb 23, 2026
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
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createApp } from './app.js';
import { registerI18nCommand } from './commands/i18n.js';

const program = createApp();

registerI18nCommand(program);

program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
Expand Down
229 changes: 229 additions & 0 deletions src/commands/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import type { Command } from 'commander';
import fs from 'fs-extra';
import path from 'node:path';
import { getConfig } from '../config.js';
import * as output from '../utils/output.js';
import type { I18nResolvedConfig } from '../types.js';

/**
* Registers the `i18n` command with the CLI program.
*
* @since TBD
*
* @param {Command} program - The Commander.js program instance.
*
* @returns {void}
*/
export function registerI18nCommand(program: Command): void {
program
.command('i18n')
.description('Fetches language files for the project.')
.option('--retries <number>', 'How many retries we do for each file.', '3')
.option('--root <dir>', 'Set the root directory for downloading language files.')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how it worked in the PHP version, but the description made it seem like it should instead load the .puprc from that directory. I've updated the description to match the actual behavior.

.action(async (options: { retries?: string; root?: string }) => {
const config = getConfig();
const i18nConfigs = config.getI18n();
const cwd = options.root ?? config.getWorkingDir();
const maxRetries = parseInt(options.retries ?? '3', 10);

if (i18nConfigs.length === 0) {
output.log('No i18n configuration found. Skipping.');
return;
}

for (const i18nConfig of i18nConfigs) {
const result = await downloadLanguageFiles(i18nConfig, cwd, maxRetries);

if (result !== 0) {
process.exitCode = result;
return;
}
}
});
}

/**
* Downloads language files for a single i18n configuration.
*
* @since TBD
*
* @param {I18nResolvedConfig} config - The resolved i18n configuration for this translation source.
* @param {string} cwd - The current working directory.
* @param {number} maxRetries - The maximum number of retry attempts for failed downloads.
*
* @returns {Promise<number>} 0 on success, 1 on failure.
*/
async function downloadLanguageFiles(
config: I18nResolvedConfig,
cwd: string,
maxRetries: number
): Promise<number> {
const projectUrl = config.url
.replace('{slug}', config.slug)
.replace('%slug%', config.slug);

output.log(`Fetching language files for ${config.textdomain} from ${projectUrl}`);

let data: TranslationApiResponse;

try {
const response = await fetch(projectUrl);
if (!response.ok) {
output.error(`Failed to fetch project data from ${projectUrl}`);
return 1;
}
data = (await response.json()) as TranslationApiResponse;
} catch (err) {
output.error(`Failed to fetch translation data: ${err}`);
return 1;
}

if (
!data.translation_sets ||
!Array.isArray(data.translation_sets) ||
data.translation_sets.length === 0
) {
output.error(`Failed to fetch translation sets from ${projectUrl}`);
return 1;
}

const minimumPercentage = config.filter.minimum_percentage;

const langDir = path.resolve(cwd, config.path);
await fs.mkdirp(langDir);

const promises: Promise<void>[] = [];

for (const translation of data.translation_sets) {
// Skip when translations are zero.
if (translation.current_count === 0) {
continue;
}

// Skip any translation set that doesn't match our min translated.
if (minimumPercentage > translation.percent_translated) {
continue;
}

for (const format of config.formats) {
const promise = downloadAndSaveTranslation(
config,
translation,
format,
projectUrl,
langDir,
maxRetries
);
promises.push(promise);
}
}

await Promise.all(promises);

return 0;
}

/**
* Downloads and saves a single translation file with retry support.
*
* @since TBD
*
* @param {I18nResolvedConfig} config - The resolved i18n configuration.
* @param {TranslationSet} translation - The translation set to download.
* @param {string} format - The file format to download (e.g. "po", "mo").
* @param {string} projectUrl - The base project API URL.
* @param {string} langDir - The absolute path to the language files directory.
* @param {number} maxRetries - The maximum number of retry attempts.
* @param {number} tried - The current attempt count.
*
* @returns {Promise<void>}
*/
async function downloadAndSaveTranslation(
config: I18nResolvedConfig,
translation: TranslationSet,
format: string,
projectUrl: string,
langDir: string,
maxRetries: number,
tried = 0
): Promise<void> {
const translationUrl = `${projectUrl}/${translation.locale}/${translation.slug}/export-translations?format=${format}`;

if (tried >= maxRetries) {
output.error(
`Failed to fetch translation from ${translationUrl} too many times, bailing on ${translation.slug}`
);
return;
}

tried++;

try {
const response = await fetch(translationUrl);

const filename = config.file_format
.replace('%domainPath%', config.path)
.replace('%textdomain%', config.textdomain)
.replace('%locale%', translation.locale ?? '')
.replace('%wp_locale%', translation.wp_locale ?? '')
.replace('%format%', format);

const buffer = Buffer.from(await response.arrayBuffer());

if (buffer.byteLength < 200) {
output.error(`Failed to fetch translation from ${translationUrl}`);

// Not sure if 2 seconds is needed, but it prevents the firewall from catching us.
await new Promise(resolve => setTimeout(resolve, 2000));

// Retries to download this file.
return downloadAndSaveTranslation(
config, translation, format, projectUrl, langDir, maxRetries, tried
);
}

const filePath = path.join(langDir, filename);
await fs.writeFile(filePath, buffer);

// Verify the written file size matches the response size.
const stat = await fs.stat(filePath);
if (stat.size !== buffer.byteLength) {
output.error(
`Failed to save the translation from ${translationUrl} to ${filePath}`
);

// Delete the file in that case.
await fs.unlink(filePath).catch(() => {});

// Not sure if 2 seconds is needed, but it prevents the firewall from catching us.
await new Promise(resolve => setTimeout(resolve, 2000));

// Retries to download this file.
return downloadAndSaveTranslation(
config, translation, format, projectUrl, langDir, maxRetries, tried
);
}

output.log(`* Translation created for ${filePath}`);
} catch {
output.error(`Failed to fetch translation from ${translationUrl}`);

await new Promise(resolve => setTimeout(resolve, 2000));

return downloadAndSaveTranslation(
config, translation, format, projectUrl, langDir, maxRetries, tried
);
}
}

interface TranslationApiResponse {
translation_sets: TranslationSet[];
}

interface TranslationSet {
locale: string;
wp_locale?: string;
slug: string;
current_count: number;
percent_translated: number;
}
14 changes: 14 additions & 0 deletions src/models/i18n-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { I18nResolvedConfig } from '../types.js';

/**
* Creates a resolved i18n configuration object.
*
* @since TBD
*
* @param {I18nResolvedConfig} config - The i18n configuration to clone.
*
* @returns {I18nResolvedConfig} A new copy of the i18n configuration object.
*/
export function createI18nConfig(config: I18nResolvedConfig): I18nResolvedConfig {
return { ...config };
}
Loading