Add Static HTML Renderer and Documentation (#204)

This commit is contained in:
Carson Katri 2020-08-01 16:27:12 -04:00 committed by GitHub
parent fbb893739b
commit 4c654da456
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 848 additions and 94 deletions

View File

@ -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"]

View File

@ -18,6 +18,7 @@
import CombineShim
import JavaScriptKit
import TokamakCore
import TokamakStaticHTML
private enum ScenePhaseObserver {
static var publisher = CurrentValueSubject<ScenePhase, Never>(.active)

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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 },

View File

@ -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) :

View File

@ -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<Content>: 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)]
}
}

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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 {

View File

@ -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

View File

@ -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!")
}
}

View File

@ -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)

View File

@ -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<ScenePhase, Never> {
CurrentValueSubject<ScenePhase, Never>(.active)
}
}

View File

@ -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)
}
}

View File

@ -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%;

View File

@ -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<V: View>(_ 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"))\
</\(html.tag)>
"""
}
}
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<StaticHTMLRenderer>?
var rootTarget: HTMLTarget
static var title: String = ""
public var html: String {
"""
<html>
<head>
<title>\(Self.title)</title>
<style>
\(tokamakStyles)
</style>
</head>
\(rootTarget.outerHTML)
</html>
"""
}
public init<V: View>(_ 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<A: App>(_ 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")
}
}

View File

@ -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 {
</\(tag)>
"""
}
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<Content>: 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<Content>: 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: " ")
}

View File

@ -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", [

View File

@ -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
}

View File

@ -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
}

View File

@ -18,7 +18,7 @@
import TokamakCore
extension ScrollView: ViewDeferredToRenderer, SpacerContainer {
var axis: SpacerContainerAxis {
public var axis: SpacerContainerAxis {
if axes.contains(.horizontal) {
return .horizontal
} else {

View File

@ -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", [

View File

@ -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 {

View File

@ -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] { [:] }
}

View File

@ -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 }

View File

@ -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] { [:] }
}

View File

@ -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`, well 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 thats it! In the next part well go more in depth on `Renderers`.

View File

@ -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 its 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.
Thats it! Lets get our project setup.

View File

@ -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`, well 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)
}
}
```
Weve omitted any stateful `Views`, as well as property wrappers used to modify state.

View File

@ -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 dont 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<V: View>(_ 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"))\
</\(html.tag)>
"""
}
}
```

View File

@ -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<StaticHTMLRenderer>?
var rootTarget: HTMLTarget
public var html: String {
"""
<html>
\(rootTarget.outerHTML)
</html>
"""
}
}
```
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<V: View>(_ 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 wont 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`
doesnt 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, heres 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, lets 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
<html>
<body>
<span style="...">Hello, world!</span>
</body>
</html>
```
Congratulations 🎉 You successfully wrote a `Renderer`. We cant wait to see what platforms youll
bring Tokamak to.

View File

@ -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. Lets 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.