Skip to content

Commit 36bab60

Browse files
authored
V1.0.2 (#42)
* chore: bumped version * fix: preserve numeric string keys in JSON objects (#40) * fix: preserve numeric string keys in JSON objects ## Changed - JSON/TS/Vue parsers now mark numeric object keys before flattening and restore them after unflattening - Updated existing numeric literal keys test to reflect marker-based output ## New - Added `markNumericKeyObjects` and `restoreNumericKeys` utilities in parser.ts - Added `NUMERIC_KEY_MARKER` constant using STX control character - Added round-trip tests for numeric string key preservation across all parsers - Added unit tests for mark/restore utility functions * refactor: use NUMERIC_KEY_MARKER constant in ts parser test ## Changed - Replaced hard-coded \x02 literal with imported NUMERIC_KEY_MARKER constant in numeric literal keys test * fix: resolve parser and utility edge case bugs (#41) * fix: resolve parser and utility edge case bugs ## Changed - TS parser brace counting now skips string literals and comments, preventing file corruption with `{name}` placeholders - Android XML and PO parsers use `??` instead of `||` to preserve falsy values like `0` and `""` - deepMerge replaces arrays from source instead of concatenating, preventing unbounded growth - Android XML no longer collapses single-item arrays unnecessarily - Checksum calculation detects deleted source keys and marks them for removal from target files - Translation engine filters out deleted keys so stale translations are cleaned up * fix: shallow-clone arrays in deepMerge to avoid aliasing * fix: coerce values to string before XML escaping in renderer * refactor: use exported ChecksumState enum instead of string literal
1 parent 9d41f2b commit 36bab60

17 files changed

Lines changed: 470 additions & 48 deletions

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.1-blue.svg)](https://github.com/translated/lara-cli)
9+
[![Version](https://img.shields.io/badge/version-1.0.2-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.1",
4+
"version": "1.0.2",
55
"description": "CLI tool for automated i18n file translation using Lara Translate",
66
"repository": {
77
"type": "git",

src/__tests__/parsers/android-xml.parser.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,37 @@ describe('AndroidXmlParser', () => {
930930
});
931931
});
932932

933+
describe('falsy value handling', () => {
934+
it('should parse numeric zero string value correctly', () => {
935+
const content = `<?xml version="1.0" encoding="utf-8"?>
936+
<resources>
937+
<string name="zero">0</string>
938+
<string name="hello">Hello</string>
939+
</resources>`;
940+
const result = parser.parse(content);
941+
942+
expect(result).toEqual({
943+
zero: 0,
944+
hello: 'Hello',
945+
});
946+
});
947+
948+
it('should round-trip serialize data with falsy values', () => {
949+
const originalContent = `<?xml version="1.0" encoding="utf-8"?>
950+
<resources>
951+
<string name="zero">0</string>
952+
<string name="empty">text</string>
953+
</resources>`;
954+
const data = {
955+
zero: 0,
956+
empty: '',
957+
};
958+
const result = parser.serialize(data, { originalContent } as AndroidXmlParserOptionsType);
959+
expect(result).toContain('<string name="zero">0</string>');
960+
expect(result).toContain('<string name="empty"></string>');
961+
});
962+
});
963+
933964
describe('getFallback', () => {
934965
it('should return default XML structure', () => {
935966
const result = parser.getFallback();

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,56 @@ describe('JsonParser', () => {
341341
});
342342
});
343343

344+
describe('numeric string keys preservation', () => {
345+
it('should round-trip objects with numeric string keys without converting to arrays', () => {
346+
const original = {
347+
product: {
348+
'0': { title: 'Multi-BM Ecosystem' },
349+
'1': { title: 'Bulk Campaign Launcher' },
350+
},
351+
};
352+
const content = JSON.stringify(original);
353+
const flattened = parser.parse(content);
354+
const serialized = parser.serialize(flattened, { indentation: 2, trailingNewline: '' });
355+
const reparsed = JSON.parse(serialized);
356+
357+
expect(reparsed).toEqual(original);
358+
expect(reparsed.product).not.toBeInstanceOf(Array);
359+
});
360+
361+
it('should round-trip mixed arrays and numeric-key objects side by side', () => {
362+
const original = {
363+
items: ['a', 'b'],
364+
lookup: { '0': 'zero', '1': 'one' },
365+
};
366+
const content = JSON.stringify(original);
367+
const flattened = parser.parse(content);
368+
const serialized = parser.serialize(flattened, { indentation: 2, trailingNewline: '' });
369+
const reparsed = JSON.parse(serialized);
370+
371+
expect(reparsed).toEqual(original);
372+
expect(Array.isArray(reparsed.items)).toBe(true);
373+
expect(reparsed.lookup).not.toBeInstanceOf(Array);
374+
});
375+
376+
it('should round-trip deeply nested numeric-key objects', () => {
377+
const original = {
378+
level1: {
379+
level2: {
380+
'0': { name: 'first' },
381+
'1': { name: 'second' },
382+
},
383+
},
384+
};
385+
const content = JSON.stringify(original);
386+
const flattened = parser.parse(content);
387+
const serialized = parser.serialize(flattened, { indentation: 2, trailingNewline: '' });
388+
const reparsed = JSON.parse(serialized);
389+
390+
expect(reparsed).toEqual(original);
391+
});
392+
});
393+
344394
describe('getFallback', () => {
345395
it('should return empty JSON object string', () => {
346396
const result = parser.getFallback();

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,36 @@ describe('PoParser', () => {
795795
});
796796
});
797797

798+
describe('falsy value handling', () => {
799+
it('should serialize numeric zero value as "0" not empty string', () => {
800+
const originalContent = `
801+
msgid ""
802+
msgstr ""
803+
"Content-Type: text/plain; charset=UTF-8\\n"
804+
805+
msgid "Hello"
806+
msgstr "Ciao"
807+
`;
808+
809+
parser.parse(originalContent);
810+
811+
const data: Record<string, unknown> = {};
812+
const key = JSON.stringify({
813+
msgid: 'Hello',
814+
msgctxt: undefined,
815+
msgid_plural: undefined,
816+
idx: 0,
817+
order: 0,
818+
});
819+
data[key] = 0;
820+
821+
const result = parser.serialize(data, { targetLocale: 'fr' });
822+
const resultStr = result.toString();
823+
824+
expect(resultStr).toContain('msgstr "0"');
825+
});
826+
});
827+
798828
describe('getFallback', () => {
799829
it('should return default PO template', () => {
800830
const result = parser.getFallback();

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { TsParser } from '../../parsers/ts.parser.js';
33
import type { TsParserOptionsType } from '../../parsers/parser.types.js';
4+
import { NUMERIC_KEY_MARKER } from '#utils/parser.js';
45

56
describe('TsParser', () => {
67
let parser: TsParser;
@@ -162,8 +163,8 @@ describe('TsParser', () => {
162163
const result = parser.parse(content);
163164

164165
expect(result).toEqual({
165-
'123': 'value',
166-
'456': 'value2',
166+
[`${NUMERIC_KEY_MARKER}123`]: 'value',
167+
[`${NUMERIC_KEY_MARKER}456`]: 'value2',
167168
});
168169
});
169170

@@ -431,6 +432,38 @@ describe('TsParser', () => {
431432
});
432433
});
433434

435+
describe('numeric string keys preservation', () => {
436+
it('should round-trip objects with numeric string keys without converting to arrays', () => {
437+
const originalContent =
438+
'const messages = { product: { "0": { title: "Multi-BM Ecosystem" }, "1": { title: "Bulk Campaign Launcher" } } };\n\nexport default messages;';
439+
const parsed = parser.parse(originalContent);
440+
const serialized = parser.serialize(parsed, { originalContent } as unknown as TsParserOptionsType);
441+
442+
const resultStr = serialized.toString();
443+
const match = resultStr.match(/const\s+messages\s*=\s*({[\s\S]*?});/);
444+
const reparsed = JSON.parse(match?.[1] || '{}');
445+
expect(reparsed.product).not.toBeInstanceOf(Array);
446+
expect(reparsed.product['0']).toEqual({ title: 'Multi-BM Ecosystem' });
447+
expect(reparsed.product['1']).toEqual({ title: 'Bulk Campaign Launcher' });
448+
});
449+
});
450+
451+
describe('brace counting in string context', () => {
452+
it('should round-trip values containing braces without corruption', () => {
453+
const originalContent =
454+
'const messages = { en: { greeting: "Hello {name}, you have {count} items" } };\n\nexport default messages;';
455+
const parsed = parser.parse(originalContent, { targetLocale: 'en' } as any);
456+
457+
expect(parsed).toEqual({ greeting: 'Hello {name}, you have {count} items' });
458+
459+
const serialized = parser.serialize(parsed, { originalContent, targetLocale: 'en' });
460+
const resultStr = serialized.toString();
461+
const match = resultStr.match(/const\s+messages\s*=\s*({[\s\S]*?});/);
462+
const reparsed = JSON.parse(match?.[1] || '{}');
463+
expect(reparsed.en.greeting).toBe('Hello {name}, you have {count} items');
464+
});
465+
});
466+
434467
describe('getFallback', () => {
435468
it('should return default TypeScript template', () => {
436469
const result = parser.getFallback();

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,22 @@ describe('VueParser', () => {
511511
});
512512
});
513513

514+
describe('numeric string keys preservation', () => {
515+
it('should round-trip objects with numeric string keys without converting to arrays', () => {
516+
const originalContent =
517+
'<template></template>\n<i18n>\n{"product": {"0": {"title": "Multi-BM Ecosystem"}, "1": {"title": "Bulk Campaign Launcher"}}}\n</i18n>';
518+
const parsed = parser.parse(originalContent);
519+
const serialized = parser.serialize(parsed, { originalContent } as VueParserOptionsType);
520+
521+
const resultStr = serialized.toString();
522+
const i18nMatch = resultStr.match(/<i18n[^>]*>([\s\S]*?)<\/i18n>/i);
523+
const reparsed = JSON.parse(i18nMatch?.[1]?.trim() || '{}');
524+
expect(reparsed.product).not.toBeInstanceOf(Array);
525+
expect(reparsed.product['0']).toEqual({ title: 'Multi-BM Ecosystem' });
526+
expect(reparsed.product['1']).toEqual({ title: 'Bulk Campaign Launcher' });
527+
});
528+
});
529+
514530
describe('getFallback', () => {
515531
it('should return default i18n block template', () => {
516532
const result = parser.getFallback();

src/__tests__/utils/checksum.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import * as fs from 'fs';
33
import * as yaml from 'yaml';
44
import * as crypto from 'crypto';
5-
import { calculateChecksum } from '#utils/checksum.js';
5+
import { calculateChecksum, resetChecksumCache } from '#utils/checksum.js';
66
import { ParserFactory } from '../../parsers/parser.factory.js';
77

88
// Helper function to calculate hash
@@ -26,6 +26,7 @@ describe('checksum utils', () => {
2626

2727
beforeEach(() => {
2828
vi.clearAllMocks();
29+
resetChecksumCache();
2930
vi.spyOn(process, 'cwd').mockReturnValue('/mock/path');
3031
});
3132

@@ -243,7 +244,15 @@ describe('checksum utils', () => {
243244
const result = calculateChecksum(mockFileName, mockParser as unknown as ParserFactory, '');
244245

245246
expect(result).toEqual({});
246-
expect(fs.writeFileSync).not.toHaveBeenCalled();
247+
// writeFileSync may be called once for initial checksum file creation,
248+
// but updateChecksum should not be called (no changes detected)
249+
const writeCalls = vi.mocked(fs.writeFileSync).mock.calls;
250+
// If called, it should only be the initial file creation, not an update
251+
for (const call of writeCalls) {
252+
const content = call[1] as string;
253+
// The initial creation writes an empty files object
254+
expect(content).not.toContain(mockFileName);
255+
}
247256
});
248257

249258
it('should handle file with no existing checksum entry', () => {
@@ -284,6 +293,48 @@ describe('checksum utils', () => {
284293
expect(fs.writeFileSync).toHaveBeenCalled();
285294
});
286295

296+
it('should detect deleted keys with state deleted', () => {
297+
// Use a unique file name to avoid cache conflicts
298+
const uniqueFileName = 'test/deleted-keys.json';
299+
// File now only has key1 and key2, but checksum has key1, key2, key3
300+
const fileContent = {
301+
key1: 'value1',
302+
key2: 'value2',
303+
};
304+
305+
const mockParser = {
306+
parse: vi.fn().mockReturnValue(fileContent),
307+
};
308+
309+
const fileNameHash = getHash(uniqueFileName);
310+
const existingChecksum = {
311+
version: '1.0.0',
312+
files: {
313+
[fileNameHash]: {
314+
key1: getHash('value1'),
315+
key2: getHash('value2'),
316+
key3: getHash('value3'),
317+
},
318+
},
319+
};
320+
321+
vi.mocked(ParserFactory).mockImplementation(() => mockParser as unknown as void);
322+
vi.mocked(fs.existsSync).mockReturnValue(true);
323+
vi.mocked(fs.readFileSync).mockReturnValue('mock yaml content');
324+
vi.mocked(yaml.parse).mockReturnValue(existingChecksum);
325+
vi.mocked(yaml.stringify).mockReturnValue('version: 1.0.0\nfiles: {}');
326+
327+
const result = calculateChecksum(uniqueFileName, mockParser as unknown as ParserFactory, '');
328+
329+
expect(result).toEqual({
330+
key1: { value: 'value1', state: 'unchanged' },
331+
key2: { value: 'value2', state: 'unchanged' },
332+
key3: { value: null, state: 'deleted' },
333+
});
334+
// changed should be true because of the deleted key
335+
expect(fs.writeFileSync).toHaveBeenCalled();
336+
});
337+
287338
it('should handle object values correctly when hashing', () => {
288339
// Use a unique file name to avoid cache conflicts
289340
const uniqueFileName = 'test/object-values.json';

0 commit comments

Comments
 (0)