Skip to content

Commit 4f5ffe6

Browse files
authored
feat: enhance xcode-strings loader (#1243)
* feat: enhance xcode-strings loader * chore: add changeset
1 parent 46860aa commit 4f5ffe6

9 files changed

Lines changed: 664 additions & 25 deletions

File tree

.changeset/afraid-feet-chew.md

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+
Improve xcode-strings loader
Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,70 @@
1+
/* Basic examples */
12
"welcome_message" = "Hello, world!";
23
"login_button" = "Log In";
34
"error_message" = "Something went wrong";
45

56
"user_profile_title" = "User Profile";
67

8+
/* Escaped characters */
79
"quote_example" = "She said \"Hello world!\"";
810
"newline_example" = "First line\nSecond line";
911
"backslash_example" = "Path: C:\\Users\\Documents";
12+
"mixed_escapes" = "Quote: \" Newline: \\n Backslash: \\\\";
13+
"tab_example" = "Column1\tColumn2\tColumn3";
1014

15+
/* Multi-line string with literal newlines */
16+
"multiline_literal" = "This is line 1
17+
This is line 2
18+
This is line 3";
1119

20+
/* Multi-line with mixed content */
21+
"multiline_mixed" = "Start here
22+
Indented line
23+
End here";
24+
25+
/* Multi-line with quotes inside */
26+
"multiline_with_quotes" = "He said \"Hello\"
27+
Then she said \"Goodbye\"
28+
The end";
29+
30+
/* Single-line comments */
31+
// This is a comment that should be ignored
32+
"after_comment" = "Value after comment";
33+
34+
/* Multi-line comment block */
35+
/*
36+
* This entire block
37+
* should be ignored
38+
* by the parser
39+
*/
40+
"after_multiline_comment" = "Value after multiline comment";
41+
42+
/* Empty value */
43+
"empty_string" = "";
44+
45+
/* Very long value */
46+
"long_value" = "This is a very long string that contains a lot of text to test how the parser handles longer content without any special characters or escapes just plain text going on and on";
47+
48+
/* Unicode and special characters */
49+
"unicode_example" = "Hello 世界 🌍";
50+
"emoji" = "👋 Hello! 🎉";
51+
"accents" = "Café, naïve, résumé";
52+
53+
/* Edge case: Value with only spaces */
54+
"spaces_only" = " ";
55+
56+
/* Edge case: Multiple quotes */
57+
"many_quotes" = "\"\"\"Multiple quotes\"\"\"";
58+
59+
/* Edge case: URL */
60+
"url_example" = "https://example.com/path?key=value&other=123";
61+
62+
/* Malformed entries (should be skipped gracefully) */
1263
This is not a valid key-value pair
13-
"incomplete_pair" =
64+
"incomplete_pair" =
1465
= "missing_key";
1566
"missing_semicolon" = "This line has no semicolon"
1667

68+
/* Valid entries after malformed ones */
1769
"settings_title" = "Settings";
1870
"save_button" = "Save";

packages/cli/demo/xcode-strings/es/example.strings

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,16 @@
55
"quote_example" = "Ella dijo \"¡Hola mundo!\"";
66
"newline_example" = "Primera línea\nSegunda línea";
77
"backslash_example" = "Ruta: C:\\Users\\Documents";
8+
"mixed_escapes" = "Comilla: \" Nueva línea: \\\n Barra invertida: \\\\";
9+
"tab_example" = "Columna1\\tColumna2\\tColumna3";
10+
"after_comment" = "Valor después del comentario";
11+
"after_multiline_comment" = "Valor después del comentario multilínea";
12+
"long_value" = "Esta es una cadena muy larga que contiene mucho texto para probar cómo el analizador maneja contenido más extenso sin caracteres especiales ni escapes, solo texto simple que continúa y continúa";
13+
"unicode_example" = "Hola 世界 🌍";
14+
"emoji" = "👋 ¡Hola! 🎉";
15+
"accents" = "Café, ingenuo, currículum";
16+
"spaces_only" = " ";
17+
"many_quotes" = "\"\"\"Múltiples comillas\"\"\"";
18+
"url_example" = "https://example.com/path?key=value&other=123";
819
"settings_title" = "Configuración";
920
"save_button" = "Guardar";
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, expect, it } from "vitest";
2+
import createXcodeStringsLoader from "./xcode-strings";
3+
4+
describe("xcode-strings loader", () => {
5+
it("should parse single-line entries", async () => {
6+
const loader = createXcodeStringsLoader();
7+
loader.setDefaultLocale("en");
8+
const input = `"hello" = "Hello";
9+
"world" = "World";`;
10+
11+
const result = await loader.pull("en", input);
12+
expect(result).toEqual({
13+
hello: "Hello",
14+
world: "World",
15+
});
16+
});
17+
18+
it("should parse multi-line string values", async () => {
19+
const loader = createXcodeStringsLoader();
20+
loader.setDefaultLocale("en");
21+
const input = `"greeting" = "Hello";
22+
"multiline" = "This is line one
23+
This is line two
24+
This is line three";
25+
"another" = "Another value";`;
26+
27+
const result = await loader.pull("en", input);
28+
expect(result).toEqual({
29+
greeting: "Hello",
30+
multiline: "This is line one\nThis is line two\nThis is line three",
31+
another: "Another value",
32+
});
33+
});
34+
35+
it("should handle multi-line string with placeholders", async () => {
36+
const loader = createXcodeStringsLoader();
37+
loader.setDefaultLocale("en");
38+
const input = `"add_new_reference_share_text" = "Hi!
39+
Could you stop by quickly and tell us what you thought of our experience together? Your words will be super important to boost my profile on Worldpackers!
40+
[url]
41+
";`;
42+
43+
const result = await loader.pull("en", input);
44+
expect(result).toEqual({
45+
add_new_reference_share_text:
46+
"Hi!\nCould you stop by quickly and tell us what you thought of our experience together? Your words will be super important to boost my profile on Worldpackers!\n[url]\n",
47+
});
48+
});
49+
50+
it("should skip comments", async () => {
51+
const loader = createXcodeStringsLoader();
52+
loader.setDefaultLocale("en");
53+
const input = `// This is a comment
54+
"hello" = "Hello";
55+
// Another comment
56+
"world" = "World";`;
57+
58+
const result = await loader.pull("en", input);
59+
expect(result).toEqual({
60+
hello: "Hello",
61+
world: "World",
62+
});
63+
});
64+
65+
it("should skip empty lines", async () => {
66+
const loader = createXcodeStringsLoader();
67+
loader.setDefaultLocale("en");
68+
const input = `"hello" = "Hello";
69+
70+
"world" = "World";`;
71+
72+
const result = await loader.pull("en", input);
73+
expect(result).toEqual({
74+
hello: "Hello",
75+
world: "World",
76+
});
77+
});
78+
79+
it("should handle escaped characters in single-line values", async () => {
80+
const loader = createXcodeStringsLoader();
81+
loader.setDefaultLocale("en");
82+
const input = `"escaped_quote" = "He said \\"Hello\\"";
83+
"escaped_newline" = "Line 1\\nLine 2";
84+
"escaped_backslash" = "Path: C:\\\\Users";`;
85+
86+
const result = await loader.pull("en", input);
87+
expect(result).toEqual({
88+
escaped_quote: 'He said "Hello"',
89+
escaped_newline: "Line 1\nLine 2",
90+
escaped_backslash: "Path: C:\\Users",
91+
});
92+
});
93+
94+
it("should handle empty values", async () => {
95+
const loader = createXcodeStringsLoader();
96+
loader.setDefaultLocale("en");
97+
const input = `"empty" = "";`;
98+
99+
const result = await loader.pull("en", input);
100+
expect(result).toEqual({
101+
empty: "",
102+
});
103+
});
104+
105+
it("push should convert object to .strings format", async () => {
106+
const loader = createXcodeStringsLoader();
107+
loader.setDefaultLocale("en");
108+
// Need to call pull first to initialize the loader state
109+
await loader.pull("en", "");
110+
111+
const payload = {
112+
hello: "Hello",
113+
world: "World",
114+
};
115+
116+
const result = await loader.push("en", payload);
117+
expect(result).toBe(`"hello" = "Hello";
118+
"world" = "World";`);
119+
});
120+
121+
it("push should escape special characters", async () => {
122+
const loader = createXcodeStringsLoader();
123+
loader.setDefaultLocale("en");
124+
// Need to call pull first to initialize the loader state
125+
await loader.pull("en", "");
126+
127+
const payload = {
128+
escaped_quote: 'He said "Hello"',
129+
escaped_newline: "Line 1\nLine 2",
130+
escaped_backslash: "Path: C:\\Users",
131+
};
132+
133+
const result = await loader.push("en", payload);
134+
expect(result).toBe(
135+
`"escaped_quote" = "He said \\"Hello\\"";
136+
"escaped_newline" = "Line 1\\nLine 2";
137+
"escaped_backslash" = "Path: C:\\\\Users";`,
138+
);
139+
});
140+
141+
it("push should handle multi-line values by escaping newlines", async () => {
142+
const loader = createXcodeStringsLoader();
143+
loader.setDefaultLocale("en");
144+
// Need to call pull first to initialize the loader state
145+
await loader.pull("en", "");
146+
147+
const payload = {
148+
multiline: "This is line one\nThis is line two\nThis is line three",
149+
};
150+
151+
const result = await loader.push("en", payload);
152+
expect(result).toBe(
153+
`"multiline" = "This is line one\\nThis is line two\\nThis is line three";`,
154+
);
155+
});
156+
157+
it("should handle mixed single-line and multi-line entries", async () => {
158+
const loader = createXcodeStringsLoader();
159+
loader.setDefaultLocale("en");
160+
const input = `"single1" = "Value 1";
161+
"multi" = "Line 1
162+
Line 2";
163+
"single2" = "Value 2";`;
164+
165+
const result = await loader.pull("en", input);
166+
expect(result).toEqual({
167+
single1: "Value 1",
168+
multi: "Line 1\nLine 2",
169+
single2: "Value 2",
170+
});
171+
});
172+
});
Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,35 @@
11
import { ILoader } from "./_types";
22
import { createLoader } from "./_utils";
3+
import { Tokenizer } from "./xcode-strings/tokenizer";
4+
import { Parser } from "./xcode-strings/parser";
5+
import { escapeString } from "./xcode-strings/escape";
36

