Skip to content
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# DevFoundation Changelog


## 1.8.0: January 13, 2026

This release adds helpers for using remote content for localization.

- Create a remote content bundle using `Bundle.makeRemoteContentBundle(at:localizedStrings:)`
- Set the default remote content bundle using `Bundle.defaultRemoteContentBundle`
- Access your remote localized strings (with a local fallback) using
`#remoteLocalizedString(_:bundle:)` and `#remoteLocalizedString(format:bundle:_:)`


## 1.7.0: October 27, 2025

This is a small release that updates `ExpiringValue` to work better with `DateProvider`.
Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// swift-tools-version: 6.2

import CompilerPluginSupport
import PackageDescription

let swiftSettings: [SwiftSetting] = [
Expand Down Expand Up @@ -34,13 +35,15 @@ let package = Package(
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.4"),
.package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"),
.package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.5.0"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"),
.package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"),
],
targets: [
.target(
name: "DevFoundation",
dependencies: [
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
"RemoteLocalizationMacros",
],
swiftSettings: swiftSettings
),
Expand All @@ -54,6 +57,16 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.macro(
name: "RemoteLocalizationMacros",
dependencies: [
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
],
swiftSettings: swiftSettings
),

.executableTarget(
name: "dfob",
Expand Down
7 changes: 7 additions & 0 deletions Sources/DevFoundation/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ for paging through data, and essential utility types for building robust applica
- ``LiveQueryResultsProducer``
- ``LiveQuerySchedulingStrategy``

### Localizing with Remote Content

- ``remoteLocalizedString(_:bundle:)``
- ``remoteLocalizedString(format:bundle:_:)``
- ``remoteLocalizedString(_:key:bundle:remoteContentBundle:)``
- ``Foundation/Bundle``

### Caching

- ``ExpiringValue``
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// Bundle+RemoteContent.swift
// DevFoundation
//
// Created by Prachi Gauriar on 1/13/26.
//

import Foundation
import Synchronization

extension Bundle {
/// A mutex used to synchronize access to the default remote content bundle.
private static let defaultRemoteContentBundleMutex: Mutex<Bundle?> = .init(nil)


/// The default bundle used to load remote content, such as localized strings fetched from a server.
///
/// This property is thread-safe and can be accessed from multiple threads concurrently.
///
/// Set this property after creating a remote content bundle using
/// ``makeRemoteContentBundle(at:localizedStrings:)``. Once set, you can use this bundle to look up localized
/// strings that were downloaded from a remote source.
///
/// - Note: This property is `nil` by default and must be explicitly set before use.
public static var defaultRemoteContentBundle: Bundle? {
get {
defaultRemoteContentBundleMutex.withLock(\.self)
}

set {
defaultRemoteContentBundleMutex.withLock { $0 = newValue }
}
}


/// Creates and returns a remote content bundle at the specified URL.
///
/// - Parameters:
/// - bundleURL: The URL at which to create the remote content bundle.
/// - localizedStrings: The localized strings to store in the bundle.
public static func makeRemoteContentBundle(
at bundleURL: URL,
localizedStrings: [String: String]
) throws -> Bundle? {
// We write directly into the resources directory rather than putting it in an lproj, as we don’t actually
// know language the strings are in.
let resourcesDirectoryURL = bundleURL.appending(path: "Contents/Resources")
try FileManager.default.createDirectory(at: resourcesDirectoryURL, withIntermediateDirectories: true)

let localizedStringsData = try PropertyListEncoder().encode(localizedStrings)
try localizedStringsData.write(to: resourcesDirectoryURL.appendingPathComponent("Localizable.strings"))

return Bundle(url: bundleURL)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// RemoteLocalizedString.swift
// DevFoundation
//
// Created by Prachi Gauriar on 1/13/26.
//

import Foundation

/// Returns a localized version of the key using a combination of remote- and local localization data.
///
/// The function works by first checking the remote content bundle, and if no key is found, falling back to the local
/// bundle.
///
/// You should generally use the ``#remoteLocalizedString(_:bundle:)`` macro instead of using this function directly.
///
/// - Parameters:
/// - keyAndValue: A `String.LocalizationValue` that provides the localization key to look up. This parameter also
/// serves as the default value if the system can’t find a localized string.
/// - key: A string representation of the localization key.
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
/// - remoteContentBundle: The bundle to use to look up remote localization data. If `nil`, no remote content is used.
/// Defaults to ``Foundation/Bundle/defaultRemoteContentBundle``.
public func remoteLocalizedString(
_ keyAndValue: String.LocalizationValue,
key: String,
bundle: Bundle,
remoteContentBundle: Bundle? = .defaultRemoteContentBundle
) -> String {
if let remoteContentBundle {
let value = String(localized: keyAndValue, bundle: remoteContentBundle)

// If you got back a value that was different than the key, that suggests that it was localized, so return it
if value != key {
return value
}
}

return String(localized: keyAndValue, bundle: bundle)
}


/// A macro that returns a localized version of the key using a combination of remote- and local localization data.
///
/// This macro transforms:
///
/// #remoteLocalizedString("feline.adoptionMessage")
///
/// Into:
///
/// remoteLocalizedString(
/// "feline.adoptionMessage",
/// key: "feline.adoptionMessage",
/// bundle: #bundle
/// )
///
/// - Parameters:
/// - key: A string literal containing the localization key.
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
/// `#bundle` by default.
@freestanding(expression)
public macro remoteLocalizedString(_ key: String, bundle: Bundle = #bundle) -> String =
#externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringMacro")


/// A macro that returns a formatted localized string using a combination of remote- and local localization data.
///
/// This macro transforms:
///
/// #remoteLocalizedString(format: "feline.count.format", bundle: .main, catCount, kittenCount)
///
/// Into:
///
/// String.localizedStringWithFormat(
/// #remoteLocalizedString("feline.count.format", bundle: .main),
/// catCount, kittenCount
/// )
///
/// - Parameters:
/// - format: A string literal containing the localization key for the format string.
/// - bundle: The bundle to use for looking up strings if a string cannot be found in the remote content bundle.
/// `#bundle` by default.
/// - arguments: The arguments to substitute into the format string.
@freestanding(expression)
public macro remoteLocalizedString(format: String, bundle: Bundle = #bundle, _ arguments: any CVarArg...) -> String =
#externalMacro(module: "RemoteLocalizationMacros", type: "RemoteLocalizedStringWithFormatMacro")
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// RemoteLocalizationMacrosPlugin.swift
// RemoteLocalizationMacros
//
// Created by Prachi Gauriar on 1/13/26.
//

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct RemoteLocalizationMacrosPlugin: CompilerPlugin {
let providingMacros: [any Macro.Type] = [
RemoteLocalizedStringMacro.self,
RemoteLocalizedStringWithFormatMacro.self,
]
}
87 changes: 87 additions & 0 deletions Sources/RemoteLocalizationMacros/RemoteLocalizedStringMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// RemoteLocalizedStringMacro.swift
// RemoteLocalizationMacros
//
// Created by Prachi Gauriar on 1/13/26.
//

import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct RemoteLocalizedStringMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
guard
let firstArgument = node.arguments.first,
let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self),
stringLiteral.segments.count == 1,
case .stringSegment(let stringSegment) = stringLiteral.segments.first
else {
throw RemoteLocalizedStringMacroError.requiresStringLiteral
}

let keyString = stringSegment.content.text

// Build the arguments for localizedString call
var argumentsArray: [LabeledExprSyntax] = []

// First argument: String.LocalizationValue from the original string literal
argumentsArray.append(
LabeledExprSyntax(
expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)),
trailingComma: .commaToken()
)
)

// Second argument: key parameter
argumentsArray.append(
LabeledExprSyntax(
label: .identifier("key"),
colon: .colonToken(),
expression: ExprSyntax(StringLiteralExprSyntax(content: keyString)),
trailingComma: .commaToken()
)
)

// Third argument: bundle parameter - use #bundle if not provided, otherwise use provided value
let bundleArgument = node.arguments.first { $0.label?.text == "bundle" }
let bundleExpression: ExprSyntax

if let bundleArgument = bundleArgument {
// Use the explicitly provided bundle argument
bundleExpression = bundleArgument.expression
} else {
// Default to #bundle
bundleExpression = ExprSyntax(
MacroExpansionExprSyntax(
macroName: .identifier("bundle"),
leftParen: .leftParenToken(),
arguments: LabeledExprListSyntax([]),
rightParen: .rightParenToken()
)
)
}

argumentsArray.append(
LabeledExprSyntax(
label: .identifier("bundle"),
colon: .colonToken(),
expression: bundleExpression
)
)

let arguments = LabeledExprListSyntax(argumentsArray)

return ExprSyntax(
FunctionCallExprSyntax(
calledExpression: DeclReferenceExprSyntax(baseName: .identifier("remoteLocalizedString")),
leftParen: .leftParenToken(),
arguments: arguments,
rightParen: .rightParenToken()
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// RemoteLocalizedStringMacroError.swift
// RemoteLocalizationMacros
//
// Created by Prachi Gauriar on 1/13/26.
//

import Foundation

enum RemoteLocalizedStringMacroError: Error, CustomStringConvertible {
case requiresStringLiteral
case requiresFormatStringLiteral

var description: String {
switch self {
case .requiresStringLiteral:
return "remoteLocalizedString macro requires a string literal as the first argument"
case .requiresFormatStringLiteral:
return "remoteLocalizedString(format:) macro requires a string literal as the format argument"
}
}
}
Loading