Fixes for the Localization package (#26)

This PR contains the work done to

Reviewed-on: rock-n-code/loud-amsterdam#26
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-07-28 23:37:33 +00:00
committed by javier
parent cdded06ba3
commit 0887135328
16 changed files with 518 additions and 80 deletions
@@ -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<String> { get }
@@ -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
}
@@ -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 = "_"
}
}
@@ -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
}
@@ -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
))
@@ -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 = ";"
}
}
@@ -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.
@@ -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)
}
}
@@ -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 ")
@@ -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"])
}
}
@@ -15,6 +15,12 @@
"state" : "translated",
"value" : "Hello"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Olá"
}
}
}
}
@@ -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<String>
var state: CatalogState = .loaded
// MARK: Methods
func string(
for key: String,
in locale: Locale
) -> String {
key
}
}
+1
View File
@@ -58,6 +58,7 @@ let package = Package(
.executableTarget(
name: "Website",
dependencies: [
.byName(name: "Localization"),
.byName(name: "Persistence"),
.byName(name: "WebsiteLibrary"),
.product(
@@ -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
@@ -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.
@@ -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)
}
}