From e7bf85e6fc2ffa8ccf575e761970028c37bfced9 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:06:10 -0600 Subject: [PATCH 1/4] Add LocalizedStringResource support to Text LocalizedStringResource now conforms to InlineElement, so it can be used anywhere inline content is accepted. Resources are resolved using the page's language and the new Site.localizationBundle, which defaults to .main. Any bundle set on an individual resource is ignored. Text gains an initializer taking a LocalizedStringResource, and the InlineElement initializer is disfavored so string literals are localized. String variables still render verbatim. Co-Authored-By: Claude Sonnet 5.5 --- .../Elements/LocalizedStringResource.swift | 34 ++++++ Sources/Ignite/Elements/Text.swift | 11 ++ Sources/Ignite/Framework/Language.swift | 9 ++ Sources/Ignite/Framework/Site.swift | 11 ++ .../Elements/LocalizedStringResource.swift | 111 ++++++++++++++++++ 5 files changed, 176 insertions(+) create mode 100644 Sources/Ignite/Elements/LocalizedStringResource.swift create mode 100644 Tests/IgniteTesting/Elements/LocalizedStringResource.swift diff --git a/Sources/Ignite/Elements/LocalizedStringResource.swift b/Sources/Ignite/Elements/LocalizedStringResource.swift new file mode 100644 index 000000000..85e72be51 --- /dev/null +++ b/Sources/Ignite/Elements/LocalizedStringResource.swift @@ -0,0 +1,34 @@ +// +// LocalizedStringResource.swift +// Ignite +// https://www.github.com/twostraws/Ignite +// See LICENSE for license information. +// + +import Foundation + +/// Allows localized string resources to be used directly inside HTML. +/// +/// Resources are resolved using the language of the page being rendered +/// and `Site.localizationBundle`. Any bundle set on the resource itself is ignored. +extension LocalizedStringResource: InlineElement { + /// The content and behavior of this HTML. + public var body: some InlineElement { self } + + /// Renders this element using publishing context passed in. + /// - Returns: The HTML for this element. + public func markup() -> Markup { + guard let context = PublishingContext.current else { + return Markup(verbatim: String(localized: self)) + } + + let resource = LocalizedStringResource( + defaultValue, + table: table, + locale: context.environment.language.locale, + bundle: .atURL(context.site.localizationBundle.bundleURL) + ) + + return Markup(verbatim: String(localized: resource)) + } +} diff --git a/Sources/Ignite/Elements/Text.swift b/Sources/Ignite/Elements/Text.swift index 4dc904bfc..7e4149a6d 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 resource. + /// + /// String literals use this initializer, so they are looked up in your site's + /// `Localizable.xcstrings`. Strings stored in a `String` variable are not localized. + public init(_ resource: LocalizedStringResource) { + self.content = resource + } + /// 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 bdebe38da..7a19f71a8 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 ab2c94759..3aef75263 100644 --- a/Sources/Ignite/Framework/Site.swift +++ b/Sources/Ignite/Framework/Site.swift @@ -70,6 +70,14 @@ public protocol Site { /// The language your site is published in. Defaults to `.en`. var language: Language { get } + /// The bundle containing your site's `Localizable.xcstrings`. Defaults to `.main`. + /// + /// Ignite uses this bundle for every `LocalizedStringResource` it renders, + /// including string literals passed to `Text`. + /// Any `bundle` you set on an individual resource is ignored. + /// Set this to `.module` if your catalog lives in a Swift package target. + var localizationBundle: Bundle { get } + /// The base URL for your site, e.g. https://www.example.com var url: URL { get } @@ -187,6 +195,9 @@ public extension Site { /// English as default language. var language: Language { .english } + /// Uses the main bundle for localized strings. + var localizationBundle: Bundle { .main } + /// Uses `.gmt` as the default. var timeZone: TimeZone? { .gmt } diff --git a/Tests/IgniteTesting/Elements/LocalizedStringResource.swift b/Tests/IgniteTesting/Elements/LocalizedStringResource.swift new file mode 100644 index 000000000..d3a60ea24 --- /dev/null +++ b/Tests/IgniteTesting/Elements/LocalizedStringResource.swift @@ -0,0 +1,111 @@ +// +// LocalizedStringResource.swift +// Ignite +// https://www.github.com/twostraws/Ignite +// See LICENSE for license information. +// + +import Foundation +import Testing + +@testable import Ignite + +/// Tests for rendering `LocalizedStringResource` values. +@Suite("LocalizedStringResource Tests") +class LocalizedStringResourceTests: IgniteTestSuite { + /// Creates a bundle containing Spanish translations for the keys used in these tests. + private func makeBundle() throws -> Bundle { + let root = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + .appending(path: "Strings.bundle") + let lproj = root.appending(path: "es.lproj") + try FileManager.default.createDirectory(at: lproj, withIntermediateDirectories: true) + + let strings = "\"Welcome\" = \"Bienvenido\";\n\"Hello %@\" = \"Hola %@\";\n" + try strings.write(to: lproj.appending(path: "Localizable.strings"), atomically: true, encoding: .utf8) + + return try #require(Bundle(url: root)) + } + + /// Switches the current publishing context to a Spanish site using the given bundle. + private func useSpanishSite(bundle: Bundle) { + var site = LocalizedTestSite() + site.localizationBundle = bundle + + 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 bundle", .publishingContext()) + func literalResolvesInSiteLanguage() async throws { + useSpanishSite(bundle: try makeBundle()) + + 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(bundle: try makeBundle()) + + let name = "Ana" + let element = Text("Hello \(name)") + let output = element.markupString() + + #expect(output == "

