diff --git a/Sources/HTMLKit/Framework/Localization/Locale.swift b/Sources/HTMLKit/Framework/Localization/Locale.swift index 257182b3..1bc8eb15 100644 --- a/Sources/HTMLKit/Framework/Localization/Locale.swift +++ b/Sources/HTMLKit/Framework/Localization/Locale.swift @@ -93,6 +93,11 @@ public struct Locale: Sendable { return timeFormats[tag] } + /// The plural rules of the language. + public var pluralRules: [String: Int]? { + return Grammar.pluralRules[tag] + } + /// The locale identifier. internal let tag: String @@ -113,6 +118,16 @@ public struct Locale: Sendable { extension Locale { + internal enum Grammar { + + internal static var pluralRules: [String: [String: Int]] { + return [ + "en-GB": ["zero": 0, "one": 1, "two": 2, "few": 3, "many": 4], + "de-DE": ["zero": 0, "one": 1, "two": 2, "few": 3, "many": 4] + ] + } + } + internal var currencyCodes: [String: String] { return [ "ar-AE": "AED‏", diff --git a/Sources/HTMLKit/Framework/Localization/Localization.swift b/Sources/HTMLKit/Framework/Localization/Localization.swift index 277c8c2c..ff3df334 100644 --- a/Sources/HTMLKit/Framework/Localization/Localization.swift +++ b/Sources/HTMLKit/Framework/Localization/Localization.swift @@ -156,7 +156,7 @@ public struct Localization: Sendable { var entries = [String: Translation]() for (key, value) in translations { - entries[key] = Translation(value: value) + entries[key] = Translation(default: value) } tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: entries)) @@ -174,7 +174,7 @@ public struct Localization: Sendable { var entries = [String: Translation]() for (key, value) in translations { - entries[key] = Translation(value: value) + entries[key] = Translation(default: value) } catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: entries)] @@ -195,9 +195,35 @@ public struct Localization: Sendable { let locale = Locale(tag: tag) - if let unit = localization.unit { + if let singular = localization.singular { - let translation = Translation(value: unit.value, comment: entry.comment) + let translation = Translation(default: singular.value, comment: entry.comment) + + if var tables = catalogs[locale] { + + if let index = tables.firstIndex(where: { $0.name == path.deletingPathExtension().lastPathComponent }) { + tables[index].upsert(translation, for: key) + + } else { + tables.append(TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: [key: translation])) + } + + catalogs[locale] = tables + + } else { + + catalogs[locale] = [TranslationTable(name: path.deletingPathExtension().lastPathComponent, translations: [key: translation])] + } + } + + if let plural = localization.plural { + + var translation = Translation(default: plural.other.value, comment: entry.comment) + translation.append(plural.zero?.value, for: .zero) + translation.append(plural.one?.value, for: .one) + translation.append(plural.two?.value, for: .two) + translation.append(plural.few?.value, for: .few) + translation.append(plural.many?.value, for: .many) if var tables = catalogs[locale] { @@ -311,7 +337,7 @@ public struct Localization: Sendable { throw Error.missingKey(identifier: string.key.value, locale: candidate) } - var value = translation.value + var value = translation.cases[.other]! interpolate(arguments: string.key.arguments, to: &value, for: candidate) @@ -323,7 +349,35 @@ public struct Localization: Sendable { if let translation = table.retrieve(for: string.key.value) { - var value = translation.value + var value = "" + + if !string.key.arguments.isEmpty { + + for argument in string.key.arguments { + + switch argument { + case .int(let int): + + for `case` in translation.cases { + + if int == candidate.pluralRules?[`case`.key.rawValue] { + + value = `case`.value + + break + + } else { + value = translation.cases[.other]! + } + } + + default: + value = translation.cases[.other]! + } + } + } else { + value = translation.cases[.other]! + } interpolate(arguments: string.key.arguments, to: &value, for: candidate) diff --git a/Sources/HTMLKit/Framework/Localization/PluralCategory.swift b/Sources/HTMLKit/Framework/Localization/PluralCategory.swift new file mode 100644 index 00000000..27f8efd2 --- /dev/null +++ b/Sources/HTMLKit/Framework/Localization/PluralCategory.swift @@ -0,0 +1,21 @@ +/// A type that represents a set of plural categories. +enum PluralCategory: String { + + /// Indicates the empty form. + case zero + + /// Indicates the singular form. + case one + + /// Indicates the dual form. + case two + + /// Indicates the paucal form. + case few + + /// Indicates the plural form. + case many + + /// Indicates the default form. + case other +} diff --git a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift index 054ad3dd..728431b0 100644 --- a/Sources/HTMLKit/Framework/Localization/StringCatalog.swift +++ b/Sources/HTMLKit/Framework/Localization/StringCatalog.swift @@ -7,7 +7,7 @@ internal struct StringCatalog: Codable { } /// The entries within the catalog. - internal let entries: [String: StringCatalog.Entry] + internal let entries: [String: Entry] } extension StringCatalog { @@ -16,27 +16,86 @@ extension StringCatalog { internal struct Entry: Codable { /// The associated values. - internal let localizations: [String: StringCatalog.Localization] + internal let localizations: [String: Localization] /// The associated comment. internal let comment: String? } - /// A type that represents a entry value. + /// A type that represents an entry value. internal struct Localization: Codable { internal enum CodingKeys: String, CodingKey { + case singular = "stringUnit" + case plural = "variations" + } + + /// Whether there is a singular value. + internal let singular: Unit? + + /// Whether there is a plural value. + internal let plural: Plural? + } + + /// A type that represents a plural value. + @dynamicMemberLookup + internal struct Plural: Codable { + + internal enum CodingKeys: String, CodingKey { + + case forms = "plural" + } + + /// The associated rules. + private let forms: Forms + + internal subscript(dynamicMember keyPath: KeyPath) -> T { + return forms[keyPath: keyPath] + } + } + + /// A type that represents the forms for the plural value. + internal struct Forms: Codable { + + /// The associated general form. + let other: Form + + /// The associated empty form. + let zero: Form? + + /// The associated singular form. + let one: Form? + + /// The associated dual form. + let two: Form? + + /// The associated paucal form. + let few: Form? + + /// The associated plural form. + let many: Form? + } + + @dynamicMemberLookup + internal struct Form: Codable { + + internal enum CodingKeys: String, CodingKey { + case unit = "stringUnit" } - internal let unit: StringCatalog.Unit? + private let unit: StringCatalog.Unit + + internal subscript(dynamicMember keyPath: KeyPath) -> T { + return unit[keyPath: keyPath] + } } /// A type that represents a value representation. internal struct Unit: Codable { + /// The associated representation. internal let value: String } } - diff --git a/Sources/HTMLKit/Framework/Localization/Translation.swift b/Sources/HTMLKit/Framework/Localization/Translation.swift index 602ff84f..ebb1c705 100644 --- a/Sources/HTMLKit/Framework/Localization/Translation.swift +++ b/Sources/HTMLKit/Framework/Localization/Translation.swift @@ -1,8 +1,8 @@ /// A type that represents the translation within the translation table. internal struct Translation: Sendable { - /// The value of the translation. - internal let value: String + /// The plural cases of the translation. + internal var cases: [PluralCategory: String] /// The comment describing the context. internal let comment: String? @@ -12,9 +12,16 @@ internal struct Translation: Sendable { /// - Parameters: /// - value: The value for the translation. /// - comment: The comment for some context. - internal init(value: String, comment: String? = nil) { + internal init(default value: String, comment: String? = nil) { - self.value = value + self.cases = [.other: value] self.comment = comment } + + internal mutating func append(_ value: String?, for category: PluralCategory) { + + if let value = value { + self.cases[category] = value + } + } } diff --git a/Tests/HTMLKitTests/Localization/Localizable.xcstrings b/Tests/HTMLKitTests/Localization/Localizable.xcstrings index 138d0abe..c55880e9 100644 --- a/Tests/HTMLKitTests/Localization/Localizable.xcstrings +++ b/Tests/HTMLKitTests/Localization/Localizable.xcstrings @@ -1,6 +1,59 @@ { "sourceLanguage" : "en", "strings" : { + "%lld iPhone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhone" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhones" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhones" + } + } + } + } + }, + "en-GB" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhone" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhones" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld iPhones" + } + } + } + } + } + } + }, "hello.xcstrings" : { "comment" : "One more thing", "extractionState" : "manual", diff --git a/Tests/HTMLKitTests/LocalizationTests.swift b/Tests/HTMLKitTests/LocalizationTests.swift index 9d11abd1..d09fd04e 100644 --- a/Tests/HTMLKitTests/LocalizationTests.swift +++ b/Tests/HTMLKitTests/LocalizationTests.swift @@ -259,6 +259,15 @@ struct LocalizationTests { #expect(translation.comment == "One more thing") } + + /// Tests the correct use of pluralization. + @Test + func testPluralization() throws { + + #expect(try localization!.localize(string: .init(key: "\(0) iPhone")) == "0 iPhones") + #expect(try localization!.localize(string: .init(key: "\(1) iPhone")) == "1 iPhone") + #expect(try localization!.localize(string: .init(key: "\(4) iPhone")) == "4 iPhones") + } } extension LocalizationTests {