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 {