Skip to content

Implement Text component localization support #82

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 2, 2025
Merged
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
128 changes: 128 additions & 0 deletions Sources/WebUI/Core/Localization/LocalizationKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

/// Represents a localization key that can be resolved to localized text content.
///
/// This type follows SwiftUI's localization patterns, allowing developers to use
/// string literals that are automatically resolved to localized content based on
/// the current locale and available string resources.
///
/// ## Example
/// ```swift
/// Text("welcome_message") // Resolves to localized welcome message
/// Text("user_count", arguments: [String(userCount)]) // With interpolation
/// ```
public struct LocalizationKey: ExpressibleByStringLiteral {
/// The key used to look up the localized string
public let key: String

/// Optional arguments for string interpolation
public let arguments: [String]

/// Optional table name for organizing localization strings
public let tableName: String?

/// Optional bundle to search for localization resources
public let bundle: Bundle?

/// Creates a localization key with the specified parameters
///
/// - Parameters:
/// - key: The localization key to resolve
/// - arguments: Optional arguments for string interpolation
/// - tableName: Optional table name for string organization
/// - bundle: Optional bundle containing localization resources
public init(
_ key: String,
arguments: [String] = [],
tableName: String? = nil,
bundle: Bundle? = nil
) {
self.key = key
self.arguments = arguments
self.tableName = tableName
self.bundle = bundle
}

/// Creates a localization key from a string literal
///
/// This enables the convenient syntax: `Text("welcome_message")`
/// where the string literal is automatically treated as a localization key.
///
/// - Parameter value: The string literal to use as a localization key
public init(stringLiteral value: String) {
self.key = value
self.arguments = []
self.tableName = nil
self.bundle = nil
}

/// Creates a localization key with interpolation arguments
///
/// - Parameters:
/// - key: The localization key to resolve
/// - arguments: Arguments for string interpolation
/// - Returns: A localization key configured for interpolation
public static func interpolated(_ key: String, arguments: [String]) -> LocalizationKey {
return LocalizationKey(key, arguments: arguments)
}

/// Creates a localization key with a specific table
///
/// - Parameters:
/// - key: The localization key to resolve
/// - tableName: The table name containing the localization strings
/// - Returns: A localization key configured for the specified table
public static func fromTable(_ key: String, tableName: String) -> LocalizationKey {
return LocalizationKey(key, tableName: tableName)
}
}

/// Protocol for types that can resolve localization keys to strings
public protocol LocalizationResolver {
/// Resolves a localization key to its localized string representation
///
/// - Parameter key: The localization key to resolve
/// - Returns: The localized string, or the key itself if no localization is found
func resolveLocalizationKey(_ key: LocalizationKey) -> String
}

/// Default localization resolver that uses Foundation's NSLocalizedString
public struct FoundationLocalizationResolver: LocalizationResolver {
public init() {}

public func resolveLocalizationKey(_ key: LocalizationKey) -> String {
let bundle = key.bundle ?? Bundle.main
let tableName = key.tableName

let localizedString = NSLocalizedString(
key.key,
tableName: tableName,
bundle: bundle,
comment: ""
)

// If no arguments for interpolation, return the localized string directly
guard !key.arguments.isEmpty else {
return localizedString
}

// Perform string interpolation with provided arguments
return performStringInterpolation(localizedString, arguments: key.arguments)
}

private func performStringInterpolation(_ format: String, arguments: [String]) -> String {
// Simple string interpolation implementation
// Replaces %@ placeholders with provided arguments in order
var result = format
var argumentIndex = 0

while result.contains("%@") && argumentIndex < arguments.count {
if let range = result.range(of: "%@") {
result.replaceSubrange(range, with: arguments[argumentIndex])
argumentIndex += 1
}
}

return result
}
}
161 changes: 161 additions & 0 deletions Sources/WebUI/Core/Localization/LocalizationManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Foundation

