diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a60105d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Build context is the repo root (see Services/Website/Dockerfile). Keep the +# context lean: exclude build artifacts, VCS, IDE cruft, secrets and anything +# not needed to compile the Swift package. + +# Version control +.git +**/.git + +# Swift / SPM build artifacts +**/.build +**/.swiftpm + +# Xcode project (not used by the Linux build) +*.xcodeproj + +# OS / editor cruft +**/.DS_Store +.vscode + +# Local environment overrides and secrets (Compose still reads these from the +# host at runtime; ignoring them here only keeps them out of the image build). +**/.env +**/.env.local + +# Compose / tooling / docs not needed inside the image +**/docker-compose.* +**/Makefile +**/README.md diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift index 2323799..99625f3 100644 --- a/Packages/Localization/Package.swift +++ b/Packages/Localization/Package.swift @@ -30,7 +30,9 @@ let package = Package( ], path: "Tests", resources: [ - .process("Catalogs/Localizable.xcstrings") + // Copied verbatim rather than processed: the String Catalog is read as raw JSON at runtime so it + // resolves identically on Darwin and Linux (which cannot compile it). + .copy("Catalogs/Localizable.xcstrings") ] ), ] diff --git a/Packages/Localization/Sources/Methods/Localize.swift b/Packages/Localization/Sources/Methods/Localize.swift index 2acb900..e2c18a6 100644 --- a/Packages/Localization/Sources/Methods/Localize.swift +++ b/Packages/Localization/Sources/Methods/Localize.swift @@ -9,17 +9,33 @@ public struct Localize: Sendable { // MARK: Properties - /// The bundle whose compiled String Catalog the keys are resolved against. - private let bundle: Bundle + /// The backend that resolves keys against the catalog. + private let resolver: any CatalogResolving // MARK: Initializers - /// Creates a localizer backed by the given bundle. - /// - Parameter bundle: the bundle whose String Catalog contains the keys to resolve. + /// Creates a localizer backed by the String Catalog in the given bundle. + /// - Parameters: + /// - bundle: the bundle whose String Catalog contains the keys to resolve. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. public init( - bundle: Bundle + bundle: Bundle, + table: String = "Localizable" ) { - self.bundle = bundle + self.init(resolver: StringCatalog( + bundle: bundle, + table: table + )) + } + + /// Creates a localizer backed by the given resolver. + /// + /// The seam for tests and alternative backends; the public API resolves against a bundled catalog. + /// - Parameter resolver: the backend that resolves keys to localized strings. + init( + resolver: any CatalogResolving + ) { + self.resolver = resolver } // MARK: Methods @@ -30,16 +46,16 @@ public struct Localize: Sendable { /// - Parameters: /// - key: the String Catalog key to look up. /// - locale: the locale to resolve the key in. - /// - Returns: the localized string for the locale, or the key itself when the bundle's catalog has no entry for it. + /// - Returns: the localized string for the locale, the source-language string when the locale has no + /// entry, or the key itself when the catalog has no entry for it. public func callAsFunction( - _ key: String.LocalizationValue, + _ key: String, locale: Locale ) -> String { - .init(localized: .init( - key, - locale: locale, - bundle: .atURL(bundle.bundleURL) - )) + resolver.string( + for: key, + in: locale + ) } } diff --git a/Packages/Localization/Sources/Methods/Negotiate.swift b/Packages/Localization/Sources/Methods/Negotiate.swift index 06cfe99..878b649 100644 --- a/Packages/Localization/Sources/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Methods/Negotiate.swift @@ -27,9 +27,10 @@ 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 matched against - /// the supported languages with `Bundle.preferredLocalizations(from:forPreferences:)`. When the - /// header is absent or matches nothing, the default language is returned. + /// 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. /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( @@ -45,13 +46,15 @@ public struct Negotiate: Sendable { return list.default } - let preferred = Bundle.preferredLocalizations( - from: list.all, - forPreferences: tags - ) + let supported = list.all - return preferred.first - ?? list.default + for tag in tags { + if let match = match(tag: tag, in: supported) { + return match + } + } + + return list.default } } @@ -85,6 +88,47 @@ private extension Negotiate { .filter { !$0.isEmpty } } + /// Finds the supported language that best matches a single `Accept-Language` tag. + /// + /// An exact, case-insensitive match wins; otherwise the tag's primary subtag is matched against the + /// supported languages' primary subtags, so a regional tag such as `de-AT` resolves to `de`. + /// - Parameters: + /// - tag: a single language tag from the header. + /// - supported: the supported language identifiers. + /// - Returns: the matching supported language, or `nil` when the tag matches none. + func match( + tag: String, + in supported: [String] + ) -> String? { + let tag = tag.lowercased() + + if let exact = supported.first( + where: { $0.lowercased() == tag } + ) { + return exact + } + + let primary = tag.primarySubtag + + return supported.first { + $0.lowercased().primarySubtag == primary + } + } + +} + +// MARK: - String+Extensions + +private extension String { + static let empty: String = "" + + /// The primary language subtag, i.e. everything before the first `-` (`de-AT` becomes `de`). + var primarySubtag: String { + split(separator: .Separator.dash) + .first + .map(String.init) + ?? self + } } // MARK: - Constants @@ -92,10 +136,7 @@ private extension Negotiate { private extension Character { enum Separator { static let comma: Character = "," + static let dash: Character = "-" static let semicolon: Character = ";" } } - -private extension String { - static let empty: String = "" -} diff --git a/Packages/Localization/Sources/Protocols/CatalogResolving.swift b/Packages/Localization/Sources/Protocols/CatalogResolving.swift new file mode 100644 index 0000000..bb003fd --- /dev/null +++ b/Packages/Localization/Sources/Protocols/CatalogResolving.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A backend that resolves localized strings for an explicit locale and reports the languages it serves. +/// +/// This is the seam that decouples ``Localize`` and ``LanguageList`` from *how* localizations are stored +/// and resolved. The shipping implementation, ``StringCatalog``, reads a raw `.xcstrings` catalog so it +/// behaves identically on Darwin and Linux. A future backend — for example one built on +/// `String(localized:)` for a native Apple app that needs plural and device variations — can conform +/// without changing any caller. +/// +/// Resolution never fails: an implementation returns the key itself when it has no localization for it, +/// mirroring Foundation's `String(localized:)`. This is the contract both a dictionary lookup and the +/// native API can honour, since the native API cannot distinguish a missing key from a translation that +/// happens to equal the key. +protocol CatalogResolving: Sendable { + + // MARK: Properties + + /// The source (development) language, used as the final fallback when a locale has no localization. + var sourceLanguage: String { get } + + /// Every language the backend can resolve strings for, including the source language. + var languages: Set { get } + + // MARK: Methods + + /// Resolves a catalog key in the given locale. + /// - Parameters: + /// - key: the catalog key to look up. + /// - locale: the locale to resolve the key in. + /// - Returns: the localized string, falling back to the source language, then to the key itself. + func string( + for key: String, + in locale: Locale + ) -> String + +} diff --git a/Packages/Localization/Sources/Types/LanguageList.swift b/Packages/Localization/Sources/Types/LanguageList.swift index 0b7f476..eff831a 100644 --- a/Packages/Localization/Sources/Types/LanguageList.swift +++ b/Packages/Localization/Sources/Types/LanguageList.swift @@ -2,9 +2,8 @@ import Foundation /// The list of languages an app can serve, derived from a bundle's String Catalog. /// -/// The languages are read from the injected `bundle`'s compiled localizations, so the catalog that -/// ships in that bundle is the single source of truth: adding a language is a translation-only -/// change — once a locale exists in the catalog, it appears in ``all`` with no code change required. +/// The languages are read from the injected `bundle`'s String Catalog, so the catalog that ships in that bundle is the single source of truth: adding +/// a language is a translation-only change — once a locale exists in the catalog, it appears in ``all`` with no code change required. public struct LanguageList: Sendable { // MARK: Properties @@ -14,8 +13,8 @@ public struct LanguageList: Sendable { /// Should match the catalog bundle's development localization. public let `default`: String - /// The bundle whose compiled String Catalog defines the available languages. - private let bundle: Bundle + /// The backend that reports the available languages. + private let resolver: any CatalogResolving // MARK: Initializers @@ -27,7 +26,23 @@ public struct LanguageList: Sendable { bundle: Bundle, `default`: String = "en" ) { - self.bundle = bundle + self.init( + resolver: StringCatalog(bundle: bundle), + default: `default` + ) + } + + /// 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. + init( + resolver: any CatalogResolving, + `default`: String = "en" + ) { + self.resolver = resolver self.`default` = `default` } @@ -35,12 +50,13 @@ public struct LanguageList: Sendable { /// 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. + /// The `Base` internationalization is excluded, as it is a development placeholder rather than a real language. + /// The result is sorted for a stable order. public var all: [String] { - bundle - .localizations + resolver + .languages .filter { $0 != "Base" } + .sorted() } } diff --git a/Packages/Localization/Sources/Types/StringCatalog.swift b/Packages/Localization/Sources/Types/StringCatalog.swift new file mode 100644 index 0000000..477c33c --- /dev/null +++ b/Packages/Localization/Sources/Types/StringCatalog.swift @@ -0,0 +1,133 @@ +import Foundation + +/// A decoded `.xcstrings` String Catalog, read directly from a bundle's resources. +/// +/// The catalog is parsed from raw JSON rather than through Foundation's compiled-catalog APIs (`String(localized:)`, +/// `Bundle.localizations`, `Bundle.preferredLocalizations`). Those are either unavailable or non-functional on non-Darwin platforms +/// (Linux), where the toolchain ships no `xcstringstool` and so copies the raw `.xcstrings` into the resource bundle instead of compiling it. +/// Reading the catalog ourselves gives identical behaviour on every platform the service builds for. +/// +/// Only simple `stringUnit` values are decoded; plural and device variations are not represented. +struct StringCatalog: Sendable { + + // MARK: Properties + + /// The source language of the catalog, used as the fallback when a key lacks a requested localization. + let sourceLanguage: String + + /// The resolved entries, keyed by catalog key then by language code. + let entries: [String: [String: String]] + + // MARK: Computed + + /// Every language the catalog provides a localization for, including the source language. + var languages: Set { + var languages = Set(entries.values.flatMap(\.keys)) + + languages.insert(sourceLanguage) + + return languages + } + + // MARK: Initializers + + /// Reads the catalog named `table` from `bundle`. + /// + /// Falls back to an empty catalog (source language `"en"`, no entries) when the resource is missing or cannot be decoded, so lookups degrade + /// to returning the key and the language list to the default. + /// - Parameters: + /// - bundle: the bundle whose resources contain the String Catalog. + /// - table: the name of the String Catalog resource, without the `.xcstrings` extension. + init( + bundle: Bundle, + table: String = "Localizable" + ) { + guard let url = bundle.url( + forResource: table, + withExtension: .Extension.stringCatalog + ) else { + self.sourceLanguage = "en" + self.entries = [:] + return + } + + do { + let decoder = JSONDecoder() + let data = try Data(contentsOf: url) + let decoded = try decoder.decode( + Decoded.self, + from: data + ) + + self.sourceLanguage = decoded.sourceLanguage + self.entries = decoded.strings + .mapValues { entry in + (entry.localizations ?? [:]) + .compactMapValues { $0.stringUnit?.value } + } + } catch { + self.sourceLanguage = "en" + self.entries = [:] + } + } + +} + +// MARK: - CatalogResolving + +extension StringCatalog: CatalogResolving { + + func string( + for key: String, + in locale: Locale + ) -> String { + guard let byLanguage = entries[key] else { + return key + } + + let language = locale + .language + .languageCode? + .identifier + ?? sourceLanguage + + return byLanguage[language] + ?? byLanguage[sourceLanguage] + ?? key + } + +} + +// MARK: - Decoding Types + +private extension StringCatalog { + + /// The subset of the `.xcstrings` format needed to resolve simple string entries. + struct Decoded: Decodable { + + let sourceLanguage: String + let strings: [String: Entry] + + struct Entry: Decodable { + let localizations: [String: Localization]? + } + + struct Localization: Decodable { + let stringUnit: StringUnit? + } + + struct StringUnit: Decodable { + let value: String + } + + } + +} + +// MARK: - String+Constants + +private extension String { + enum Extension { + static let stringCatalog = "xcstrings" + } +} diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index e3f71bf..db51f45 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -14,17 +14,18 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ WORKDIR /build # First just resolve dependencies. -# This creates a cached layer that can be reused -# as long as your Package.swift/Package.resolved -# files do not change. -COPY ./Package.* ./ -RUN swift package resolve +# This creates a cached layer that can be reused as long as the manifests do +# not change. The Website package depends on the local Localization package via +# a relative path, so its manifest must be present for resolution to succeed. +COPY ./Packages/Localization/Package.swift ./Packages/Localization/ +COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/ +RUN swift package --package-path ./Services/Website resolve # Copy entire repo into container COPY . . # Build the application, with optimizations, with static linking, and using jemalloc -RUN swift build -c release \ +RUN swift build --package-path ./Services/Website -c release \ --product "Website" \ --static-swift-stdlib \ -Xlinker -ljemalloc @@ -33,17 +34,17 @@ RUN swift build -c release \ WORKDIR /staging # Copy main executable to staging area -RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Website" ./ +RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./ # Copy static swift backtracer binary to staging area RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ # Copy resources bundled by SPM to staging area -RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; +RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; # Copy the static files directory (served by FileMiddleware) if it exists # Ensure that by default, neither the directory nor any of its contents are writable. -RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true +RUN [ -d /build/Services/Website/Resources ] && { mv /build/Services/Website/Resources ./Resources && chmod -R a-w ./Resources; } || true # ================================ # Run image diff --git a/Services/Website/Makefile b/Services/Website/Makefile index 30aa03f..a086c67 100644 --- a/Services/Website/Makefile +++ b/Services/Website/Makefile @@ -76,7 +76,8 @@ img-release: ## Build the production (amd64) image, tag with version + latest, p --platform $(IMAGE_PLATFORM) \ --tag $(IMAGE_URL):$(version) \ --tag $(IMAGE_URL):latest \ - . + --file Dockerfile \ + ../.. @echo "${HOST_PASSWORD}" \ | docker login $(HOST_CONTAINER) \ --username $(HOST_USER) \ diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 7e4b279..b8e1f07 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -91,7 +91,9 @@ let package = Package( ], path: "Sources/Library", resources: [ - .process("Catalogs/Localizable.xcstrings") + // Copied verbatim rather than processed: the String Catalog is read as raw JSON at + // runtime so it resolves identically on Darwin and Linux (which cannot compile it). + .copy("Catalogs/Localizable.xcstrings") ] ), .testTarget( diff --git a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift index b343f3c..4ebf3bb 100644 --- a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift @@ -2,9 +2,9 @@ import HTTPTypes extension HTTPField.Name { /// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`). - public static let permissionsPolicy = Self("Permissions-Policy")! + static let permissionsPolicy = Self("Permissions-Policy")! /// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`). - public static let referrerPolicy = Self("Referrer-Policy")! + static let referrerPolicy = Self("Referrer-Policy")! /// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`). - public static let frameOptions = Self("X-Frame-Options")! + static let frameOptions = Self("X-Frame-Options")! } diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index 22467b1..24390a3 100644 --- a/Services/Website/docker-compose.override.yml +++ b/Services/Website/docker-compose.override.yml @@ -12,7 +12,9 @@ services: image: ${IMAGE_NAME}:${IMAGE_TAG:-latest} platform: linux/arm64 build: - context: . - dockerfile: Dockerfile + # The build context is the repo root so the local Localization package + # (referenced via ../../Packages/Localization) is inside the context. + context: ../.. + dockerfile: Services/Website/Dockerfile environment: LOG_LEVEL: debug