Skip to content

Commit 9d41f2b

Browse files
authored
V1.0.1 (#39)
* fix: restrict .ts file parsing to i18n files only (#37) * fix: restrict .ts file parsing to i18n files only ## Changed - Skip non-i18n .ts files during locale extraction to avoid parse errors on regular TypeScript files - Updated extractLocaleFromPath and extractAllLocalesFromProject to match only `i18n.ts` files ## New - Tests for non-i18n .ts file filtering in both extraction functions - Added .claude/ to .gitignore * fix: added checks for non i18n files in locale directory * refactor: replace `any` with proper type assertions in tests ## Changed - Replace `as any` with `as unknown as ParserFactory` for mock parser arguments in checksum tests - Replace `as any` with `as unknown as void` for mock implementations in checksum and locale tests - Replace `as any` with `as unknown as Record<string, unknown>` for edge-case inputs in parser tests - Replace `as any` with `as unknown` for mock return values in locale tests Made-with: Cursor * fix: improved ts files recognition for patterns xx-i18n.ts * fix: prevent deletion of keys containing "/" (#38) * fix: prevent deletion of keys containing "/" ## Changed - Use null byte (\0) as internal flat/unflatten delimiter instead of "/" - Convert flattened keys back to "/" for user-facing pattern matching (lockedKeys, ignoredKeys, keyInstructions) - Update locale extraction to split on null byte delimiter ## New - Tests verifying keys with "/" (e.g. "harassment/threatening") survive parse/serialize round-trip * refactor: deduplicate delimiter conversion logic ## Changed - Extract toUserKey() helper for \0 → "/" key conversion - Compute user-facing key once per key in translation loop - Update JSDoc examples to reflect \0 delimiter * chore: bump version to 1.0.1 ## Changed - Updated version in package.json from 1.0.0 to 1.0.1 - Updated version badge in README.md to 1.0.1
1 parent cee4edf commit 9d41f2b

16 files changed

Lines changed: 330 additions & 157 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ i18n-test/
2424
coverage/
2525

2626
# tmp files
27-
tmp/
27+
tmp/
28+
29+
.claude/

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Lara Cli automates translation of your i18n files with a single command, preserv
66

77
Supports multiple file formats including JSON, PO (gettext), TypeScript, Vue I18n single-file components, Markdown and MDX files, and Android XML string resource files. See [Supported Formats](docs/config/formats.md) for details.
88

9-
[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/translated/lara-cli)
9+
[![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)](https://github.com/translated/lara-cli)
1010

1111
</div>
1212

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@translated/lara-cli",
33
"type": "module",
4-
"version": "1.0.0",
4+
"version": "1.0.1",
55
"description": "CLI tool for automated i18n file translation using Lara Translate",
66
"repository": {
77
"type": "git",

src/__tests__/parsers/json.parser.test.ts

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ describe('JsonParser', () => {
2020
const content = '{"dashboard": {"title": "Dashboard"}}';
2121
const result = parser.parse(content);
2222

23-
expect(result).toEqual({ 'dashboard/title': 'Dashboard' });
23+
expect(result).toEqual({ 'dashboard\0title': 'Dashboard' });
2424
});
2525

2626
it('should flatten deeply nested objects', () => {
2727
const content = '{"level1": {"level2": {"level3": {"key": "value"}}}}';
2828
const result = parser.parse(content);
2929

30-
expect(result).toEqual({ 'level1/level2/level3/key': 'value' });
30+
expect(result).toEqual({ 'level1\0level2\0level3\0key': 'value' });
3131
});
3232

3333
it('should flatten arrays', () => {
3434
const content = '{"items": ["item1", "item2", "item3"]}';
3535
const result = parser.parse(content);
3636

3737
expect(result).toEqual({
38-
'items/0': 'item1',
39-
'items/1': 'item2',
40-
'items/2': 'item3',
38+
'items\x000': 'item1',
39+
'items\x001': 'item2',
40+
'items\x002': 'item3',
4141
});
4242
});
4343

@@ -47,9 +47,9 @@ describe('JsonParser', () => {
4747
const result = parser.parse(content);
4848

4949
expect(result).toEqual({
50-
'dashboard/title': 'Dashboard',
51-
'dashboard/content/0': 'content 1',
52-
'dashboard/content/1': 'content 2',
50+
'dashboard\0title': 'Dashboard',
51+
'dashboard\0content\x000': 'content 1',
52+
'dashboard\0content\x001': 'content 2',
5353
});
5454
});
5555

@@ -108,9 +108,9 @@ describe('JsonParser', () => {
108108
number: 123,
109109
boolean: true,
110110
null: null,
111-
'array/0': 1,
112-
'array/1': 2,
113-
'object/nested': 'value',
111+
'array\x000': 1,
112+
'array\x001': 2,
113+
'object\0nested': 'value',
114114
});
115115
});
116116

@@ -125,16 +125,16 @@ describe('JsonParser', () => {
125125
const content = '{"parent": {"child": {}}}';
126126
const result = parser.parse(content);
127127

128-
expect(result).toEqual({ 'parent/child': {} });
128+
expect(result).toEqual({ 'parent\0child': {} });
129129
});
130130

131131
it('should handle arrays with objects', () => {
132132
const content = '{"users": [{"name": "John"}, {"name": "Jane"}]}';
133133
const result = parser.parse(content);
134134

135135
expect(result).toEqual({
136-
'users/0/name': 'John',
137-
'users/1/name': 'Jane',
136+
'users\x000\0name': 'John',
137+
'users\x001\0name': 'Jane',
138138
});
139139
});
140140

@@ -170,6 +170,42 @@ describe('JsonParser', () => {
170170

171171
expect(reparsed).toEqual(JSON.parse(original));
172172
});
173+
174+
it('should preserve keys containing forward slashes', () => {
175+
const content = JSON.stringify({
176+
moderation_categories: {
177+
harassment: 'Harassment',
178+
'harassment/threatening': 'Harassment/Threatening',
179+
'self-harm': 'Self-Harm',
180+
'self-harm/intent': 'Self-Harm/Intent',
181+
},
182+
});
183+
const result = parser.parse(content);
184+
185+
expect(result).toEqual({
186+
'moderation_categories\0harassment': 'Harassment',
187+
'moderation_categories\0harassment/threatening': 'Harassment/Threatening',
188+
'moderation_categories\0self-harm': 'Self-Harm',
189+
'moderation_categories\0self-harm/intent': 'Self-Harm/Intent',
190+
});
191+
});
192+
193+
it('should round-trip keys containing forward slashes through parse and serialize', () => {
194+
const original = {
195+
moderation_categories: {
196+
harassment: 'Molestie',
197+
'harassment/threatening': 'Molestie/Minacce',
198+
'self-harm': 'Autolesionismo',
199+
'self-harm/intent': 'Autolesionismo/Intento',
200+
},
201+
};
202+
const content = JSON.stringify(original);
203+
const flattened = parser.parse(content);
204+
const serialized = parser.serialize(flattened, { indentation: 2, trailingNewline: '\n' });
205+
const reparsed = JSON.parse(serialized);
206+
207+
expect(reparsed).toEqual(original);
208+
});
173209
});
174210

