Skip to content

Commit 126f3eb

Browse files
committed
feat: add plain text (.txt) file support
## Changed - Fix [locale] at start of include paths in config validation - Fix no-this-alias lint error in ts.parser - Direct translation now parses .txt files line-by-line instead of as raw text ## New - Add TxtParser for line-by-line .txt file translation with structure preservation - Add TXT format to parser factory, supported types, and config schema - Add txt parser unit tests (30 tests) and integration tests (10 tests) - Add plain text files documentation and format guide
1 parent 274c70e commit 126f3eb

16 files changed

Lines changed: 1189 additions & 25 deletions

File tree

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ The `lara.yaml` configuration file controls how Lara CLI works with your project
5555
- [Xcode Strings Files Guide](config/files/xcode-strings-files.md) - Complete guide for Xcode .strings files
5656
- [Xcode Stringsdict Files Guide](config/files/xcode-stringsdict-files.md) - Complete guide for Xcode .stringsdict plural files
5757
- [Xcode String Catalogs Guide](config/files/xcode-xcstrings-files.md) - Complete guide for Xcode .xcstrings String Catalogs
58+
- [TXT Files Guide](config/files/txt-files.md) - Complete guide for plain text files

docs/commands/translate.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ lara-cli translate --file "messages.json" --source en --target fr --output "mess
163163

164164
#### Structured vs Plain Text Files
165165

166-
- **Structured files** (JSON, PO, XML, Markdown, and other [supported formats](../config/formats.md)): Parsed key-by-key, preserving file structure, formatting, and non-string values.
167-
- **Plain text files** (`.txt` and unsupported extensions): The entire file content is translated as a single block of text.
166+
- **Structured files** (JSON, PO, XML, Markdown, TXT, and other [supported formats](../config/formats.md)): Parsed and translated preserving file structure, formatting, and non-string values. For `.txt` files, each non-empty line is translated independently while empty lines are preserved.
167+
- **Unsupported extensions**: The entire file content is translated as a single block of text.
168168

169169
### Direct Translation Options
170170