Hola Ana

") + } + + @Test("Explicit resource bundle is ignored in favor of the site bundle", .publishingContext()) + func explicitBundleIsIgnored() async throws { + useSpanishSite(bundle: try makeBundle()) + + let resource = LocalizedStringResource("Welcome", bundle: .atURL(Bundle.main.bundleURL)) + let output = Text(resource).markupString() + + #expect(output == "

Bienvenido

") + } + + @Test("Resource works anywhere an inline element is accepted", .publishingContext()) + func resourceInInlineElement() async throws { + useSpanishSite(bundle: try makeBundle()) + + let output = Span(LocalizedStringResource("Welcome")).markupString() + + #expect(output == "Bienvenido") + } + + @Test("String variables are not localized", .publishingContext()) + func stringVariableIsVerbatim() async throws { + useSpanishSite(bundle: try makeBundle()) + + 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 localizationBundle: Bundle = .main + + var homePage = TestPage() + var layout = EmptyLayout() +} From 2271c825ba71354c0b4c11e2ad9822f8237389c0 Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:18:38 -0600 Subject: [PATCH 2/4] Use an xcstrings catalog in localization tests The LocalizedStringResource tests no longer build a bundle in a temporary directory. They now resolve strings from a Localizable.xcstrings catalog in the test target, which is processed into Bundle.module. Co-Authored-By: Claude Sonnet 5.5 --- Package.swift | 5 ++- .../Elements/LocalizedStringResource.swift | 34 ++++++------------- .../Resources/Localizable.xcstrings | 26 ++++++++++++++ 3 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 Tests/IgniteTesting/Resources/Localizable.xcstrings diff --git a/Package.swift b/Package.swift index 51a061e93..b1a5a1428 100644 --- a/Package.swift +++ b/Package.swift @@ -39,6 +39,9 @@ let package = Package( ), .testTarget( name: "IgniteTesting", - dependencies: ["Ignite"]) + dependencies: ["Ignite"], + resources: [ + .process("Resources") + ]) ] ) diff --git a/Tests/IgniteTesting/Elements/LocalizedStringResource.swift b/Tests/IgniteTesting/Elements/LocalizedStringResource.swift index d3a60ea24..69a061a76 100644 --- a/Tests/IgniteTesting/Elements/LocalizedStringResource.swift +++ b/Tests/IgniteTesting/Elements/LocalizedStringResource.swift @@ -13,24 +13,10 @@ import Testing /// Tests for rendering `LocalizedStringResource` values. @Suite("LocalizedStringResource Tests") class LocalizedStringResourceTests: IgniteTestSuite { - /// Creates a bundle containing Spanish translations for the keys used in these tests. - private func makeBundle() throws -> Bundle { - let root = FileManager.default.temporaryDirectory - .appending(path: UUID().uuidString) - .appending(path: "Strings.bundle") - let lproj = root.appending(path: "es.lproj") - try FileManager.default.createDirectory(at: lproj, withIntermediateDirectories: true) - - let strings = "\"Welcome\" = \"Bienvenido\";\n\"Hello %@\" = \"Hola %@\";\n" - try strings.write(to: lproj.appending(path: "Localizable.strings"), atomically: true, encoding: .utf8) - - return try #require(Bundle(url: root)) - } - - /// Switches the current publishing context to a Spanish site using the given bundle. - private func useSpanishSite(bundle: Bundle) { - var site = LocalizedTestSite() - site.localizationBundle = bundle + /// 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 @@ -51,7 +37,7 @@ class LocalizedStringResourceTests: IgniteTestSuite { @Test("Literal resolves using the site language and bundle", .publishingContext()) func literalResolvesInSiteLanguage() async throws { - useSpanishSite(bundle: try makeBundle()) + useSpanishSite() let element = Text("Welcome") let output = element.markupString() @@ -61,7 +47,7 @@ class LocalizedStringResourceTests: IgniteTestSuite { @Test("Interpolated literal resolves using the site language", .publishingContext()) func interpolatedLiteral() async throws { - useSpanishSite(bundle: try makeBundle()) + useSpanishSite() let name = "Ana" let element = Text("Hello \(name)") @@ -72,7 +58,7 @@ class LocalizedStringResourceTests: IgniteTestSuite { @Test("Explicit resource bundle is ignored in favor of the site bundle", .publishingContext()) func explicitBundleIsIgnored() async throws { - useSpanishSite(bundle: try makeBundle()) + useSpanishSite() let resource = LocalizedStringResource("Welcome", bundle: .atURL(Bundle.main.bundleURL)) let output = Text(resource).markupString() @@ -82,7 +68,7 @@ class LocalizedStringResourceTests: IgniteTestSuite { @Test("Resource works anywhere an inline element is accepted", .publishingContext()) func resourceInInlineElement() async throws { - useSpanishSite(bundle: try makeBundle()) + useSpanishSite() let output = Span(LocalizedStringResource("Welcome")).markupString() @@ -91,7 +77,7 @@ class LocalizedStringResourceTests: IgniteTestSuite { @Test("String variables are not localized", .publishingContext()) func stringVariableIsVerbatim() async throws { - useSpanishSite(bundle: try makeBundle()) + useSpanishSite() let value = "Welcome" let output = Text(value).markupString() @@ -104,7 +90,7 @@ private struct LocalizedTestSite: Site { var name = "My Localized Site" var url = URL(static: "https://www.example.com") var language: Language = .spanish - var localizationBundle: Bundle = .main + var localizationBundle: Bundle { .module } var homePage = TestPage() var layout = EmptyLayout() diff --git a/Tests/IgniteTesting/Resources/Localizable.xcstrings b/Tests/IgniteTesting/Resources/Localizable.xcstrings new file mode 100644 index 000000000..7ab46ddde --- /dev/null +++ b/Tests/IgniteTesting/Resources/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" +} From a7c4422488636c2663356b46224a49553ff8406d Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:31:21 -0600 Subject: [PATCH 3/4] Replace LocalizedStringResource with LocalizedString LocalizedStringResource is not available on Linux, and string catalogs processed by SwiftPM there are copied as-is instead of compiled into .lproj files, so Bundle lookups find nothing. Ignite now has its own LocalizedString type, which string literals and interpolations create, and reads Localizable.xcstrings directly as JSON on every platform. Strings resolve using the page's language, falling back from the full language identifier to the language code, then the source language, then the key. Plural and device variations are not supported. Site.localizationBundle is replaced by Site.localizationCatalog, a URL that defaults to nil. The catalog must be declared as a .copy resource, because .process compiles it and removes the original file. Co-Authored-By: Claude Sonnet 5.5 --- Package.swift | 2 +- Sources/Ignite/Elements/LocalizedString.swift | 155 ++++++++++++++++++ .../Elements/LocalizedStringResource.swift | 34 ---- Sources/Ignite/Elements/Text.swift | 10 +- Sources/Ignite/Framework/Site.swift | 29 +++- Sources/Ignite/Framework/StringCatalog.swift | 121 ++++++++++++++ .../Ignite/Publishing/PublishingContext.swift | 3 + .../Elements/LocalizedString.swift | 137 ++++++++++++++++ .../Elements/LocalizedStringResource.swift | 97 ----------- .../Localizable.xcstrings | 0 10 files changed, 443 insertions(+), 145 deletions(-) create mode 100644 Sources/Ignite/Elements/LocalizedString.swift delete mode 100644 Sources/Ignite/Elements/LocalizedStringResource.swift create mode 100644 Sources/Ignite/Framework/StringCatalog.swift create mode 100644 Tests/IgniteTesting/Elements/LocalizedString.swift delete mode 100644 Tests/IgniteTesting/Elements/LocalizedStringResource.swift rename Tests/IgniteTesting/{Resources => Localization}/Localizable.xcstrings (100%) diff --git a/Package.swift b/Package.swift index b1a5a1428..6cb59638b 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,7 @@ let package = Package( name: "IgniteTesting", dependencies: ["Ignite"], resources: [ - .process("Resources") + .copy("Localization") ]) ] ) diff --git a/Sources/Ignite/Elements/LocalizedString.swift b/Sources/Ignite/Elements/LocalizedString.swift new file mode 100644 index 000000000..182660775 --- /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/LocalizedStringResource.swift b/Sources/Ignite/Elements/LocalizedStringResource.swift deleted file mode 100644 index 85e72be51..000000000 --- a/Sources/Ignite/Elements/LocalizedStringResource.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// LocalizedStringResource.swift -// Ignite -// https://www.github.com/twostraws/Ignite -// See LICENSE for license information. -// - -import Foundation - -/// Allows localized string resources to be used directly inside HTML. -/// -/// Resources are resolved using the language of the page being rendered -/// and `Site.localizationBundle`. Any bundle set on the resource itself is ignored. -extension LocalizedStringResource: InlineElement { - /// The content and behavior of this HTML. - public var body: some InlineElement { self } - - /// Renders this element using publishing context passed in. - /// - Returns: The HTML for this element. - public func markup() -> Markup { - guard let context = PublishingContext.current else { - return Markup(verbatim: String(localized: self)) - } - - let resource = LocalizedStringResource( - defaultValue, - table: table, - locale: context.environment.language.locale, - bundle: .atURL(context.site.localizationBundle.bundleURL) - ) - - return Markup(verbatim: String(localized: resource)) - } -} diff --git a/Sources/Ignite/Elements/Text.swift b/Sources/Ignite/Elements/Text.swift index 7e4149a6d..b6faf8b25 100644 --- a/Sources/Ignite/Elements/Text.swift +++ b/Sources/Ignite/Elements/Text.swift @@ -46,12 +46,12 @@ public struct Text: HTML, DropdownItem { self.content = string } - /// Creates a new `Text` instance from a localized string resource. + /// Creates a new `Text` instance from a localized string. /// - /// String literals use this initializer, so they are looked up in your site's - /// `Localizable.xcstrings`. Strings stored in a `String` variable are not localized. - public init(_ resource: LocalizedStringResource) { - self.content = resource + /// 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. diff --git a/Sources/Ignite/Framework/Site.swift b/Sources/Ignite/Framework/Site.swift index 3aef75263..eb68eb582 100644 --- a/Sources/Ignite/Framework/Site.swift +++ b/Sources/Ignite/Framework/Site.swift @@ -70,13 +70,26 @@ public protocol Site { /// The language your site is published in. Defaults to `.en`. var language: Language { get } - /// The bundle containing your site's `Localizable.xcstrings`. Defaults to `.main`. + /// The location of your site's `Localizable.xcstrings`. Defaults to `nil`. /// - /// Ignite uses this bundle for every `LocalizedStringResource` it renders, - /// including string literals passed to `Text`. - /// Any `bundle` you set on an individual resource is ignored. - /// Set this to `.module` if your catalog lives in a Swift package target. - var localizationBundle: Bundle { get } + /// 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 } @@ -195,8 +208,8 @@ public extension Site { /// English as default language. var language: Language { .english } - /// Uses the main bundle for localized strings. - var localizationBundle: Bundle { .main } + /// 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 000000000..b9290dbdd --- /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 f78ae88d0..26589e0e5 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/LocalizedString.swift b/Tests/IgniteTesting/Elements/LocalizedString.swift new file mode 100644 index 000000000..d484fd0a8 --- /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/Elements/LocalizedStringResource.swift b/Tests/IgniteTesting/Elements/LocalizedStringResource.swift deleted file mode 100644 index 69a061a76..000000000 --- a/Tests/IgniteTesting/Elements/LocalizedStringResource.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// LocalizedStringResource.swift -// Ignite -// https://www.github.com/twostraws/Ignite -// See LICENSE for license information. -// - -import Foundation -import Testing - -@testable import Ignite - -/// Tests for rendering `LocalizedStringResource` values. -@Suite("LocalizedStringResource Tests") -class LocalizedStringResourceTests: 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 bundle", .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("Explicit resource bundle is ignored in favor of the site bundle", .publishingContext()) - func explicitBundleIsIgnored() async throws { - useSpanishSite() - - let resource = LocalizedStringResource("Welcome", bundle: .atURL(Bundle.main.bundleURL)) - let output = Text(resource).markupString() - - #expect(output == "

