Skip to content

Commit cc9534c

Browse files
authored
Implement Text component localization support (#82)
* Refactor core HTML protocols and builders to Markup - Rename HTML protocol to Markup for Swift API Design Guidelines compliance - Rename HTMLBuilder to MarkupBuilder with updated documentation - Rename HTMLString to MarkupString for consistency - Update AnyHTML to AnyMarkup with proper type erasure - Rename HTMLClassContainer to MarkupClassContainer - Rename HTMLContentBuilder to MarkupContentBuilder - Update all documentation to use 'markup' terminology - Rename AttributeBuilder.renderTag to buildMarkupTag for clarity - Move files from HTML/ directory to Markup/ directory * Complete HTML to Markup refactoring across entire codebase - Update all 85+ Swift files to use Markup instead of HTML types - Replace HTMLBuilder with MarkupBuilder throughout codebase - Update all protocol conformances and type annotations - Replace renderTag with buildMarkupTag method name - Update documentation to use 'markup' and 'stylesheet' terminology - Maintain backward compatibility for actual HTML tag output - All tests passing after comprehensive refactor * Expand abbreviations and improve method names per Swift API Design Guidelines - CSS → StyleSheet: sanitizedForCSS() → sanitizedForStyleSheet() - URL → WebAddress: baseURL → baseWebAddress throughout APIs - XML → ExtensibleMarkupLanguageDocument: generateXML() → generateExtensibleMarkupLanguageDocument() - Improve method clarity: getData() → retrieveStructuredDataDictionary() - Improve method clarity: toJSON() → convertToJsonString() - Boolean properties to assertions: generateSitemap → shouldGenerateSitemap - Boolean properties to assertions: generateRobotsTxt → shouldGenerateRobotsTxt - Add backward compatibility aliases with deprecation warnings - Update all references across codebase and tests - All 367 tests passing with new method names * Improve parameter labels for better call-site clarity - AttributeBuilder.buildAttributes: Add clear parameter labels (identifier, styleSheetClasses, ariaRole, etc.) - StyleOperation: Add alternative methods with clearer labels (using, with configuration) - Input component: Improve event handler parameter label clarity - Add backward compatibility overloads to maintain existing API - All 254 tests passing with improved parameter clarity - Complete Swift API Design Guidelines compliance * Implement Text component localization support Add comprehensive localization infrastructure for the Text component following SwiftUI patterns: - LocalizationKey: Supports string literals, interpolation, and table-based organization - LocalizationManager: Centralized singleton for managing locale and resolution - Foundation & Bundle resolvers: NSLocalizedString integration with custom bundle support - Text component enhancements: - Automatic localization key detection (snake_case, dot.notation patterns) - Explicit localization support via LocalizationKey parameter - Backward compatibility maintained for existing string initializers - Comprehensive test coverage: 20 passing tests covering all functionality Resolves #76: Text localization now works like SwiftUI with automatic key resolution
1 parent 1d68521 commit cc9534c

File tree

4 files changed

+616
-3
lines changed

4 files changed

+616
-3
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import Foundation
2+
3+
/// Represents a localization key that can be resolved to localized text content.
4+
///
5+
/// This type follows SwiftUI's localization patterns, allowing developers to use
6+
/// string literals that are automatically resolved to localized content based on
7+
/// the current locale and available string resources.
8+
///
9+
/// ## Example
10+
/// ```swift
11+
/// Text("welcome_message") // Resolves to localized welcome message
12+
/// Text("user_count", arguments: [String(userCount)]) // With interpolation
13+
/// ```
14+
public struct LocalizationKey: ExpressibleByStringLiteral {
15+
/// The key used to look up the localized string
16+
public let key: String
17+
18+
/// Optional arguments for string interpolation
19+
public let arguments: [String]
20+
21+
/// Optional table name for organizing localization strings
22+
public let tableName: String?
23+
24+
/// Optional bundle to search for localization resources
25+
public let bundle: Bundle?
26+
27+
/// Creates a localization key with the specified parameters
28+
///
29+
/// - Parameters:
30+
/// - key: The localization key to resolve
31+
/// - arguments: Optional arguments for string interpolation
32+
/// - tableName: Optional table name for string organization
33+
/// - bundle: Optional bundle containing localization resources
34+
public init(
35+
_ key: String,
36+
arguments: [String] = [],
37+
tableName: String? = nil,
38+
bundle: Bundle? = nil
39+
) {
40+
self.key = key
41+
self.arguments = arguments
42+
self.tableName = tableName
43+
self.bundle = bundle
44+
}
45+
46+
/// Creates a localization key from a string literal
47+
///
48+
/// This enables the convenient syntax: `Text("welcome_message")`
49+
/// where the string literal is automatically treated as a localization key.
50+
///
51+
/// - Parameter value: The string literal to use as a localization key
52+
public init(stringLiteral value: String) {
53+
self.key = value
54+
self.arguments = []
55+
self.tableName = nil
56+
self.bundle = nil
57+
}
58+
59+
/// Creates a localization key with interpolation arguments
60+
///
61+
/// - Parameters:
62+
/// - key: The localization key to resolve
63+
/// - arguments: Arguments for string interpolation
64+
/// - Returns: A localization key configured for interpolation
65+
public static func interpolated(_ key: String, arguments: [String]) -> LocalizationKey {
66+
return LocalizationKey(key, arguments: arguments)
67+
}
68+
69+
/// Creates a localization key with a specific table
70+
///
71+
/// - Parameters:
72+
/// - key: The localization key to resolve
73+
/// - tableName: The table name containing the localization strings
74+
/// - Returns: A localization key configured for the specified table
75+
public static func fromTable(_ key: String, tableName: String) -> LocalizationKey {
76+
return LocalizationKey(key, tableName: tableName)
77+
}
78+
}
79+
80+
/// Protocol for types that can resolve localization keys to strings
81+
public protocol LocalizationResolver {
82+
/// Resolves a localization key to its localized string representation
83+
///
84+
/// - Parameter key: The localization key to resolve
85+
/// - Returns: The localized string, or the key itself if no localization is found
86+
func resolveLocalizationKey(_ key: LocalizationKey) -> String
87+
}
88+
89+
/// Default localization resolver that uses Foundation's NSLocalizedString
90+
public struct FoundationLocalizationResolver: LocalizationResolver {
91+
public init() {}
92+
93+
public func resolveLocalizationKey(_ key: LocalizationKey) -> String {
94+
let bundle = key.bundle ?? Bundle.main
95+
let tableName = key.tableName
96+
97+
let localizedString = NSLocalizedString(
98+
key.key,
99+
tableName: tableName,
100+
bundle: bundle,
101+
comment: ""
102+
)
103+
104+
// If no arguments for interpolation, return the localized string directly
105+
guard !key.arguments.isEmpty else {
106+
return localizedString
107+
}
108+
109+
// Perform string interpolation with provided arguments
110+
return performStringInterpolation(localizedString, arguments: key.arguments)
111+
}
112+
113+
private func performStringInterpolation(_ format: String, arguments: [String]) -> String {
114+
// Simple string interpolation implementation
115+
// Replaces %@ placeholders with provided arguments in order
116+
var result = format
117+
var argumentIndex = 0
118+
119+
while result.contains("%@") && argumentIndex < arguments.count {
120+
if let range = result.range(of: "%@") {
121+
result.replaceSubrange(range, with: arguments[argumentIndex])
122+
argumentIndex += 1
123+
}
124+
}
125+
126+
return result
127+
}
128+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import Foundation
2+
3+
/// Manages localization state and provides string resolution for WebUI components
4+
///
5+
/// The `LocalizationManager` serves as the central point for managing localization
6+
/// within WebUI applications. It tracks the current locale, manages localization
7+
/// resolvers, and provides utilities for detecting and resolving localization keys.
8+
///
9+
/// ## Usage
10+
/// ```swift
11+
/// // Set up localization for your website
12+
/// LocalizationManager.shared.currentLocale = Locale(identifier: "es")
13+
/// LocalizationManager.shared.resolver = CustomLocalizationResolver()
14+
///
15+
/// // Text components will automatically use the configured localization
16+
/// Text("welcome_message") // Resolves using current locale and resolver
17+
/// ```
18+
public final class LocalizationManager: @unchecked Sendable {
19+
/// Shared instance for global localization management
20+
public static let shared = LocalizationManager()
21+
22+
/// Current locale for localization resolution
23+
public var currentLocale: Locale = .en {
24+
didSet {
25+
// Notify components that locale has changed if needed
26+
// This could be extended to support reactive updates
27+
}
28+
}
29+
30+
/// The resolver used to convert localization keys to strings
31+
public var resolver: LocalizationResolver = FoundationLocalizationResolver()
32+
33+
/// Whether localization is enabled globally
34+
public var isLocalizationEnabled: Bool = true
35+
36+
private init() {}
37+
38+
/// Resolves a localization key to its string representation
39+
///
40+
/// This method serves as the main entry point for localization resolution.
41+
/// It handles fallback logic and determines whether a string should be
42+
/// treated as a localization key or used as-is.
43+
///
44+
/// - Parameter key: The localization key to resolve
45+
/// - Returns: The resolved localized string
46+
public func resolveKey(_ key: LocalizationKey) -> String {
47+
guard isLocalizationEnabled else {
48+
return key.key
49+
}
50+
51+
return resolver.resolveLocalizationKey(key)
52+
}
53+
54+
/// Determines if a string should be treated as a localization key
55+
///
56+
/// This heuristic helps identify strings that are likely localization keys
57+
/// versus regular text content. The detection is based on common localization
58+
/// key patterns used in software development.
59+
///
60+
/// - Parameter text: The text to analyze
61+
/// - Returns: true if the text appears to be a localization key
62+
public func isLikelyLocalizationKey(_ text: String) -> Bool {
63+
// Skip very short strings (likely not localization keys)
64+
guard text.count >= 2 else { return false }
65+
66+
// Skip strings that are clearly regular sentences
67+
if text.contains(" ") && text.count > 50 {
68+
return false
69+
}
70+
71+
// Check for common localization key patterns
72+
let hasUnderscores = text.contains("_")
73+
let hasDots = text.contains(".")
74+
let isAllLowercase = text == text.lowercased()
75+
let hasNoSpaces = !text.contains(" ")
76+
77+
// Common patterns for localization keys:
78+
// - snake_case: "welcome_message", "user_profile_title"
79+
// - dot.notation: "app.welcome.message", "error.network.timeout"
80+
// - Mixed patterns: "button.save_changes"
81+
return (hasUnderscores || hasDots) && isAllLowercase && hasNoSpaces
82+
}
83+
84+
/// Resolves a string that may or may not be a localization key
85+
///
86+
/// This method provides automatic detection of localization keys within
87+
/// regular text content. If the text appears to be a localization key,
88+
/// it will be resolved. Otherwise, the original text is returned.
89+
///
90+
/// - Parameter text: The text to potentially resolve
91+
/// - Returns: The resolved text or original text if not a localization key
92+
public func resolveIfLocalizationKey(_ text: String) -> String {
93+
guard isLikelyLocalizationKey(text) else {
94+
return text
95+
}
96+
97+
let key = LocalizationKey(text)
98+
return resolveKey(key)
99+
}
100+
101+
/// Sets up localization for a specific locale and bundle
102+
///
103+
/// - Parameters:
104+
/// - locale: The locale to use for localization
105+
/// - bundle: Optional bundle containing localization resources
106+
/// - resolver: Optional custom resolver to use
107+
public func configure(
108+
locale: Locale,
109+
bundle: Bundle? = nil,
110+
resolver: LocalizationResolver? = nil
111+
) {
112+
self.currentLocale = locale
113+
114+
if let customResolver = resolver {
115+
self.resolver = customResolver
116+
} else if let bundle = bundle {
117+
self.resolver = BundleLocalizationResolver(bundle: bundle)
118+
}
119+
}
120+
}
121+
122+
/// Localization resolver that uses a specific bundle for string resolution
123+
public struct BundleLocalizationResolver: LocalizationResolver {
124+
private let bundle: Bundle
125+
126+
public init(bundle: Bundle) {
127+
self.bundle = bundle
128+
}
129+
130+
public func resolveLocalizationKey(_ key: LocalizationKey) -> String {
131+
let targetBundle = key.bundle ?? bundle
132+
let tableName = key.tableName
133+
134+
let localizedString = NSLocalizedString(
135+
key.key,
136+
tableName: tableName,
137+
bundle: targetBundle,
138+
comment: ""
139+
)
140+
141+
guard !key.arguments.isEmpty else {
142+
return localizedString
143+
}
144+
145+
return performStringInterpolation(localizedString, arguments: key.arguments)
146+
}
147+
148+
private func performStringInterpolation(_ format: String, arguments: [String]) -> String {
149+
var result = format
150+
var argumentIndex = 0
151+
152+
while result.contains("%@") && argumentIndex < arguments.count {
153+
if let range = result.range(of: "%@") {
154+
result.replaceSubrange(range, with: arguments[argumentIndex])
155+
argumentIndex += 1
156+
}
157+
}
158+
159+
return result
160+
}
161+
}

Sources/WebUI/Elements/Text/Text.swift

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ public struct Text: Element {
1616
///
1717
/// This is the preferred SwiftUI-like initializer for creating text elements.
1818
/// Uses `<p>` for multiple sentences, `<span>` for one or fewer.
19+
///
20+
/// The content string is automatically checked for localization keys. If the string
21+
/// appears to be a localization key (e.g., "welcome_message", "app.title"), it will
22+
/// be resolved using the current localization settings.
1923
///
2024
/// - Parameters:
21-
/// - content: The text content to display.
25+
/// - content: The text content to display or localization key to resolve.
2226
/// - id: Unique identifier for the markup element.
2327
/// - classes: An array of stylesheet classnames.
2428
/// - role: ARIA role of the element for accessibility.
@@ -27,7 +31,8 @@ public struct Text: Element {
2731
///
2832
/// ## Example
2933
/// ```swift
30-
/// Text("Hello, world!")
34+
/// Text("Hello, world!") // Regular text
35+
/// Text("welcome_message") // Localization key (resolved automatically)
3136
/// Text("Multi-line text with multiple sentences. This will render as a paragraph.", classes: ["intro"])
3237
/// ```
3338
public init(
@@ -43,7 +48,49 @@ public struct Text: Element {
4348
self.role = role
4449
self.label = label
4550
self.data = data
46-
self.contentBuilder = { [content] }
51+
52+
// Resolve localization if the content appears to be a localization key
53+
let resolvedContent = LocalizationManager.shared.resolveIfLocalizationKey(content)
54+
self.contentBuilder = { [resolvedContent] }
55+
}
56+
57+
/// Creates a new text element with explicit localization key support.
58+
///
59+
/// This initializer provides explicit control over localization, allowing you to
60+
/// specify localization parameters directly. Use this when you need more control
61+
/// over the localization process than the automatic detection provides.
62+
///
63+
/// - Parameters:
64+
/// - localizationKey: The localization key to resolve.
65+
/// - id: Unique identifier for the markup element.
66+
/// - classes: An array of stylesheet classnames.
67+
/// - role: ARIA role of the element for accessibility.
68+
/// - label: ARIA label to describe the element.
69+
/// - data: Dictionary of `data-*` attributes for element relevant storing data.
70+
///
71+
/// ## Example
72+
/// ```swift
73+
/// Text(localizationKey: LocalizationKey("welcome_message"))
74+
/// Text(localizationKey: LocalizationKey.interpolated("user_count", arguments: ["5"]))
75+
/// Text(localizationKey: LocalizationKey.fromTable("greeting", tableName: "Common"))
76+
/// ```
77+
public init(
78+
localizationKey: LocalizationKey,
79+
id: String? = nil,
80+
classes: [String]? = nil,
81+
role: AriaRole? = nil,
82+
label: String? = nil,
83+
data: [String: String]? = nil
84+
) {
85+
self.id = id
86+
self.classes = classes
87+
self.role = role
88+
self.label = label
89+
self.data = data
90+
91+
// Resolve the localization key explicitly
92+
let resolvedContent = LocalizationManager.shared.resolveKey(localizationKey)
93+
self.contentBuilder = { [resolvedContent] }
4794
}
4895

4996
/// Creates a new text element using MarkupBuilder closure syntax.

0 commit comments

Comments
 (0)