docs/config/files/txt-files.md

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# Plain Text Files Configuration
2+
3+
This guide explains how to use Lara CLI with plain text (`.txt`) files for internationalization.
4+
5+
## Configuration
6+
7+
To configure plain text files in your `lara.yaml`:
8+
9+
```yaml
10+
files:
11+
txt:
12+
include:
13+
- 'texts/[locale]/*.txt'
14+
exclude: []
15+
lockedKeys: []
16+
ignoredKeys: []
17+
```
18+
19+
### File Path Patterns
20+
21+
Plain text files can be configured using glob patterns with the `[locale]` placeholder:
22+
23+
#### Locale-Based Directory Pattern (Recommended)
24+
25+
Organize text files by locale in separate directories:
26+
27+
```yaml
28+
files:
29+
txt:
30+
include:
31+
- 'texts/[locale]/*.txt'
32+
- 'content/[locale]/**/*.txt'
33+
```
34+
35+
This pattern expects separate files per locale:
36+
37+
```text
38+
texts/
39+
├── en/
40+
│ ├── messages.txt
41+
│ ├── notifications.txt
42+
│ └── emails/
43+
│ └── welcome.txt
44+
├── es/
45+
│ ├── messages.txt
46+
│ ├── notifications.txt
47+
│ └── emails/
48+
│ └── welcome.txt
49+
└── fr/
50+
├── messages.txt
51+
├── notifications.txt
52+
└── emails/
53+
└── welcome.txt
54+
```
55+
56+
#### Locale in Filename Pattern
57+
58+
If you use locale codes in filenames:
59+
60+
```yaml
61+
files:
62+
txt:
63+
include:
64+
- 'texts/[locale].txt'
65+
```
66+
67+
This matches files like:
68+
69+
- `texts/en.txt`
70+
- `texts/es.txt`
71+
- `texts/fr.txt`
72+
73+
## Plain Text File Structure
74+
75+
### Basic Structure
76+
77+
Lara CLI extracts text content from plain text files on a line-by-line basis:
78+
79+
```text
80+
Welcome to our application
81+
Please sign in to continue
82+
Thank you for your purchase
83+
```
84+
85+
Each non-empty line becomes an independent translatable segment. Empty lines are preserved structurally but not translated.
86+
87+
### How Lines Are Extracted
88+
89+
Lara CLI extracts non-empty lines sequentially and assigns them keys:
90+
91+
- `line_0` - First non-empty line
92+
- `line_1` - Second non-empty line
93+
- `line_2` - Third non-empty line
94+
- etc.
95+
96+
**Example:**
97+
98+
```text
99+
Hello World
100+
101+
Welcome to our application.
102+
103+
Thank you for using our product.
104+
```
105+
106+
Extracted segments:
107+
108+
- `line_0`: "Hello World"
109+
- `line_1`: "Welcome to our application."
110+
- `line_2`: "Thank you for using our product."
111+
112+
Empty lines between content are preserved in the translated output but are not assigned keys.
113+
114+
### What Is Translated
115+
116+
- Each non-empty line (lines containing at least one non-whitespace character)
117+
118+
### What Is Preserved (Not Translated)
119+
120+
- Empty lines (used as structural separators)
121+
- Whitespace-only lines
122+
- Leading and trailing whitespace within content lines
123+
- Trailing newlines at end of file
124+
125+
## Key Path Format
126+
127+
When using `lockedKeys` or `ignoredKeys` with plain text files, use line-based keys:
128+
129+
```yaml
130+
files:
131+
txt:
132+
include:
133+
- 'texts/[locale]/*.txt'
134+
lockedKeys:
135+
- 'line_0' # First line (e.g., title)
136+
- 'line_5' # Specific line
137+
ignoredKeys:
138+
- 'line_10' # Ignore specific line
139+
```
140+
141+
**Note:** Line indices are position-based (counting only non-empty lines) and depend on the document structure. Use with caution as document changes may shift line indices.
142+
143+
## Complete Example
144+
145+
Here's a complete configuration example:
146+
147+
```yaml
148+
version: '1.0.0'
149+
150+
project:
151+
instruction: 'Simple, clear language for UI text'
152+
153+
locales:
154+
source: en
155+
target:
156+
- es
157+
- fr
158+
- de
159+
- it
160+
161+
memories:
162+
- mem_abc123
163+
164+
glossaries:
165+
- gls_xyz789
166+
167+
files:
168+
txt:
169+
include:
170+
- 'texts/[locale]/messages.txt'
171+
- 'texts/[locale]/notifications.txt'
172+
exclude:
173+
- 'texts/[locale]/draft-*.txt'
174+
fileInstructions:
175+
- path: 'texts/[locale]/notifications.txt'
176+
instruction: 'Short notification messages, concise and clear'
177+
```
178+
179+
## Working with Existing Text Files
180+
181+
If you already have text files organized by locale:
182+
183+
1. **Run `lara-cli init`** to create your configuration
184+
2. **Ensure your file paths match the `include` patterns**
185+
3. **Run `lara-cli translate`**
186+
4. **Continue developing** - Lara CLI tracks changes via checksums and only translates what's new or modified
187+
188+
If your text files aren't organized by locale yet:
189+
190+
1. **Organize files** into locale-based directories or use locale prefixes in filenames
191+
2. **Update your `lara.yaml`** to match your file structure
192+
3. **Run `lara-cli translate`**
193+
194+
## Best Practices
195+
196+
### 1. Organize by Locale
197+
198+
Keep separate text files for each locale:
199+
200+
```text
201+
texts/
202+
├── en/
203+
│ └── messages.txt
204+
├── es/
205+
│ └── messages.txt
206+
└── fr/
207+
└── messages.txt
208+
```
209+
210+
### 2. One Translatable Unit Per Line
211+
212+
Each line should contain one independent piece of text:
213+
214+
```text
215+
Welcome to our app
216+
Sign in to continue
217+
Forgot your password?
218+
```
219+
220+
Avoid splitting a single sentence across multiple lines, as each line is translated independently.
221+
222+
### 3. Use Empty Lines as Separators
223+
224+
Group related content with empty lines for readability:
225+
226+
```text
227+
Welcome to our app
228+
229+
Sign in to continue
230+
Create a new account
231+
232+
Need help?
233+
Contact support
234+
```
235+
236+
### 4. Use Consistent Structure
237+
238+
Keep the same line structure across all locale files to ensure translations align correctly.
239+
240+
### 5. Leverage Instructions
241+
242+
Use file-level instructions for better translation quality:
243+
244+
```yaml
245+
files:
246+
txt:
247+
include:
248+
- 'texts/[locale]/*.txt'
249+
fileInstructions:
250+
- path: 'texts/[locale]/ui-messages.txt'
251+
instruction: 'Short UI labels and button text, keep concise'
252+
- path: 'texts/[locale]/emails.txt'
253+
instruction: 'Email content, professional and friendly tone'
254+
```
255+
256+
## Limitations
257+
258+
### File Format
259+
260+
- Each non-empty line is treated as an independent translatable unit
261+
- No support for multi-line paragraphs (each line is a separate segment)
262+
- No support for key-value pairs (use JSON or PO for structured translations)
263+
- No support for comments or metadata within the file
264+
265+
### Supported Patterns
266+
267+
- Locale-based directories: `texts/[locale]/*.txt`
268+
- Locale in filenames: `texts/[locale].txt`
269+
- Recursive patterns: `texts/[locale]/**/*.txt`
270+
- Files without `[locale]` placeholder are not supported
271+
272+
### Line Indexing
273+
274+
- Line indices (`line_0`, `line_1`, etc.) count only non-empty lines
275+
- Adding or removing lines shifts line indices
276+
- Use `lockedKeys` and `ignoredKeys` carefully with line indices
277+
- Consider the document structure when using line-based keys
278+
279+
## Related Documentation
280+
281+
- [Supported Formats](../formats.md) - Overview of all supported file formats
282+
- [Files Configuration](../files.md) - General file configuration options
283+
- [Instructions](../instructions.md) - How to use translation instructions
284+
- [Locales](../locales.md) - Supported locale codes