Bienvenido

") - } - - @Test("Resource works anywhere an inline element is accepted", .publishingContext()) - func resourceInInlineElement() async throws { - useSpanishSite() - - let output = Span(LocalizedStringResource("Welcome")).markupString() - - #expect(output == "Bienvenido") - } - - @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 localizationBundle: Bundle { .module } - - var homePage = TestPage() - var layout = EmptyLayout() -} diff --git a/Tests/IgniteTesting/Resources/Localizable.xcstrings b/Tests/IgniteTesting/Localization/Localizable.xcstrings similarity index 100% rename from Tests/IgniteTesting/Resources/Localizable.xcstrings rename to Tests/IgniteTesting/Localization/Localizable.xcstrings From 70c543d377891d874d8ed66304b08c329226846c Mon Sep 17 00:00:00 2001 From: VictorPuga <39507381+VictorPuga@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:31:21 -0600 Subject: [PATCH 4/4] Make tests build on Linux Remove the unused SwiftUI import from the Button tests, and check for built files with FileManager instead of URL.checkPromisedItemIsReachable(), which only exists on Apple platforms. Co-Authored-By: Claude Sonnet 5.5 --- Tests/IgniteTesting/Elements/Button.swift | 1 - Tests/IgniteTesting/Publishing/Site.swift | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/IgniteTesting/Elements/Button.swift b/Tests/IgniteTesting/Elements/Button.swift index 677f729bd..f98d18088 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/Publishing/Site.swift b/Tests/IgniteTesting/Publishing/Site.swift index ac5e9774c..701ef8ea3 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 {