diff --git a/Package.swift b/Package.swift index 51a061e9..6cb59638 100644 --- a/Package.swift +++ b/Package.swift @@ -39,6 +39,9 @@ let package = Package( ), .testTarget( name: "IgniteTesting", - dependencies: ["Ignite"]) + dependencies: ["Ignite"], + resources: [ + .copy("Localization") + ]) ] ) diff --git a/Sources/Ignite/Elements/LocalizedString.swift b/Sources/Ignite/Elements/LocalizedString.swift new file mode 100644 index 00000000..18266077 --- /dev/null +++ b/Sources/Ignite/Elements/LocalizedString.swift @@ -0,0 +1,155 @@ +// +// LocalizedString.swift +// Ignite +// https://www.github.com/twostraws/Ignite +// See LICENSE for license information. +// + +import Foundation + +/// A string that is translated using your site's string catalog when it is rendered. +/// +/// String literals become localized strings automatically wherever one is accepted, +/// including `Text`. Interpolated values are passed to the translation as arguments, +/// so `Text("Hello \(name)")` looks up the key `"Hello %@"`. +/// Strings stored in a `String` variable are never localized. +/// +/// Strings are translated into the language of the page being rendered +/// using the catalog at `Site.localizationCatalog`. +/// Plural and device variations are not supported. +public struct LocalizedString: ExpressibleByStringInterpolation, InlineElement, Sendable { + /// A value interpolated into a localized string. + enum Argument: Sendable { + case string(String) + case integer(Int) + case double(Double) + + /// The text this argument renders as. + var text: String { + switch self { + case .string(let value): value + case .integer(let value): String(value) + case .double(let value): String(value) + } + } + } + + /// Collects the key and arguments of an interpolated string literal. + public struct StringInterpolation: StringInterpolationProtocol { + var key = "" + var arguments = [Argument]() + + public init(literalCapacity: Int, interpolationCount: Int) {} + + public mutating func appendLiteral(_ literal: String) { + key += literal + } + + public mutating func appendInterpolation(_ value: String) { + key += "%@" + arguments.append(.string(value)) + } + + public mutating func appendInterpolation(_ value: T) { + key += "%lld" + arguments.append(.integer(Int(clamping: value))) + } + + public mutating func appendInterpolation(_ value: T) { + key += "%lf" + arguments.append(.double(Double(value))) + } + + public mutating func appendInterpolation(_ value: T) { + key += "%@" + arguments.append(.string(String(describing: value))) + } + } + + /// The catalog key, including format specifiers for interpolated values. + var key: String + + /// The interpolated values, in order. + var arguments: [Argument] + + /// The content and behavior of this HTML. + public var body: some InlineElement { self } + + public init(stringLiteral value: String) { + key = value + arguments = [] + } + + public init(stringInterpolation: StringInterpolation) { + key = stringInterpolation.key + arguments = stringInterpolation.arguments + } + + /// Renders this element using publishing context passed in. + /// - Returns: The HTML for this element. + public func markup() -> Markup { + let template = PublishingContext.current?.localizedString(for: key) ?? key + return Markup(verbatim: substitutingArguments(into: template)) + } + + /// Replaces format specifiers in a translated template with this string's arguments. + /// + /// Supports `%@`, `%d`, `%lld`, `%f`, `%lf`, `%s`, positional forms such as `%1$@`, + /// and `%%`. Templates without arguments are returned unchanged. + func substitutingArguments(into template: String) -> String { + guard !arguments.isEmpty else { return template } + + var result = "" + var nextIndex = 0 + var characters = template[...] + + while let character = characters.popFirst() { + guard character == "%" else { + result.append(character) + continue + } + + if characters.first == "%" { + characters.removeFirst() + result.append("%") + continue + } + + var lookahead = characters + var digits = "" + while let digit = lookahead.first, digit.isNumber { + digits.append(digit) + lookahead.removeFirst() + } + + var position: Int? + if lookahead.first == "$", let value = Int(digits) { + position = value - 1 + lookahead.removeFirst() + } else { + lookahead = characters + } + + while let modifier = lookahead.first, "lhqzt".contains(modifier) { + lookahead.removeFirst() + } + + guard let conversion = lookahead.first, "@dDiuUfFeEgGs".contains(conversion) else { + result.append("%") + continue + } + + lookahead.removeFirst() + characters = lookahead + + let index = position ?? nextIndex + if position == nil { nextIndex += 1 } + + if arguments.indices.contains(index) { + result += arguments[index].text + } + } + + return result + } +} diff --git a/Sources/Ignite/Elements/Text.swift b/Sources/Ignite/Elements/Text.swift index 4dc904bf..b6faf8b2 100644 --- a/Sources/Ignite/Elements/Text.swift +++ b/Sources/Ignite/Elements/Text.swift @@ -5,6 +5,8 @@ // See LICENSE for license information. // +import Foundation + /// A structured piece of text, such as a paragraph of heading. If you are just /// placing content inside a list, table, table header, and so on, you can usually /// just use a simple string. Using `Text` is required if you want a specific paragraph @@ -39,10 +41,19 @@ public struct Text: HTML, DropdownItem { } /// Creates a new `Text` instance from one inline element. + @_disfavoredOverload public init(_ string: any InlineElement) { self.content = string } + /// Creates a new `Text` instance from a localized string. + /// + /// String literals use this initializer, so they are looked up in the catalog + /// at `Site.localizationCatalog`. Strings stored in a `String` variable are not localized. + public init(_ string: LocalizedString) { + self.content = string + } + /// Sets the maximum number of lines for the text to display. /// - Parameter number: The line limit. If `nil`, no line limit applies. /// - Returns: A new `Text` instance with the line limit applied. diff --git a/Sources/Ignite/Framework/Language.swift b/Sources/Ignite/Framework/Language.swift index bdebe38d..7a19f71a 100644 --- a/Sources/Ignite/Framework/Language.swift +++ b/Sources/Ignite/Framework/Language.swift @@ -5,6 +5,8 @@ // See LICENSE for license information. // +import Foundation + // It's difficult to find a list of actual language codes // supported in web pages, so the list below is a composite // of several sources in order to provide maximum flexibility. @@ -280,3 +282,10 @@ public enum Language: String, Sendable { // swiftlint:enable type_body_length extension Language: CaseIterable {} + +extension Language { + /// The locale matching this language's RFC 5646 code. + var locale: Locale { + Locale(identifier: rawValue) + } +} diff --git a/Sources/Ignite/Framework/Site.swift b/Sources/Ignite/Framework/Site.swift index ab2c9475..eb68eb58 100644 --- a/Sources/Ignite/Framework/Site.swift +++ b/Sources/Ignite/Framework/Site.swift @@ -70,6 +70,27 @@ public protocol Site { /// The language your site is published in. Defaults to `.en`. var language: Language { get } + /// The location of your site's `Localizable.xcstrings`. Defaults to `nil`. + /// + /// Ignite reads this catalog directly, on every platform, to translate + /// string literals passed to `Text` and other `LocalizedString` values + /// into the language of each page. Plural and device variations are not supported. + /// When this is `nil`, strings render as written. + /// + /// Declare the catalog as a `.copy` resource, not `.process`, + /// because processing compiles the catalog and removes the original file: + /// + /// ```swift + /// resources: [.copy("Localizable.xcstrings")] + /// ``` + /// + /// ```swift + /// var localizationCatalog: URL? { + /// Bundle.module.url(forResource: "Localizable", withExtension: "xcstrings") + /// } + /// ``` + var localizationCatalog: URL? { get } + /// The base URL for your site, e.g. https://www.example.com var url: URL { get } @@ -187,6 +208,9 @@ public extension Site { /// English as default language. var language: Language { .english } + /// No string catalog by default. + var localizationCatalog: URL? { nil } + /// Uses `.gmt` as the default. var timeZone: TimeZone? { .gmt } diff --git a/Sources/Ignite/Framework/StringCatalog.swift b/Sources/Ignite/Framework/StringCatalog.swift new file mode 100644 index 00000000..b9290dbd --- /dev/null +++ b/Sources/Ignite/Framework/StringCatalog.swift @@ -0,0 +1,121 @@ +// +// StringCatalog.swift +// Ignite +// https://www.github.com/twostraws/Ignite +// See LICENSE for license information. +// + +import Foundation + +/// A string catalog (`.xcstrings`) read directly from its JSON source. +/// +/// Plural and device variations are not supported. +struct StringCatalog: Decodable, Sendable { + /// The translations for one key. + struct Entry: Decodable, Sendable { + /// The translations for this key, keyed by language identifier. + var localizations: [String: Localization]? + } + + /// The translation of one key into one language. + struct Localization: Decodable, Sendable { + /// The translated string, if this localization has one. + var stringUnit: StringUnit? + } + + /// A translated string. + struct StringUnit: Decodable, Sendable { + /// The translated text. + var value: String + } + + /// The language the catalog's keys are written in. + var sourceLanguage: String + + /// The entries in this catalog, keyed by string key. + var strings: [String: Entry] + + /// An empty catalog, used when no catalog is configured or loading fails. + static let empty = StringCatalog(sourceLanguage: "en", strings: [:]) + + init(sourceLanguage: String, strings: [String: Entry]) { + self.sourceLanguage = sourceLanguage + self.strings = strings + } + + /// Reads a catalog from a `.xcstrings` file. + /// - Parameter url: The location of the catalog. + init(contentsOf url: URL) throws { + let data = try Data(contentsOf: url) + self = try JSONDecoder().decode(StringCatalog.self, from: data) + } + + /// Looks up the translation for a key. + /// + /// Tries the full language identifier, then its language code, + /// then the source language, and finally returns the key itself. + /// - Parameters: + /// - key: The string key to look up. + /// - language: The language identifier to translate into, such as `pt-BR`. + /// - Returns: The best available translation of the key. + func localizedString(for key: String, language: String) -> String { + guard let localizations = strings[key]?.localizations else { return key } + + let identifier = language.replacingOccurrences(of: "_", with: "-") + let languageCode = identifier.split(separator: "-").first.map(String.init) ?? identifier + + for candidate in [identifier, languageCode, sourceLanguage] { + if let value = localizations[candidate]?.stringUnit?.value { + return value + } + } + + return key + } +} + +/// Loads string catalogs once and shares them across a publish operation. +final class StringCatalogCache: @unchecked Sendable { + /// Guards `catalogs`. + private let lock = NSLock() + + /// The catalogs loaded so far, keyed by file location. + private var catalogs = [URL: StringCatalog]() + + /// Returns the catalog at a location, loading it on first use. + /// - Parameters: + /// - url: The location of the catalog. + /// - onFailure: Called with a message if the catalog can't be read. + /// - Returns: The catalog, or an empty catalog if it can't be read. + func catalog(at url: URL, onFailure: (String) -> Void) -> StringCatalog { + lock.lock() + defer { lock.unlock() } + + if let cached = catalogs[url] { + return cached + } + + let catalog: StringCatalog + do { + catalog = try StringCatalog(contentsOf: url) + } catch { + onFailure("Unable to read string catalog at \(url.path): \(error.localizedDescription)") + catalog = .empty + } + + catalogs[url] = catalog + return catalog + } +} + +extension PublishingContext { + /// Translates a string key using the site's catalog and the current page language. + /// - Parameter key: The string key to look up. + /// - Returns: The translation, or the key if there is none. + func localizedString(for key: String) -> String { + guard let url = site.localizationCatalog else { return key } + + let catalog = stringCatalogs.catalog(at: url) { addWarning($0) } + return catalog.localizedString(for: key, language: environment.language.rawValue) + } +} diff --git a/Sources/Ignite/Publishing/PublishingContext.swift b/Sources/Ignite/Publishing/PublishingContext.swift index f78ae88d..26589e0e 100644 --- a/Sources/Ignite/Publishing/PublishingContext.swift +++ b/Sources/Ignite/Publishing/PublishingContext.swift @@ -137,6 +137,9 @@ final class PublishingContext: @unchecked Sendable { /// control!) private(set) var siteMap = [Location]() + /// The string catalogs loaded for this publish operation. + let stringCatalogs = StringCatalogCache() + /// The CSS registry for this publish operation. let cssManager = CSSManager() diff --git a/Tests/IgniteTesting/Elements/Button.swift b/Tests/IgniteTesting/Elements/Button.swift index 677f729b..f98d1808 100644 --- a/Tests/IgniteTesting/Elements/Button.swift +++ b/Tests/IgniteTesting/Elements/Button.swift @@ -6,7 +6,6 @@ // import Foundation -import SwiftUI import Testing @testable import Ignite diff --git a/Tests/IgniteTesting/Elements/LocalizedString.swift b/Tests/IgniteTesting/Elements/LocalizedString.swift new file mode 100644 index 00000000..d484fd0a --- /dev/null +++ b/Tests/IgniteTesting/Elements/LocalizedString.swift @@ -0,0 +1,137 @@ +// +// LocalizedString.swift +// Ignite +// https://www.github.com/twostraws/Ignite +// See LICENSE for license information. +// + +import Foundation +import Testing + +@testable import Ignite + +/// Tests for rendering `LocalizedString` values. +@Suite("LocalizedString Tests") +class LocalizedStringTests: IgniteTestSuite { + /// Switches the current publishing context to a Spanish site whose strings + /// come from the test target's `Localizable.xcstrings`. + private func useSpanishSite() { + let site = LocalizedTestSite() + + let context = PublishingContext.shared + context.site = site + context.environment = EnvironmentValues( + sourceDirectory: URL(filePath: ""), + site: site, + allContent: [] + ) + } + + @Test("Literal without a catalog entry renders unchanged", .publishingContext()) + func literalWithoutEntry() async throws { + let element = Text("Hello") + let output = element.markupString() + + #expect(output == "

