Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ let package = Package(
),
.testTarget(
name: "IgniteTesting",
dependencies: ["Ignite"])
dependencies: ["Ignite"],
resources: [
.copy("Localization")
])
]
)
155 changes: 155 additions & 0 deletions Sources/Ignite/Elements/LocalizedString.swift
Original file line number Diff line number Diff line change
@@ -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<T: BinaryInteger>(_ value: T) {
key += "%lld"
arguments.append(.integer(Int(clamping: value)))
}

public mutating func appendInterpolation<T: BinaryFloatingPoint>(_ value: T) {
key += "%lf"
arguments.append(.double(Double(value)))
}

public mutating func appendInterpolation<T>(_ 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
}
}
11 changes: 11 additions & 0 deletions Sources/Ignite/Elements/Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions Sources/Ignite/Framework/Language.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
24 changes: 24 additions & 0 deletions Sources/Ignite/Framework/Site.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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 }

Expand Down
121 changes: 121 additions & 0 deletions Sources/Ignite/Framework/StringCatalog.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions Sources/Ignite/Publishing/PublishingContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 0 additions & 1 deletion Tests/IgniteTesting/Elements/Button.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import Foundation
import SwiftUI
import Testing

@testable import Ignite
Expand Down
Loading
Loading