47
export default function createXcodeStringsLoader(): ILoader<
58
string,
69
Record<string, any>
710
> {
811
return createLoader({
912
async pull(locale, input) {
10-
const lines = input.split("\n");
11-
const result: Record<string, string> = {};
13+
// Tokenize the input
14+
const tokenizer = new Tokenizer(input);
15+
const tokens = tokenizer.tokenize();
1216

13-
for (const line of lines) {
14-
const trimmedLine = line.trim();
15-
if (trimmedLine && !trimmedLine.startsWith("//")) {
16-
const match = trimmedLine.match(/^"(.+)"\s*=\s*"(.+)";$/);
17-
if (match) {
18-
const [, key, value] = match;
19-
result[key] = unescapeXcodeString(value);
20-
}
21-
}
22-
}
17+
// Parse tokens into key-value pairs
18+
const parser = new Parser(tokens);
19+
const result = parser.parse();
2320

2421
return result;
2522
},
23+
2624
async push(locale, payload) {
27-
const lines = Object.entries(payload).map(([key, value]) => {
28-
const escapedValue = escapeXcodeString(value);
29-
return `"${key}" = "${escapedValue}";`;
30-
});
25+
const lines = Object.entries(payload)
26+
.filter(([_, value]) => value != null)
27+
.map(([key, value]) => {
28+
const escapedValue = escapeString(value);
29+
return `"${key}" = "${escapedValue}";`;
30+
});
31+
3132
return lines.join("\n");
3233
},
3334
});
3435
}
35-
36-
function unescapeXcodeString(str: string): string {
37-
return str.replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\\\/g, "\\");
38-
}
39-
40-
function escapeXcodeString(str: string): string {
41-
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
42-
}

0 commit comments

Comments
 (0)