/// Manages localization state and provides string resolution for WebUI components
///
/// The `LocalizationManager` serves as the central point for managing localization
/// within WebUI applications. It tracks the current locale, manages localization
/// resolvers, and provides utilities for detecting and resolving localization keys.
///
/// ## Usage
/// ```swift
/// // Set up localization for your website
/// LocalizationManager.shared.currentLocale = Locale(identifier: "es")
/// LocalizationManager.shared.resolver = CustomLocalizationResolver()
///
/// // Text components will automatically use the configured localization
/// Text("welcome_message") // Resolves using current locale and resolver
/// ```
public final class LocalizationManager: @unchecked Sendable {
/// Shared instance for global localization management
public static let shared = LocalizationManager()

/// Current locale for localization resolution
public var currentLocale: Locale = .en {
didSet {
// Notify components that locale has changed if needed
// This could be extended to support reactive updates
}
}

/// The resolver used to convert localization keys to strings
public var resolver: LocalizationResolver = FoundationLocalizationResolver()

/// Whether localization is enabled globally
public var isLocalizationEnabled: Bool = true

private init() {}

/// Resolves a localization key to its string representation
///
/// This method serves as the main entry point for localization resolution.
/// It handles fallback logic and determines whether a string should be
/// treated as a localization key or used as-is.
///
/// - Parameter key: The localization key to resolve
/// - Returns: The resolved localized string
public func resolveKey(_ key: LocalizationKey) -> String {
guard isLocalizationEnabled else {
return key.key
}

return resolver.resolveLocalizationKey(key)
}

/// Determines if a string should be treated as a localization key
///
/// This heuristic helps identify strings that are likely localization keys
/// versus regular text content. The detection is based on common localization
/// key patterns used in software development.
///
/// - Parameter text: The text to analyze
/// - Returns: true if the text appears to be a localization key
public func isLikelyLocalizationKey(_ text: String) -> Bool {
// Skip very short strings (likely not localization keys)
guard text.count >= 2 else { return false }

// Skip strings that are clearly regular sentences
if text.contains(" ") && text.count > 50 {
return false
}

// Check for common localization key patterns
let hasUnderscores = text.contains("_")
let hasDots = text.contains(".")
let isAllLowercase = text == text.lowercased()
let hasNoSpaces = !text.contains(" ")

// Common patterns for localization keys:
// - snake_case: "welcome_message", "user_profile_title"
// - dot.notation: "app.welcome.message", "error.network.timeout"
// - Mixed patterns: "button.save_changes"
return (hasUnderscores || hasDots) && isAllLowercase && hasNoSpaces
}

/// Resolves a string that may or may not be a localization key
///
/// This method provides automatic detection of localization keys within
/// regular text content. If the text appears to be a localization key,
/// it will be resolved. Otherwise, the original text is returned.
///
/// - Parameter text: The text to potentially resolve
/// - Returns: The resolved text or original text if not a localization key
public func resolveIfLocalizationKey(_ text: String) -> String {
guard isLikelyLocalizationKey(text) else {
return text
}

let key = LocalizationKey(text)
return resolveKey(key)
}

/// Sets up localization for a specific locale and bundle
///
/// - Parameters:
/// - locale: The locale to use for localization
/// - bundle: Optional bundle containing localization resources
/// - resolver: Optional custom resolver to use
public func configure(
locale: Locale,
bundle: Bundle? = nil,
resolver: LocalizationResolver? = nil
) {
self.currentLocale = locale

if let customResolver = resolver {
self.resolver = customResolver
} else if let bundle = bundle {
self.resolver = BundleLocalizationResolver(bundle: bundle)
}
}
}

/// Localization resolver that uses a specific bundle for string resolution
public struct BundleLocalizationResolver: LocalizationResolver {
private let bundle: Bundle

public init(bundle: Bundle) {
self.bundle = bundle
}

public func resolveLocalizationKey(_ key: LocalizationKey) -> String {
let targetBundle = key.bundle ?? bundle
let tableName = key.tableName

let localizedString = NSLocalizedString(
key.key,
tableName: tableName,
bundle: targetBundle,
comment: ""
)

guard !key.arguments.isEmpty else {
return localizedString
}

return performStringInterpolation(localizedString, arguments: key.arguments)
}

private func performStringInterpolation(_ format: String, arguments: [String]) -> String {
var result = format
var argumentIndex = 0

while result.contains("%@") && argumentIndex < arguments.count {
if let range = result.range(of: "%@") {
result.replaceSubrange(range, with: arguments[argumentIndex])
argumentIndex += 1
}
}

return result
}
}
53 changes: 50 additions & 3 deletions Sources/WebUI/Elements/Text/Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ public struct Text: Element {
///
/// This is the preferred SwiftUI-like initializer for creating text elements.
/// Uses `<p>` for multiple sentences, `<span>` for one or fewer.
///
/// The content string is automatically checked for localization keys. If the string
/// appears to be a localization key (e.g., "welcome_message", "app.title"), it will
/// be resolved using the current localization settings.
///
/// - Parameters:
/// - content: The text content to display.
/// - content: The text content to display or localization key to resolve.
/// - id: Unique identifier for the markup element.
/// - classes: An array of stylesheet classnames.
/// - role: ARIA role of the element for accessibility.
Expand All @@ -27,7 +31,8 @@ public struct Text: Element {
///
/// ## Example
/// ```swift
/// Text("Hello, world!")
/// Text("Hello, world!") // Regular text
/// Text("welcome_message") // Localization key (resolved automatically)
/// Text("Multi-line text with multiple sentences. This will render as a paragraph.", classes: ["intro"])
/// ```
public init(
Expand All @@ -43,7 +48,49 @@ public struct Text: Element {
self.role = role
self.label = label
self.data = data
self.contentBuilder = { [content] }

// Resolve localization if the content appears to be a localization key
let resolvedContent = LocalizationManager.shared.resolveIfLocalizationKey(content)
self.contentBuilder = { [resolvedContent] }
}

/// Creates a new text element with explicit localization key support.
///
/// This initializer provides explicit control over localization, allowing you to
/// specify localization parameters directly. Use this when you need more control
/// over the localization process than the automatic detection provides.
///
/// - Parameters:
/// - localizationKey: The localization key to resolve.
/// - id: Unique identifier for the markup element.
/// - classes: An array of stylesheet classnames.
/// - role: ARIA role of the element for accessibility.
/// - label: ARIA label to describe the element.
/// - data: Dictionary of `data-*` attributes for element relevant storing data.
///
/// ## Example
/// ```swift
/// Text(localizationKey: LocalizationKey("welcome_message"))
/// Text(localizationKey: LocalizationKey.interpolated("user_count", arguments: ["5"]))
/// Text(localizationKey: LocalizationKey.fromTable("greeting", tableName: "Common"))
/// ```
public init(
localizationKey: LocalizationKey,
id: String? = nil,
classes: [String]? = nil,
role: AriaRole? = nil,
label: String? = nil,
data: [String: String]? = nil
) {
self.id = id
self.classes = classes
self.role = role
self.label = label
self.data = data

// Resolve the localization key explicitly
let resolvedContent = LocalizationManager.shared.resolveKey(localizationKey)
self.contentBuilder = { [resolvedContent] }
}

/// Creates a new text element using MarkupBuilder closure syntax.
Expand Down
Loading