docs/config/formats.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Lara CLI supports multiple internationalization file formats. The appropriate pa
1515
| **Xcode Strings** | `.strings` | Xcode .strings key-value localization files for iOS/macOS applications | [Xcode Strings Files Guide](./files/xcode-strings-files.md) |
1616
| **Xcode Stringsdict** | `.stringsdict` | Xcode .stringsdict plist files with plural rules for iOS/macOS applications | [Xcode Stringsdict Files Guide](./files/xcode-stringsdict-files.md) |
1717
| **Xcode String Catalogs** | `.xcstrings` | Xcode String Catalogs (Xcode 15+), all locales in a single JSON file | [Xcode String Catalogs Guide](./files/xcode-xcstrings-files.md) |
18+
| **Plain Text** | `.txt` | Plain text files with one translatable segment per line | [TXT Files Guide](./files/txt-files.md) |
1819

1920
## How It Works
2021

@@ -29,6 +30,7 @@ The file format is automatically detected based on the file extension:
2930
- Files ending with `.strings` are parsed as Xcode .strings localization files
3031
- Files ending with `.stringsdict` are parsed as Xcode .stringsdict plural files
3132
- Files ending with `.xcstrings` are parsed as Xcode String Catalogs (all locales in one file)
33+
- Files ending with `.txt` are parsed as plain text files
3234

3335
You can configure multiple formats simultaneously in the same project by adding different format sections under the `files` configuration.
3436

@@ -60,6 +62,9 @@ files:
6062
xcode-xcstrings:
6163
include:
6264
- 'Localizable.xcstrings'
65+
txt:
66+
include:
67+
- 'texts/[locale]/messages.txt'
6368
```
6469
6570
## Format-Specific Documentation
@@ -74,3 +79,4 @@ For detailed information about a specific format, see its dedicated documentatio
7479
- [Xcode Strings Files Guide](./files/xcode-strings-files.md) - Complete guide for Xcode .strings files
7580
- [Xcode Stringsdict Files Guide](./files/xcode-stringsdict-files.md) - Complete guide for Xcode .stringsdict plural files
7681
- [Xcode String Catalogs Guide](./files/xcode-xcstrings-files.md) - Complete guide for Xcode .xcstrings String Catalogs
82+
- [TXT Files Guide](./files/txt-files.md) - Complete guide for plain text files

src/__tests__/integration/direct-translate.integration.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ describe('Direct Translation Integration Tests', () => {
190190
});
191191
});
192192

193-
describe('file mode - plain text', () => {
194-
it('should translate a plain text file and output to stdout', async () => {
193+
describe('file mode - txt files', () => {
194+
it('should translate a txt file and output to stdout', async () => {
195195
const inputFile = path.join(testDir, 'hello.txt');
196196
await writeFile(inputFile, 'Hello, world!');
197197

@@ -278,6 +278,29 @@ describe('Direct Translation Integration Tests', () => {
278278
).rejects.toThrow();
279279
});
280280

281+
it('should translate multi-line txt file line-by-line', async () => {
282+
const inputFile = path.join(testDir, 'multi.txt');
283+
await writeFile(inputFile, 'Hello\n\nWelcome\nGoodbye\n');
284+
285+
await executeCommand(translateCommand, [
286+
'--file',
287+
inputFile,
288+
'--source',
289+
'en',
290+
'--target',
291+
'fr',
292+
]);
293+
294+
const stdoutCalls = stdoutWriteSpy.mock.calls.map((call: unknown[]) => String(call[0]));
295+
const output = stdoutCalls.find((call: string) => call.includes('[fr]'));
296+
expect(output).toBeDefined();
297+
expect(output).toContain('[fr] Hello');
298+
expect(output).toContain('[fr] Welcome');
299+
expect(output).toContain('[fr] Goodbye');
300+
// Verify empty lines are preserved
301+
expect(output).toContain('\n\n');
302+
});
303+
281304
it('should work without a lara.yaml config file', async () => {
282305
expect(existsSync(path.join(testDir, 'lara.yaml'))).toBe(false);
283306

@@ -298,6 +321,25 @@ describe('Direct Translation Integration Tests', () => {
298321
});
299322
});
300323

324+
describe('file mode - plain text fallback', () => {
325+
it('should translate unsupported file extensions as plain text', async () => {
326+
const inputFile = path.join(testDir, 'data.csv');
327+
await writeFile(inputFile, 'Hello, world!');
328+
329+
await executeCommand(translateCommand, [
330+
'--file',
331+
inputFile,
332+
'--source',
333+
'en',
334+
'--target',
335+
'fr',
336+
]);
337+
338+
const stdoutCalls = stdoutWriteSpy.mock.calls.map((call: unknown[]) => call[0]);
339+
expect(stdoutCalls).toContainEqual('[fr] Hello, world!');
340+
});
341+
});
342+
301343
describe('file mode - structured files (JSON)', () => {
302344
it('should translate JSON file preserving structure', async () => {
303345
const inputFile = path.join(testDir, 'messages.json');

0 commit comments

Comments
 (0)