From 088713532836fe053cb44431ce86af5036ce133c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 28 Jul 2026 23:37:33 +0000 Subject: [PATCH] Fixes for the Localization package (#26) This PR contains the work done to Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/26 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Internal/Protocols/CatalogResolving.swift | 6 +- .../Internal/Types/LanguageRange.swift | 18 +++ .../Internal/Types/StringCatalog.swift | 120 ++++++++++++++-- .../Public/Enumerations/CatalogState.swift | 17 +++ .../Sources/Public/Methods/Localize.swift | 12 +- .../Sources/Public/Methods/Negotiate.swift | 131 +++++++++++++----- .../Sources/Public/Types/LanguageList.swift | 41 +++--- .../Cases/Public/Methods/LocalizeTests.swift | 86 ++++++++++++ .../Cases/Public/Methods/NegotiateTests.swift | 53 ++++++- .../Public/Types/LanguageListTests.swift | 55 ++++++-- .../Tests/Catalogs/Localizable.xcstrings | 6 + .../Tests/Utils/Resolvers/StubCatalog.swift | 23 +++ Services/Website/Package.swift | 1 + .../Sources/App/Extensions/App+Build.swift | 14 +- .../Internal/Extensions/Page+Defaults.swift | 2 +- .../Extensions/LanguageList+Defaults.swift | 13 ++ 16 files changed, 518 insertions(+), 80 deletions(-) create mode 100644 Packages/Localization/Sources/Internal/Types/LanguageRange.swift create mode 100644 Packages/Localization/Sources/Public/Enumerations/CatalogState.swift create mode 100644 Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift diff --git a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift index bb003fd..a0e4dcf 100644 --- a/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift +++ b/Packages/Localization/Sources/Internal/Protocols/CatalogResolving.swift @@ -16,9 +16,13 @@ protocol CatalogResolving: Sendable { // MARK: Properties - /// The source (development) language, used as the final fallback when a locale has no localization. + /// The source (development) language, used as the final fallback when a locale has no localization + /// and as the default language a ``LanguageList`` serves. var sourceLanguage: String { get } + /// The outcome of reading the backing catalog, for consumers to surface at startup. + var state: CatalogState { get } + /// Every language the backend can resolve strings for, including the source language. var languages: Set { get } diff --git a/Packages/Localization/Sources/Internal/Types/LanguageRange.swift b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift new file mode 100644 index 0000000..1afaa6b --- /dev/null +++ b/Packages/Localization/Sources/Internal/Types/LanguageRange.swift @@ -0,0 +1,18 @@ +/// A single language range parsed from an `Accept-Language` header entry. +/// +/// A range pairs the language tag a client asked for with the `q` weight expressing how much the +/// client prefers it, as RFC 9110 defines them. ``Negotiate`` parses each comma-separated header +/// entry into one of these, then orders the ranges by descending weight so the most preferred tag +/// is matched first. +struct LanguageRange { + + // MARK: Properties + + /// The language tag the client asked for, such as `de` or `de-AT`, or the `*` wildcard. + let tag: String + + /// The tag's `q` weight, from 0 ("not acceptable") to 1 (most preferred, the default when + /// an entry names no weight). + let quality: Double + +} diff --git a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift index 477c33c..5eb7bd0 100644 --- a/Packages/Localization/Sources/Internal/Types/StringCatalog.swift +++ b/Packages/Localization/Sources/Internal/Types/StringCatalog.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization /// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources. /// @@ -18,6 +19,9 @@ struct StringCatalog: Sendable { /// The resolved entries, keyed by catalog key then by language code. let entries: [String: [String: String]] + /// The outcome of reading the catalog from its bundle. + let state: CatalogState + // MARK: Computed /// Every language the catalog provides a localization for, including the source language. @@ -46,8 +50,9 @@ struct StringCatalog: Sendable { forResource: table, withExtension: .Extension.stringCatalog ) else { - self.sourceLanguage = "en" + self.sourceLanguage = .Default.sourceLanguage self.entries = [:] + self.state = .missing return } @@ -65,9 +70,59 @@ struct StringCatalog: Sendable { (entry.localizations ?? [:]) .compactMapValues { $0.stringUnit?.value } } + self.state = .loaded } catch { - self.sourceLanguage = "en" + self.sourceLanguage = .Default.sourceLanguage self.entries = [:] + self.state = .undecodable + } + } + +} + +// MARK: - Cache + +extension StringCatalog { + + /// A cache key identifying a catalog by its bundle location and table name. + private struct Key: Hashable, Sendable { + let bundle: URL + let table: String + } + + /// The decoded catalogs, keyed by bundle and table. + private static let cache = Mutex<[Key: StringCatalog]>([:]) + + /// Returns the catalog for the given bundle and table, decoding it on first access. + /// + /// Catalogs are immutable at runtime, so every ``Localize``, ``Negotiate``, and ``LanguageList`` + /// bound to the same bundle shares one decoded catalog instead of re-reading its JSON. + /// - Parameters: + /// - bundle: the bundle whose resources contain the String Catalog. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. + /// - Returns: the decoded catalog, from the cache when it has been read before. + static func cached( + bundle: Bundle, + table: String = "Localizable" + ) -> StringCatalog { + let key = Key( + bundle: bundle.bundleURL, + table: table + ) + + return cache.withLock { cache in + if let catalog = cache[key] { + return catalog + } + + let catalog = StringCatalog( + bundle: bundle, + table: table + ) + + cache[key] = catalog + + return catalog } } @@ -77,6 +132,9 @@ struct StringCatalog: Sendable { extension StringCatalog: CatalogResolving { + /// Resolves a key by the most specific language tag first: the locale's full tag (`pt-BR`), then its + /// primary language code (`pt`), then the source language, then the key itself. Tags are matched + /// case-insensitively, so a regional catalog entry resolves for the locale ``Negotiate`` picked it for. func string( for key: String, in locale: Locale @@ -85,15 +143,46 @@ extension StringCatalog: CatalogResolving { return key } - let language = locale - .language - .languageCode? - .identifier - ?? sourceLanguage + for tag in locale.catalogTags { + if let match = byLanguage.first( + where: { $0.key.lowercased() == tag } + ) { + return match.value + } + } - return byLanguage[language] - ?? byLanguage[sourceLanguage] - ?? key + return byLanguage[sourceLanguage] ?? key + } + +} + +// MARK: - Locale+Extensions + +private extension Locale { + + /// The language tags to resolve a catalog entry against, most specific first. + /// + /// The locale's full identifier comes first, as a hyphenated, lowercased tag (`pt_BR` becomes + /// `pt-br`), followed by its primary language code when the two differ. + var catalogTags: [String] { + var tags: [String] = [] + let identifier = identifier + .replacingOccurrences( + of: String.Separator.underscore, + with: String.Separator.dash + ) + .lowercased() + + if !identifier.isEmpty { + tags.append(identifier) + } + + if let code = language.languageCode?.identifier.lowercased(), + code != identifier { + tags.append(code) + } + + return tags } } @@ -127,7 +216,18 @@ private extension StringCatalog { // MARK: - String+Constants private extension String { + /// The source language assumed when a catalog is missing or cannot be decoded, matching the + /// package's default localization. + enum Default { + static let sourceLanguage = "en" + } + enum Extension { static let stringCatalog = "xcstrings" } + + enum Separator { + static let dash = "-" + static let underscore = "_" + } } diff --git a/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift new file mode 100644 index 0000000..2e35e1b --- /dev/null +++ b/Packages/Localization/Sources/Public/Enumerations/CatalogState.swift @@ -0,0 +1,17 @@ +/// The outcome of reading a String Catalog from its bundle. +/// +/// The package degrades gracefully when a catalog cannot be read — lookups return their keys and the +/// language list falls back to the default language — so nothing fails at the call site. This state is +/// the signal a server checks once at startup to warn before visitors ever see raw localization keys. +public enum CatalogState: Equatable, Sendable { + + /// The catalog was found and decoded; its entries are resolvable. + case loaded + + /// No catalog resource exists in the bundle; every lookup returns its key. + case missing + + /// The catalog resource exists but is not valid `.xcstrings` JSON; every lookup returns its key. + case undecodable + +} diff --git a/Packages/Localization/Sources/Public/Methods/Localize.swift b/Packages/Localization/Sources/Public/Methods/Localize.swift index e2c18a6..b088d27 100644 --- a/Packages/Localization/Sources/Public/Methods/Localize.swift +++ b/Packages/Localization/Sources/Public/Methods/Localize.swift @@ -12,6 +12,16 @@ public struct Localize: Sendable { /// The backend that resolves keys against the catalog. private let resolver: any CatalogResolving + // MARK: Computed + + /// The outcome of reading the bundle's String Catalog. + /// + /// Resolution degrades to returning raw keys rather than failing, so check this once at startup + /// and warn when it is not ``CatalogState/loaded``. + public var catalogState: CatalogState { + resolver.state + } + // MARK: Initializers /// Creates a localizer backed by the String Catalog in the given bundle. @@ -22,7 +32,7 @@ public struct Localize: Sendable { bundle: Bundle, table: String = "Localizable" ) { - self.init(resolver: StringCatalog( + self.init(resolver: StringCatalog.cached( bundle: bundle, table: table )) diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index 878b649..e0604be 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -4,7 +4,8 @@ import Foundation /// /// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a /// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a -/// supported language identifier, falling back to the default language. +/// supported language identifier, honouring the header's `q` weights as RFC 9110 prescribes and +/// falling back to the default language. public struct Negotiate: Sendable { // MARK: Properties @@ -27,10 +28,12 @@ public struct Negotiate: Sendable { /// Picks the best supported language for the given `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. - /// The header is split into its language tags (dropping any `q` weights), then each tag is matched - /// against the supported languages in order of preference — first by an exact match, then by its - /// primary language subtag, so `de-AT` resolves to a supported `de`. When the header is absent or - /// matches nothing, the default language is returned. + /// The header is parsed into its language ranges, which are ordered by descending `q` weight as + /// RFC 9110 prescribes: an entry without a weight counts as 1, entries weighted 0 are + /// "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched + /// against the supported languages in turn — first by an exact match, then by its primary language + /// subtag, so `de-AT` resolves to a supported `de` — while the `*` wildcard accepts the default + /// language. When the header is absent or matches nothing, the default language is returned. /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( @@ -40,16 +43,20 @@ public struct Negotiate: Sendable { return list.default } - let tags = tags(from: language) + let ranges = ranges(from: language) - guard !tags.isEmpty else { + guard !ranges.isEmpty else { return list.default } let supported = list.all - for tag in tags { - if let match = match(tag: tag, in: supported) { + for range in ranges { + if range.tag == .wildcard { + return list.default + } + + if let match = match(range.tag, in: supported) { return match } } @@ -65,27 +72,69 @@ private extension Negotiate { // MARK: Methods - /// Extracts the ordered language tags from an `Accept-Language` header value. + /// Parses an `Accept-Language` header value into its ``LanguageRange`` list, ordered by preference. /// - /// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and - /// blank entries are removed. The original order is kept, which mirrors descending preference - /// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly. + /// Each comma-separated entry yields its language tag and `q` weight. The ranges are sorted by + /// descending weight, entries weighted 0 are dropped as "not acceptable", and equally weighted + /// entries keep the header order. /// - Parameter acceptLanguage: the raw `Accept-Language` header value. - /// - Returns: the ordered, weight-stripped language tags. - func tags( + /// - Returns: the language ranges, most preferred first. + func ranges( from acceptLanguage: String - ) -> [String] { + ) -> [LanguageRange] { acceptLanguage .split(separator: .Separator.comma) - .map { entry in - entry - .split(separator: .Separator.semicolon) - .first - .map(String.init)? - .trimmingCharacters(in: .whitespaces) - ?? .empty + .compactMap(range(from:)) + .filter { $0.quality > 0 } + .enumerated() + .sorted { lhs, rhs in + lhs.element.quality == rhs.element.quality + ? lhs.offset < rhs.offset + : lhs.element.quality > rhs.element.quality } - .filter { !$0.isEmpty } + .map(\.element) + } + + /// Parses a single `Accept-Language` header entry into its ``LanguageRange``. + /// + /// The entry's language tag precedes the first `;`; a `q` parameter after it sets the weight. + /// A missing or malformed weight counts as 1, the highest preference, matching a tag sent + /// without one. + /// - Parameter entry: a single comma-separated header entry. + /// - Returns: the entry's language range, or `nil` when it has no language tag. + func range( + from entry: Substring + ) -> LanguageRange? { + let parts = entry.split(separator: .Separator.semicolon) + let tag = parts.first + .map(String.init)? + .trimmingCharacters(in: .whitespaces) + ?? .empty + + guard !tag.isEmpty else { + return nil + } + + let quality = parts + .dropFirst() + .compactMap { parameter -> Double? in + let parameter = parameter + .trimmingCharacters(in: .whitespaces) + .lowercased() + + guard parameter.hasPrefix(.Prefix.quality) else { + return nil + } + + return Double(parameter.dropFirst(String.Prefix.quality.count)) + } + .first + ?? 1 + + return .init( + tag: tag, + quality: quality + ) } /// Finds the supported language that best matches a single `Accept-Language` tag. @@ -97,7 +146,7 @@ private extension Negotiate { /// - supported: the supported language identifiers. /// - Returns: the matching supported language, or `nil` when the tag matches none. func match( - tag: String, + _ tag: String, in supported: [String] ) -> String? { let tag = tag.lowercased() @@ -117,10 +166,32 @@ private extension Negotiate { } +// MARK: - Character+Extensions + +private extension Character { + + // MARK: Constants + + enum Separator { + static let comma: Character = "," + static let dash: Character = "-" + static let semicolon: Character = ";" + } +} + + // MARK: - String+Extensions private extension String { + + // MARK: Constants + static let empty: String = "" + static let wildcard: String = "*" + + enum Prefix { + static let quality = "q=" + } /// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`). var primarySubtag: String { @@ -130,13 +201,3 @@ private extension String { ?? self } } - -// MARK: - Constants - -private extension Character { - enum Separator { - static let comma: Character = "," - static let dash: Character = "-" - static let semicolon: Character = ";" - } -} diff --git a/Packages/Localization/Sources/Public/Types/LanguageList.swift b/Packages/Localization/Sources/Public/Types/LanguageList.swift index eff831a..42a0e84 100644 --- a/Packages/Localization/Sources/Public/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Public/Types/LanguageList.swift @@ -8,46 +8,47 @@ public struct LanguageList: Sendable { // MARK: Properties - /// The language served when none of the supported languages match a request. - /// - /// Should match the catalog bundle's development localization. - public let `default`: String - /// The backend that reports the available languages. private let resolver: any CatalogResolving // MARK: Initializers /// Creates a language list backed by the given bundle. - /// - Parameters: - /// - bundle: the bundle whose String Catalog defines the available languages. - /// - default: the language served when none of the supported languages match. Defaults to `"en"`. + /// - Parameter bundle: the bundle whose String Catalog defines the available languages. public init( - bundle: Bundle, - `default`: String = "en" + bundle: Bundle ) { - self.init( - resolver: StringCatalog(bundle: bundle), - default: `default` - ) + self.init(resolver: StringCatalog.cached(bundle: bundle)) } /// Creates a language list backed by the given resolver. /// /// The seam for tests and alternative backends; the public API derives languages from a bundled catalog. - /// - Parameters: - /// - resolver: the backend that reports the available languages. - /// - default: the language served when none of the supported languages match. + /// - Parameter resolver: the backend that reports the available languages. init( - resolver: any CatalogResolving, - `default`: String = "en" + resolver: any CatalogResolving ) { self.resolver = resolver - self.`default` = `default` } // MARK: Computed + /// The outcome of reading the bundle's String Catalog. + /// + /// The list degrades to the default language rather than failing, so check this once at startup + /// and warn when it is not ``CatalogState/loaded``. + public var catalogState: CatalogState { + resolver.state + } + + /// The language served when none of the supported languages match a request. + /// + /// Always the catalog's source (development) language, so the default cannot drift from the + /// bundle the list is bound to. + public var `default`: String { + resolver.sourceLanguage + } + /// Every language the bundle's String Catalog provides a localization for. /// /// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language. diff --git a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift index 502d226..f071950 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/LocalizeTests.swift @@ -32,6 +32,36 @@ struct LocalizeTests { #expect(text == "Hallo") } + @Test + func `resolves a key in a regional locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "pt-BR"), + ) + + #expect(text == "Olá") + } + + @Test + func `falls back to the primary language for an unmatched regional locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "de-AT"), + ) + + #expect(text == "Hallo") + } + + @Test + func `falls back to the source language for an unsupported locale`() { + let text = localize( + "test.greeting", + locale: Locale(identifier: "fr"), + ) + + #expect(text == "Hello") + } + @Test func `falls back to the key for an unknown entry`() { let text = localize( @@ -42,4 +72,60 @@ struct LocalizeTests { #expect(text == "unknown.key") } + @Test + func `falls back to the key for a missing catalog`() { + let localize = Localize(bundle: .main) + + let text = localize( + "test.greeting", + locale: Locale(identifier: "en") + ) + + #expect(text == "test.greeting") + } + + // MARK: Properties tests + + @Test + func `reports a loaded catalog`() { + #expect(localize.catalogState == .loaded) + } + + @Test + func `reports a missing catalog`() { + let localize = Localize(bundle: .main) + + #expect(localize.catalogState == .missing) + } + + // A malformed catalog cannot ship as a test resource — Xcode generates string symbols for every + // bundled `.xcstrings` and fails the build on invalid JSON — so one is staged in a temporary + // directory bundle instead. + @Test + func `reports an undecodable catalog`() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent( + "UndecodableCatalog-\(UUID().uuidString)", + isDirectory: true + ) + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + defer { + try? FileManager.default.removeItem(at: directory) + } + + try Data("not a catalog".utf8).write( + to: directory.appendingPathComponent("Localizable.xcstrings") + ) + + let bundle = try #require(Bundle(url: directory)) + let localize = Localize(bundle: bundle) + + #expect(localize.catalogState == .undecodable) + } + } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index 4ab3160..5bd2680 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -26,6 +26,20 @@ struct NegotiateTests { #expect(language == "de") } + @Test + func `matches a regional catalog language exactly`() { + let language = negotiate(acceptLanguage: "pt-BR") + + #expect(language == "pt-BR") + } + + @Test + func `matches a regional catalog language by its primary subtag`() { + let language = negotiate(acceptLanguage: "pt") + + #expect(language == "pt-BR") + } + @Test func `respects the header order of preference`() { let language = negotiate(acceptLanguage: "de, en") @@ -34,12 +48,47 @@ struct NegotiateTests { } @Test - func `strips quality weights from the language tags`() { - let language = negotiate(acceptLanguage: "de;q=0.5, en;q=0.9") + func `orders the language tags by descending quality weight`() { + let language = negotiate(acceptLanguage: "en;q=0.5, de;q=0.9") #expect(language == "de") } + @Test + func `treats a tag without a weight as the highest preference`() { + let language = negotiate(acceptLanguage: "en;q=0.9, de") + + #expect(language == "de") + } + + @Test + func `treats a tag with a malformed weight as the highest preference`() { + let language = negotiate(acceptLanguage: "en;q=0.9, de;q=broken") + + #expect(language == "de") + } + + @Test + func `drops language tags weighted as not acceptable`() { + let language = negotiate(acceptLanguage: "de;q=0, en;q=0.8") + + #expect(language == "en") + } + + @Test + func `falls back to the default when every tag is weighted as not acceptable`() { + let language = negotiate(acceptLanguage: "de;q=0, fr;q=0") + + #expect(language == "en") + } + + @Test + func `serves the default language for a wildcard`() { + let language = negotiate(acceptLanguage: "fr;q=0.9, *;q=0.5") + + #expect(language == "en") + } + @Test func `trims whitespace around the language tags`() { let language = negotiate(acceptLanguage: " de , en ") diff --git a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift index 4e144e2..ea4eaa7 100644 --- a/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Types/LanguageListTests.swift @@ -20,16 +20,39 @@ struct LanguageListTests { } @Test - func `uses the provided default language`() { + func `follows the catalog's source language`() { let list = LanguageList( - bundle: .module, - default: "de" + resolver: StubCatalog( + sourceLanguage: "de", + languages: ["de", "en"] + ) ) #expect(list.default == "de") } } + @Suite("catalogState") + struct CatalogState { + @Test + func `reports a loaded catalog`() { + let list = LanguageList( + bundle: .module + ) + + #expect(list.catalogState == .loaded) + } + + @Test + func `reports a missing catalog`() { + let list = LanguageList( + bundle: .main + ) + + #expect(list.catalogState == .missing) + } + } + @Suite("all") struct All { @Test @@ -45,20 +68,34 @@ struct LanguageListTests { @Test func `excludes the base localization`() { let list = LanguageList( - bundle: .module + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["Base", "de", "en"] + ) ) - #expect(!list.all.contains("Base")) + #expect(list.all == ["de", "en"]) } @Test - func `lists each language only once`() { + func `sorts the languages`() { let list = LanguageList( - bundle: .module + resolver: StubCatalog( + sourceLanguage: "en", + languages: ["fr", "en", "de"] + ) ) - let all = list.all - #expect(all.count == Set(all).count) + #expect(list.all == ["de", "en", "fr"]) + } + + @Test + func `degrades to the default language for a missing catalog`() { + let list = LanguageList( + bundle: .main + ) + + #expect(list.all == ["en"]) } } diff --git a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings index 509d868..7135307 100644 --- a/Packages/Localization/Tests/Catalogs/Localizable.xcstrings +++ b/Packages/Localization/Tests/Catalogs/Localizable.xcstrings @@ -15,6 +15,12 @@ "state" : "translated", "value" : "Hello" } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Olá" + } } } } diff --git a/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift new file mode 100644 index 0000000..1bd3ead --- /dev/null +++ b/Packages/Localization/Tests/Utils/Resolvers/StubCatalog.swift @@ -0,0 +1,23 @@ +import Foundation + +@testable import Localization + +/// A ``CatalogResolving`` backend with fixed languages, for exercising types apart from a bundled catalog. +struct StubCatalog: CatalogResolving { + + // MARK: Properties + + let sourceLanguage: String + let languages: Set + var state: CatalogState = .loaded + + // MARK: Methods + + func string( + for key: String, + in locale: Locale + ) -> String { + key + } + +} diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 0163147..71e1f67 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -58,6 +58,7 @@ let package = Package( .executableTarget( name: "Website", dependencies: [ + .byName(name: "Localization"), .byName(name: "Persistence"), .byName(name: "WebsiteLibrary"), .product( diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index d4f66ed..254319f 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -1,6 +1,7 @@ import Configuration import Hummingbird import HummingbirdCompression +import Localization import Logging import Persistence import Infrastructure @@ -9,7 +10,8 @@ import WebsiteLibrary /// Builds the website application. /// /// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles -/// the router, server configuration, and logger. It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts +/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys. +/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts /// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of /// band (so a shared database is never migrated on boot). /// - Parameter reader: the configuration reader the values are read from. @@ -17,10 +19,20 @@ import WebsiteLibrary func application( reader: ConfigReader ) async -> some ApplicationProtocol { + let languages = LanguageList() let logger = logger( serverName: reader.serverName, logLevel: reader.logLevel ) + + // A broken catalog degrades to serving raw localization keys rather than failing, so it is + // only ever visible to visitors — surface it here instead. + if languages.catalogState != .loaded { + let isCatalogMissing = languages.catalogState == .missing + + logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys") + } + let persistence = Service( driver: reader.driver, logger: logger diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift index 71eefba..f852a71 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -11,7 +11,7 @@ extension Page { /// The document language, derived from the page's locale and falling back to the default language. var lang: String { locale.language.languageCode?.identifier - ?? LanguageList(bundle: .module).default + ?? LanguageList().default } /// The icon, manifest, and theme colour metadata shared by every page of the website. diff --git a/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift new file mode 100644 index 0000000..49f9bdc --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/LanguageList+Defaults.swift @@ -0,0 +1,13 @@ +import Foundation +import Localization + +public extension LanguageList { + + // MARK: Initializers + + /// Creates a language list backed by the module's String Catalog. + init() { + self.init(bundle: .module) + } + +}