Hello

") + } + + @Test("Literal resolves using the site language and catalog", .publishingContext()) + func literalResolvesInSiteLanguage() async throws { + useSpanishSite() + + let element = Text("Welcome") + let output = element.markupString() + + #expect(output == "

Bienvenido

") + } + + @Test("Interpolated literal resolves using the site language", .publishingContext()) + func interpolatedLiteral() async throws { + useSpanishSite() + + let name = "Ana" + let element = Text("Hello \(name)") + let output = element.markupString() + + #expect(output == "

Hola Ana

") + } + + @Test("Localized string works anywhere an inline element is accepted", .publishingContext()) + func localizedStringInInlineElement() async throws { + useSpanishSite() + + let output = Span("Welcome" as LocalizedString).markupString() + + #expect(output == "Bienvenido") + } + + @Test("Positional arguments can be reordered by translations", .publishingContext()) + func positionalArguments() async throws { + let first = "A" + let second = "B" + let string: LocalizedString = "Pair \(first) \(second)" + let output = string.substitutingArguments(into: "%2$@ then %1$@ 100%%") + + #expect(output == "B then A 100%") + } + + @Test("String variables are not localized", .publishingContext()) + func stringVariableIsVerbatim() async throws { + useSpanishSite() + + let value = "Welcome" + let output = Text(value).markupString() + + #expect(output == "