175211
describe('serialize', () => {
@@ -182,7 +218,7 @@ describe('JsonParser', () => {
182218
});
183219

184220
it('should unflatten and serialize nested objects', () => {
185-
const data = { 'dashboard/title': 'Dashboard' };
221+
const data = { 'dashboard\0title': 'Dashboard' };
186222
const options = { indentation: 2, trailingNewline: '\n' };
187223
const result = parser.serialize(data, options);
188224

@@ -191,9 +227,9 @@ describe('JsonParser', () => {
191227

192228
it('should unflatten and serialize arrays', () => {
193229
const data = {
194-
'items/0': 'item1',
195-
'items/1': 'item2',
196-
'items/2': 'item3',
230+
'items\x000': 'item1',
231+
'items\x001': 'item2',
232+
'items\x002': 'item3',
197233
};
198234
const options = { indentation: 2, trailingNewline: '\n' };
199235
const result = parser.serialize(data, options);
@@ -245,10 +281,10 @@ describe('JsonParser', () => {
245281

246282
it('should serialize complex nested structure', () => {
247283
const data = {
248-
'dashboard/title': 'Dashboard',
249-
'dashboard/content/0': 'content 1',
250-
'dashboard/content/1': 'content 2',
251-
'settings/theme': 'dark',
284+
'dashboard\0title': 'Dashboard',
285+
'dashboard\0content\x000': 'content 1',
286+
'dashboard\0content\x001': 'content 2',
287+
'settings\0theme': 'dark',
252288
};
253289
const options = { indentation: 2, trailingNewline: '\n' };
254290
const result = parser.serialize(data, options);
@@ -292,8 +328,8 @@ describe('JsonParser', () => {
292328

293329
it('should handle arrays with objects', () => {
294330
const data = {
295-
'users/0/name': 'John',
296-
'users/1/name': 'Jane',
331+
'users\x000\0name': 'John',
332+
'users\x001\0name': 'Jane',
297333
};
298334
const options = { indentation: 2, trailingNewline: '\n' };
299335
const result = parser.serialize(data, options);

src/__tests__/parsers/ts.parser.test.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ describe('TsParser', () => {
2222
'const messages = { dashboard: { title: "Dashboard" } };\n\nexport default messages;';
2323
const result = parser.parse(content);
2424

25-
expect(result).toEqual({ 'dashboard/title': 'Dashboard' });
25+
expect(result).toEqual({ 'dashboard\0title': 'Dashboard' });
2626
});
2727

2828
it('should flatten deeply nested objects', () => {
2929
const content =
3030
'const messages = { level1: { level2: { level3: { key: "value" } } } };\n\nexport default messages;';
3131
const result = parser.parse(content);
3232

33-
expect(result).toEqual({ 'level1/level2/level3/key': 'value' });
33+
expect(result).toEqual({ 'level1\0level2\0level3\0key': 'value' });
3434
});
3535

3636
it('should flatten arrays', () => {
@@ -39,9 +39,9 @@ describe('TsParser', () => {
3939
const result = parser.parse(content);
4040

4141
expect(result).toEqual({
42-
'items/0': 'item1',
43-
'items/1': 'item2',
44-
'items/2': 'item3',
42+
'items\x000': 'item1',
43+
'items\x001': 'item2',
44+
'items\x002': 'item3',
4545
});
4646
});
4747

@@ -51,9 +51,9 @@ describe('TsParser', () => {
5151
const result = parser.parse(content);
5252

5353
expect(result).toEqual({
54-
'dashboard/title': 'Dashboard',
55-
'dashboard/content/0': 'content 1',
56-
'dashboard/content/1': 'content 2',
54+
'dashboard\0title': 'Dashboard',
55+
'dashboard\0content\x000': 'content 1',
56+
'dashboard\0content\x001': 'content 2',
5757
});
5858
});
5959

@@ -114,9 +114,9 @@ describe('TsParser', () => {
114114
number: 123,
115115
boolean: true,
116116
nullValue: null,
117-
'array/0': 1,
118-
'array/1': 2,
119-
'object/nested': 'value',
117+
'array\x000': 1,
118+
'array\x001': 2,
119+
'object\0nested': 'value',
120120
});
121121
});
122122

@@ -131,7 +131,7 @@ describe('TsParser', () => {
131131
const content = 'const messages = { parent: { child: {} } };\n\nexport default messages;';
132132
const result = parser.parse(content);
133133

134-
expect(result).toEqual({ 'parent/child': {} });
134+
expect(result).toEqual({ 'parent\0child': {} });
135135
});
136136

137137
it('should handle arrays with objects', () => {
@@ -140,8 +140,8 @@ describe('TsParser', () => {
140140
const result = parser.parse(content);
141141

142142
expect(result).toEqual({
143-
'users/0/name': 'John',
144-
'users/1/name': 'Jane',
143+
'users\x000\0name': 'John',
144+
'users\x001\0name': 'Jane',
145145
});
146146
});
147147

@@ -192,7 +192,7 @@ describe('TsParser', () => {
192192
'const messages = { en: { dashboard: { title: "Dashboard" } }, es: { dashboard: { title: "Panel" } } };\n\nexport default messages;';
193193
const result = parser.parse(content, { targetLocale: 'en' } as any);
194194

195-
expect(result).toEqual({ 'dashboard/title': 'Dashboard' });
195+
expect(result).toEqual({ 'dashboard\0title': 'Dashboard' });
196196
});
197197

198198
it('should handle exact locale match in targetLocale', () => {
@@ -212,6 +212,34 @@ describe('TsParser', () => {
212212
expect(result).not.toHaveProperty('es');
213213
expect(result).not.toHaveProperty('fr');
214214
});
215+
216+
it('should preserve keys containing forward slashes', () => {
217+
const content =
218+
'const messages = { moderation: { harassment: "Harassment", "harassment/threatening": "Harassment/Threatening" } };\n\nexport default messages;';
219+
const result = parser.parse(content);
220+
221+
expect(result).toEqual({
222+
'moderation\0harassment': 'Harassment',
223+
'moderation\0harassment/threatening': 'Harassment/Threatening',
224+
});
225+
});
226+
227+
it('should round-trip keys containing forward slashes through parse and serialize', () => {
228+
const originalContent =
229+
'const messages = { moderation: { harassment: "Harassment", "harassment/threatening": "Harassment/Threatening" } };\n\nexport default messages;';
230+
const parsed = parser.parse(originalContent);
231+
const serialized = parser.serialize(parsed, { originalContent } as unknown as TsParserOptionsType);
232+
233+
const resultStr = serialized.toString();
234+
const match = resultStr.match(/const\s+messages\s*=\s*({[\s\S]*?});/);
235+
const reparsed = JSON.parse(match?.[1] || '{}');
236+
expect(reparsed).toEqual({
237+
moderation: {
238+
harassment: 'Harassment',
239+
'harassment/threatening': 'Harassment/Threatening',
240+
},
241+
});
242+
});
215243
});
216244

217245
describe('serialize', () => {
@@ -229,7 +257,7 @@ describe('TsParser', () => {
229257
it('should unflatten and serialize nested objects', () => {
230258
const originalContent =
231259
'const messages = { dashboard: { title: "Dashboard" } };\n\nexport default messages;';
232-
const data = { 'dashboard/title': 'New Dashboard' };
260+
const data = { 'dashboard\0title': 'New Dashboard' };
233261
const result = parser.serialize(data, { originalContent } as unknown as TsParserOptionsType);
234262

235263
const resultStr = result.toString();
@@ -299,7 +327,7 @@ describe('TsParser', () => {
299327
const originalContent =
300328
'const messages = { en: { dashboard: { title: "Dashboard", subtitle: "Welcome" } }, es: { dashboard: { title: "Panel", subtitle: "Bienvenido" } } };\n\nexport default messages;';
301329
// subtitle was removed from source
302-
const data = { 'dashboard/title': 'Dashboard' };
330+
const data = { 'dashboard\0title': 'Dashboard' };
303331
const result = parser.serialize(data, { originalContent, targetLocale: 'en' });
304332

305333
const resultStr = result.toString();
@@ -314,9 +342,9 @@ describe('TsParser', () => {
314342
it('should handle arrays in serialization', () => {
315343
const originalContent = 'const messages = {};\n\nexport default messages;';
316344
const data = {
317-
'items/0': 'item1',
318-
'items/1': 'item2',
319-
'items/2': 'item3',
345+
'items\x000': 'item1',
346+
'items\x001': 'item2',
347+
'items\x002': 'item3',
320348
};
321349
const result = parser.serialize(data, { originalContent } as unknown as TsParserOptionsType);
322350

@@ -329,10 +357,10 @@ describe('TsParser', () => {
329357
it('should handle complex nested structure', () => {
330358
const originalContent = 'const messages = {};\n\nexport default messages;';
331359
const data = {
332-
'dashboard/title': 'Dashboard',
333-
'dashboard/content/0': 'content 1',
334-
'dashboard/content/1': 'content 2',
335-
'settings/theme': 'dark',
360+
'dashboard\0title': 'Dashboard',
361+
'dashboard\0content\x000': 'content 1',
362+
'dashboard\0content\x001': 'content 2',
363+
'settings\0theme': 'dark',
336364
};
337365
const result = parser.serialize(data, { originalContent } as unknown as TsParserOptionsType);
338366

0 commit comments

Comments
 (0)