diff --git a/Package.swift b/Package.swift index 79505c68d..a2e9ca67a 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,14 @@ let package = Package( name: "TokamakShim", targets: ["TokamakShim"] ), + .library( + name: "TokamakStaticHTML", + targets: ["TokamakStaticHTML"] + ), + .executable( + name: "TokamakStaticDemo", + targets: ["TokamakStaticDemo"] + ), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -51,17 +59,29 @@ let package = Package( dependencies: ["CombineShim", "Runtime"] ), .target( - name: "TokamakDemo", - dependencies: ["JavaScriptKit", "TokamakShim"] + name: "TokamakStaticHTML", + dependencies: [ + "TokamakCore", + ] ), .target( name: "TokamakDOM", - dependencies: ["CombineShim", "JavaScriptKit", "TokamakCore"] + dependencies: ["CombineShim", "JavaScriptKit", "TokamakCore", "TokamakStaticHTML"] ), .target( name: "TokamakShim", dependencies: [.target(name: "TokamakDOM", condition: .when(platforms: [.wasi]))] ), + .target( + name: "TokamakDemo", + dependencies: ["JavaScriptKit", "TokamakShim"] + ), + .target( + name: "TokamakStaticDemo", + dependencies: [ + "TokamakStaticHTML", + ] + ), .target( name: "TokamakTestRenderer", dependencies: ["TokamakCore"] diff --git a/Sources/TokamakDOM/App.swift b/Sources/TokamakDOM/App.swift index 10cf1f45f..25c3ee0fd 100644 --- a/Sources/TokamakDOM/App.swift +++ b/Sources/TokamakDOM/App.swift @@ -18,6 +18,7 @@ import CombineShim import JavaScriptKit import TokamakCore +import TokamakStaticHTML private enum ScenePhaseObserver { static var publisher = CurrentValueSubject(.active) diff --git a/Sources/TokamakDOM/DOMNode.swift b/Sources/TokamakDOM/DOMNode.swift index a6c539b67..344cfc99e 100644 --- a/Sources/TokamakDOM/DOMNode.swift +++ b/Sources/TokamakDOM/DOMNode.swift @@ -14,6 +14,28 @@ import JavaScriptKit import TokamakCore +import TokamakStaticHTML + +extension AnyHTML { + func update(dom: DOMNode) { + // FIXME: is there a sensible way to diff attributes and listeners to avoid + // crossing the JavaScript bridge and touching DOM if not needed? + + // @carson-katri: For diffing, could you build a Set from the keys and values of the dictionary, + // then use the standard lib to get the difference? + + for (attribute, value) in attributes { + _ = dom.ref[dynamicMember: attribute] = .string(value) + } + + if let dynamicSelf = self as? AnyDynamicHTML { + dom.reinstall(dynamicSelf.listeners) + } + + guard let innerHTML = innerHTML else { return } + dom.ref.innerHTML = .string(innerHTML) + } +} final class DOMNode: Target { let ref: JSObjectRef diff --git a/Sources/TokamakDOM/DOMRenderer.swift b/Sources/TokamakDOM/DOMRenderer.swift index 20356701e..359dec1f0 100644 --- a/Sources/TokamakDOM/DOMRenderer.swift +++ b/Sources/TokamakDOM/DOMRenderer.swift @@ -17,6 +17,7 @@ import JavaScriptKit import TokamakCore +import TokamakStaticHTML extension EnvironmentValues { /// Returns default settings for the DOM environment @@ -91,10 +92,10 @@ final class DOMRenderer: Renderer { ) } - func mountTarget(to parent: DOMNode, with host: MountedHost) -> DOMNode? { - guard let (outerHTML, listeners) = mapAnyView( + public func mountTarget(to parent: DOMNode, with host: MountedHost) -> DOMNode? { + guard let anyHTML = mapAnyView( host.view, - transform: { (html: AnyHTML) in (html.outerHTML, html.listeners) } + transform: { (html: AnyHTML) in html } ) else { // handle cases like `TupleView` if mapAnyView(host.view, transform: { (view: ParentView) in view }) != nil { @@ -104,7 +105,7 @@ final class DOMRenderer: Renderer { return nil } - _ = parent.ref.insertAdjacentHTML!("beforeend", JSValue(stringLiteral: outerHTML)) + _ = parent.ref.insertAdjacentHTML!("beforeend", JSValue(stringLiteral: anyHTML.outerHTML)) guard let children = parent.ref.childNodes.object, @@ -121,7 +122,11 @@ final class DOMRenderer: Renderer { lastChild.style.object!.height = "100%" } - return DOMNode(host.view, lastChild, listeners) + if let dynamicHTML = anyHTML as? AnyDynamicHTML { + return DOMNode(host.view, lastChild, dynamicHTML.listeners) + } else { + return DOMNode(host.view, lastChild, [:]) + } } func update(target: DOMNode, with host: MountedHost) { diff --git a/Sources/TokamakDOM/Styles/ToggleStyle.swift b/Sources/TokamakDOM/Styles/ToggleStyle.swift index b2bd1b73f..93bb7e313 100644 --- a/Sources/TokamakDOM/Styles/ToggleStyle.swift +++ b/Sources/TokamakDOM/Styles/ToggleStyle.swift @@ -16,11 +16,14 @@ // import TokamakCore +import TokamakStaticHTML public struct DefaultToggleStyle: ToggleStyle { public func makeBody(configuration: Configuration) -> some View { CheckboxToggleStyle().makeBody(configuration: configuration) } + + public init() {} } public struct CheckboxToggleStyle: ToggleStyle { @@ -30,7 +33,7 @@ public struct CheckboxToggleStyle: ToggleStyle { attrs["checked"] = "checked" } return HTML("label") { - HTML("input", attrs, listeners: [ + DynamicHTML("input", attrs, listeners: [ "change": { event in let checked = event.target.object?.checked.boolean ?? false configuration.isOn = checked diff --git a/Sources/TokamakDOM/Views/Buttons/Button.swift b/Sources/TokamakDOM/Views/Buttons/Button.swift index 203d971cd..3caf067a2 100644 --- a/Sources/TokamakDOM/Views/Buttons/Button.swift +++ b/Sources/TokamakDOM/Views/Buttons/Button.swift @@ -26,7 +26,7 @@ extension _Button: ViewDeferredToRenderer where Label == Text { attributes = ["class": "_tokamak-buttonstyle-reset"] } - return AnyView(HTML("button", attributes, listeners: [ + return AnyView(DynamicHTML("button", attributes, listeners: [ "click": { _ in action() }, "pointerdown": { _ in isPressed = true }, "pointerup": { _ in isPressed = false }, diff --git a/Sources/TokamakDOM/Views/Containers/DisclosureGroup.swift b/Sources/TokamakDOM/Views/Containers/DisclosureGroup.swift index 6b09857f2..18f844467 100644 --- a/Sources/TokamakDOM/Views/Containers/DisclosureGroup.swift +++ b/Sources/TokamakDOM/Views/Containers/DisclosureGroup.swift @@ -16,16 +16,19 @@ // import TokamakCore +import TokamakStaticHTML extension DisclosureGroup: ViewDeferredToRenderer { var chevron: some View { - HTML("div", - ["class": "_tokamak-disclosuregroup-chevron-container"], - listeners: [ - "click": { _ in - _DisclosureGroupProxy(self).toggleIsExpanded() - }, - ]) { + DynamicHTML( + "div", + ["class": "_tokamak-disclosuregroup-chevron-container"], + listeners: [ + "click": { _ in + _DisclosureGroupProxy(self).toggleIsExpanded() + }, + ] + ) { HTML("div", ["class": "_tokamak-disclosuregroup-chevron"]) .rotationEffect(_DisclosureGroupProxy(self).isExpanded ? .degrees(90) : diff --git a/Sources/TokamakDOM/Views/DynamicHTML.swift b/Sources/TokamakDOM/Views/DynamicHTML.swift new file mode 100644 index 000000000..4d8ac37c8 --- /dev/null +++ b/Sources/TokamakDOM/Views/DynamicHTML.swift @@ -0,0 +1,67 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/31/20. +// + +import JavaScriptKit +import TokamakCore +import TokamakStaticHTML + +public typealias Listener = (JSObjectRef) -> () + +protocol AnyDynamicHTML: AnyHTML { + var listeners: [String: Listener] { get } +} + +public struct DynamicHTML: View, AnyDynamicHTML where Content: View { + public let tag: String + public let attributes: [String: String] + public let listeners: [String: Listener] + let content: Content + + public init( + _ tag: String, + _ attributes: [String: String] = [:], + listeners: [String: Listener] = [:], + @ViewBuilder content: () -> Content + ) { + self.tag = tag + self.attributes = attributes + self.listeners = listeners + self.content = content() + } + + public var innerHTML: String? { nil } + + public var body: Never { + neverBody("HTML") + } +} + +extension DynamicHTML where Content == EmptyView { + public init( + _ tag: String, + _ attributes: [String: String] = [:], + listeners: [String: Listener] = [:] + ) { + self = DynamicHTML(tag, attributes, listeners: listeners) { EmptyView() } + } +} + +extension DynamicHTML: ParentView { + public var children: [AnyView] { + [AnyView(content)] + } +} diff --git a/Sources/TokamakDOM/Views/NavigationLink.swift b/Sources/TokamakDOM/Views/Navigation/NavigationLink.swift similarity index 97% rename from Sources/TokamakDOM/Views/NavigationLink.swift rename to Sources/TokamakDOM/Views/Navigation/NavigationLink.swift index 85a3be3ad..da179da49 100644 --- a/Sources/TokamakDOM/Views/NavigationLink.swift +++ b/Sources/TokamakDOM/Views/Navigation/NavigationLink.swift @@ -18,7 +18,7 @@ extension NavigationLink: ViewDeferredToRenderer { public var deferredBody: AnyView { let proxy = _NavigationLinkProxy(self) return AnyView( - HTML("a", [ + DynamicHTML("a", [ "href": "javascript:void%200", ], listeners: [ // FIXME: Focus destination or something so assistive diff --git a/Sources/TokamakDOM/Views/Selectors/Picker.swift b/Sources/TokamakDOM/Views/Selectors/Picker.swift index 5fa076e5d..7289a7d8a 100644 --- a/Sources/TokamakDOM/Views/Selectors/Picker.swift +++ b/Sources/TokamakDOM/Views/Selectors/Picker.swift @@ -14,13 +14,14 @@ import JavaScriptKit import TokamakCore +import TokamakStaticHTML extension _PickerContainer: ViewDeferredToRenderer { public var deferredBody: AnyView { AnyView(HTML("label") { label Text(" ") - HTML("select", listeners: ["change": { + DynamicHTML("select", listeners: ["change": { guard let valueString = $0.target.object!.value.string, let value = Int(valueString) as? SelectionValue diff --git a/Sources/TokamakDOM/Views/Text/SecureField.swift b/Sources/TokamakDOM/Views/Text/SecureField.swift index 18018e694..176f517e8 100644 --- a/Sources/TokamakDOM/Views/Text/SecureField.swift +++ b/Sources/TokamakDOM/Views/Text/SecureField.swift @@ -20,7 +20,7 @@ import TokamakCore extension SecureField: ViewDeferredToRenderer where Label == Text { public var deferredBody: AnyView { let proxy = _SecureFieldProxy(self) - return AnyView(HTML("input", [ + return AnyView(DynamicHTML("input", [ "type": "password", "value": proxy.textBinding.wrappedValue, "placeholder": proxy.label.rawText, diff --git a/Sources/TokamakDOM/Views/Text/TextField.swift b/Sources/TokamakDOM/Views/Text/TextField.swift index d0c4dbcb4..71d820fcc 100644 --- a/Sources/TokamakDOM/Views/Text/TextField.swift +++ b/Sources/TokamakDOM/Views/Text/TextField.swift @@ -32,7 +32,7 @@ extension TextField: ViewDeferredToRenderer where Label == Text { public var deferredBody: AnyView { let proxy = _TextFieldProxy(self) - return AnyView(HTML("input", [ + return AnyView(DynamicHTML("input", [ "type": proxy.textFieldStyle is RoundedBorderTextFieldStyle ? "search" : "text", "value": proxy.textBinding.wrappedValue, "placeholder": proxy.label.rawText, diff --git a/Sources/TokamakDemo/AppStorageDemo.swift b/Sources/TokamakDemo/AppStorageDemo.swift index cb37111c4..fca69cc8b 100644 --- a/Sources/TokamakDemo/AppStorageDemo.swift +++ b/Sources/TokamakDemo/AppStorageDemo.swift @@ -15,11 +15,7 @@ // Created by Carson Katri on 7/17/20. // -#if canImport(SwiftUI) -import SwiftUI -#else -import TokamakDOM -#endif +import TokamakShim @available(OSX 11.0, iOS 14.0, *) struct AppStorageButtons: View { diff --git a/Sources/TokamakDemo/ToggleDemo.swift b/Sources/TokamakDemo/ToggleDemo.swift index 92121456d..e1201a011 100644 --- a/Sources/TokamakDemo/ToggleDemo.swift +++ b/Sources/TokamakDemo/ToggleDemo.swift @@ -12,12 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#if canImport(SwiftUI) -import SwiftUI -#else -import TokamakCore -import TokamakDOM -#endif +import TokamakShim public struct ToggleDemo: View { @State var checked = false diff --git a/Sources/TokamakStaticDemo/ContentView.swift b/Sources/TokamakStaticDemo/ContentView.swift new file mode 100644 index 000000000..597e68032 --- /dev/null +++ b/Sources/TokamakStaticDemo/ContentView.swift @@ -0,0 +1,24 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/31/20. +// + +import TokamakStaticHTML + +struct ContentView: View { + var body: some View { + Text("Hello, world!") + } +} diff --git a/Sources/TokamakStaticDemo/main.swift b/Sources/TokamakStaticDemo/main.swift new file mode 100644 index 000000000..08577a805 --- /dev/null +++ b/Sources/TokamakStaticDemo/main.swift @@ -0,0 +1,28 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/20/20. +// + +import TokamakStaticHTML + +struct TestApp: App { + var body: some Scene { + WindowGroup("TokamakStaticHTML Demo") { + ContentView() + } + } +} + +print(StaticHTMLRenderer(TestApp()).html) diff --git a/Sources/TokamakStaticHTML/App.swift b/Sources/TokamakStaticHTML/App.swift new file mode 100644 index 000000000..073588b1a --- /dev/null +++ b/Sources/TokamakStaticHTML/App.swift @@ -0,0 +1,33 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/31/20. +// + +import CombineShim +import TokamakCore + +extension App { + public static func _launch(_ app: Self, _ rootEnvironment: EnvironmentValues) { + fatalError("TokamakStaticHTML does not support default `App._launch`") + } + + public static func _setTitle(_ title: String) { + StaticHTMLRenderer.title = title + } + + public var _phasePublisher: CurrentValueSubject { + CurrentValueSubject(.active) + } +} diff --git a/Sources/TokamakStaticHTML/Core.swift b/Sources/TokamakStaticHTML/Core.swift new file mode 100644 index 000000000..35963d91f --- /dev/null +++ b/Sources/TokamakStaticHTML/Core.swift @@ -0,0 +1,96 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/20/20. +// + +import TokamakCore + +// MARK: Environment & State + +public typealias Environment = TokamakCore.Environment + +// MARK: Modifiers & Styles + +public typealias ViewModifier = TokamakCore.ViewModifier +public typealias ModifiedContent = TokamakCore.ModifiedContent + +public typealias DefaultListStyle = TokamakCore.DefaultListStyle +public typealias PlainListStyle = TokamakCore.PlainListStyle +public typealias InsetListStyle = TokamakCore.InsetListStyle +public typealias GroupedListStyle = TokamakCore.GroupedListStyle +public typealias InsetGroupedListStyle = TokamakCore.InsetGroupedListStyle + +// MARK: Shapes + +public typealias Shape = TokamakCore.Shape + +public typealias Capsule = TokamakCore.Capsule +public typealias Circle = TokamakCore.Circle +public typealias Ellipse = TokamakCore.Ellipse +public typealias Path = TokamakCore.Path +public typealias Rectangle = TokamakCore.Rectangle +public typealias RoundedRectangle = TokamakCore.RoundedRectangle + +// MARK: Primitive values + +public typealias Color = TokamakCore.Color +public typealias Font = TokamakCore.Font + +public typealias CGAffineTransform = TokamakCore.CGAffineTransform +public typealias CGPoint = TokamakCore.CGPoint +public typealias CGRect = TokamakCore.CGRect +public typealias CGSize = TokamakCore.CGSize + +// MARK: Views + +public typealias Divider = TokamakCore.Divider +public typealias ForEach = TokamakCore.ForEach +public typealias GridItem = TokamakCore.GridItem +public typealias Group = TokamakCore.Group +public typealias HStack = TokamakCore.HStack +public typealias LazyHGrid = TokamakCore.LazyHGrid +public typealias LazyVGrid = TokamakCore.LazyVGrid +public typealias List = TokamakCore.List +public typealias ScrollView = TokamakCore.ScrollView +public typealias Section = TokamakCore.Section +public typealias Spacer = TokamakCore.Spacer +public typealias Text = TokamakCore.Text +public typealias VStack = TokamakCore.VStack +public typealias ZStack = TokamakCore.ZStack + +// MARK: Special Views + +public typealias View = TokamakCore.View +public typealias AnyView = TokamakCore.AnyView +public typealias EmptyView = TokamakCore.EmptyView + +// MARK: App & Scene + +public typealias App = TokamakCore.App +public typealias Scene = TokamakCore.Scene +public typealias WindowGroup = TokamakCore.WindowGroup +public typealias ScenePhase = TokamakCore.ScenePhase +public typealias AppStorage = TokamakCore.AppStorage +public typealias SceneStorage = TokamakCore.SceneStorage + +// MARK: Misc + +// FIXME: I would put this inside TokamakCore, but for +// some reason it doesn't get exported with the typealias +extension Text { + public static func + (lhs: Self, rhs: Self) -> Self { + _concatenating(lhs: lhs, rhs: rhs) + } +} diff --git a/Sources/TokamakDOM/Modifiers/Effects/RotationEffect.swift b/Sources/TokamakStaticHTML/Modifiers/Effects/RotationEffect.swift similarity index 100% rename from Sources/TokamakDOM/Modifiers/Effects/RotationEffect.swift rename to Sources/TokamakStaticHTML/Modifiers/Effects/RotationEffect.swift diff --git a/Sources/TokamakDOM/Modifiers/LayoutModifiers.swift b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift similarity index 100% rename from Sources/TokamakDOM/Modifiers/LayoutModifiers.swift rename to Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift diff --git a/Sources/TokamakDOM/Modifiers/ViewModifier.swift b/Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift similarity index 100% rename from Sources/TokamakDOM/Modifiers/ViewModifier.swift rename to Sources/TokamakStaticHTML/Modifiers/ViewModifier.swift diff --git a/Sources/TokamakDOM/Modifiers/_ViewModifier_Content.swift b/Sources/TokamakStaticHTML/Modifiers/_ViewModifier_Content.swift similarity index 100% rename from Sources/TokamakDOM/Modifiers/_ViewModifier_Content.swift rename to Sources/TokamakStaticHTML/Modifiers/_ViewModifier_Content.swift diff --git a/Sources/TokamakDOM/Resources/TokamakStyles.swift b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift similarity index 96% rename from Sources/TokamakDOM/Resources/TokamakStyles.swift rename to Sources/TokamakStaticHTML/Resources/TokamakStyles.swift index f4f6e2a4f..afc42b15e 100644 --- a/Sources/TokamakDOM/Resources/TokamakStyles.swift +++ b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -let tokamakStyles = """ +public let tokamakStyles = """ ._tokamak-stack > * { flex-shrink: 0; } @@ -65,7 +65,7 @@ let tokamakStyles = """ } """ -let rootNodeStyles = """ +public let rootNodeStyles = """ display: flex; width: 100%; height: 100%; diff --git a/Sources/TokamakDOM/Scenes/WindowGroup.swift b/Sources/TokamakStaticHTML/Scenes/WindowGroup.swift similarity index 100% rename from Sources/TokamakDOM/Scenes/WindowGroup.swift rename to Sources/TokamakStaticHTML/Scenes/WindowGroup.swift diff --git a/Sources/TokamakDOM/Shapes/Path.swift b/Sources/TokamakStaticHTML/Shapes/Path.swift similarity index 100% rename from Sources/TokamakDOM/Shapes/Path.swift rename to Sources/TokamakStaticHTML/Shapes/Path.swift diff --git a/Sources/TokamakDOM/Shapes/Shape.swift b/Sources/TokamakStaticHTML/Shapes/Shape.swift similarity index 100% rename from Sources/TokamakDOM/Shapes/Shape.swift rename to Sources/TokamakStaticHTML/Shapes/Shape.swift diff --git a/Sources/TokamakDOM/Shapes/_ShapeView.swift b/Sources/TokamakStaticHTML/Shapes/_ShapeView.swift similarity index 100% rename from Sources/TokamakDOM/Shapes/_ShapeView.swift rename to Sources/TokamakStaticHTML/Shapes/_ShapeView.swift diff --git a/Sources/TokamakStaticHTML/StaticRenderer.swift b/Sources/TokamakStaticHTML/StaticRenderer.swift new file mode 100644 index 000000000..4dfea447b --- /dev/null +++ b/Sources/TokamakStaticHTML/StaticRenderer.swift @@ -0,0 +1,135 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 7/20/20. +// + +import TokamakCore + +public final class HTMLTarget: Target { + var html: AnyHTML + var children: [HTMLTarget] = [] + + public var view: AnyView + + init(_ view: V, _ html: AnyHTML) { + self.html = html + self.view = AnyView(view) + } + + init(_ html: AnyHTML) { + self.html = html + view = AnyView(EmptyView()) + } +} + +extension HTMLTarget { + var outerHTML: String { + """ + <\(html.tag)\(html.attributes.isEmpty ? "" : " ")\ + \(html.attributes.map { #"\#($0)="\#($1)""# }.joined(separator: " "))>\ + \(html.innerHTML ?? "")\ + \(children.map(\.outerHTML).joined(separator: "\n"))\ + + """ + } +} + +struct HTMLBody: AnyHTML { + let tag: String = "body" + let innerHTML: String? = nil + let attributes: [String: String] = [ + "style": "margin: 0;" + rootNodeStyles, + ] +} + +public final class StaticHTMLRenderer: Renderer { + public private(set) var reconciler: StackReconciler? + + var rootTarget: HTMLTarget + + static var title: String = "" + public var html: String { + """ + + + \(Self.title) + + + \(rootTarget.outerHTML) + + """ + } + + public init(_ view: V, _ rootEnvironment: EnvironmentValues? = nil) { + rootTarget = HTMLTarget(view, HTMLBody()) + + reconciler = StackReconciler( + view: view, + target: rootTarget, + environment: EnvironmentValues(), + renderer: self, + scheduler: { _ in + fatalError("Stateful apps cannot be created with TokamakStaticHTML") + } + ) + } + + public init(_ app: A, _ rootEnvironment: EnvironmentValues? = nil) { + rootTarget = HTMLTarget(HTMLBody()) + + reconciler = StackReconciler( + app: app, + target: rootTarget, + environment: EnvironmentValues(), + renderer: self, + scheduler: { _ in + fatalError("Stateful apps cannot be created with TokamakStaticHTML") + } + ) + } + + public func mountTarget(to parent: HTMLTarget, with host: MountedHost) -> HTMLTarget? { + guard let html = mapAnyView( + host.view, + transform: { (html: AnyHTML) in html } + ) else { + // handle cases like `TupleView` + if mapAnyView(host.view, transform: { (view: ParentView) in view }) != nil { + return parent + } + + return nil + } + + let node = HTMLTarget(host.view, html) + parent.children.append(node) + return node + } + + public func update(target: HTMLTarget, with host: MountedHost) { + fatalError("Stateful apps cannot be created with TokamakStaticHTML") + } + + public func unmount( + target: HTMLTarget, + from parent: HTMLTarget, + with host: MountedHost, + completion: @escaping () -> () + ) { + fatalError("Stateful apps cannot be created with TokamakStaticHTML") + } +} diff --git a/Sources/TokamakDOM/Tokens/Tokens.swift b/Sources/TokamakStaticHTML/Tokens/Tokens.swift similarity index 100% rename from Sources/TokamakDOM/Tokens/Tokens.swift rename to Sources/TokamakStaticHTML/Tokens/Tokens.swift diff --git a/Sources/TokamakDOM/Views/Containers/List.swift b/Sources/TokamakStaticHTML/Views/Containers/List.swift similarity index 100% rename from Sources/TokamakDOM/Views/Containers/List.swift rename to Sources/TokamakStaticHTML/Views/Containers/List.swift diff --git a/Sources/TokamakDOM/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift similarity index 59% rename from Sources/TokamakDOM/Views/HTML.swift rename to Sources/TokamakStaticHTML/Views/HTML.swift index e787f7703..0cf013ac9 100644 --- a/Sources/TokamakDOM/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -15,16 +15,12 @@ // Created by Max Desiatov on 11/04/2020. // -import JavaScriptKit import TokamakCore -public typealias Listener = (JSObjectRef) -> () - -protocol AnyHTML { +public protocol AnyHTML { var innerHTML: String? { get } var tag: String { get } var attributes: [String: String] { get } - var listeners: [String: Listener] { get } } extension AnyHTML { @@ -36,44 +32,24 @@ extension AnyHTML { """ } - - func update(dom: DOMNode) { - // FIXME: is there a sensible way to diff attributes and listeners to avoid - // crossing the JavaScript bridge and touching DOM if not needed? - - // @carson-katri: For diffing, could you build a Set from the keys and values of the dictionary, - // then use the standard lib to get the difference? - - for (attribute, value) in attributes { - _ = dom.ref[dynamicMember: attribute] = .string(value) - } - - dom.reinstall(listeners) - - guard let innerHTML = innerHTML else { return } - dom.ref.innerHTML = .string(innerHTML) - } } public struct HTML: View, AnyHTML where Content: View { - let tag: String - let attributes: [String: String] - let listeners: [String: Listener] + public let tag: String + public let attributes: [String: String] let content: Content public init( _ tag: String, _ attributes: [String: String] = [:], - listeners: [String: Listener] = [:], @ViewBuilder content: () -> Content ) { self.tag = tag self.attributes = attributes - self.listeners = listeners self.content = content() } - var innerHTML: String? { nil } + public var innerHTML: String? { nil } public var body: Never { neverBody("HTML") @@ -83,10 +59,9 @@ public struct HTML: View, AnyHTML where Content: View { extension HTML where Content == EmptyView { public init( _ tag: String, - _ attributes: [String: String] = [:], - listeners: [String: Listener] = [:] + _ attributes: [String: String] = [:] ) { - self = HTML(tag, attributes, listeners: listeners) { EmptyView() } + self = HTML(tag, attributes) { EmptyView() } } } @@ -96,12 +71,12 @@ extension HTML: ParentView { } } -protocol StylesConvertible { +public protocol StylesConvertible { var styles: [String: String] { get } } extension Dictionary { - var inlineStyles: String { + public var inlineStyles: String { map { "\($0.0): \($0.1);" } .joined(separator: " ") } diff --git a/Sources/TokamakDOM/Views/Layout/HStack.swift b/Sources/TokamakStaticHTML/Views/Layout/HStack.swift similarity index 95% rename from Sources/TokamakDOM/Views/Layout/HStack.swift rename to Sources/TokamakStaticHTML/Views/Layout/HStack.swift index 0139fb530..b302b68e9 100644 --- a/Sources/TokamakDOM/Views/Layout/HStack.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/HStack.swift @@ -28,7 +28,7 @@ extension VerticalAlignment { } extension HStack: ViewDeferredToRenderer, SpacerContainer { - var axis: SpacerContainerAxis { .horizontal } + public var axis: SpacerContainerAxis { .horizontal } public var deferredBody: AnyView { AnyView(HTML("div", [ diff --git a/Sources/TokamakDOM/Views/Layout/LazyHGrid.swift b/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift similarity index 91% rename from Sources/TokamakDOM/Views/Layout/LazyHGrid.swift rename to Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift index f7f9177e8..c9548a44b 100644 --- a/Sources/TokamakDOM/Views/Layout/LazyHGrid.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift @@ -18,9 +18,9 @@ import TokamakCore extension LazyHGrid: SpacerContainer { - var axis: SpacerContainerAxis { .horizontal } - var hasSpacer: Bool { false } - var fillCrossAxis: Bool { + public var axis: SpacerContainerAxis { .horizontal } + public var hasSpacer: Bool { false } + public var fillCrossAxis: Bool { _LazyHGridProxy(self).rows.contains { if case .adaptive(minimum: _, maximum: _) = $0.size { return true @@ -32,7 +32,7 @@ extension LazyHGrid: SpacerContainer { } extension LazyHGrid: ViewDeferredToRenderer { - var lastRow: GridItem? { + public var lastRow: GridItem? { _LazyHGridProxy(self).rows.last } diff --git a/Sources/TokamakDOM/Views/Layout/LazyVGrid.swift b/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift similarity index 91% rename from Sources/TokamakDOM/Views/Layout/LazyVGrid.swift rename to Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift index a18279e13..f9e94e342 100644 --- a/Sources/TokamakDOM/Views/Layout/LazyVGrid.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift @@ -18,9 +18,9 @@ import TokamakCore extension LazyVGrid: SpacerContainer { - var axis: SpacerContainerAxis { .vertical } - var hasSpacer: Bool { false } - var fillCrossAxis: Bool { + public var axis: SpacerContainerAxis { .vertical } + public var hasSpacer: Bool { false } + public var fillCrossAxis: Bool { _LazyVGridProxy(self).columns.contains { if case .adaptive(minimum: _, maximum: _) = $0.size { return true @@ -32,7 +32,7 @@ extension LazyVGrid: SpacerContainer { } extension LazyVGrid: ViewDeferredToRenderer { - var lastColumn: GridItem? { + public var lastColumn: GridItem? { _LazyVGridProxy(self).columns.last } diff --git a/Sources/TokamakDOM/Views/Layout/ScrollView.swift b/Sources/TokamakStaticHTML/Views/Layout/ScrollView.swift similarity index 97% rename from Sources/TokamakDOM/Views/Layout/ScrollView.swift rename to Sources/TokamakStaticHTML/Views/Layout/ScrollView.swift index b9d873e77..ae8e884f9 100644 --- a/Sources/TokamakDOM/Views/Layout/ScrollView.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/ScrollView.swift @@ -18,7 +18,7 @@ import TokamakCore extension ScrollView: ViewDeferredToRenderer, SpacerContainer { - var axis: SpacerContainerAxis { + public var axis: SpacerContainerAxis { if axes.contains(.horizontal) { return .horizontal } else { diff --git a/Sources/TokamakDOM/Views/Layout/VStack.swift b/Sources/TokamakStaticHTML/Views/Layout/VStack.swift similarity index 95% rename from Sources/TokamakDOM/Views/Layout/VStack.swift rename to Sources/TokamakStaticHTML/Views/Layout/VStack.swift index 36371d7bb..0483f6ff8 100644 --- a/Sources/TokamakDOM/Views/Layout/VStack.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/VStack.swift @@ -28,7 +28,7 @@ extension HorizontalAlignment { } extension VStack: ViewDeferredToRenderer, SpacerContainer { - var axis: SpacerContainerAxis { .vertical } + public var axis: SpacerContainerAxis { .vertical } public var deferredBody: AnyView { AnyView(HTML("div", [ diff --git a/Sources/TokamakDOM/Views/Layout/ZStack.swift b/Sources/TokamakStaticHTML/Views/Layout/ZStack.swift similarity index 100% rename from Sources/TokamakDOM/Views/Layout/ZStack.swift rename to Sources/TokamakStaticHTML/Views/Layout/ZStack.swift diff --git a/Sources/TokamakDOM/Views/NavigationView.swift b/Sources/TokamakStaticHTML/Views/NavigationView.swift similarity index 97% rename from Sources/TokamakDOM/Views/NavigationView.swift rename to Sources/TokamakStaticHTML/Views/NavigationView.swift index 297548cfd..e3cc63f96 100644 --- a/Sources/TokamakDOM/Views/NavigationView.swift +++ b/Sources/TokamakStaticHTML/Views/NavigationView.swift @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import JavaScriptKit import TokamakCore extension NavigationView: ViewDeferredToRenderer { diff --git a/Sources/TokamakDOM/Views/Spacers/Divider.swift b/Sources/TokamakStaticHTML/Views/Spacers/Divider.swift similarity index 85% rename from Sources/TokamakDOM/Views/Spacers/Divider.swift rename to Sources/TokamakStaticHTML/Views/Spacers/Divider.swift index 8dc959b7a..57358bdda 100644 --- a/Sources/TokamakDOM/Views/Spacers/Divider.swift +++ b/Sources/TokamakStaticHTML/Views/Spacers/Divider.swift @@ -15,9 +15,9 @@ import TokamakCore extension Divider: AnyHTML { - var innerHTML: String? { nil } - var tag: String { "hr" } - var attributes: [String: String] { + public var innerHTML: String? { nil } + public var tag: String { "hr" } + public var attributes: [String: String] { [ "style": """ width: 100%; height: 0; margin: 0; @@ -28,6 +28,4 @@ extension Divider: AnyHTML { """, ] } - - var listeners: [String: Listener] { [:] } } diff --git a/Sources/TokamakDOM/Views/Spacers/Spacer.swift b/Sources/TokamakStaticHTML/Views/Spacers/Spacer.swift similarity index 92% rename from Sources/TokamakDOM/Views/Spacers/Spacer.swift rename to Sources/TokamakStaticHTML/Views/Spacers/Spacer.swift index 0ba11a88a..7fef197a5 100644 --- a/Sources/TokamakDOM/Views/Spacers/Spacer.swift +++ b/Sources/TokamakStaticHTML/Views/Spacers/Spacer.swift @@ -14,18 +14,18 @@ import TokamakCore -enum SpacerContainerAxis { +public enum SpacerContainerAxis { case horizontal, vertical } -protocol SpacerContainer { +public protocol SpacerContainer { var hasSpacer: Bool { get } var axis: SpacerContainerAxis { get } var fillCrossAxis: Bool { get } } extension SpacerContainer where Self: ParentView { - var hasSpacer: Bool { + public var hasSpacer: Bool { children .compactMap { mapAnyView($0) { (v: Spacer) in @@ -45,7 +45,7 @@ extension SpacerContainer where Self: ParentView { // Does a child SpacerContainer along the opposite axis have a spacer? // (e.g., an HStack with a child VStack which contains a spacer) // If so, we need to fill the cross-axis so the child can show the correct layout. - var fillCrossAxis: Bool { + public var fillCrossAxis: Bool { children .compactMap { mapAnyView($0) { (v: SpacerContainer) in v } diff --git a/Sources/TokamakDOM/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift similarity index 96% rename from Sources/TokamakDOM/Views/Text/Text.swift rename to Sources/TokamakStaticHTML/Views/Text/Text.swift index 5eba49244..cc7d7f140 100644 --- a/Sources/TokamakDOM/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import JavaScriptKit import TokamakCore extension Font.Design: CustomStringConvertible { @@ -78,7 +77,7 @@ extension Font.Leading: CustomStringConvertible { } extension Font: StylesConvertible { - var styles: [String: String] { + public var styles: [String: String] { [ "font-family": _name == _FontNames.system.rawValue ? _design.description : _name, "font-weight": "\(_bold ? Font.Weight.bold.value : _weight.value)", @@ -102,8 +101,8 @@ extension Text: AnyHTML { } } - var tag: String { "span" } - var attributes: [String: String] { + public var tag: String { "span" } + public var attributes: [String: String] { var font: Font? var color: Color? var italic: Bool = false @@ -159,6 +158,4 @@ extension Text: AnyHTML { """, ] } - - var listeners: [String: Listener] { [:] } } diff --git a/docs/Building a Renderer/1 Renderers in Tokamak.md b/docs/Building a Renderer/1 Renderers in Tokamak.md new file mode 100644 index 000000000..c2f0af4bf --- /dev/null +++ b/docs/Building a Renderer/1 Renderers in Tokamak.md @@ -0,0 +1,19 @@ +# `Renderers` in Tokamak + +Tokamak is a flexible library. `TokamakCore` provides the SwiftUI-API, which your `Renderer` can use +to construct a representation of `Views` that your platform understands. + +To explain the creation of `Renderers`, we’ll be creating a simple one: `TokamakStaticHTML` (which +you can find in the `Tokamak` repository). + +Before we create the `Renderer`, we need to understand the requirements of our platform: + +1. Stateful apps cannot be created This simplifies the scope of our project, as we only have to + render once. However, if you are building a `Renderer` that supports state changes, the process + is largely the same. `TokamakCore`’s `StackReconciler` will let your `Renderer` know when a + `View` has to be redrawn. +2. HTML should be rendered `TokamakDOM` provides HTML representations of many `Views`, so we can + utilize it. However, we will cover how to provide custom `View` bodies your `Renderer` can + understand, and when you are required to do so. + +And that’s it! In the next part we’ll go more in depth on `Renderers`. diff --git a/docs/Building a Renderer/2 Understanding Renderers.md b/docs/Building a Renderer/2 Understanding Renderers.md new file mode 100644 index 000000000..23af81be6 --- /dev/null +++ b/docs/Building a Renderer/2 Understanding Renderers.md @@ -0,0 +1,17 @@ +# Understanding `Renderers` + +So, what goes into a `Renderer`? + +1. A `Target` - Targets are the destination for rendered `Views`. For instance, on iOS this is + `UIView`, on macOS an `NSView`, and on the web we render to DOM nodes. +2. A `StackReconciler` - The reconciler does all the heavy lifting to understand the view tree. It + notifies your `Renderer` of what views need to be mounted/unmounted. +3. `func mountTarget`- This function is called when a new target instance should be created and + added to the parent (either as a subview or some other way, e.g. installed if it’s a layout + constraint). +4. `func update` - This function is called when an existing target instance should be updated (e.g. + when `State` changes). +5. `func unmount` - This function is called when an existing target instance should be unmounted: + removed from the parent and most likely destroyed. + +That’s it! Let’s get our project setup. diff --git a/docs/Building a Renderer/3 TokamakStatic Setup.md b/docs/Building a Renderer/3 TokamakStatic Setup.md new file mode 100644 index 000000000..09aaab331 --- /dev/null +++ b/docs/Building a Renderer/3 TokamakStatic Setup.md @@ -0,0 +1,80 @@ +# `TokamakStaticHTML` Setup + +Every `Renderer` can choose what `Views`, `ViewModifiers`, property wrappers, etc. are available to +use. A `Core.swift` file is used to reexport these symbols. For `TokamakStaticHTML`, we’ll use the +following `Core.swift` file: + +```swift +import TokamakCore + +// MARK: Environment & State + +public typealias Environment = TokamakCore.Environment + +// MARK: Modifiers & Styles + +public typealias ViewModifier = TokamakCore.ViewModifier +public typealias ModifiedContent = TokamakCore.ModifiedContent + +public typealias DefaultListStyle = TokamakCore.DefaultListStyle +public typealias PlainListStyle = TokamakCore.PlainListStyle +public typealias InsetListStyle = TokamakCore.InsetListStyle +public typealias GroupedListStyle = TokamakCore.GroupedListStyle +public typealias InsetGroupedListStyle = TokamakCore.InsetGroupedListStyle + +// MARK: Shapes + +public typealias Shape = TokamakCore.Shape + +public typealias Capsule = TokamakCore.Capsule +public typealias Circle = TokamakCore.Circle +public typealias Ellipse = TokamakCore.Ellipse +public typealias Path = TokamakCore.Path +public typealias Rectangle = TokamakCore.Rectangle +public typealias RoundedRectangle = TokamakCore.RoundedRectangle + +// MARK: Primitive values + +public typealias Color = TokamakCore.Color +public typealias Font = TokamakCore.Font + +public typealias CGAffineTransform = TokamakCore.CGAffineTransform +public typealias CGPoint = TokamakCore.CGPoint +public typealias CGRect = TokamakCore.CGRect +public typealias CGSize = TokamakCore.CGSize + +// MARK: Views + +public typealias Divider = TokamakCore.Divider +public typealias ForEach = TokamakCore.ForEach +public typealias GridItem = TokamakCore.GridItem +public typealias Group = TokamakCore.Group +public typealias HStack = TokamakCore.HStack +public typealias LazyHGrid = TokamakCore.LazyHGrid +public typealias LazyVGrid = TokamakCore.LazyVGrid +public typealias List = TokamakCore.List +public typealias ScrollView = TokamakCore.ScrollView +public typealias Section = TokamakCore.Section +public typealias Spacer = TokamakCore.Spacer +public typealias Text = TokamakCore.Text +public typealias VStack = TokamakCore.VStack +public typealias ZStack = TokamakCore.ZStack + +// MARK: Special Views + +public typealias View = TokamakCore.View +public typealias AnyView = TokamakCore.AnyView +public typealias EmptyView = TokamakCore.EmptyView + +// MARK: Misc + +// Note: This extension is required to support concatenation of `Text`. +extension Text { + public static func + (lhs: Self, rhs: Self) -> Self { + _concatenating(lhs: lhs, rhs: rhs) + } +} + +``` + +We’ve omitted any stateful `Views`, as well as property wrappers used to modify state. diff --git a/docs/Building a Renderer/4 Building the Target.md b/docs/Building a Renderer/4 Building the Target.md new file mode 100644 index 000000000..c5a0be1f8 --- /dev/null +++ b/docs/Building a Renderer/4 Building the Target.md @@ -0,0 +1,45 @@ +# Building the `Target` + +If you recall, we defined a `Target` as: + +> the destination for rendered `Views` + +In `TokamakStaticHTML`, this would be a tag in an `HTML` file. A tag has several properties, +although we don’t need to worry about all of them. For now, we can consider a tag to have: + +- The HTML for the tag itself (outer HTML) +- Child tags (inner HTML) + +We can describe our target simply: + +```swift +public final class HTMLTarget: Target { + var html: AnyHTML + var children: [HTMLTarget] = [] + + init(_ view: V, + _ html: AnyHTML) { + self.html = html + super.init(view) + } +} +``` + +`AnyHTML` is from `TokamakDOM`, which you can declare as a dependency. The target stores the `View` +it hosts, the `HTML` that represents it, and its child elements. + +Lastly, we can also provide an HTML string representation of the target: + +```swift +extension HTMLTarget { + var outerHTML: String { + """ + <\(html.tag)\(html.attributes.isEmpty ? "" : " ")\ + \(html.attributes.map { #"\#($0)="\#($1)""# }.joined(separator: " "))>\ + \(html.innerHTML ?? "")\ + \(children.map(\.outerHTML).joined(separator: "\n"))\ + + """ + } +} +``` diff --git a/docs/Building a Renderer/5 Building the Renderer.md b/docs/Building a Renderer/5 Building the Renderer.md new file mode 100644 index 000000000..aeb0e5fab --- /dev/null +++ b/docs/Building a Renderer/5 Building the Renderer.md @@ -0,0 +1,134 @@ +# Building the `Renderer` + +Now that we have a `Target`, we can start the `Renderer`: + +```swift +public final class StaticHTMLRenderer: Renderer { + public private(set) var reconciler: StackReconciler? + var rootTarget: HTMLTarget + + public var html: String { + """ + + \(rootTarget.outerHTML) + + """ + } +} +``` + +We start by declaring the `StackReconciler`. It will handle the app, while our `Renderer` can focus +on mounting and un-mounting `Views`. + +```swift +... +public init(_ view: V) { + rootTarget = HTMLTarget(view, HTMLBody()) + reconciler = StackReconciler( + view: view, + target: rootTarget, + renderer: self, + environment: EnvironmentValues() + ) { closure in + fatalError("Stateful apps cannot be created with TokamakStaticHTML") + } +} +``` + +Next we declare an initializer that takes a `View` and builds a reconciler. The reconciler takes the +`View`, our root `Target` (in this case, `HTMLBody`), the renderer (`self`), and any default +`EnvironmentValues` we may need to setup. The closure at the end is the scheduler. It tells the +reconciler when it can update. In this case, we won’t need to update, so we can crash. + +`HTMLBody` is declared like so: + +```swift +struct HTMLBody: AnyHTML { + let tag: String = "body" + let innerHTML: String? = nil + let attributes: [String : String] = [:] + let listeners: [String : Listener] = [:] +} +``` + +## Mounting + +Now that we have a reconciler, we need to be able to mount the `HTMLTargets` it asks for. + +```swift +public func mountTarget(to parent: HTMLTarget, with host: MountedHost) -> HTMLTarget? { + // 1. + guard let html = mapAnyView( + host.view, + transform: { (html: AnyHTML) in html } + ) else { + // 2. + if mapAnyView(host.view, transform: { (view: ParentView) in view }) != nil { + return parent + } + + return nil + } + + // 3. + let node = HTMLTarget(host.view, html) + parent.children.append(node) + return node +}} +``` + +1. We use the `mapAnyView` function to convert the `AnyView` passed in to `AnyHTML`, which can be + used with our `HTMLTarget`. +2. `ParentView` is a special type of `View` in Tokamak. It indicates that the view has no + representation itself, and is purely a container for children (e.g. `ForEach` or `Group`). +3. We create a new `HTMLTarget` for the view, assign it as a child of the parent, and return it. + +The other two functions required by the `Renderer` protocol can crash, as `TokamakStaticHTML` +doesn’t support state changes: + +```swift +public func update(target: HTMLTarget, with host: MountedHost) { + fatalError("Stateful apps cannot be created with TokamakStaticHTML") +} + +public func unmount( + target: HTMLTarget, + from parent: HTMLTarget, + with host: MountedHost, + completion: @escaping () -> () +) { + fatalError("Stateful apps cannot be created with TokamakStaticHTML") +} +``` + +If you are creating a `Renderer` that supports state changes, here’s a quick synopsis: + +- `func update` - Mutate the `target` to match the `host`. +- `func unmount` - Remove the `target` from the `parent`, and call `completion` once it has been + removed. + +Now that we can mount, let’s give it a try: + +```swift +struct ContentView : View { + var body: some View { + Text("Hello, world!") + } +} + +let renderer = StaticHTMLRenderer(ContentView()) +print(renderer.html) +``` + +This spits out: + +```html + + + Hello, world! + + +``` + +Congratulations 🎉 You successfully wrote a `Renderer`. We can’t wait to see what platforms you’ll +bring Tokamak to. diff --git a/docs/Building a Renderer/6 Providing platform-specific primitives.md b/docs/Building a Renderer/6 Providing platform-specific primitives.md new file mode 100644 index 000000000..165e21f26 --- /dev/null +++ b/docs/Building a Renderer/6 Providing platform-specific primitives.md @@ -0,0 +1,61 @@ +# Providing platform-specific primitives + +Primitive `Views`, such as `Text`, `Button`, `HStack`, etc. have a body type of `Never`. When the +`StackReconciler` goes to render these `Views`, it expects your `Renderer` to provide a body. + +This is done via the `ViewDeferredToRenderer` protocol. There we can provide a `View` that our +`Renderer` understands. For instance, `TokamakDOM` (and `TokamakStaticHTML` by extension) use the +`HTML` view. Let’s look at a simpler version of this view: + +```swift +protocol AnyHTML { + let tag: String + let attributes: [String:String] + let innerHTML: String +} + +struct HTML: View, AnyHTML { + let tag: String + let attributes: [String:String] + let innerHTML: String + var body: Never { + neverBody("HTML") + } +} +``` + +Here we define an `HTML` view to have a body type of `Never`, like other primitive `Views`. It also +conforms to `AnyHTML`, which allows our `Renderer` to access the attributes of the `HTML` without +worrying about the `associatedtypes` involved with `View`. + +## `ViewDeferredToRenderer` + +Now we can use `HTML` to override the body of the primitive `Views` provided by `TokamakCore`: + +```swift +extension Text: ViewDeferredToRenderer { + var deferredBody: AnyView { + AnyView(HTML("span", [:], _TextProxy(self).rawText)) + } +} +``` + +If you recall, our `Renderer` mapped the `AnyView` received from the reconciler to `AnyHTML`: + +```swift +// 1. +guard let html = mapAnyView( + host.view, + transform: { (html: AnyHTML) in html } +) else { ... } +``` + +Then we were able to access the properties of the HTML. + +## Proxies + +Proxies allow access to internal properties of views implemented by `TokamakCore`. For instance, to +access the storage of the `Text` view, we were required to use a `_TextProxy`. + +Proxies contain all of the properties of the primitive necessary to build your platform-specific +implementation.