Skip to content

Commit

Permalink
Support meta tags in StaticHTMLRenderer (#483)
Browse files Browse the repository at this point in the history
This PR adds the ability to control `<head>` tags using a new `HTMLTitle` view and `HTMLMeta` view. Taking inspiration from Next.js `<NextHead>`, you can use these views anywhere in your view hierarchy and they will be hoisted to the top `<head>` section of the html.

Use as a view:

```swift
var body: some View {
  VStack {
    ...
    HTMLTitle("Hello, Tokamak")
    HTMLMeta(charset: "utf-8")
    ...
  }
}
```

Use as a view modifier:

```swift
var body: some View {
  VStack {
    ...
  }
  .htmlTitle("Hello, Tokamak")
  .htmlMeta(charset: "utf-8")
}
```

And the resulting html (no matter where these are used in your view hierarchy):

```html
<html>
  <head>
    <title>Hello, Tokamak</title>
    <meta charset="utf-8">
  </head>
  <body>
    ...
  </body>
</html>
```
  • Loading branch information
AndrewBarba authored May 23, 2022
1 parent 8177fc8 commit 2ba5488
Show file tree
Hide file tree
Showing 19 changed files with 2,425 additions and 28 deletions.
3 changes: 0 additions & 3 deletions Sources/TokamakCore/MountedViews/MountedCompositeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ final class MountedCompositeView<R: Renderer>: MountedCompositeElement<R> {

if let preferenceModifier = self.view.view as? _PreferenceWritingViewProtocol {
self.view = preferenceModifier.modifyPreferenceStore(&self.preferenceStore)
if let parent = parent {
parent.preferenceStore.merge(with: self.preferenceStore)
}
}

if let preferenceReader = self.view.view as? _PreferenceReadingViewProtocol {
Expand Down
17 changes: 10 additions & 7 deletions Sources/TokamakCore/MountedViews/MountedElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,9 @@ public class MountedElement<R: Renderer> {

public internal(set) var environmentValues: EnvironmentValues

weak var parent: MountedElement<R>?
/// `didSet` on this field propagates the preference changes up the view tree.
var preferenceStore: _PreferenceStore = .init() {
didSet {
parent?.preferenceStore.merge(with: preferenceStore)
}
}
private(set) weak var parent: MountedElement<R>?

var preferenceStore: _PreferenceStore = .init()

public internal(set) var viewTraits: _ViewTraitStore

Expand All @@ -110,6 +106,7 @@ public class MountedElement<R: Renderer> {
self.environmentValues = environmentValues
viewTraits = .init()
updateEnvironment()
connectParentPreferenceStore()
}

init(_ scene: _AnyScene, _ environmentValues: EnvironmentValues, _ parent: MountedElement<R>?) {
Expand All @@ -118,6 +115,7 @@ public class MountedElement<R: Renderer> {
self.environmentValues = environmentValues
viewTraits = .init()
updateEnvironment()
connectParentPreferenceStore()
}

init(
Expand All @@ -131,6 +129,7 @@ public class MountedElement<R: Renderer> {
self.environmentValues = environmentValues
self.viewTraits = viewTraits
updateEnvironment()
connectParentPreferenceStore()
}

func updateEnvironment() {
Expand All @@ -145,6 +144,10 @@ public class MountedElement<R: Renderer> {
}
}

func connectParentPreferenceStore() {
preferenceStore.parent = parent?.preferenceStore
}

/// You must call `super.prepareForMount` before all other mounting work.
func prepareForMount(with transaction: Transaction) {
// `GroupView`'s don't really mount, so let their children transition if the group can.
Expand Down
19 changes: 5 additions & 14 deletions Sources/TokamakCore/Preferences/PreferenceKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ public extension _PreferenceValue {
}
}

public struct _PreferenceStore {
public final class _PreferenceStore {
/// The backing values of the `_PreferenceStore`.
private var values: [String: Any]

weak var parent: _PreferenceStore?

public init(values: [String: Any] = [:]) {
self.values = values
}
Expand All @@ -63,23 +65,12 @@ public struct _PreferenceStore {
?? _PreferenceValue(valueList: [Key.defaultValue])
}

public mutating func insert<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self)
public func insert<Key>(_ value: Key.Value, forKey key: Key.Type = Key.self)
where Key: PreferenceKey
{
let previousValues = self.value(forKey: key).valueList
values[String(reflecting: key)] = _PreferenceValue<Key>(valueList: previousValues + [value])
}

public mutating func merge(with other: Self) {
self = merging(with: other)
}

public func merging(with other: Self) -> Self {
var result = values
for (key, value) in other.values {
result[key] = value
}
return .init(values: result)
parent?.insert(value, forKey: key)
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/TokamakCore/StackReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public final class StackReconciler<R: Renderer> {
*/
public let rootTarget: R.TargetType

/** A root renderer's main preference store.
*/
public var preferenceStore: _PreferenceStore {
rootElement.preferenceStore
}

/** A root of the mounted elements tree to which all other mounted elements are attached to.
*/
private let rootElement: MountedElement<R>
Expand Down
2 changes: 1 addition & 1 deletion Sources/TokamakCore/Views/Containers/Group.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public struct Group<Content> {
}
}

extension Group: _PrimitiveView & View where Content: View {}
extension Group: _PrimitiveView, View where Content: View {}

extension Group: ParentView where Content: View {
@_spi(TokamakCore)
Expand Down
2 changes: 1 addition & 1 deletion Sources/TokamakStaticHTML/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public extension App {
}

static func _setTitle(_ title: String) {
StaticHTMLRenderer.title = title
// no-op: use Title view
}

var _phasePublisher: AnyPublisher<ScenePhase, Never> {
Expand Down
37 changes: 37 additions & 0 deletions Sources/TokamakStaticHTML/Preferences/Preferences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2022 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 Andrew Barba on 5/20/22.
//

import TokamakCore

public struct HTMLTitlePreferenceKey: PreferenceKey {
public static var defaultValue: String = ""

public static func reduce(value: inout String, nextValue: () -> String) {
value = nextValue()
}
}

public struct HTMLMetaPreferenceKey: PreferenceKey {
public static var defaultValue: [HTMLMeta.MetaTag] = []

public static func reduce(
value: inout [HTMLMeta.MetaTag],
nextValue: () -> [HTMLMeta.MetaTag]
) {
value += nextValue()
}
}
26 changes: 24 additions & 2 deletions Sources/TokamakStaticHTML/StaticHTMLRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,40 @@ struct HTMLBody: AnyHTML {
]
}

extension HTMLMeta.MetaTag {
func outerHTML() -> String {
switch self {
case let .charset(charset):
return #"<meta charset="\#(charset)">"#
case let .name(name, content):
return #"<meta name="\#(name)" content="\#(content)">"#
case let .property(property, content):
return #"<meta property="\#(property)" content="\#(content)">"#
case let .httpEquiv(httpEquiv, content):
return #"<meta http-equiv="\#(httpEquiv)" content="\#(content)">"#
}
}
}

public final class StaticHTMLRenderer: Renderer {
private var reconciler: StackReconciler<StaticHTMLRenderer>?

var rootTarget: HTMLTarget

static var title: String = ""
var title: String {
reconciler?.preferenceStore.value(forKey: HTMLTitlePreferenceKey.self).value ?? ""
}

var meta: [HTMLMeta.MetaTag] {
reconciler?.preferenceStore.value(forKey: HTMLMetaPreferenceKey.self).value ?? []
}

public func render(shouldSortAttributes: Bool = false) -> String {
"""
<html>
<head>
<title>\(Self.title)</title>
<title>\(title)</title>
\(meta.map { $0.outerHTML() }.joined(separator: "\n "))
<style>
\(tokamakStyles)
</style>
Expand Down
83 changes: 83 additions & 0 deletions Sources/TokamakStaticHTML/Views/Head/Meta.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2022 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 Andrew Barba on 5/20/22.
//

import TokamakCore

public struct HTMLMeta: View {
public enum MetaTag: Equatable, Hashable {
case charset(_ charset: String)
case name(_ name: String, content: String)
case property(_ property: String, content: String)
case httpEquiv(_ httpEquiv: String, content: String)
}

var meta: MetaTag

public init(_ value: MetaTag) {
meta = value
}

public init(charset: String) {
meta = .charset(charset)
}

public init(name: String, content: String) {
meta = .name(name, content: content)
}

public init(property: String, content: String) {
meta = .property(property, content: content)
}

public init(httpEquiv: String, content: String) {
meta = .httpEquiv(httpEquiv, content: content)
}

public var body: some View {
EmptyView()
.preference(key: HTMLMetaPreferenceKey.self, value: [meta])
}
}

public extension View {
func htmlMeta(_ value: HTMLMeta.MetaTag) -> some View {
htmlMeta(.init(value))
}

func htmlMeta(charset: String) -> some View {
htmlMeta(.init(charset: charset))
}

func htmlMeta(name: String, content: String) -> some View {
htmlMeta(.init(name: name, content: content))
}

func htmlMeta(property: String, content: String) -> some View {
htmlMeta(.init(property: property, content: content))
}

func htmlMeta(httpEquiv: String, content: String) -> some View {
htmlMeta(.init(httpEquiv: httpEquiv, content: content))
}

func htmlMeta(_ meta: HTMLMeta) -> some View {
Group {
self
meta
}
}
}
44 changes: 44 additions & 0 deletions Sources/TokamakStaticHTML/Views/Head/Title.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2022 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 Andrew Barba on 5/20/22.
//

import TokamakCore

public struct HTMLTitle: View {
var title: String

public init(_ title: String) {
self.title = title
}

public var body: some View {
EmptyView()
.preference(key: HTMLTitlePreferenceKey.self, value: title)
}
}

public extension View {
func htmlTitle(_ title: String) -> some View {
htmlTitle(.init(title))
}

func htmlTitle(_ title: HTMLTitle) -> some View {
Group {
self
title
}
}
}
Loading

0 comments on commit 2ba5488

Please sign in to comment.