Welcome

") + } +} + +private struct LocalizedTestSite: Site { + var name = "My Localized Site" + var url = URL(static: "https://www.example.com") + var language: Language = .spanish + var localizationCatalog: URL? { + Bundle.module.url(forResource: "Localizable", withExtension: "xcstrings", subdirectory: "Localization") + } + + var homePage = TestPage() + var layout = EmptyLayout() +} + +/// Tests for reading string catalogs. +@Suite("StringCatalog Tests") +struct StringCatalogTests { + private let catalog = StringCatalog( + sourceLanguage: "en", + strings: [ + "Welcome": .init(localizations: [ + "en": .init(stringUnit: .init(value: "Welcome!")), + "pt": .init(stringUnit: .init(value: "Bem-vindo")), + "pt-BR": .init(stringUnit: .init(value: "Bem-vindo, brasileiro")), + ]), + "Goodbye": .init(localizations: [ + "en": .init(stringUnit: .init(value: "Goodbye!")), + ]), + ] + ) + + @Test("Full language identifier is preferred") + func fullIdentifier() { + #expect(catalog.localizedString(for: "Welcome", language: "pt-BR") == "Bem-vindo, brasileiro") + } + + @Test("Region falls back to the language code") + func languageCodeFallback() { + #expect(catalog.localizedString(for: "Welcome", language: "pt-PT") == "Bem-vindo") + } + + @Test("Missing language falls back to the source language") + func sourceLanguageFallback() { + #expect(catalog.localizedString(for: "Goodbye", language: "es") == "Goodbye!") + } + + @Test("Missing key returns the key") + func missingKey() { + #expect(catalog.localizedString(for: "Unknown", language: "es") == "Unknown") + } +} diff --git a/Tests/IgniteTesting/Localization/Localizable.xcstrings b/Tests/IgniteTesting/Localization/Localizable.xcstrings new file mode 100644 index 00000000..7ab46ddd --- /dev/null +++ b/Tests/IgniteTesting/Localization/Localizable.xcstrings @@ -0,0 +1,26 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Hello %@" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hola %@" + } + } + } + }, + "Welcome" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Tests/IgniteTesting/Publishing/Site.swift b/Tests/IgniteTesting/Publishing/Site.swift index ac5e9774..701ef8ea 100644 --- a/Tests/IgniteTesting/Publishing/Site.swift +++ b/Tests/IgniteTesting/Publishing/Site.swift @@ -159,12 +159,11 @@ private struct TestPackage { } func checkIndexFileExists() -> Bool { - (try? buildDirectoryURL.appending(path: "index.html").checkPromisedItemIsReachable()) ?? false + checkFileExists(at: "index.html") } func checkFileExists(at path: String) -> Bool { - let fileURL = buildDirectoryURL.appending(path: path) - return (try? fileURL.checkPromisedItemIsReachable()) ?? false + FileManager.default.fileExists(atPath: buildDirectoryURL.appending(path: path).path) } func contentsOfFile(at path: String) throws -> String {