Skip to content

Commit

Permalink
support async HTML content (#27)
Browse files Browse the repository at this point in the history
* added AsyncHTML type

* moved initializers to elements

* added async convenience initializers

* renamed AsyncHTML to AsyncContent

* added async to readme
  • Loading branch information
sliemeobn authored Sep 13, 2024
1 parent 2d5b57f commit 80c63fc
Show file tree
Hide file tree
Showing 10 changed files with 207 additions and 52 deletions.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,26 @@ div {

As a sensible default, _class_ and _style_ attributes are merged (with a blank space or semicolon respectively). All other attributes are overwritten by default.

## Fist class async support

Elementary supports Swift Concurrency in HTML content. Simply `await` something needed for rendering, while the first bytes are already flying towards the browser.

```swift
div {
let text = await getMyData()
p { "This totally works: \(text)" }
MyComponent()
}

struct MyComponent: HTML {
var content: some HTML {
AsyncContent {
"So does this: \(await getMoreData())"
}
}
}
```

### 🚧 Work in progress 🚧

The list of built-in attributes is rather short still, but adding them is really simple (and can be done in external packages as well).
Expand All @@ -202,5 +222,4 @@ My main motivation for Elementary was to create an experience like these ([Swift

## Future directions

- Experiment with an `AsyncHTML` type, that can include `await` in bodies, and a `ForEach` type that takes an async sequence
- Experiment with embedded swift for wasm and bolt a lean state tracking/reconciler for reactive DOM manipulation on top
34 changes: 34 additions & 0 deletions Sources/Elementary/Core/AsyncContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// An element that awaits its content before rendering.
///
/// The this element can only be rendered in an async context (ie: by calling ``HTML/render(into:chunkSize:)`` or ``HTML/renderAsync()``).
/// All HTML tag types (``HTMLElement``) support async content closures in their initializers, so you don't need to use this element directly in most cases.
public struct AsyncContent<Content: HTML>: HTML, Sendable {
var content: @Sendable () async throws -> Content
public typealias Tag = Content.Tag

/// Creates a new async HTML element with the specified content.
///
/// - Parameters:
/// - content: The future content of the element.
///
/// ```swift
/// AsyncContent {
/// let value = await fetchValue()
/// "Waiting for "
/// span { value }
/// }
/// ```
public init(@HTMLBuilder content: @escaping @Sendable () async throws -> Content) {
self.content = content
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
context.assertionFailureNoAsyncContext(self)
}

@_spi(Rendering)
public static func _render<Renderer: _AsyncHTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) async throws {
try await Content._render(await html.content(), into: &renderer, with: context)
}
}
2 changes: 1 addition & 1 deletion Sources/Elementary/Core/ForEach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ public struct ForEach<Data, Content>: HTML
}
}

extension ForEach: Sendable where Data: Sendable, Content: Sendable {}
extension ForEach: Sendable where Data: Sendable {}
49 changes: 0 additions & 49 deletions Sources/Elementary/Core/Html+Attributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,55 +61,6 @@ public struct _AttributedElement<Content: HTML>: HTML {

extension _AttributedElement: Sendable where Content: Sendable {}

public extension HTMLElement {
/// Creates a new HTML element with the specified attribute and content.
/// - Parameters:
/// - attribute: The attribute to apply to the element.
/// - content: The content of the element.
init(_ attribute: HTMLAttribute<Tag>, @HTMLBuilder content: () -> Content) {
attributes = .init(attribute)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The content of the element.
init(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element as an array.
/// - content: The content of the element.
init(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}
}

public extension HTMLVoidElement {
/// Creates a new HTML void element with the specified attribute.
/// - Parameter attribute: The attribute to apply to the element.
init(_ attribute: HTMLAttribute<Tag>) {
attributes = .init(attribute)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element.
init(_ attributes: HTMLAttribute<Tag>...) {
self.attributes = .init(attributes)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element as an array.
init(attributes: [HTMLAttribute<Tag>]) {
self.attributes = .init(attributes)
}
}

public extension HTML where Tag: HTMLTrait.Attributes.Global {
/// Adds the specified attribute to the element.
/// - Parameters:
Expand Down
45 changes: 45 additions & 0 deletions Sources/Elementary/Core/Html+Elements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ public struct HTMLElement<Tag: HTMLTagDefinition, Content: HTML>: HTML where Tag
self.content = content()
}

/// Creates a new HTML element with the specified attribute and content.
/// - Parameters:
/// - attribute: The attribute to apply to the element.
/// - content: The content of the element.
public init(_ attribute: HTMLAttribute<Tag>, @HTMLBuilder content: () -> Content) {
attributes = .init(attribute)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The content of the element.
public init(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element as an array.
/// - content: The content of the element.
public init(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
html.attributes.append(context.attributes)
Expand Down Expand Up @@ -42,6 +69,24 @@ public struct HTMLVoidElement<Tag: HTMLTagDefinition>: HTML where Tag: HTMLTrait
attributes = .init()
}

/// Creates a new HTML void element with the specified attribute.
/// - Parameter attribute: The attribute to apply to the element.
public init(_ attribute: HTMLAttribute<Tag>) {
attributes = .init(attribute)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element.
public init(_ attributes: HTMLAttribute<Tag>...) {
self.attributes = .init(attributes)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element as an array.
public init(attributes: [HTMLAttribute<Tag>]) {
self.attributes = .init(attributes)
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
html.attributes.append(context.attributes)
Expand Down
29 changes: 29 additions & 0 deletions Sources/Elementary/Core/HtmlElement+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
public extension HTMLElement {
/// Creates a new HTML element with the specified tag and async content.
///
/// The async content closure is automatically wrapped in an ``AsyncContent`` element and can only be rendered in an async context.
///
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The future content of the element.
init<AwaitedContent: HTML>(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent)
where Self.Content == AsyncContent<AwaitedContent>
{
self.attributes = .init(attributes)
self.content = AsyncContent(content: content)
}

/// Creates a new HTML element with the specified tag and async content.
///
/// The async content closure is automatically wrapped in an ``AsyncContent`` element and can only be rendered in an async context.
///
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The future content of the element.
init<AwaitedContent: HTML>(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent)
where Self.Content == AsyncContent<AwaitedContent>
{
self.attributes = .init(attributes)
self.content = AsyncContent(content: content)
}
}
2 changes: 1 addition & 1 deletion Sources/Elementary/HtmlDocument.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// A type that represents a full HTML document.
///
/// Provides a simple structure to model top-level HTML types.
/// A default ``content`` implementation takes your ``title``, ``head`` and ``body``
/// A default ``HTML/content`` implementation takes your ``title``, ``head`` and ``body``
/// properties and renders them into a full HTML document.
///
/// ```swift
Expand Down
7 changes: 7 additions & 0 deletions Sources/Elementary/Rendering/RenderingUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ extension _RenderingContext {
func assertNoAttributes(_ type: (some HTML).Type) {
assert(attributes.isEmpty, "Attributes are not supported on \(type)")
}

@inline(__always)
func assertionFailureNoAsyncContext(_ type: (some HTML).Type) {
let message = "Cannot render \(type) in a synchronous context, please use .render(into:) or .renderAsync() instead."
print("Elementary rendering error: \(message)")
assertionFailure(message)
}
}

// NOTE: weirdly, using string interpolation tokens is faster than appending to the buffer directly. keeping this here for future experiments
Expand Down
66 changes: 66 additions & 0 deletions Tests/ElementaryTests/AsyncRenderingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Elementary
import XCTest

final class AsyncRenderingTests: XCTestCase {
func testRendersAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
AsyncContent {
let text = await getValue()
"Waiting for "
span { text }
},
"Waiting for <span>late response</span>"
)
}

func testAsyncElementInTuple() async throws {
try await HTMLAssertEqualAsyncOnly(
div {
AwaitedP(number: 1)
AwaitedP(number: 2)
AwaitedP(number: 3)
},
"<div><p>1</p><p>2</p><p>3</p></div>"
)
}

func testImplicitlyAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
p(.id("hello")) {
let text = await getValue()
"Waiting for \(text)"
},
#"<p id="hello">Waiting for late response</p>"#
)
}

func testNestedImplicitAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
div(attributes: [.class("c1")]) {
p {
await getValue()
}
"again \(await getValue())"
p(.class("c2")) {
"and again \(await getValue())"
}
},
#"<div class="c1"><p>late response</p>again late response<p class="c2">and again late response</p></div>"#
)
}
}

private struct AwaitedP: HTML {
var number: Int
var content: some HTML {
AsyncContent {
let _ = try await Task.sleep(for: .milliseconds(1))
p { "\(number)" }
}
}
}

private func getValue() async -> String {
await Task.yield() // just for fun
return "late response"
}
4 changes: 4 additions & 0 deletions Tests/ElementaryTests/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import XCTest
func HTMLAssertEqual(_ html: some HTML, _ expected: String, file: StaticString = #filePath, line: UInt = #line) async throws {
XCTAssertEqual(expected, html.render(), file: file, line: line)

try await HTMLAssertEqualAsyncOnly(html, expected, file: file, line: line)
}

func HTMLAssertEqualAsyncOnly(_ html: some HTML, _ expected: String, file: StaticString = #filePath, line: UInt = #line) async throws {
let asyncText = try await html.renderAsync()
XCTAssertEqual(expected, asyncText, file: file, line: line)
}
Expand Down

0 comments on commit 80c63fc

Please sign in to comment.