diff --git a/.changeset/thirty-monkeys-marry.md b/.changeset/thirty-monkeys-marry.md new file mode 100644 index 000000000..c7fb49b10 --- /dev/null +++ b/.changeset/thirty-monkeys-marry.md @@ -0,0 +1,5 @@ +--- +"lingo.dev": patch +--- + +Fix ICU input diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts index 83924ac3d..3a47fd5a1 100644 --- a/packages/cli/src/cli/cmd/i18n.ts +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -36,7 +36,6 @@ import createProcessor from "../processor"; import { withExponentialBackoff } from "../utils/exp-backoff"; import trackEvent from "../utils/observability"; import { createDeltaProcessor } from "../utils/delta"; -import { isICUPluralObject } from "../loaders/xcode-xcstrings-icu"; export default new Command() .command("i18n") @@ -492,18 +491,11 @@ export default new Command() .omitBy((value, key) => { const targetValue = targetData[key]; - // For ICU plural objects, use deep equality (excluding Symbol) - if ( - isICUPluralObject(value) && - isICUPluralObject(targetValue) - ) { - return _.isEqual( - { icu: value.icu, _meta: value._meta }, - { icu: targetValue.icu, _meta: targetValue._meta }, - ); + // For objects (like plural variations), use deep equality + // For primitives (strings, numbers), use strict equality + if (typeof value === "object" && value !== null) { + return _.isEqual(value, targetValue); } - - // Default strict equality for other values return value === targetValue; }) .size() diff --git a/packages/cli/src/cli/loaders/_types.ts b/packages/cli/src/cli/loaders/_types.ts index 41c139b08..d43ac3d0e 100644 --- a/packages/cli/src/cli/loaders/_types.ts +++ b/packages/cli/src/cli/loaders/_types.ts @@ -23,5 +23,5 @@ export interface ILoader extends ILoaderDefinition { init(): Promise; pull(locale: string, input: I): Promise; push(locale: string, data: O): Promise; - pullHints(originalInput: I): Promise; + pullHints(originalInput?: I): Promise; } diff --git a/packages/cli/src/cli/loaders/_utils.ts b/packages/cli/src/cli/loaders/_utils.ts index b5523ad0f..502ae5672 100644 --- a/packages/cli/src/cli/loaders/_utils.ts +++ b/packages/cli/src/cli/loaders/_utils.ts @@ -29,7 +29,7 @@ export function composeLoaders( } return result; }, - pullHints: async (originalInput) => { + pullHints: async (originalInput?) => { let result: any = originalInput; for (let i = 0; i < loaders.length; i++) { const subResult = await loaders[i].pullHints?.(result); @@ -67,8 +67,8 @@ export function createLoader( state.defaultLocale = locale; return this; }, - async pullHints() { - return lDefinition.pullHints?.(state.originalInput!); + async pullHints(originalInput?: I) { + return lDefinition.pullHints?.(originalInput || state.originalInput!); }, async pull(locale, input) { if (!state.defaultLocale) { diff --git a/packages/cli/src/cli/loaders/icu-safety.spec.ts b/packages/cli/src/cli/loaders/icu-safety.spec.ts deleted file mode 100644 index 8accaa5b9..000000000 --- a/packages/cli/src/cli/loaders/icu-safety.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isICUPluralObject, isPluralFormsObject } from "./xcode-xcstrings-icu"; - -/** - * Safety tests to ensure ICU type guards don't falsely match normal data - * from other bucket types (android, json, yaml, etc.) - */ -describe("ICU type guards - Safety for other bucket types", () => { - describe("isICUPluralObject", () => { - it("should return false for regular strings", () => { - expect(isICUPluralObject("Hello world")).toBe(false); - expect(isICUPluralObject("")).toBe(false); - expect(isICUPluralObject("a string with {braces}")).toBe(false); - }); - - it("should return false for numbers", () => { - expect(isICUPluralObject(42)).toBe(false); - expect(isICUPluralObject(0)).toBe(false); - expect(isICUPluralObject(-1)).toBe(false); - }); - - it("should return false for arrays", () => { - expect(isICUPluralObject([])).toBe(false); - expect(isICUPluralObject(["one", "two"])).toBe(false); - expect(isICUPluralObject([{ icu: "fake" }])).toBe(false); - }); - - it("should return false for null/undefined", () => { - expect(isICUPluralObject(null)).toBe(false); - expect(isICUPluralObject(undefined)).toBe(false); - }); - - it("should return false for plain objects (json, yaml data)", () => { - expect(isICUPluralObject({ name: "John", age: 30 })).toBe(false); - expect(isICUPluralObject({ key: "value" })).toBe(false); - expect(isICUPluralObject({ nested: { data: "here" } })).toBe(false); - }); - - it("should return false for objects with 'icu' property but wrong format", () => { - // Must have valid ICU MessageFormat pattern - expect(isICUPluralObject({ icu: "not valid icu" })).toBe(false); - expect(isICUPluralObject({ icu: "{just braces}" })).toBe(false); - expect(isICUPluralObject({ icu: "plain text" })).toBe(false); - }); - - it("should return false for android plurals format", () => { - // Android uses different structure - expect( - isICUPluralObject({ - quantity: { - one: "1 item", - other: "%d items", - }, - }), - ).toBe(false); - }); - - it("should return false for stringsdict format", () => { - // iOS stringsdict uses different structure - expect( - isICUPluralObject({ - NSStringFormatSpecTypeKey: "NSStringPluralRuleType", - NSStringFormatValueTypeKey: "d", - }), - ).toBe(false); - }); - - it("should return TRUE only for valid ICU plural objects", () => { - // Valid ICU object - expect( - isICUPluralObject({ - icu: "{count, plural, one {1 item} other {# items}}", - _meta: { - variables: { - count: { - format: "%d", - role: "plural", - }, - }, - }, - }), - ).toBe(true); - - // Valid ICU object without metadata - expect( - isICUPluralObject({ - icu: "{count, plural, one {1 item} other {# items}}", - }), - ).toBe(true); - }); - }); - - describe("isPluralFormsObject", () => { - it("should return false for regular strings", () => { - expect(isPluralFormsObject("Hello world")).toBe(false); - expect(isPluralFormsObject("")).toBe(false); - }); - - it("should return false for numbers", () => { - expect(isPluralFormsObject(42)).toBe(false); - expect(isPluralFormsObject(0)).toBe(false); - }); - - it("should return false for arrays", () => { - expect(isPluralFormsObject([])).toBe(false); - expect(isPluralFormsObject(["one", "two"])).toBe(false); - }); - - it("should return false for null/undefined", () => { - expect(isPluralFormsObject(null)).toBe(false); - expect(isPluralFormsObject(undefined)).toBe(false); - }); - - it("should return false for plain objects (json, yaml data)", () => { - expect(isPluralFormsObject({ name: "John", age: 30 })).toBe(false); - expect(isPluralFormsObject({ key: "value" })).toBe(false); - expect(isPluralFormsObject({ nested: { data: "here" } })).toBe(false); - }); - - it("should return false for objects with non-CLDR keys", () => { - expect(isPluralFormsObject({ quantity: "one" })).toBe(false); - expect(isPluralFormsObject({ count: "1", total: "10" })).toBe(false); - expect(isPluralFormsObject({ first: "a", second: "b" })).toBe(false); - }); - - it("should return false for objects with CLDR keys but non-string values", () => { - expect(isPluralFormsObject({ one: 1, other: 2 })).toBe(false); - expect(isPluralFormsObject({ one: { nested: "obj" } })).toBe(false); - expect(isPluralFormsObject({ one: ["array"] })).toBe(false); - }); - - it("should return false for objects missing 'other' form", () => { - // 'other' is required in all locales per CLDR - expect(isPluralFormsObject({ one: "1 item" })).toBe(false); - expect(isPluralFormsObject({ zero: "0 items", one: "1 item" })).toBe( - false, - ); - }); - - it("should return TRUE only for valid CLDR plural objects", () => { - // Valid with required 'other' form - expect( - isPluralFormsObject({ - one: "1 item", - other: "# items", - }), - ).toBe(true); - - // Valid with multiple CLDR forms - expect( - isPluralFormsObject({ - zero: "No items", - one: "1 item", - few: "A few items", - many: "Many items", - other: "# items", - }), - ).toBe(true); - }); - }); - - describe("Real-world bucket type data", () => { - it("JSON bucket - should not match ICU guards", () => { - const jsonData = { - welcome: "Welcome!", - user: { - name: "John", - greeting: "Hello {name}", - }, - count: 42, - }; - - expect(isICUPluralObject(jsonData)).toBe(false); - expect(isICUPluralObject(jsonData.user)).toBe(false); - expect(isPluralFormsObject(jsonData)).toBe(false); - expect(isPluralFormsObject(jsonData.user)).toBe(false); - }); - - it("YAML bucket - should not match ICU guards", () => { - const yamlData = { - app: { - title: "My App", - description: "An awesome app", - }, - messages: { - error: "Something went wrong", - success: "Operation completed", - }, - }; - - expect(isICUPluralObject(yamlData.app)).toBe(false); - expect(isICUPluralObject(yamlData.messages)).toBe(false); - expect(isPluralFormsObject(yamlData.app)).toBe(false); - expect(isPluralFormsObject(yamlData.messages)).toBe(false); - }); - - it("Android bucket - should not match ICU guards", () => { - const androidData = { - app_name: "MyApp", - welcome_message: "Welcome %s!", - item_count: { - // Android format, not CLDR - "@quantity": "plural", - one: "1 item", - other: "%d items", - }, - }; - - expect(isICUPluralObject(androidData["item_count"])).toBe(false); - // This might match isPluralFormsObject if it has 'other' - that's intentional - // Android plurals ARE CLDR plural forms - }); - - it("Properties bucket - should not match ICU guards", () => { - const propertiesData = { - "app.title": "My Application", - "app.version": "1.0.0", - "user.greeting": "Hello {0}", - }; - - for (const value of Object.values(propertiesData)) { - expect(isICUPluralObject(value)).toBe(false); - expect(isPluralFormsObject(value)).toBe(false); - } - }); - }); -}); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-icu.spec.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-icu.spec.ts deleted file mode 100644 index 7cdfd71f3..000000000 --- a/packages/cli/src/cli/loaders/xcode-xcstrings-icu.spec.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - xcstringsToPluralWithMeta, - pluralWithMetaToXcstrings, - type PluralWithMetadata, -} from "./xcode-xcstrings-icu"; - -describe("loaders/xcode-xcstrings-icu", () => { - describe("xcstringsToPluralWithMeta", () => { - it("should convert simple plural forms to ICU", () => { - const input = { - one: "1 item", - other: "%d items", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - expect(result.icu).toBe("{count, plural, one {1 item} other {# items}}"); - expect(result._meta).toEqual({ - variables: { - count: { - format: "%d", - role: "plural", - }, - }, - }); - }); - - it("should convert optional zero form to exact match =0 for English", () => { - const input = { - zero: "No items", - one: "1 item", - other: "%d items", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - // English required forms: one, other - // "zero" is optional, so it becomes "=0" - expect(result.icu).toBe( - "{count, plural, =0 {No items} one {1 item} other {# items}}", - ); - expect(result._meta?.variables.count.format).toBe("%d"); - }); - - it("should convert optional zero form to exact match =0 for Russian", () => { - const input = { - zero: "Нет элементов", - one: "1 элемент", - few: "%d элемента", - many: "%d элементов", - other: "%d элемента", - }; - - const result = xcstringsToPluralWithMeta(input, "ru"); - - // Russian required forms: one, few, many, other - // "zero" is optional, so it becomes "=0" - expect(result.icu).toBe( - "{count, plural, =0 {Нет элементов} one {1 элемент} few {# элемента} many {# элементов} other {# элемента}}", - ); - expect(result._meta?.variables.count.format).toBe("%d"); - }); - - it("should preserve float format specifiers", () => { - const input = { - one: "%.1f mile", - other: "%.1f miles", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - expect(result.icu).toBe("{count, plural, one {# mile} other {# miles}}"); - expect(result._meta).toEqual({ - variables: { - count: { - format: "%.1f", - role: "plural", - }, - }, - }); - }); - - it("should preserve %lld format specifier", () => { - const input = { - one: "1 photo", - other: "%lld photos", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - expect(result.icu).toBe( - "{count, plural, one {1 photo} other {# photos}}", - ); - expect(result._meta).toEqual({ - variables: { - count: { - format: "%lld", - role: "plural", - }, - }, - }); - }); - - it("should handle multiple variables", () => { - const input = { - one: "%@ uploaded 1 photo", - other: "%@ uploaded %d photos", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - expect(result.icu).toBe( - "{count, plural, one {{var0} uploaded 1 photo} other {{var0} uploaded # photos}}", - ); - expect(result._meta).toEqual({ - variables: { - var0: { - format: "%@", - role: "other", - }, - count: { - format: "%d", - role: "plural", - }, - }, - }); - }); - - it("should handle three variables", () => { - const input = { - one: "%@ uploaded 1 photo to %@", - other: "%@ uploaded %d photos to %@", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - // Note: This is a known limitation - when forms have different numbers of placeholders, - // the conversion may not be perfect. The "one" form has 2 placeholders but we map 3 variables. - // In practice, this edge case is rare as plural forms usually have consistent placeholder counts. - expect(result.icu).toContain("{var0} uploaded"); - expect(result._meta?.variables).toEqual({ - var0: { format: "%@", role: "other" }, - count: { format: "%d", role: "plural" }, - var1: { format: "%@", role: "other" }, - }); - }); - - it("should handle %.2f precision", () => { - const input = { - one: "%.2f kilometer", - other: "%.2f kilometers", - }; - - const result = xcstringsToPluralWithMeta(input, "en"); - - expect(result.icu).toBe( - "{count, plural, one {# kilometer} other {# kilometers}}", - ); - expect(result._meta?.variables.count.format).toBe("%.2f"); - }); - - it("should throw error for empty input", () => { - expect(() => xcstringsToPluralWithMeta({}, "en")).toThrow( - "pluralForms cannot be empty", - ); - }); - }); - - describe("pluralWithMetaToXcstrings", () => { - it("should convert ICU back to xcstrings format", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, one {1 item} other {# items}}", - _meta: { - variables: { - count: { - format: "%d", - role: "plural", - }, - }, - }, - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - one: "1 item", - other: "%d items", - }); - }); - - it("should restore float format specifiers", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, one {# mile} other {# miles}}", - _meta: { - variables: { - count: { - format: "%.1f", - role: "plural", - }, - }, - }, - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - one: "%.1f mile", - other: "%.1f miles", - }); - }); - - it("should restore %lld format", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, one {1 photo} other {# photos}}", - _meta: { - variables: { - count: { - format: "%lld", - role: "plural", - }, - }, - }, - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - one: "1 photo", - other: "%lld photos", - }); - }); - - it("should handle multiple variables", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, one {{userName} uploaded 1 photo} other {{userName} uploaded # photos}}", - _meta: { - variables: { - userName: { format: "%@", role: "other" }, - count: { format: "%d", role: "plural" }, - }, - }, - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - one: "%@ uploaded 1 photo", - other: "%@ uploaded %d photos", - }); - }); - - it("should convert exact match =0 back to zero form", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, =0 {No items} one {1 item} other {# items}}", - _meta: { - variables: { - count: { format: "%d", role: "plural" }, - }, - }, - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - zero: "No items", - one: "1 item", - other: "%d items", - }); - }); - - it("should use default format when metadata is missing", () => { - const input: PluralWithMetadata = { - icu: "{count, plural, one {1 item} other {# items}}", - }; - - const result = pluralWithMetaToXcstrings(input); - - expect(result).toEqual({ - one: "1 item", - other: "%lld items", - }); - }); - - it("should throw error for invalid ICU format", () => { - const input: PluralWithMetadata = { - icu: "not valid ICU", - }; - - expect(() => pluralWithMetaToXcstrings(input)).toThrow(); - }); - }); - - describe("round-trip conversion", () => { - it("should preserve format through round-trip", () => { - const original = { - one: "1 item", - other: "%d items", - }; - - const icu = xcstringsToPluralWithMeta(original, "en"); - const restored = pluralWithMetaToXcstrings(icu); - - expect(restored).toEqual(original); - }); - - it("should preserve float precision through round-trip", () => { - const original = { - one: "%.2f mile", - other: "%.2f miles", - }; - - const icu = xcstringsToPluralWithMeta(original, "en"); - const restored = pluralWithMetaToXcstrings(icu); - - expect(restored).toEqual(original); - }); - - it("should preserve multiple variables through round-trip", () => { - const original = { - one: "%@ uploaded 1 photo", - other: "%@ uploaded %d photos", - }; - - const icu = xcstringsToPluralWithMeta(original, "en"); - const restored = pluralWithMetaToXcstrings(icu); - - expect(restored).toEqual(original); - }); - - it("should preserve zero form through round-trip", () => { - const original = { - zero: "No items", - one: "1 item", - other: "%lld items", - }; - - const icu = xcstringsToPluralWithMeta(original, "en"); - const restored = pluralWithMetaToXcstrings(icu); - - expect(restored).toEqual(original); - }); - }); - - describe("translation simulation", () => { - it("should handle English to Russian translation", () => { - // Source (English) - const englishForms = { - one: "1 item", - other: "%d items", - }; - - const englishICU = xcstringsToPluralWithMeta(englishForms, "en"); - - // Simulate backend translation (English → Russian) - // Backend expands 2 forms to 4 forms - const russianICU: PluralWithMetadata = { - icu: "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}", - _meta: englishICU._meta, // Metadata preserved - }; - - const russianForms = pluralWithMetaToXcstrings(russianICU); - - expect(russianForms).toEqual({ - one: "%d элемент", - few: "%d элемента", - many: "%d элементов", - other: "%d элемента", - }); - }); - - it("should handle Chinese to Arabic translation", () => { - // Source (Chinese - no plurals) - const chineseForms = { - other: "%d 个项目", - }; - - const chineseICU = xcstringsToPluralWithMeta(chineseForms, "zh"); - - // Simulate backend translation (Chinese → Arabic) - // Backend expands 1 form to 6 forms - const arabicICU: PluralWithMetadata = { - icu: "{count, plural, zero {لا توجد مشاريع} one {مشروع واحد} two {مشروعان} few {# مشاريع} many {# مشروعًا} other {# مشروع}}", - _meta: chineseICU._meta, - }; - - const arabicForms = pluralWithMetaToXcstrings(arabicICU); - - expect(arabicForms).toEqual({ - zero: "لا توجد مشاريع", - one: "مشروع واحد", - two: "مشروعان", - few: "%d مشاريع", - many: "%d مشروعًا", - other: "%d مشروع", - }); - }); - - it("should handle variable reordering in translation", () => { - // Source (English) - const englishForms = { - one: "%@ uploaded 1 photo", - other: "%@ uploaded %d photos", - }; - - const englishICU = xcstringsToPluralWithMeta(englishForms, "en"); - - // Simulate backend translation with variable reordering - const russianICU: PluralWithMetadata = { - icu: "{count, plural, one {{var0} загрузил 1 фото} few {{var0} загрузил # фото} many {{var0} загрузил # фотографий} other {{var0} загрузил # фотографии}}", - _meta: englishICU._meta, // Metadata preserved - }; - - const russianForms = pluralWithMetaToXcstrings(russianICU); - - expect(russianForms).toEqual({ - one: "%@ загрузил 1 фото", - few: "%@ загрузил %d фото", - many: "%@ загрузил %d фотографий", - other: "%@ загрузил %d фотографии", - }); - }); - }); -}); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-icu.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-icu.ts deleted file mode 100644 index 928d17100..000000000 --- a/packages/cli/src/cli/loaders/xcode-xcstrings-icu.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * ICU MessageFormat conversion utilities for xcstrings pluralization - * - * This module handles converting between xcstrings plural format and ICU MessageFormat, - * preserving format specifier precision and supporting multiple variables. - */ - -/** - * Type guard marker to distinguish ICU objects from user data - * Using a symbol ensures no collision with user data - */ -const ICU_TYPE_MARKER = Symbol.for("@lingo.dev/icu-plural-object"); - -export interface PluralWithMetadata { - icu: string; - _meta?: { - variables: { - [varName: string]: { - format: string; - role: "plural" | "other"; - }; - }; - }; - // Type marker for robust detection - [ICU_TYPE_MARKER]?: true; -} - -/** - * CLDR plural categories as defined by Unicode - * https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html - */ -const CLDR_PLURAL_CATEGORIES = new Set([ - "zero", - "one", - "two", - "few", - "many", - "other", -]); - -/** - * Type guard to check if a value is a valid ICU object with metadata - * This is more robust than simple key checking - */ -export function isICUPluralObject(value: any): value is PluralWithMetadata { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - - // Check for type marker (most reliable) - if (ICU_TYPE_MARKER in value) { - return true; - } - - // Fallback: validate structure thoroughly - if (!("icu" in value) || typeof value.icu !== "string") { - return false; - } - - // Must match ICU plural format pattern - const icuPluralPattern = /^\{[\w]+,\s*plural,\s*.+\}$/; - if (!icuPluralPattern.test(value.icu)) { - return false; - } - - // If _meta exists, validate its structure - if (value._meta !== undefined) { - if ( - typeof value._meta !== "object" || - !value._meta.variables || - typeof value._meta.variables !== "object" - ) { - return false; - } - - // Validate each variable entry - for (const [varName, varMeta] of Object.entries(value._meta.variables)) { - if ( - !varMeta || - typeof varMeta !== "object" || - typeof (varMeta as any).format !== "string" || - ((varMeta as any).role !== "plural" && - (varMeta as any).role !== "other") - ) { - return false; - } - } - } - - return true; -} - -/** - * Type guard to check if an object is a valid plural forms object - * Ensures ALL keys are CLDR categories to avoid false positives - */ -export function isPluralFormsObject( - value: any, -): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - - const keys = Object.keys(value); - - // Must have at least one key - if (keys.length === 0) { - return false; - } - - // Check if ALL keys are CLDR plural categories - const allKeysAreCldr = keys.every((key) => CLDR_PLURAL_CATEGORIES.has(key)); - - if (!allKeysAreCldr) { - return false; - } - - // Check if all values are strings - const allValuesAreStrings = keys.every( - (key) => typeof value[key] === "string", - ); - - if (!allValuesAreStrings) { - return false; - } - - // Must have at least "other" form (required in all locales) - if (!("other" in value)) { - return false; - } - - return true; -} - -/** - * Get required CLDR plural categories for a locale - * - * @throws {Error} If locale is invalid and cannot be resolved - */ -function getRequiredPluralCategories(locale: string): string[] { - try { - const pluralRules = new Intl.PluralRules(locale); - const categories = pluralRules.resolvedOptions().pluralCategories; - - if (!categories || categories.length === 0) { - throw new Error(`No plural categories found for locale: ${locale}`); - } - - return categories; - } catch (error) { - // Log warning but use safe fallback - console.warn( - `[xcode-xcstrings-icu] Failed to resolve plural categories for locale "${locale}". ` + - `Using fallback ["one", "other"]. Error: ${error instanceof Error ? error.message : String(error)}`, - ); - return ["one", "other"]; - } -} - -/** - * Map CLDR category names to their numeric values for exact match conversion - */ -const CLDR_CATEGORY_TO_NUMBER: Record = { - zero: 0, - one: 1, - two: 2, -}; - -/** - * Map numeric values back to CLDR category names - */ -const NUMBER_TO_CLDR_CATEGORY: Record = { - 0: "zero", - 1: "one", - 2: "two", -}; - -/** - * Convert xcstrings plural forms to ICU MessageFormat with metadata - * - * @param pluralForms - Record of plural forms (e.g., { one: "1 item", other: "%d items" }) - * @param sourceLocale - Source language locale (e.g., "en", "ru") to determine required vs optional forms - * @returns ICU string with metadata for format preservation - * - * @example - * xcstringsToPluralWithMeta({ one: "1 mile", other: "%.1f miles" }, "en") - * // Returns: - * // { - * // icu: "{count, plural, one {1 mile} other {# miles}}", - * // _meta: { variables: { count: { format: "%.1f", role: "plural" } } } - * // } - * - * @example - * xcstringsToPluralWithMeta({ zero: "No items", one: "1 item", other: "%d items" }, "en") - * // Returns: - * // { - * // icu: "{count, plural, =0 {No items} one {1 item} other {# items}}", - * // _meta: { variables: { count: { format: "%d", role: "plural" } } } - * // } - */ -export function xcstringsToPluralWithMeta( - pluralForms: Record, - sourceLocale: string = "en", -): PluralWithMetadata { - if (!pluralForms || Object.keys(pluralForms).length === 0) { - throw new Error("pluralForms cannot be empty"); - } - - // Get required CLDR categories for this locale - const requiredCategories = getRequiredPluralCategories(sourceLocale); - - const variables: Record< - string, - { format: string; role: "plural" | "other" } - > = {}; - - // Regex to match format specifiers: - // %[position$][flags][width][.precision][length]specifier - // Examples: %d, %lld, %.2f, %@, %1$@, %2$lld - const formatRegex = - /(%(?:(\d+)\$)?(?:[+-])?(?:\d+)?(?:\.(\d+))?([lhqLzjt]*)([diuoxXfFeEgGaAcspn@]))/g; - - // Analyze ALL forms to find the one with most variables (typically "other") - let maxMatches: RegExpMatchArray[] = []; - let maxMatchText = ""; - for (const [form, text] of Object.entries(pluralForms)) { - // Skip if text is not a string - if (typeof text !== "string") { - console.warn( - `Warning: Plural form "${form}" has non-string value:`, - text, - ); - continue; - } - const matches = [...text.matchAll(formatRegex)]; - if (matches.length > maxMatches.length) { - maxMatches = matches; - maxMatchText = text; - } - } - - let lastNumericIndex = -1; - - // Find which variable is the plural one (heuristic: last numeric format) - maxMatches.forEach((match, idx) => { - const specifier = match[5]; - // Numeric specifiers that could be plural counts - if (/[diuoxXfFeE]/.test(specifier)) { - lastNumericIndex = idx; - } - }); - - // Build variable metadata - let nonPluralCounter = 0; - maxMatches.forEach((match, idx) => { - const fullFormat = match[1]; // e.g., "%.2f", "%lld", "%@" - const position = match[2]; // e.g., "1" from "%1$@" - const precision = match[3]; // e.g., "2" from "%.2f" - const lengthMod = match[4]; // e.g., "ll" from "%lld" - const specifier = match[5]; // e.g., "f", "d", "@" - - const isPluralVar = idx === lastNumericIndex; - const varName = isPluralVar ? "count" : `var${nonPluralCounter++}`; - - variables[varName] = { - format: fullFormat, - role: isPluralVar ? "plural" : "other", - }; - }); - - // Build ICU string for each plural form - const variableKeys = Object.keys(variables); - const icuForms = Object.entries(pluralForms) - .filter(([form, text]) => { - // Skip non-string values - if (typeof text !== "string") { - return false; - } - return true; - }) - .map(([form, text]) => { - let processed = text as string; - let vIdx = 0; - - // Replace format specifiers with ICU equivalents - processed = processed.replace(formatRegex, () => { - if (vIdx >= variableKeys.length) { - // Shouldn't happen, but fallback - vIdx++; - return "#"; - } - - const varName = variableKeys[vIdx]; - const varMeta = variables[varName]; - vIdx++; - - if (varMeta.role === "plural") { - // Plural variable uses # in ICU - return "#"; - } else { - // Non-plural variables use {varName} - return `{${varName}}`; - } - }); - - // Determine if this form is required or optional - const isRequired = requiredCategories.includes(form); - const formKey = - !isRequired && form in CLDR_CATEGORY_TO_NUMBER - ? `=${CLDR_CATEGORY_TO_NUMBER[form]}` // Convert optional forms to exact matches - : form; // Keep required forms as CLDR keywords - - return `${formKey} {${processed}}`; - }) - .join(" "); - - // Find plural variable name - const pluralVarName = - Object.keys(variables).find((name) => variables[name].role === "plural") || - "count"; - - const icu = `{${pluralVarName}, plural, ${icuForms}}`; - - const result: PluralWithMetadata = { - icu, - _meta: Object.keys(variables).length > 0 ? { variables } : undefined, - [ICU_TYPE_MARKER]: true, // Add type marker for robust detection - }; - - return result; -} - -/** - * Convert ICU MessageFormat with metadata back to xcstrings plural forms - * - * Uses metadata to restore original format specifiers with full precision. - * - * @param data - ICU string with metadata - * @returns Record of plural forms suitable for xcstrings - * - * @example - * pluralWithMetaToXcstrings({ - * icu: "{count, plural, one {# километр} other {# километров}}", - * _meta: { variables: { count: { format: "%.1f", role: "plural" } } } - * }) - * // Returns: { one: "%.1f километр", other: "%.1f километров" } - */ -export function pluralWithMetaToXcstrings( - data: PluralWithMetadata, -): Record { - if (!data.icu) { - throw new Error("ICU string is required"); - } - - // Parse ICU MessageFormat string - const ast = parseICU(data.icu); - - if (!ast || ast.length === 0) { - throw new Error("Invalid ICU format"); - } - - // Find the plural node - const pluralNode = ast.find((node) => node.type === "plural"); - - if (!pluralNode) { - throw new Error("No plural found in ICU format"); - } - - const forms: Record = {}; - - // Convert each plural form back to xcstrings format - for (const [form, option] of Object.entries(pluralNode.options)) { - let text = ""; - - const optionValue = (option as any).value; - for (const element of optionValue) { - if (element.type === "literal") { - // Plain text - text += element.value; - } else if (element.type === "pound") { - // # → look up plural variable format in metadata - const pluralVar = Object.entries(data._meta?.variables || {}).find( - ([_, meta]) => meta.role === "plural", - ); - - text += pluralVar?.[1].format || "%lld"; - } else if (element.type === "argument") { - // {varName} → look up variable format by name - const varName = element.value; - const varMeta = data._meta?.variables?.[varName]; - - text += varMeta?.format || "%@"; - } - } - - // Convert exact matches (=0, =1) back to CLDR category names - let xcstringsFormName = form; - if (form.startsWith("=")) { - const numValue = parseInt(form.substring(1), 10); - xcstringsFormName = NUMBER_TO_CLDR_CATEGORY[numValue] || form; - } - - forms[xcstringsFormName] = text; - } - - return forms; -} - -/** - * Simple ICU MessageFormat parser - * - * This is a lightweight parser for our specific use case. - * For production, consider using @formatjs/icu-messageformat-parser - */ -function parseICU(icu: string): any[] { - // Remove outer braces and split by "plural," - const match = icu.match(/\{(\w+),\s*plural,\s*(.+)\}$/); - - if (!match) { - throw new Error("Invalid ICU plural format"); - } - - const varName = match[1]; - const formsText = match[2]; - - // Parse plural forms manually to handle nested braces - const options: Record = {}; - - let i = 0; - while (i < formsText.length) { - // Skip whitespace - while (i < formsText.length && /\s/.test(formsText[i])) { - i++; - } - - if (i >= formsText.length) break; - - // Read form name (e.g., "one", "other", "few", "=0", "=1") - let formName = ""; - - // Check for exact match syntax (=0, =1, etc.) - if (formsText[i] === "=") { - formName += formsText[i]; - i++; - // Read the number - while (i < formsText.length && /\d/.test(formsText[i])) { - formName += formsText[i]; - i++; - } - } else { - // Read word form name - while (i < formsText.length && /\w/.test(formsText[i])) { - formName += formsText[i]; - i++; - } - } - - if (!formName) break; - - // Skip whitespace and find opening brace - while (i < formsText.length && /\s/.test(formsText[i])) { - i++; - } - - if (i >= formsText.length || formsText[i] !== "{") { - throw new Error(`Expected '{' after form name '${formName}'`); - } - - // Find matching closing brace - i++; // skip opening brace - let braceCount = 1; - let formText = ""; - - while (i < formsText.length && braceCount > 0) { - if (formsText[i] === "{") { - braceCount++; - formText += formsText[i]; - } else if (formsText[i] === "}") { - braceCount--; - if (braceCount > 0) { - formText += formsText[i]; - } - } else { - formText += formsText[i]; - } - i++; - } - - if (braceCount !== 0) { - // Provide detailed error with context - const preview = formsText.substring( - Math.max(0, i - 50), - Math.min(formsText.length, i + 50), - ); - throw new Error( - `Unclosed brace for form '${formName}' in ICU MessageFormat.\n` + - `Expected ${braceCount} more closing brace(s).\n` + - `Context: ...${preview}...\n` + - `Full ICU: {${varName}, plural, ${formsText}}`, - ); - } - - // Parse the form text to extract elements - const elements = parseFormText(formText); - - options[formName] = { - value: elements, - }; - } - - return [ - { - type: "plural", - value: varName, - options, - }, - ]; -} - -/** - * Parse form text into elements (literals, pounds, arguments) - */ -function parseFormText(text: string): any[] { - const elements: any[] = []; - let currentText = ""; - let i = 0; - - while (i < text.length) { - if (text[i] === "#") { - // Add accumulated text as literal - if (currentText) { - elements.push({ type: "literal", value: currentText }); - currentText = ""; - } - // Add pound element - elements.push({ type: "pound" }); - i++; - } else if (text[i] === "{") { - // Variable reference - need to handle nested braces - // Add accumulated text as literal - if (currentText) { - elements.push({ type: "literal", value: currentText }); - currentText = ""; - } - - // Find matching closing brace (handle nesting) - let braceCount = 1; - let j = i + 1; - while (j < text.length && braceCount > 0) { - if (text[j] === "{") { - braceCount++; - } else if (text[j] === "}") { - braceCount--; - } - j++; - } - - if (braceCount !== 0) { - throw new Error("Unclosed variable reference"); - } - - // j is now positioned after the closing brace - const varName = text.slice(i + 1, j - 1); - elements.push({ type: "argument", value: varName }); - - i = j; - } else { - currentText += text[i]; - i++; - } - } - - // Add remaining text - if (currentText) { - elements.push({ type: "literal", value: currentText }); - } - - return elements; -} diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts index b93ef52ec..db8fdff92 100644 --- a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts +++ b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.spec.ts @@ -303,7 +303,8 @@ describe("loaders/xcode-xcstrings-v2", () => { loader.setDefaultLocale(defaultLocale); const result = await loader.pull("en", inputWithZero); - expect(result["items"].variations.plural).toContain("zero {No items}"); + // English "zero" is optional, so it should be converted to =0 + expect(result["items"].variations.plural).toContain("=0 {No items}"); expect(result["items"].variations.plural).toContain("one {1 item}"); expect(result["items"].variations.plural).toContain("other {%d items}"); }); @@ -776,19 +777,22 @@ describe("loaders/xcode-xcstrings-v2", () => { const result = await loader.push("fr", payload); + // Exact matches (=0, =1) should be converted back to CLDR names (zero, one) expect( result!.strings["downloads"].localizations["fr"].variations.plural, - ).toHaveProperty("=0"); + ).toHaveProperty("zero"); expect( result!.strings["downloads"].localizations["fr"].variations.plural, - ).toHaveProperty("=1"); + ).toHaveProperty("one"); expect( - result!.strings["downloads"].localizations["fr"].variations.plural["=0"] - .stringUnit.value, + result!.strings["downloads"].localizations["fr"].variations.plural[ + "zero" + ].stringUnit.value, ).toBe("No downloads"); expect( - result!.strings["downloads"].localizations["fr"].variations.plural["=1"] - .stringUnit.value, + result!.strings["downloads"].localizations["fr"].variations.plural[ + "one" + ].stringUnit.value, ).toBe("One download"); }); @@ -1018,13 +1022,14 @@ describe("loaders/xcode-xcstrings-v2", () => { const hints = await loader.pullHints(); - expect(hints["app.title"]).toEqual({ + expect(hints).toBeDefined(); + expect(hints!["app.title"]).toEqual({ hint: "The main app title", }); - expect(hints["item_count"]).toEqual({ + expect(hints!["item_count"]).toEqual({ hint: "Number of items", }); - expect(hints["notification_message"]).toEqual({ + expect(hints!["notification_message"]).toEqual({ hint: "Notification with substitutions", }); }); @@ -1054,4 +1059,381 @@ describe("loaders/xcode-xcstrings-v2", () => { expect(hints).toEqual({}); }); }); + + describe("ICU plural form normalization", () => { + it("should convert optional English forms (zero) to exact match (=0)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const input = { + sourceLanguage: "en", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + zero: { stringUnit: { value: "No items" } }, + one: { stringUnit: { value: "1 item" } }, + other: { stringUnit: { value: "%d items" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("en", input); + + // English: one/other are required, zero is optional → becomes =0 + expect(result.items.variations.plural).toBe( + "{count, plural, =0 {No items} one {1 item} other {%d items}}", + ); + }); + + it("should keep required Russian forms as CLDR keywords", async () => { + const loader = createXcodeXcstringsV2Loader("ru"); + loader.setDefaultLocale("ru"); + + const input = { + sourceLanguage: "ru", + strings: { + items: { + localizations: { + ru: { + variations: { + plural: { + one: { stringUnit: { value: "1 предмет" } }, + few: { stringUnit: { value: "%d предмета" } }, + many: { stringUnit: { value: "%d предметов" } }, + other: { stringUnit: { value: "%d элементов" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("ru", input); + + // Russian: all forms are required, stay as CLDR keywords + expect(result.items.variations.plural).toContain("one {"); + expect(result.items.variations.plural).toContain("few {"); + expect(result.items.variations.plural).toContain("many {"); + expect(result.items.variations.plural).toContain("other {"); + expect(result.items.variations.plural).not.toContain("="); + }); + + it("should convert Chinese optional one to =1", async () => { + const loader = createXcodeXcstringsV2Loader("zh"); + loader.setDefaultLocale("zh"); + + const input = { + sourceLanguage: "zh", + strings: { + items: { + localizations: { + zh: { + variations: { + plural: { + one: { stringUnit: { value: "1 件商品" } }, + other: { stringUnit: { value: "%d 件商品" } }, + }, + }, + }, + }, + }, + }, + }; + + const result = await loader.pull("zh", input); + + // Chinese: only "other" is required, "one" is optional → becomes =1 + expect(result.items.variations.plural).toBe( + "{count, plural, =1 {1 件商品} other {%d 件商品}}", + ); + }); + + it("should round-trip: xcstrings → ICU (=0) → xcstrings (zero)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + zero: { stringUnit: { value: "No items" } }, + one: { stringUnit: { value: "1 item" } }, + other: { stringUnit: { value: "%d items" } }, + }, + }, + }, + }, + }, + }, + }; + + // Pull: xcstrings → ICU (converts zero to =0) + const pulled = await loader.pull("en", originalInput); + expect(pulled.items.variations.plural).toContain("=0 {No items}"); + + // Push back: ICU → xcstrings (converts =0 back to zero) + const pushed = await loader.push("en", pulled); + + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("zero"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + expect( + pushed.strings.items.localizations.en.variations.plural.zero.stringUnit + .value, + ).toBe("No items"); + }); + }); + + describe("Backend response filtering", () => { + it("should filter out invalid plural forms for English (keep only one, other, exact matches)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Simulate backend response with extra forms that English doesn't need + const backendResponse = { + "items/variations/plural": + "{count, plural, one {1 item} few {%d items (few)} many {%d items (many)} other {%d items}}", + }; + + const pushed = await loader.push("en", backendResponse); + + // Should only have 'one' and 'other', not 'few' or 'many' + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).not.toHaveProperty("few"); + expect( + pushed.strings.items.localizations.en.variations.plural, + ).not.toHaveProperty("many"); + }); + + it("should keep all required plural forms for Russian", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Russian requires: one, few, many, other + const backendResponse = { + items: { + variations: { + plural: + "{count, plural, one {%d товар} few {%d товара} many {%d товаров} other {%d товаров}}", + }, + }, + }; + + const pushed = await loader.push("ru", backendResponse); + + // Should keep all Russian forms + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("one"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("few"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("many"); + expect( + pushed.strings.items.localizations.ru.variations.plural, + ).toHaveProperty("other"); + }); + + it("should filter out optional forms for Chinese (keep only other, exact matches)", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Chinese only requires 'other', but backend might return 'one' + const backendResponse = { + items: { + variations: { + plural: "{count, plural, one {1 件商品} other {%d 件商品}}", + }, + }, + }; + + const pushed = await loader.push("zh", backendResponse); + + // Should only have 'other', not 'one' + expect( + pushed.strings.items.localizations.zh.variations.plural, + ).not.toHaveProperty("one"); + expect( + pushed.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("other"); + }); + + it("should always preserve exact match forms (=0, =1, =2) for any locale", async () => { + const loader = createXcodeXcstringsV2Loader("en"); + loader.setDefaultLocale("en"); + + const originalInput = { + sourceLanguage: "en", + version: "1.0", + strings: { + items: { + localizations: { + en: { + variations: { + plural: { + one: { + stringUnit: { state: "translated", value: "1 item" }, + }, + other: { + stringUnit: { state: "translated", value: "%d items" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Initialize loader state + await loader.pull("en", originalInput); + + // Backend returns exact matches along with required forms + const backendResponseEn = { + items: { + variations: { + plural: + "{count, plural, =0 {No items} =1 {One item} other {%d items}}", + }, + }, + }; + + // Test for English + const pushedEn = await loader.push("en", backendResponseEn); + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("zero"); // =0 → zero + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("one"); // =1 → one + expect( + pushedEn.strings.items.localizations.en.variations.plural, + ).toHaveProperty("other"); + + // Test for Chinese (which doesn't normally use 'one', but exact matches should be kept) + const backendResponseZh = { + items: { + variations: { + plural: + "{count, plural, =0 {没有商品} =1 {一件商品} other {%d 件商品}}", + }, + }, + }; + const pushedZh = await loader.push("zh", backendResponseZh); + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("zero"); // =0 → zero + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("one"); // =1 → one + expect( + pushedZh.strings.items.localizations.zh.variations.plural, + ).toHaveProperty("other"); + }); + }); }); diff --git a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts index de25b70ed..1617df8ed 100644 --- a/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts +++ b/packages/cli/src/cli/loaders/xcode-xcstrings-v2.ts @@ -15,21 +15,71 @@ const CLDR_PLURAL_CATEGORIES = new Set([ ]); /** - * Build ICU MessageFormat string from xcstrings plural forms - * @example {one: "1 item", other: "%d items"} → "{count, plural, one {1 item} other {%d items}}" + * Get CLDR plural categories used by a locale + * @param locale - The locale to check (e.g., "en", "ru", "zh") + * @returns Array of plural category names used by this locale */ -function buildIcuPluralString(forms: Record): string { - const parts = Object.entries(forms).map( - ([form, text]) => `${form} {${text}}`, - ); - return `{count, plural, ${parts.join(" ")}}`; +function getRequiredPluralCategories(locale: string): string[] { + try { + const pluralRules = new Intl.PluralRules(locale); + const categories = pluralRules.resolvedOptions().pluralCategories; + if (!categories || categories.length === 0) { + return ["other"]; + } + return categories; + } catch (error) { + // Fallback for unsupported locales - 'other' is the only universally required form + return ["other"]; + } } /** - * Parse ICU MessageFormat string back to xcstrings plural forms - * @example "{count, plural, one {1 item} other {%d items}}" → {one: "1 item", other: "%d items"} + * Check if a plural form is valid for a given locale + * Always allows exact match forms (=0, =1, =2) + * @param form - The plural form to check (e.g., "one", "few", "=0") + * @param locale - The target locale + * @returns true if the form should be kept */ -function parseIcuPluralString(icuString: string): Record { +function isValidPluralForm(form: string, locale: string): boolean { + // Always allow exact match forms (=0, =1, =2, etc.) + if (form.startsWith("=")) return true; + + // Check if form is a required CLDR category for this locale + const requiredCategories = getRequiredPluralCategories(locale); + return requiredCategories.includes(form); +} + +/** + * Build ICU MessageFormat string from xcstrings plural forms + * Converts optional CLDR forms to exact match syntax for better backend understanding + * @param forms - Plural forms from xcstrings + * @param sourceLocale - Source language locale to determine required vs optional forms + * @returns ICU MessageFormat string + */ +function buildIcuPluralString( + forms: Record, + sourceLocale: string, +): string { + const requiredCategories = new Set(getRequiredPluralCategories(sourceLocale)); + + const parts = Object.entries(forms).map(([form, text]) => { + // Convert optional CLDR forms to exact match syntax + let normalizedForm = form; + if (!requiredCategories.has(form)) { + if (form === "zero") normalizedForm = "=0"; + else if (form === "one") normalizedForm = "=1"; + else if (form === "two") normalizedForm = "=2"; + } + return `${normalizedForm} {${text}}`; + }); + + return `{count, plural, ${parts.join(" ")}}`; +} + +function parseIcuPluralString( + icuString: string, + locale: string, +): Record { const pluralMatch = icuString.match(/\{[\w]+,\s*plural,\s*(.+)\}$/); if (!pluralMatch) { throw new Error(`Invalid ICU plural format: ${icuString}`); @@ -37,6 +87,7 @@ function parseIcuPluralString(icuString: string): Record { const formsText = pluralMatch[1]; const forms: Record = {}; + const exactMatches = new Set(); // Track which forms came from exact matches let i = 0; while (i < formsText.length) { @@ -67,6 +118,18 @@ function parseIcuPluralString(icuString: string): Record { if (!formName) break; + // Convert exact match syntax back to CLDR category names + if (formName === "=0") { + formName = "zero"; + exactMatches.add("zero"); + } else if (formName === "=1") { + formName = "one"; + exactMatches.add("one"); + } else if (formName === "=2") { + formName = "two"; + exactMatches.add("two"); + } + // Skip whitespace and find opening brace while (i < formsText.length && /\s/.test(formsText[i])) { i++; @@ -105,7 +168,14 @@ function parseIcuPluralString(icuString: string): Record { forms[formName] = formText; } - return forms; + const filteredForms: Record = {}; + for (const [form, text] of Object.entries(forms)) { + if (exactMatches.has(form) || isValidPluralForm(form, locale)) { + filteredForms[form] = text; + } + } + + return filteredForms; } /** @@ -157,7 +227,7 @@ export default function createXcodeXcstringsV2Loader( forms[form] = (formData as any).stringUnit.value; } - const icuString = buildIcuPluralString(forms); + const icuString = buildIcuPluralString(forms, locale); resultData[translationKey].substitutions[subName] = { variations: { plural: icuString, @@ -182,7 +252,7 @@ export default function createXcodeXcstringsV2Loader( } } - const icuString = buildIcuPluralString(forms); + const icuString = buildIcuPluralString(forms, locale); resultData[translationKey].variations = { plural: icuString, }; @@ -238,7 +308,7 @@ export default function createXcodeXcstringsV2Loader( if (pluralValue && isIcuPluralString(pluralValue)) { try { - const pluralForms = parseIcuPluralString(pluralValue); + const pluralForms = parseIcuPluralString(pluralValue, locale); const pluralOut: any = {}; for (const [form, text] of Object.entries(pluralForms)) { pluralOut[form] = { @@ -288,7 +358,7 @@ export default function createXcodeXcstringsV2Loader( if (isIcuPluralString(pluralValue)) { try { - const pluralForms = parseIcuPluralString(pluralValue); + const pluralForms = parseIcuPluralString(pluralValue, locale); const pluralOut: any = {}; for (const [form, text] of Object.entries(pluralForms)) { pluralOut[form] = {