Skip to content

Commit 1764b4f

Browse files
Add support for localization with remote content
1 parent 946a8be commit 1764b4f

13 files changed

Lines changed: 586 additions & 2 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# DevFoundation Changelog
22

33

4+
## 1.8.0: January 13, 2026
5+
6+
This release adds helpers for using remote content for localization.
7+
8+
- Create a remote content bundle using `Bundle.makeRemoteContentBundle(at:localizedStrings:)`
9+
- Set the default remote content bundle using `Bundle.defaultRemoteContentBundle`
10+
- Access your remote localized strings (with a local fallback) using
11+
`#remoteLocalizedString(_:bundle:)` and `#remoteLocalizedString(format:bundle:_:)`
12+
13+
414
## 1.7.0: October 27, 2025
515

616
This is a small release that updates `ExpiringValue` to work better with `DateProvider`.

Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// swift-tools-version: 6.2
22

3+
import CompilerPluginSupport
34
import PackageDescription
45

56
let swiftSettings: [SwiftSetting] = [
@@ -34,13 +35,15 @@ let package = Package(
3435
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.4"),
3536
.package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"),
3637
.package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"),
38+
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"),
3739
.package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"),
3840
],
3941
targets: [
4042
.target(
4143
name: "DevFoundation",
4244
dependencies: [
43-
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
45+
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
46+
"RemoteLocalizationMacros",
4447
],
4548
swiftSettings: swiftSettings
4649
),
@@ -54,6 +57,16 @@ let package = Package(
5457
],
5558
swiftSettings: swiftSettings
5659
),
60+
.macro(
61+
name: "RemoteLocalizationMacros",
62+
dependencies: [
63+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
64+
.product(name: "SwiftSyntax", package: "swift-syntax"),
65+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
66+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
67+
],
68+
swiftSettings: swiftSettings
69+
),
5770

5871
.executableTarget(
5972
name: "dfob",

Sources/DevFoundation/Documentation.docc/Documentation.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ for paging through data, and essential utility types for building robust applica
3030
- ``LiveQueryResultsProducer``
3131
- ``LiveQuerySchedulingStrategy``
3232

33+
### Localizing with Remote Content
34+
35+
- ``remoteLocalizedString(_:bundle:)``
36+
- ``remoteLocalizedString(format:bundle:_:)``
37+
- ``remoteLocalizedString(_:key:bundle:remoteContentBundle:)``
38+
- ``Foundation/Bundle``
39+
3340
### Caching
3441

3542
- ``ExpiringValue``
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// Bundle+RemoteContent.swift
3+
// DevFoundation
4+
//
5+
// Created by Prachi Gauriar on 1/13/26.
6+
//
7+
8+
import Foundation
9+
import Synchronization
10+
11+
extension Bundle {
12+
/// A mutex used to synchronize access to the default remote content bundle.
13+
private static let defaultRemoteContentBundleMutex: Mutex<Bundle?> = .init(nil)
14+
15+
16+
/// The default bundle used to load remote content, such as localized strings fetched from a server.
17+
///
18+
/// This property is thread-safe and can be accessed from multiple threads concurrently.
19+
///
20+
/// Set this property after creating a remote content bundle using
21+
/// ``makeRemoteContentBundle(at:localizedStrings:)``. Once set, you can use this bundle to look up localized
22+
/// strings that were downloaded from a remote source.
23+
///
24+
/// - Note: This property is `nil` by default and must be explicitly set before use.
25+
public static var defaultRemoteContentBundle: Bundle? {
26+
get {
27+
defaultRemoteContentBundleMutex.withLock(\.self)
28+
}
29+
30+
set {
31+
defaultRemoteContentBundleMutex.withLock { $0 = newValue }
32+
}
33+
}
34+
35+
36+
/// Creates and returns a remote content bundle at the specified URL.
37+
///
38+
/// - Parameters:
39+
/// - bundleURL: The URL at which to create the remote content bundle.
40+
/// - localizedStrings: The localized strings to store in the bundle.
41+
public static func makeRemoteContentBundle(
42+
at bundleURL: URL,
43+
localizedStrings: [String: String]
44+
) throws -> Bundle? {
45+
// We write directly into the resources directory rather than putting it in an lproj, as we don’t actually
46+
// know language the strings are in.
47+
let resourcesDirectoryURL = bundleURL.appending(path: "Contents/Resources")
48+
try FileManager.default.createDirectory(at: resourcesDirectoryURL, withIntermediateDirectories: true)
49+
50+
let localizedStringsData = try PropertyListEncoder().encode(localizedStrings)
51+
try localizedStringsData.write(to: resourcesDirectoryURL.appendingPathComponent("Localizable.strings"))
52+
53+
return Bundle(url: bundleURL)
54+
}
55+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// RemoteLocalizedString.swift
3+
// DevFoundation
4+
//
5+
// Created by Prachi Gauriar on 1/13/26.
6+
//
7+
8+
import Foundation
9+
10+
/// Returns a localized version of the key using a combination of remote- and local localization data.
11+
///
12+
/// The function works by first checking the remote content bundle, and if no key is found, falling back to the local
13+
/// bundle.
14+
///
15+
/// You should generally use the ``#remoteLocalizedString(_:bundle:)`` macro instead of using this function directly.
16+
///
17+
/// - Parameters:
18+
/// - keyAndValue: A `String.LocalizationValue` that provides the localization key to look up. This parameter also
19+
/// serves as the default value if the system can’t find a localized string.
20+
/// - key: A string representation of the localization key.
21+
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
22+
/// - remoteContentBundle: The bundle to use to look up remote localization data. If `nil`, no remote content is used.
23+
/// Defaults to ``Foundation/Bundle/defaultRemoteContentBundle``.
24+
public func remoteLocalizedString(
25+
_ keyAndValue: String.LocalizationValue,
26+
key: String,
27+
bundle: Bundle,
28+
remoteContentBundle: Bundle? = .defaultRemoteContentBundle
29+
) -> String {
30+
if let remoteContentBundle {
31+
let value = String(localized: keyAndValue, bundle: remoteContentBundle)
32+
33+
// If you got back a value that was different than the key, that suggests that it was localized, so return it
34+
if value != key {
35+
return value
36+
}
37+
}
38+
39+
return String(localized: keyAndValue, bundle: bundle)
40+
}
41+
42+
43+
/// A macro that returns a localized version of the key using a combination of remote- and local localization data.
44+
///
45+
/// This macro transforms:
46+
///
47+
/// #remoteLocalizedString("feline.adoptionMessage")
48+
///
49+
/// Into:
50+
///
51+
/// remoteLocalizedString(
52+
/// "feline.adoptionMessage",
53+
/// key: "feline.adoptionMessage",
54+
/// bundle: #bundle
55+
/// )
56+
///
57+
/// - Parameters:
58+
/// - key: A string literal containing the localization key.
59+
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
60+
/// `#bundle` by default.
61+
@freestanding(expression)
62+
public macro remoteLocalizedString(_ key: String, bundle: Bundle = #bundle) -> String =
63+
#externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringMacro")
64+
65+
66+
/// A macro that returns a formatted localized string using a combination of remote- and local localization data.
67+
///
68+
/// This macro transforms:
69+
///
70+
/// #remoteLocalizedString(format: "feline.count.format", bundle: .main, catCount, kittenCount)
71+
///
72+
/// Into:
73+
///
74+
/// String.localizedStringWithFormat(
75+
/// #remoteLocalizedString("feline.count.format", bundle: .main),
76+
/// catCount, kittenCount
77+
/// )
78+
///
79+
/// - Parameters:
80+
/// - format: A string literal containing the localization key for the format string.
81+
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
82+
/// `#bundle` by default.
83+
/// - arguments: The arguments to substitute into the format string.
84+
@freestanding(expression)
85+
public macro remoteLocalizedString(format: String, bundle: Bundle = #bundle, _ arguments: any CVarArg...) -> String =
86+
#externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringWithFormatMacro")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// RemoteLocalizationMacrosPlugin.swift
3+
// RemoteLocalizationMacros
4+
//
5+
// Created by Prachi Gauriar on 1/13/26.
6+
//
7+
8+
import SwiftCompilerPlugin
9+
import SwiftSyntaxMacros
10+
11+
@main
12+
struct RemoteLocalizationMacrosPlugin: CompilerPlugin {
13+
let providingMacros: [any Macro.Type] = [
14+
RemoteLocalizedStringMacro.self,
15+
RemoteLocalizedStringWithFormatMacro.self,
16+
]
17+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// RemoteLocalizedStringMacro.swift
3+
// RemoteLocalizationMacros
4+
//
5+
// Created by Prachi Gauriar on 1/13/26.
6+
//
7+
8+
import SwiftSyntax
9+
import SwiftSyntaxBuilder
10+
import SwiftSyntaxMacros
11+
12+
public struct RemoteLocalizedStringMacro: ExpressionMacro {
13+
public static func expansion(
14+
of node: some FreestandingMacroExpansionSyntax,
15+
in context: some MacroExpansionContext
16+
) throws -> ExprSyntax {
17+
guard
18+
let firstArgument = node.arguments.first,
19+
let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self),
20+
stringLiteral.segments.count == 1,
21+
case .stringSegment(let stringSegment) = stringLiteral.segments.first
22+
else {
23+
throw RemoteLocalizedStringMacroError.requiresStringLiteral
24+
}
25+
26+
let keyString = stringSegment.content.text
27+
28+
// Build the arguments for localizedString call
29+
var argumentsArray: [LabeledExprSyntax] = []
30+
31+
// First argument: String.LocalizationValue from the original string literal
32+
argumentsArray.append(
33+
LabeledExprSyntax(
34+
expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)),
35+
trailingComma: .commaToken()
36+
)
37+
)
38+
39+
// Second argument: key parameter
40+
argumentsArray.append(
41+
LabeledExprSyntax(
42+
label: .identifier("key"),
43+
colon: .colonToken(),
44+
expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)),
45+
trailingComma: .commaToken()
46+
)
47+
)
48+
49+
// Third argument: bundle parameter - use #bundle if not provided, otherwise use provided value
50+
let bundleArgument = node.arguments.first { $0.label?.text == "bundle" }
51+
let bundleExpression: ExprSyntax
52+
53+
if let bundleArgument = bundleArgument {
54+
// Use the explicitly provided bundle argument
55+
bundleExpression = bundleArgument.expression
56+
} else {
57+
// Default to #bundle
58+
bundleExpression = ExprSyntax(
59+
MacroExpansionExprSyntax(
60+
macroName: .identifier("bundle"),
61+
leftParen: .leftParenToken(),
62+
arguments: LabeledExprListSyntax([]),
63+
rightParen: .rightParenToken()
64+
)
65+
)
66+
}
67+
68+
argumentsArray.append(
69+
LabeledExprSyntax(
70+
label: .identifier("bundle"),
71+
colon: .colonToken(),
72+
expression: bundleExpression
73+
)
74+
)
75+
76+
let arguments = LabeledExprListSyntax(argumentsArray)
77+
78+
return ExprSyntax(
79+
FunctionCallExprSyntax(
80+
calledExpression: DeclReferenceExprSyntax(baseName: .identifier("remoteLocalizedString")),
81+
leftParen: .leftParenToken(),
82+
arguments: arguments,
83+
rightParen: .rightParenToken()
84+
)
85+
)
86+
}
87+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//
2+
// RemoteLocalizedStringMacroError.swift
3+
// RemoteLocalizationMacros
4+
//
5+
// Created by Prachi Gauriar on 1/13/26.
6+
//
7+
8+
import Foundation
9+
10+
enum RemoteLocalizedStringMacroError: Error, CustomStringConvertible {
11+
case requiresStringLiteral
12+
case requiresFormatStringLiteral
13+
14+
var description: String {
15+
switch self {
16+
case .requiresStringLiteral:
17+
return "remoteLocalizedString macro requires a string literal as the first argument"
18+
case .requiresFormatStringLiteral:
19+
return "remoteLocalizedString(format:) macro requires a string literal as the format argument"
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)