Skip to content

Commit 25b4acb

Browse files
authored
Added localization support (#42)
* added localization support * Revert "added localization support" This reverts commit 81f5da2. * localization support only needs set locale or passing default to the server as its handled server side. Reverted local checks * reverted changes * Updated local logic so as to allow for changes to local refreshing the views without requesting network updates * setup locale as optional * added locale for columns * added support for list rendering in HTML * resolved code review comments * updated tests for locale default in url * code clean up
1 parent df41714 commit 25b4acb

File tree

10 files changed

+146
-98
lines changed

10 files changed

+146
-98
lines changed

Sources/BuilderIO/BuilderIOManager.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,17 @@ public final class BuilderIOManager {
5050
return customNavigationScheme
5151
}
5252

53-
public func fetchBuilderContent(model: String = "page", url: String? = nil) async -> Result<
54-
BuilderContent, Error
55-
> {
53+
public func fetchBuilderContent(model: String = "page", url: String? = nil, locale: String) async
54+
-> Result<BuilderContent, Error>
55+
{
5656
do {
5757
let resolvedUrl = url ?? ""
5858

5959
if let content = await BuilderContentAPI.getContent(
6060
model: model,
6161
apiKey: apiKey,
6262
url: resolvedUrl,
63-
locale: "",
63+
locale: locale,
6464
preview: ""
6565
) {
6666
return .success(content)

Sources/BuilderIO/ComponentRegistry/BuilderComponentProtocol.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ extension BuilderViewProtocol {
1818
func codeBindings() -> [String: String]? {
1919
return nil
2020
}
21+
22+
func localize(localizedValue: AnyCodable) -> String? {
23+
24+
if let localeDictionary = localizedValue.dictionaryValue {
25+
if let currentLocale = block.locale {
26+
if let localizedString = localeDictionary[currentLocale]?.stringValue {
27+
return localizedString
28+
}
29+
}
30+
31+
return localeDictionary["Default"]?.stringValue
32+
33+
} else {
34+
return localizedValue.stringValue
35+
}
36+
}
2137
}
2238

2339
struct BuilderEmptyView: BuilderViewProtocol {

Sources/BuilderIO/Components/BuilderColumns.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ struct BuilderColumns: BuilderViewProtocol {
5252
}
5353
}
5454

55+
if let locale = block.locale {
56+
57+
for columnIndex in decodedColumns.indices {
58+
for blockIndex in decodedColumns[columnIndex].blocks.indices {
59+
decodedColumns[columnIndex].blocks[blockIndex]
60+
.setLocaleRecursively(locale)
61+
}
62+
}
63+
}
64+
5565
self.columns = decodedColumns
5666

5767
} else {

Sources/BuilderIO/Components/BuilderImage.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct BuilderImage: BuilderViewProtocol {
66
var block: BuilderBlockModel
77
var children: [BuilderBlockModel]?
88

9-
var imageURL: URL?
9+
var imageURL: URL? = nil
1010
var aspectRatio: CGFloat? = nil
1111
var lockAspectRatio: Bool = false
1212
var contentMode: ContentMode = .fit
@@ -16,8 +16,12 @@ struct BuilderImage: BuilderViewProtocol {
1616

1717
init(block: BuilderBlockModel) {
1818
self.block = block
19-
self.imageURL = URL(
20-
string: block.component?.options?.dictionaryValue?["image"]?.stringValue ?? "")
19+
20+
if let imageLink = block.component?.options?.dictionaryValue?["image"] {
21+
self.imageURL = URL(
22+
string: localize(localizedValue: imageLink) ?? "")
23+
}
24+
2125
if let ratio = block.component?.options?.dictionaryValue?["aspectRatio"]?.doubleValue {
2226
self.aspectRatio = CGFloat(1 / ratio)
2327
}

Sources/BuilderIO/Components/BuilderText.swift

Lines changed: 23 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ struct BuilderText: BuilderViewProtocol {
1111

1212
init(block: BuilderBlockModel) {
1313
self.block = block
14-
self.text = block.component?.options?.dictionaryValue?["text"]?.stringValue ?? ""
14+
var processedText: String = ""
15+
if let textValue = block.component?.options?.dictionaryValue?["text"] {
16+
self.text = localize(localizedValue: textValue) ?? ""
17+
}
18+
1519
self.responsiveStyles = getFinalStyle(responsiveStyles: block.responsiveStyles)
1620

1721
if let textBinding = block.codeBindings(for: "text") {
@@ -106,12 +110,25 @@ struct HTMLTextView: View {
106110
// Perform the NSAttributedString conversion on the MainActor
107111
await MainActor.run {
108112
do {
109-
let nsAttributedString = try NSAttributedString(
110-
data: data,
111-
options: [
113+
114+
var attributedOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [:]
115+
116+
if #available(iOS 18.0, *) {
117+
attributedOptions = [
112118
.documentType: NSAttributedString.DocumentType.html,
113119
.characterEncoding: String.Encoding.utf8.rawValue,
114-
],
120+
.textKit1ListMarkerFormatDocumentOption: true,
121+
]
122+
} else {
123+
attributedOptions = [
124+
.documentType: NSAttributedString.DocumentType.html,
125+
.characterEncoding: String.Encoding.utf8.rawValue,
126+
]
127+
}
128+
129+
let nsAttributedString = try NSAttributedString(
130+
data: data,
131+
options: attributedOptions,
115132
documentAttributes: nil
116133
)
117134

@@ -121,6 +138,7 @@ struct HTMLTextView: View {
121138
else {
122139
throw HTMLProcessingError.attributedStringConversionFailed
123140
}
141+
124142
self.attributedString = swiftUIAttributedString
125143
self.errorInProcessing = nil // Clear error if successful
126144
} catch {
@@ -227,73 +245,3 @@ struct HTMLTextView: View {
227245
}
228246

229247
}
230-
231-
struct BuilderText_Previews: PreviewProvider {
232-
static let builderJSONString = """
233-
{
234-
"@type": "@builder.io/sdk:Element",
235-
"@version": 2,
236-
"id": "builder-54d67576377d4a9293c6f8d2efcda0ef",
237-
"meta": {
238-
"previousId": "builder-ad756879bc6c4ee3ac7977d5af0b6811"
239-
},
240-
"component": {
241-
"name": "Text",
242-
"options": {
243-
"text": "<h1><strong>Right<em> </em></strong><em>Align</em><strong><em> </em></strong><strong style=\\\"color: rgb(144, 19, 254);\\\"><em><u>Text</u></em></strong></h1><p> This is a paragraph with some content that will determine the height dynamically. This text should wrap to multiple lines if the width is constrained.</p>"
244-
},
245-
"isRSC": null
246-
},
247-
"responsiveStyles": {
248-
"large": {
249-
"display": "flex",
250-
"flexDirection": "column",
251-
"position": "relative",
252-
"flexShrink": "0",
253-
"boxSizing": "border-box",
254-
"marginTop": "20px",
255-
"lineHeight": "normal",
256-
"height": "auto",
257-
"marginLeft": "auto",
258-
"paddingLeft": "20px",
259-
"marginRight": "20px"
260-
},
261-
"medium": {
262-
"display": "none"
263-
},
264-
"small": {
265-
"borderWidth": "2px",
266-
"borderStyle": "solid",
267-
"borderColor": "rgba(219, 20, 20, 1)",
268-
"backgroundColor": "rgba(80, 227, 194, 1)",
269-
"backgroundRepeat": "no-repeat",
270-
"backgroundPosition": "center",
271-
"backgroundSize": "cover",
272-
"display": "flex",
273-
"fontSize": "12px",
274-
"fontWeight": "600",
275-
"fontFamily": "Aldrich, sans-serif"
276-
}
277-
}
278-
}
279-
"""
280-
281-
static func decodeBuilderBlockModel(from jsonString: String) -> BuilderBlockModel? {
282-
let data = Data(jsonString.utf8)
283-
do {
284-
let decoder = JSONDecoder()
285-
return try decoder.decode(BuilderBlockModel.self, from: data)
286-
} catch {
287-
print("Decoding failed:", error)
288-
return nil
289-
}
290-
}
291-
292-
static var previews: some View {
293-
if let block = decodeBuilderBlockModel(from: builderJSONString) {
294-
BuilderText(block: block)
295-
} else {
296-
Text("Failed to decode block")
297-
}
298-
}
299-
}

Sources/BuilderIO/ExportedView/BuilderIOContentView.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,27 @@ public struct BuilderIOContentView: View {
77
let url: String?
88

99
@State private var viewModel: BuilderIOViewModel
10+
@Binding var locale: String
1011

11-
public init(model: String) {
12+
public init(model: String, locale: String = "Default") {
13+
self.init(model: model, locale: .constant(locale))
14+
}
15+
16+
public init(model: String, locale: Binding<String>) {
1217
self.model = model
1318
self.url = nil
19+
self._locale = locale
1420
_viewModel = State(wrappedValue: BuilderIOViewModel())
1521
}
1622

17-
init(url: String, model: String = "page") {
23+
public init(url: String, model: String = "page", locale: String = "Default") {
24+
self.init(url: url, model: model, locale: .constant(locale))
25+
}
26+
27+
init(url: String, model: String = "page", locale: Binding<String>) {
1828
self.url = url
1929
self.model = model
30+
self._locale = locale
2031
_viewModel = State(wrappedValue: BuilderIOViewModel())
2132
}
2233

@@ -66,13 +77,18 @@ public struct BuilderIOContentView: View {
6677
if viewModel.builderContent == nil && !viewModel.isLoading {
6778
await loadContent()
6879
}
80+
}.onChange(of: locale) {
81+
Task {
82+
await loadContent()
83+
}
6984
}
7085
}
7186

7287
func loadContent() async {
7388
if !viewModel.isLoading {
7489
print("Calling fetchBuilderPageContent from .task for section model : \(model)")
75-
await viewModel.fetchBuilderContent(model: model, url: url ?? "")
90+
await viewModel.fetchBuilderContent(
91+
model: model, url: url ?? "", locale: _locale.wrappedValue)
7692
} else if viewModel.isLoading {
7793
print("Already loading content for section model: \(model). Not re-fetching.")
7894
}

Sources/BuilderIO/ExportedView/BuilderIOPage.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,36 @@ public struct BuilderIOPage: View {
55

66
let url: String
77
let model: String
8+
@Binding var locale: String
89

910
@StateObject private var buttonActionManager = BuilderActionManager()
1011
var onClickEventHandler: ((BuilderAction) -> Void)? = nil
1112

1213
@State private var activeNavigationTarget: NavigationTarget? = nil
1314

1415
public init(
15-
url: String, model: String = "page", onClickEventHandler: ((BuilderAction) -> Void)? = nil
16+
url: String, model: String = "page", locale: String = "Default",
17+
onClickEventHandler: ((BuilderAction) -> Void)? = nil
18+
) {
19+
self.init(
20+
url: url, model: model, locale: .constant(locale), onClickEventHandler: onClickEventHandler)
21+
}
22+
23+
public init(
24+
url: String, model: String = "page", locale: Binding<String>,
25+
onClickEventHandler: ((BuilderAction) -> Void)? = nil
1626
) {
1727
self.url = url
1828
self.model = model
1929
self.onClickEventHandler = onClickEventHandler
30+
self._locale = locale
2031
}
2132

2233
public var body: some View {
2334
NavigationStack(path: $buttonActionManager.path) {
24-
BuilderIOContentView(url: url, model: model)
35+
BuilderIOContentView(url: url, model: model, locale: $locale)
2536
.navigationDestination(for: NavigationTarget.self) { target in
26-
BuilderIOContentView(url: target.url, model: target.model)
37+
BuilderIOContentView(url: target.url, model: target.model, locale: $locale)
2738
}
2839
}.environmentObject(buttonActionManager).onAppear {
2940
if let onClickEventHandler = onClickEventHandler {

Sources/BuilderIO/ExportedView/BuilderIOViewModel.swift

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public final class BuilderIOViewModel {
4444

4545
/// Fetches the Builder.io page content for a given URL.
4646
/// Manages loading, content, and error states.
47-
public func fetchBuilderContent(model: String = "page", url: String = "") async {
47+
public func fetchBuilderContent(model: String = "page", url: String = "", locale: String) async {
4848
// Set loading state immediately
4949
isLoading = true
5050
errorMessage = nil // Clear any previous error
@@ -58,16 +58,24 @@ public final class BuilderIOViewModel {
5858

5959
do {
6060
// Await the content fetching
61-
let result = await BuilderIOManager.shared.fetchBuilderContent(model: model, url: url)
61+
let result = await BuilderIOManager.shared.fetchBuilderContent(
62+
model: model, url: url, locale: locale)
6263
switch result {
6364
case .success(let fetchedContent):
64-
if fetchedContent.data.httpRequests != nil {
65+
if let httpRequests = fetchedContent.data.httpRequests {
6566
self.stateModel.apiResponses = try await fetchParallelAPIData(
66-
urls: fetchedContent.data.httpRequests!)
67+
urls: httpRequests, locale: locale)
6768
}
6869

6970
if self.stateModel.apiResponses.isEmpty {
71+
var newContentBlocks = fetchedContent.data.blocks ?? []
72+
for i in 0..<newContentBlocks.count {
73+
newContentBlocks[i].setLocaleRecursively(locale)
74+
}
75+
7076
self.builderContent = fetchedContent
77+
78+
self.builderContent?.data.blocks = newContentBlocks
7179
} else {
7280
// Further logic for content binding/loops can go here if needed.
7381
var contentBlocks = fetchedContent.data.blocks ?? []
@@ -103,8 +111,13 @@ public final class BuilderIOViewModel {
103111
}
104112

105113
}
114+
106115
self.builderContent = fetchedContent
107116

117+
for i in 0..<newContentBlocks.count {
118+
newContentBlocks[i].setLocaleRecursively(locale)
119+
}
120+
108121
self.builderContent?.data.blocks = newContentBlocks
109122
}
110123

@@ -120,12 +133,23 @@ public final class BuilderIOViewModel {
120133
isLoading = false
121134
}
122135

136+
func replaceLocalePlaceholder(in string: String, with locale: String) -> String {
137+
let placeholder = "&locale={{state.locale}}"
138+
if string.contains(placeholder) {
139+
return string.replacingOccurrences(of: placeholder, with: "&locale=\(locale)")
140+
} else {
141+
return string
142+
}
143+
}
144+
123145
/// Sends a tracking pixel to Builder.io.
124146
public func sendTrackingPixel() {
125147
BuilderIOManager.shared.sendTrackingPixel()
126148
}
127149

128-
public func fetchParallelAPIData(urls: [String: String]) async -> [String: AnyCodable] {
150+
public func fetchParallelAPIData(urls: [String: String], locale: String) async -> [String:
151+
AnyCodable]
152+
{
129153
isLoading = true
130154
errorMessage = nil // Clear any previous error
131155

@@ -140,13 +164,18 @@ public final class BuilderIOViewModel {
140164

141165
// Use withTaskGroup for concurrent execution and collection of results
142166
// The Task will now return (originalKey, apiResult)
143-
await withTaskGroup(of: (String, AnyCodable?).self) { group in
167+
await withTaskGroup(of: (String, AnyCodable?).self) { [weak self] group in
144168
for (key, urlString) in urls { // Iterate over key-value pairs
145169
group.addTask {
146170
do {
147-
let apiResult = try await BuilderContentAPI.getDataFromBoundAPI(url: urlString)
148-
// Return the original key along with the API result
149-
return (key, apiResult)
171+
if let self = self {
172+
let apiResult = try await BuilderContentAPI.getDataFromBoundAPI(
173+
url: self.replaceLocalePlaceholder(in: urlString, with: locale))
174+
// Return the original key along with the API result
175+
return (key, apiResult)
176+
} else {
177+
return (key, nil)
178+
}
150179
} catch {
151180
print(
152181
"Error fetching data from \(urlString) for key \(key): \(error.localizedDescription)")

0 commit comments

Comments
 (0)