Skip to content

Commit be8de32

Browse files
authored
fix: cli android bucket loader (#1238)
* fix: enhance Android bucket loader * chore: formatting * chore: fix test
1 parent 44a928b commit be8de32

8 files changed

Lines changed: 1743 additions & 229 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
enchance Android bucket loader
Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,63 @@
1+
<?xml version="1.0" encoding="utf-8"?>
12
<resources>
2-
<string name="app_name">MyApp</string>
3-
<string name="welcome_message">¡Hola, mundo!</string>
4-
<string name="button_text">Comenzar</string>
5-
<string name="html_snippet">&lt;b&gt;Negrita&lt;/b&gt;</string>
6-
<string name="apostrophe_example">¡No olvides!</string>
7-
<string name="cdata_example">Especial &lt;tag&gt; </string>
8-
<string-array name="color_names">
9-
<item>Rojo</item>
10-
<item>Verde</item>
11-
<item>Azul</item>
12-
</string-array>
13-
<string-array name="mixed_items">
14-
<item>Elemento con espacios</item>
15-
<item> </item>
16-
</string-array>
17-
<plurals name="notification_count">
18-
<item quantity="one">%d mensaje nuevo</item>
19-
<item quantity="other">%d mensajes nuevos</item>
20-
</plurals>
21-
<bool name="show_tutorial">true</bool>
22-
<bool name="enable_animations">false</bool>
23-
<integer name="max_retry_attempts">3</integer>
24-
<integer name="default_timeout">30</integer>
3+
<string name="app_name">MyApp</string>
4+
<string name="welcome_message">¡Hola, mundo!</string>
5+
<string name="button_text">Comenzar</string>
6+
7+
<string name="api_endpoint" translatable="false">https://api.example.com</string>
8+
<string name="debug_key" translatable="false">DEBUG_MODE_ENABLED</string>
9+
10+
<string-array name="color_names">
11+
<item>Rojo</item>
12+
<item>Verde</item>
13+
<item>Azul</item>
14+
</string-array>
15+
16+
<string-array name="server_urls" translatable="false">
17+
<item>https://prod.example.com</item>
18+
<item>https://staging.example.com</item>
19+
<item>https://dev.example.com</item>
20+
</string-array>
21+
22+
<plurals name="notification_count">
23+
<item quantity="one">%d mensaje nuevo</item>
24+
<item quantity="other">%d mensajes nuevos</item>
25+
</plurals>
26+
27+
<plurals name="cache_size" translatable="false">
28+
<item quantity="one">%d byte</item>
29+
<item quantity="other">%d bytes</item>
30+
</plurals>
31+
32+
<bool name="show_tutorial">true</bool>
33+
<bool name="enable_animations">false</bool>
34+
35+
<bool name="is_debug_build" translatable="false">false</bool>
36+
<bool name="enable_logging" translatable="false">true</bool>
37+
38+
<integer name="max_retry_attempts">3</integer>
39+
<integer name="default_timeout">30</integer>
40+
41+
<integer name="build_version" translatable="false">42</integer>
42+
<integer name="api_version" translatable="false">1</integer>
43+
44+
<string name="html_snippet">&lt;b&gt;Negrita&lt;/b&gt;</string>
45+
46+
<string name="apostrophe_example">¡No olvides!</string>
47+
48+
<string name="cdata_example"><![CDATA[Especial <tag> ]]></string>
49+
50+
<string-array name="mixed_items">
51+
<item> Elemento con espacios </item>
52+
<item> </item>
53+
</string-array>
54+
55+
<string-array name="non_localised_array" translatable="false">
56+
<item>Ignored</item>
57+
</string-array>
58+
59+
<plurals name="non_localised_plural" translatable="false">
60+
<item quantity="one">Ignored</item>
61+
<item quantity="other">Ignored</item>
62+
</plurals>
2563
</resources>

packages/cli/i18n.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,16 +412,16 @@ checksums:
412412
app_name: 7dc70110429d46e3685f385bd2cc941c
413413
welcome_message: 0468579ef2fbc83c9d520c2f2f1c5059
414414
button_text: 1d5f030c4ec9c869e647ae060518b948
415-
html_snippet: f060191b1af70b3848106a4df91f43cd
416-
apostrophe_example: 997099339b144b06266f8da411de8d93
417-
cdata_example: ba876d1379f854628eaebf67ea330ccc
418415
color_names/0: bace0083b78cdb188523bc4abc7b55c6
419416
color_names/1: 482ff383a4258357ba404f283682471d
420417
color_names/2: a5cf034b2d370a976119335cd99f4217
421-
mixed_items/0: 9278f79dfb062c6c04f6395108907816
422-
mixed_items/1: 9823a57cbe6e6e84c1d025ce24a1eec4
423418
notification_count/one: fe0aceb70f334c52a87937c36898a1d0
424419
notification_count/other: 13acfd95b16962ebe1f67dcd343513e1
420+
html_snippet: f060191b1af70b3848106a4df91f43cd
421+
apostrophe_example: 997099339b144b06266f8da411de8d93
422+
cdata_example: ba876d1379f854628eaebf67ea330ccc
423+
mixed_items/0: 31c5d470a2fe8e1ae88e964fc673aee3
424+
mixed_items/1: 9823a57cbe6e6e84c1d025ce24a1eec4
425425
df547e152136431bbc29e26ae0eeabb4:
426426
title: 0468579ef2fbc83c9d520c2f2f1c5059
427427
description: 49f8864eb0e53903f04532bf33e1e4fa

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@
207207
"remark-parse": "^11.0.0",
208208
"remark-rehype": "^11.1.2",
209209
"remark-stringify": "^11.0.0",
210+
"sax": "^1.4.1",
210211
"srt-parser-2": "^1.2.3",
211212
"unified": "^11.0.5",
212213
"unist-util-visit": "^5.0.0",

packages/cli/src/cli/loaders/android.spec.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,4 +604,249 @@ Line 2
604604
expect(pushed).toContain("- %d user\\'s item");
605605
expect(pushed).not.toContain("- %d user\\\\'s item");
606606
});
607+
608+
// Tests for Issue Fixes
609+
610+
it("should preserve whitespace in array items during pull and push", async () => {
611+
const input = `
612+
<resources>
613+
<string-array name="mixed_items">
614+
<item> Item with spaces </item>
615+
<item> </item>
616+
</string-array>
617+
</resources>
618+
`.trim();
619+
620+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
621+
const pulled = await androidLoader.pull("en", input);
622+
623+
expect(pulled.mixed_items).toEqual([" Item with spaces ", " "]);
624+
625+
const pushed = await androidLoader.push("en", {
626+
mixed_items: [" Elemento con espacios ", " "],
627+
});
628+
629+
expect(pushed).toContain("<item> Elemento con espacios </item>");
630+
expect(pushed).toContain("<item> </item>");
631+
});
632+
633+
it("should retain CDATA wrappers for translated strings", async () => {
634+
const input = `
635+
<resources>
636+
<string name="cdata_example"><![CDATA[Special <tag> ]]></string>
637+
</resources>
638+
`.trim();
639+
640+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
641+
await androidLoader.pull("en", input);
642+
643+
const pushed = await androidLoader.push("es", {
644+
cdata_example: "Especial <tag> ",
645+
});
646+
647+
expect(pushed).toContain(
648+
'<string name="cdata_example"><![CDATA[Especial <tag> ]]></string>',
649+
);
650+
});
651+
652+
it("should preserve resource ordering after push", async () => {
653+
const input = `
654+
<resources>
655+
<string name="first">First</string>
656+
<string-array name="colors">
657+
<item>Red</item>
658+
<item>Green</item>
659+
</string-array>
660+
<plurals name="messages">
661+
<item quantity="one">%d message</item>
662+
<item quantity="other">%d messages</item>
663+
</plurals>
664+
<bool name="show_tutorial">true</bool>
665+
<integer name="retry_count">3</integer>
666+
</resources>
667+
`.trim();
668+
669+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
670+
const roundTrip = await androidLoader.pull("en", input);
671+
const pushed = await androidLoader.push("en", roundTrip);
672+
673+
const order = Array.from(
674+
pushed.matchAll(
675+
/<(string|string-array|plurals|bool|integer)\s+name="([^"]+)"/g,
676+
),
677+
).map(([, , name]) => name);
678+
679+
expect(order).toEqual([
680+
"first",
681+
"colors",
682+
"messages",
683+
"show_tutorial",
684+
"retry_count",
685+
]);
686+
});
687+
688+
it("should preserve XML declaration from source file", async () => {
689+
const input = `<?xml version="1.0" encoding="utf-8"?>
690+
<resources>
691+
<string name="test">Test</string>
692+
</resources>`;
693+
694+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
695+
await androidLoader.pull("en", input);
696+
697+
const result = await androidLoader.push("es", { test: "Prueba" });
698+
699+
expect(result).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>/);
700+
});
701+
702+
it('should preserve translatable="false" items in target locale', async () => {
703+
const input = `
704+
<resources>
705+
<string name="app_name">My App</string>
706+
<string name="api_url" translatable="false">https://api.example.com</string>
707+
<string name="debug_key" translatable="false">DEBUG_KEY</string>
708+
<string-array name="colors">
709+
<item>Red</item>
710+
</string-array>
711+
<string-array name="urls" translatable="false">
712+
<item>https://example.com</item>
713+
</string-array>
714+
<plurals name="items">
715+
<item quantity="one">%d item</item>
716+
<item quantity="other">%d items</item>
717+
</plurals>
718+
<plurals name="bytes" translatable="false">
719+
<item quantity="one">%d byte</item>
720+
<item quantity="other">%d bytes</item>
721+
</plurals>
722+
<bool name="show_tutorial">true</bool>
723+
<bool name="is_debug" translatable="false">false</bool>
724+
<integer name="timeout">30</integer>
725+
<integer name="version" translatable="false">42</integer>
726+
</resources>
727+
`.trim();
728+
729+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
730+
await androidLoader.pull("en", input);
731+
732+
const result = await androidLoader.push("es", {
733+
app_name: "Mi Aplicación",
734+
colors: ["Rojo"],
735+
items: { one: "%d elemento", other: "%d elementos" },
736+
show_tutorial: true,
737+
timeout: 30,
738+
});
739+
740+
// Check that translatable="false" items are included
741+
expect(result).toContain('name="api_url"');
742+
expect(result).toContain("https://api.example.com");
743+
expect(result).toContain('translatable="false"');
744+
expect(result).toContain('name="debug_key"');
745+
expect(result).toContain("DEBUG_KEY");
746+
expect(result).toContain('name="urls"');
747+
expect(result).toContain("https://example.com");
748+
expect(result).toContain('name="bytes"');
749+
expect(result).toContain('name="is_debug"');
750+
expect(result).toContain('name="version"');
751+
expect(result).toContain(">42<");
752+
753+
// Check that translatable items are translated
754+
expect(result).toContain("Mi Aplicación");
755+
expect(result).toContain("Rojo");
756+
expect(result).toContain("elemento");
757+
});
758+
759+
it("should use 4-space indentation by default", async () => {
760+
const input = `<?xml version="1.0" encoding="utf-8"?>
761+
<resources>
762+
<string name="test">Test</string>
763+
<string name="another">Another</string>
764+
</resources>`;
765+
766+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
767+
await androidLoader.pull("en", input);
768+
769+
const result = await androidLoader.push("es", {
770+
test: "Prueba",
771+
another: "Otro",
772+
});
773+
774+
// Check for 4-space indentation (default)
775+
// Note: Users should use formatters (Prettier/Biome) for custom indentation
776+
expect(result).toContain('\n <string name="test">');
777+
expect(result).toContain('\n <string name="another">');
778+
});
779+
780+
it("should preserve XML declaration encoding from source file", async () => {
781+
const inputUtf8 = `<?xml version="1.0" encoding="utf-8"?>
782+
<resources>
783+
<string name="test">Test</string>
784+
</resources>`;
785+
786+
const inputUpperUTF8 = `<?xml version="1.0" encoding="UTF-8"?>
787+
<resources>
788+
<string name="test">Test</string>
789+
</resources>`;
790+
791+
const inputISO = `<?xml version="1.0" encoding="ISO-8859-1"?>
792+
<resources>
793+
<string name="test">Test</string>
794+
</resources>`;
795+
796+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
797+
798+
// Test lowercase utf-8
799+
await androidLoader.pull("en", inputUtf8);
800+
let result = await androidLoader.push("es", { test: "Prueba" });
801+
expect(result).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>/);
802+
803+
// Test uppercase UTF-8
804+
await androidLoader.pull("en", inputUpperUTF8);
805+
result = await androidLoader.push("es", { test: "Prueba" });
806+
expect(result).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/);
807+
808+
// Test ISO-8859-1
809+
await androidLoader.pull("en", inputISO);
810+
result = await androidLoader.push("es", { test: "Prueba" });
811+
expect(result).toMatch(/^<\?xml version="1\.0" encoding="ISO-8859-1"\?>/);
812+
});
813+
814+
it("should preserve XML version from source file", async () => {
815+
const inputV10 = `<?xml version="1.0" encoding="utf-8"?>
816+
<resources>
817+
<string name="test">Test</string>
818+
</resources>`;
819+
820+
const inputV11 = `<?xml version="1.1" encoding="utf-8"?>
821+
<resources>
822+
<string name="test">Test</string>
823+
</resources>`;
824+
825+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
826+
827+
// Test version 1.0
828+
await androidLoader.pull("en", inputV10);
829+
let result = await androidLoader.push("es", { test: "Prueba" });
830+
expect(result).toMatch(/^<\?xml version="1\.0"/);
831+
832+
// Test version 1.1
833+
await androidLoader.pull("en", inputV11);
834+
result = await androidLoader.push("es", { test: "Prueba" });
835+
expect(result).toMatch(/^<\?xml version="1\.1"/);
836+
});
837+
838+
it("should omit XML declaration when source has none", async () => {
839+
const inputNoDeclaration = `<resources>
840+
<string name="test">Test</string>
841+
</resources>`;
842+
843+
const androidLoader = createAndroidLoader().setDefaultLocale("en");
844+
await androidLoader.pull("en", inputNoDeclaration);
845+
846+
const result = await androidLoader.push("es", { test: "Prueba" });
847+
848+
// Should start immediately with the root element (no declaration)
849+
expect(result).not.toMatch(/^<\?xml/);
850+
expect(result.trim().startsWith("<resources>")).toBe(true);
851+
});
607852
});

0 commit comments

Comments
 (0)