diff --git a/examples/react-i18next/locales/sv.json b/examples/react-i18next/locales/sv.json
new file mode 100644
index 00000000..586ffaae
--- /dev/null
+++ b/examples/react-i18next/locales/sv.json
@@ -0,0 +1,17 @@
+{
+ "welcome": "Välkommen till vår applikation!",
+ "user": {
+ "greeting": "Hej, {{name}}!",
+ "profile": {
+ "title": "Din profil",
+ "edit": "Redigera profil"
+ }
+ },
+ "notifications": {
+ "messages": "Du har {{count}} nya meddelande(n).",
+ "empty": "Inga nya meddelanden."
+ },
+ "date": {
+ "today": "Idag är det {{date}}."
+ }
+}
diff --git a/examples/xcode-strings/Example/es.lproj/Localizable.strings b/examples/xcode-strings/Example/es.lproj/Localizable.strings
index 1e44ad41..c79d8b00 100644
--- a/examples/xcode-strings/Example/es.lproj/Localizable.strings
+++ b/examples/xcode-strings/Example/es.lproj/Localizable.strings
@@ -1,17 +1,17 @@
-"welcome_message" = "¡Bienvenido a Weather App!";
+"welcome_message" = "¡Bienvenido a la aplicación del clima!";
"temperature_format" = "%1$.1f°%2$@";
"wind_speed" = "Viento: %1$.1f %2$@";
"forecast_daily" = "Pronóstico diario para %@";
-"notification_body" = "Alerta de tormenta para la región de %1$@\nSe esperan velocidades de viento de hasta %2$d mph";
+"notification_body" = "Alerta de tormenta para la región %1$@\nSe esperan velocidades de viento de hasta %2$d mph";
"battery_empty" = "Batería agotada";
-"battery_low" = "Batería al %d%% - por favor carga pronto";
+"battery_low" = "Batería al %d%% - por favor, carga pronto";
"battery_full" = "Batería completamente cargada";
-"photo_count_zero" = "No hay fotos";
+"photo_count_zero" = "Sin fotos";
"photo_count_one" = "%d foto";
"photo_count_other" = "%d fotos";
"time_remaining_now" = "¡Descarga completa!";
"time_remaining_minutes" = "%d minutos restantes";
-"time_remaining_hours" = "Aproximadamente %d horas restantes";
-"share_message" = "Mira esta foto que tomé con %1$@!\nUbicación: %2$@\nHora: %3$@";
+"time_remaining_hours" = "Acerca de %d horas restantes";
+"share_message" = "¡Mira esta foto que tomé con %1$@!\nUbicación: %2$@\nHora: %3$@";
"rich_notification" = "Nuevo mensaje de %@:\n%@\nVer detalles";
-"hello" = "Hola";
\ No newline at end of file
+"hello" = "Hola";
diff --git a/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict
index 71897c39..7366c18b 100644
--- a/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict
+++ b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict
@@ -13,17 +13,17 @@
NSStringFormatValueTypeKey
lld
zero
- No hay artículos disponibles
+ No hay elementos disponibles
one
- Un artículo disponible
+ Un elemento disponible
two
- Dos artículos disponibles
+ Dos elementos disponibles
few
- %lld artículos disponibles
+ %lld elementos disponibles
many
- %lld artículos disponibles
+ %lld elementos disponibles
other
- %lld artículos disponibles
+ %lld elementos disponibles
countdown_days
@@ -39,7 +39,7 @@
zero
¡El evento comienza hoy!
one
- El evento comienza mañana!
+ ¡El evento comienza mañana!
two
El evento comienza en dos días
few
@@ -59,10 +59,7 @@
current_date
Fecha actual: %@
formatted_message
- Aquí tienes un mensaje <b>formateado</b> con:
-• Formato HTML
-• Símbolos especiales: ©®™
-• Un <a href="https://example.com">hipervínculo</a>
+ Aquí hay un <b>mensaje formateado</b> con:\n• Formato HTML\n• Símbolos especiales: ©®™\n• Un <a href=\"https://example.com\">hipervínculo</a>
alert_count
NSStringLocalizedFormatKey
@@ -84,7 +81,7 @@
remaining_time
Tiempo restante: %1$@ (%2$lld segundos)
version_info
- Versión %1$@ (Construcción %2$@)
+ Versión %1$@ (Compilación %2$@)
test
Prueba
diff --git a/packages/cli/src/parsers/formats/__tests__/xcode-stringsdict.test.ts b/packages/cli/src/parsers/formats/__tests__/xcode-stringsdict.test.ts
index 7da0f982..f4db19e3 100644
--- a/packages/cli/src/parsers/formats/__tests__/xcode-stringsdict.test.ts
+++ b/packages/cli/src/parsers/formats/__tests__/xcode-stringsdict.test.ts
@@ -24,6 +24,46 @@ describe("Xcode stringsdict parser", () => {
});
});
+ it("should parse plural rules in stringsdict plist", async () => {
+ const input = `
+
+
+
+ items_count
+
+ NSStringLocalizedFormatKey
+ %#@items@
+ items
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ lld
+ zero
+ No items
+ one
+ One item
+ other
+ %lld items
+
+
+ simple
+ Simple string
+
+`;
+
+ const result = await parser.parse(input);
+ expect(result.simple).toBe("Simple string");
+ const itemsCount = JSON.parse(result.items_count);
+ expect(itemsCount.NSStringLocalizedFormatKey).toBe("%#@items@");
+ expect(itemsCount.items.NSStringFormatSpecTypeKey).toBe(
+ "NSStringPluralRuleType",
+ );
+ expect(itemsCount.items.zero).toBe("No items");
+ expect(itemsCount.items.one).toBe("One item");
+ expect(itemsCount.items.other).toBe("%lld items");
+ });
+
it("should handle empty plist", async () => {
const input = `
@@ -79,6 +119,35 @@ describe("Xcode stringsdict parser", () => {
expect(result).toContain("World");
});
+ it("should serialize object with plural rules to stringsdict plist format", async () => {
+ const pluralRule = {
+ NSStringLocalizedFormatKey: "%#@items@",
+ items: {
+ NSStringFormatSpecTypeKey: "NSStringPluralRuleType",
+ NSStringFormatValueTypeKey: "lld",
+ zero: "No items",
+ one: "One item",
+ other: "%lld items",
+ },
+ };
+
+ const input = {
+ items_count: JSON.stringify(pluralRule),
+ simple: "Simple string",
+ };
+
+ const result = await parser.serialize("en", input);
+ expect(result).toContain("items_count");
+ expect(result).toContain("NSStringLocalizedFormatKey");
+ expect(result).toContain("%#@items@");
+ expect(result).toContain("NSStringFormatSpecTypeKey");
+ expect(result).toContain("NSStringPluralRuleType");
+ expect(result).toContain("zero");
+ expect(result).toContain("No items");
+ expect(result).toContain("simple");
+ expect(result).toContain("Simple string");
+ });
+
it("should handle empty object", async () => {
const result = await parser.serialize("en", {});
expect(result).toContain("");
@@ -94,5 +163,25 @@ describe("Xcode stringsdict parser", () => {
'Failed to serialize Xcode stringsdict translations: Value for key "key" must be a string',
);
});
+
+ it("should serialize remaining time format", async () => {
+ const input = {
+ remaining_time: "Tiempo restante: %1$@ (%2$lld segundos)",
+ version_info: "Versión %1$@ (Compilación %2$@)",
+ test: "Prueba",
+ };
+
+ const result = await parser.serialize("es", input);
+ expect(result).toContain("remaining_time");
+ expect(result).toContain(
+ "Tiempo restante: %1$@ (%2$lld segundos)",
+ );
+ expect(result).toContain("version_info");
+ expect(result).toContain(
+ "Versión %1$@ (Compilación %2$@)",
+ );
+ expect(result).toContain("test");
+ expect(result).toContain("Prueba");
+ });
});
});
diff --git a/packages/cli/src/parsers/formats/__tests__/xcode-xcstrings.test.ts b/packages/cli/src/parsers/formats/__tests__/xcode-xcstrings.test.ts
index 24b42523..a63407cd 100644
--- a/packages/cli/src/parsers/formats/__tests__/xcode-xcstrings.test.ts
+++ b/packages/cli/src/parsers/formats/__tests__/xcode-xcstrings.test.ts
@@ -32,6 +32,7 @@ describe("Xcode xcstrings parser", () => {
},
},
version: "1.0",
+ sourceLanguage: "en",
});
const result = await parser.parse(input);
@@ -69,6 +70,7 @@ describe("Xcode xcstrings parser", () => {
},
},
version: "1.0",
+ sourceLanguage: "en",
});
const result = await parser.parse(input);
@@ -81,6 +83,7 @@ describe("Xcode xcstrings parser", () => {
const input = JSON.stringify({
strings: {},
version: "1.0",
+ sourceLanguage: "en",
});
const result = await parser.parse(input);
@@ -106,6 +109,7 @@ describe("Xcode xcstrings parser", () => {
const parsed = JSON.parse(result);
expect(parsed.version).toBe("1.0");
+ expect(parsed.sourceLanguage).toBe("en");
expect(parsed.strings.greeting.extractionState).toBe("manual");
expect(parsed.strings.greeting.localizations.en.stringUnit.value).toBe(
"Hello",
@@ -120,6 +124,7 @@ describe("Xcode xcstrings parser", () => {
const parsed = JSON.parse(result);
expect(parsed.version).toBe("1.0");
+ expect(parsed.sourceLanguage).toBe("en");
expect(parsed.strings).toEqual({});
});
diff --git a/packages/cli/src/parsers/formats/types.ts b/packages/cli/src/parsers/formats/types.ts
index 05e6912a..72c835cc 100644
--- a/packages/cli/src/parsers/formats/types.ts
+++ b/packages/cli/src/parsers/formats/types.ts
@@ -27,4 +27,5 @@ export type XcstringsOutput = {
}
>;
version: string;
+ sourceLanguage: string;
};
diff --git a/packages/cli/src/parsers/formats/xcode-stringsdict.ts b/packages/cli/src/parsers/formats/xcode-stringsdict.ts
index 041a69e3..0157786a 100644
--- a/packages/cli/src/parsers/formats/xcode-stringsdict.ts
+++ b/packages/cli/src/parsers/formats/xcode-stringsdict.ts
@@ -2,6 +2,31 @@ import plist from "plist";
import { createFormatParser } from "../core/format.ts";
import type { Parser } from "../core/types.ts";
+type PlistValue =
+ | string
+ | number
+ | boolean
+ | Date
+ | Buffer
+ | PlistValue[]
+ | { [key: string]: PlistValue };
+
+interface PluralDict {
+ NSStringLocalizedFormatKey: string;
+ [key: string]:
+ | {
+ NSStringFormatSpecTypeKey: string;
+ NSStringFormatValueTypeKey: string;
+ zero?: string;
+ one?: string;
+ two?: string;
+ few?: string;
+ many?: string;
+ other?: string;
+ }
+ | string;
+}
+
export function createXcodeStringsDictParser(): Parser {
return createFormatParser({
async parse(input: string) {
@@ -15,6 +40,13 @@ export function createXcodeStringsDictParser(): Parser {
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") {
result[key] = value;
+ } else if (typeof value === "object" && value !== null) {
+ // Handle plural rules
+ const pluralDict = value as PluralDict;
+ if (pluralDict.NSStringLocalizedFormatKey) {
+ // Store the plural forms as is - they will be handled by the iOS system
+ result[key] = JSON.stringify(value);
+ }
}
}
@@ -28,13 +60,34 @@ export function createXcodeStringsDictParser(): Parser {
async serialize(_, data) {
try {
- // Validate that all values are strings
+ const result: Record = {};
+
+ // Process each translation
for (const [key, value] of Object.entries(data)) {
if (typeof value !== "string") {
throw new Error(`Value for key "${key}" must be a string`);
}
+
+ // Try to parse as JSON to see if it's a plural rule
+ try {
+ const parsed = JSON.parse(value);
+ if (
+ typeof parsed === "object" &&
+ parsed.NSStringLocalizedFormatKey
+ ) {
+ result[key] = parsed as PlistValue;
+ continue;
+ }
+ } catch {
+ // Not JSON, treat as regular string
+ }
+
+ // Strip surrounding quotes if present
+ const cleanValue = value.replace(/^"(.*)"$/, "$1");
+ result[key] = cleanValue;
}
- return plist.build(data);
+
+ return plist.build(result);
} catch (error) {
throw new Error(
`Failed to serialize Xcode stringsdict translations: ${(error as Error).message}`,
diff --git a/packages/cli/src/parsers/formats/xcode-xcstrings.ts b/packages/cli/src/parsers/formats/xcode-xcstrings.ts
index eef34332..0212eac9 100644
--- a/packages/cli/src/parsers/formats/xcode-xcstrings.ts
+++ b/packages/cli/src/parsers/formats/xcode-xcstrings.ts
@@ -42,7 +42,7 @@ export function createXcodeXcstringsParser(): Parser {
}
},
- async serialize(_, data) {
+ async serialize(locale, data) {
try {
// Validate input data
for (const [key, value] of Object.entries(data)) {
@@ -54,13 +54,14 @@ export function createXcodeXcstringsParser(): Parser {
const result: XcstringsOutput = {
strings: {},
version: "1.0",
+ sourceLanguage: locale,
};
for (const [key, value] of Object.entries(data)) {
result.strings[key] = {
extractionState: "manual",
localizations: {
- en: {
+ [locale]: {
stringUnit: {
state: "translated",
value,