Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Lara Cli automates translation of your i18n files with a single command, preserv

Supports multiple file formats including JSON, PO (gettext), TypeScript, Vue I18n single-file components, Markdown and MDX files, Android XML string resource files, Xcode localization files (.strings, .stringsdict, .xcstrings), and plain text (.txt) files. See [Supported Formats](docs/config/formats.md) for details.

[![Version](https://img.shields.io/badge/version-1.3.3-blue.svg)](https://github.com/translated/lara-cli)
[![Version](https://img.shields.io/badge/version-1.3.4-blue.svg)](https://github.com/translated/lara-cli)

</div>

Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@translated/lara-cli",
"type": "module",
"version": "1.3.3",
"version": "1.3.4",
"description": "CLI tool for automated i18n file translation using Lara Translate",
"repository": {
"type": "git",
Expand Down Expand Up @@ -45,7 +45,7 @@
"fast-xml-parser": "^5.5.9",
"flat": "^6.0.1",
"gettext-parser": "^8.0.0",
"glob": "^11.1.0",
"glob": "^13.0.6",
"ora": "^8.2.0",
"picomatch": "^4.0.4",
"remark": "^15.0.1",
Expand Down Expand Up @@ -87,5 +87,10 @@
"typescript-eslint": "^8.57.2",
"vite": "^7.3.3",
"vitest": "^4.1.2"
},
"pnpm": {
"overrides": {
"brace-expansion@>=5.0.0 <5.0.6": "^5.0.6"
}
}
}
56 changes: 13 additions & 43 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 33 additions & 1 deletion src/__tests__/integration/android-xml.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';

import yaml from 'yaml';

import { executeCommand } from './test-helpers.js';
import { executeCommand, mockTranslateBatchWithFallback } from './test-helpers.js';
import initCommand from '../../cli/cmd/init/init.js';
import translateCommand from '../../cli/cmd/translate/translate.js';
import { ConfigProvider } from '#modules/config/config.provider.js';
Expand Down Expand Up @@ -744,4 +744,36 @@ describe('Android XML Repository Integration Tests', () => {
expect(contentAfter).toContain('[it] Hello World');
expect(contentAfter).toContain('[it] Welcome to the app');
});

// Android string resources support inline HTML markup (<b>, <i>, <u>, <br/>),
// so the engine should declare contentType=text/html — that tells Lara to
// preserve the markup instead of stripping or escaping it.
it('passes contentType=text/html for Android XML sources', async () => {
mockTranslateBatchWithFallback.mockClear();
await mkdir(path.join(testDir, 'res', 'en'), { recursive: true });
await writeFile(
path.join(testDir, 'res', 'en', 'strings.xml'),
`<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hello">Hello</string>
</resources>`
);

await executeCommand(initCommand, [
'--non-interactive',
'--source',
'en',
'--target',
'it',
'--paths',
'res/[locale]/strings.xml',
]);
(ConfigProvider as any).instance = null;

await executeCommand(translateCommand, []);

expect(mockTranslateBatchWithFallback).toHaveBeenCalled();
const [, , , batchOptions] = mockTranslateBatchWithFallback.mock.calls[0]!;
expect((batchOptions as any).contentType).toBe('text/html');
});
});
58 changes: 58 additions & 0 deletions src/__tests__/integration/batch-translation.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,62 @@ describe('Batch translation', () => {
a: '[it] third',
});
});

// Regression test: Lara auto-detects TextBlock[] input as HTML-flavored content
// unless contentType is set explicitly. For JSON sources the values are plain
// text, so the engine must pass contentType: 'text/plain' to avoid the API
// mangling characters into '?' placeholders.
it('passes contentType=text/plain for JSON sources', async () => {
await writeJsonSource({ a: 'one', b: 'two' });
await initJson();

await executeCommand(translateCommand, []);

expect(mockTranslateBatchWithFallback).toHaveBeenCalledTimes(1);
const [, , , batchOptions] = mockTranslateBatchWithFallback.mock.calls[0]!;
expect((batchOptions as any).contentType).toBe('text/plain');
});

// When a JSON file mixes plain values with values that contain inline HTML
// markup, the engine must split them into two API calls — one with
// contentType=text/plain (so non-HTML text isn't mangled) and one with
// contentType=text/html (so the tags survive the round trip).
it('splits mixed plain + HTML values into two batch calls', async () => {
await writeJsonSource({
plain1: 'Hello world',
htmlA: 'Click <a href="/x">here</a>',
plain2: 'Goodbye',
htmlB: 'Be <b>bold</b>',
});
await initJson();

await executeCommand(translateCommand, []);

expect(mockTranslateBatchWithFallback).toHaveBeenCalledTimes(2);

const callsByType = new Map<string, string[]>();
for (const [textBlocks, , , options] of mockTranslateBatchWithFallback.mock.calls) {
const contentType = (options as any).contentType as string;
callsByType.set(
contentType,
textBlocks.map((b) => b.text)
);
}

expect(callsByType.get('text/plain')?.sort()).toEqual(['Goodbye', 'Hello world']);
expect(callsByType.get('text/html')?.sort()).toEqual([
'Be <b>bold</b>',
'Click <a href="/x">here</a>',
]);

const itContent = JSON.parse(
await readFile(path.join(testDir, 'i18n', 'locales', 'it.json'), 'utf-8')
);
expect(itContent).toEqual({
plain1: '[it] Hello world',
htmlA: '[it] Click <a href="/x">here</a>',
plain2: '[it] Goodbye',
htmlB: '[it] Be <b>bold</b>',
});
});
});
Loading