Elementary: A modern and efficient HTML rendering library - inspired by SwiftUI, built for the web

Hello there, lovely swift community!

So, I ended up building yet another Swift DSL package for HTML.

I know, but hear me out (getting-it-off-the-chest story inside ;)

I am on a personal mission to seriously use Swift for web apps, and I am close to a wonderful dev experience with Hummingbird + Tailwind + HTMX.

I really want typesafe HTML templating, so things like Leaf or mustache are not no the table for me.

Also, I really want SwiftUI-like composition, but full control over the HTML and CSS (to style it with tailwind) - so otherwise excellent projects like Tokamak or SwiftWebUI are just not what I am looking for.

That leaves us with packages like Plot, HTMLKit, Swim, and similar ones - which are all very nice.

I really like Plot and started with it, but it always nagged me that it is using any Component instead of some Component for its composition feature (same as all the other packages). I know - not exactly a "real" problem - but I just felt so sorry about all the wasted allocations of array after array of existental containers, just to get a bit of HTML text out.

So I thought, out of pure curiosity, what would it look like to make this fully generic? I hacked together result builder using a variadic tuple thing and things fell into place quite nicely.

I liked how the API felt so much that I decided to push through and polish it up to be a releasable package - and here we are.

Here is a copy of the readme

Elementary: HTML Templating in Pure Swift

struct MainPage: HtmlDocument {
    var title: String = "Elementary"

    var head: some Html {
        meta(.name(.description), .content("Typesafe HTML in modern Swift"))
    }

    var body: some Html {
        main {
            h1 { "Features" }

            FeatureList(features: [
                "HTML in pure Swift",
                "SwiftUI-inspired composition",
                "Lightweight and fast",
                "Framework agnostic and unopinionated",
            ])

            a(.href("https://github.com/sliemeobn/elementary"), .class("fancy-style")) {
                "Learn more"
            }
        }
    }
}

struct FeatureList: Html {
    var features: [String]

    var content: some Html {
        ul {
            for feature in features {
                li { feature }
            }
        }
    }
}

Play with it

Check out the Hummingbird example app.

Lightweight and fast

Elementary renders straight to text, ideal for serving generated HTML from a Hummingbird or Vapor server app.

Any element can be rendered individually, ideal for htmx.

let html = div(.class("pretty")) { "Hello" }.render()
// <div class="pretty">Hello</div>

let fragment = FeatureList(features: ["Anything conforming to Html can be rendered"]).render()
// <ul><li>Anything conforming to Html can be rendered</li></ul>

Optionally you can render formatted output, or stream the rendered HTML without collecting it in a string first.

print(
    div {
        p(.class("greeting")) { "Hi mom!" }
        p { "Look how pretty." }
    }.renderFormatted()
)

// <div>
//   <p class="greeting">Hi mom!</p>
//   <p>Look how pretty.</p>
// </div>
// Have HTML arrive at the browser before the full page is rendered out.
MainPage().render(into: responseStream)

Elementary has zero dependencies (not even Foundation) and does not use runtime reflection or existential containers (there is not a single any in the code base).

By design, it does not come with a layout engine, reactive state tracking, or built-in CSS styling: it just renders HTML.

Clean and composable

Structure your HTML with a SwiftUI-inspired composition API.

struct List: Html {
    var items: [String]
    var importantIndex: Int

    var content: some Html {
        // conditional rendering
        if items.isEmpty {
            p { "No items" }
        } else {
            ul {
                // list rendering
                for (index, item) in items.enumerated() {
                    // seamless composition of elements
                    ListItem(text: item, isimportant: index == importantIndex)
                }
            }
        }
    }
}

struct ListItem: Html {
    var text: String
    var isimportant: Bool = false

    var content: some Html {
        // conditional attributes
        li { text }
            .attributes(.class("important"), when: isimportant)
    }
}

First class attribute handling

Elementary utilizes Swift's powerful generics to provide an attribute system that knows what goes where. Every element knows which Tag it is for.

As in HTML, attributes go right after the "opening tag".

// staying close to HTML syntax really helps
div(.data("hello", value: "there")) {
    a(.href("/swift"), .target(.blank)) {
        img(.src("/swift.png"))
        span(.class("fancy")) { "Click Me" }
    }
}

Attributes can also be altered by using the modifier syntax, this allows for easy handling of conditional attributes.

div {
    p { "Hello" }
        .attributes(.id("maybe-fancy"))
        .attributes(.class("fancy"), when: isFancy)
}

By exposing the tag type of content, attributes will fall through and be applied correctly.

struct Button: Html {
    var text: String

    // by exposing the HtmlTag type information...
    var content: some Html<HtmlTag.input> {
        input(.type(.button), .value(text))
    }
}

div {
    // ... Button will know it really is an <input> element ...
    Button(text: "Hello")
        .attributes(.autofocus) // ... and pass on any attributes
}

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

:construction: Work in progress :construction:

The list of built-in attributes is quite short still, but adding them is quite easy (and can be done in external packages as well).

Feel free to open a PR with additional attributes that are missing from the model.

Motivation and other packages

Plot, HTMLKit, and Swim are all excellent packages for doing a similar thing.

My main motivation for Elementary was to create an experience like these, but

  • stay true to HTML tag names and conventions (including the choice of lowercase types)
  • avoid allocating an intermedate structure and go straght to text
  • using generics to stay away from allocating a ton of lists of existential anys
  • have a list of attributes go before the content block
  • provide attribute fallthrough and merging
  • zero dependencies on other packages

Tokamak is an awesome project and very inspiring. It can produce HTML, but it's main focus is on a very different beast. Check it out!

swift-html and swift-dom will produce HTML nicely, but they use a different syntax for composing HTML elements.

Future directions

  • Try out a hummingbird + elementary + htmx + tailwind stack for fully-featured web apps (without too much client javascript or wasm)
  • Experiment with an AsyncHtml type, that can include await in bodies and stream html and an async sequence
  • Experiment with embedded swift for wasm and bolt a lean state tracking/reconciler for reactive DOM manipulation on top
16 Likes

Thanks for sharing this, looks really cool.
The way you're handling the attributes and tag names is very neat, semantically it looks more like classic HTML, which is a good thing IMHO.

I'd love to see a full example/template with HTMX. Love the concept of it but didn't have time to dig into it yet.

I'll definitely try it out and might use it in future projects. Might even open a few PRs in the future :slight_smile:

1 Like

This is awesome! I've been daydreaming lately of building full stack web apps with swift recently as well. With official WASM support looking really promising, I'd love to be able to write full stack components ala React and have server rendered components with hydration on the client side.

If you've been following the React story, RSCs (react server components) are a very interesting direction to help "dissolve" away the network between the browser and server and I've been thinking a lot about how to acheive something like this with Swift on server/WASM. There are a lot of missing pieces but it would be amazing.

Anyways great work!

1 Like

I'm glad to see packages such as this appearing, but I'm also curious to see if anyone considered a much more efficient HTML renderer that relies on macros instead of result builders. I'll post a few ideas here that I had on my wishlist for a long time for a macro-based HTML rendering library.

With function body macros there's no need to pollute the global namespaces with a separate function for each HTML tag.

IMO for this to be viable in the embedded mode and in applications caring about performance (both Wasm and microcontrollers), it also has to evaluate as much as possible at compile-time and minimize the amount of allocations.

E.g. I'd expect this

div {
  p { "Look how pretty." }
}

to be compiled down into a single string

"<div><p>Look how pretty.</p></div>"

instead of two function calls (to both div and p) each allocating a temporary string and concatenating them at compile-time.

Also, with function body macros one would be able to support arguments passed in any order, i.e. both

p(class: "greeting", id: "paragraph")

and

p(id: "paragraph", class: "greeting")

This way a library doesn't need to define helper enums with case .class or multiple overloads for p function that support all combinations of arguments.

6 Likes

for what it’s worth, the approach swift-dom takes is to encode the attributes through assignment to encoder properties, so the example above would look like

$0[.p] { $0.class = "greeting" ; $0.id = "paragraph" } 
$0[.p] { $0.id = "paragraph" ; $0.class = "greeting" } 

note that this doesn’t involve any intermediate buffering; it emits the attributes in the order that you write them.

I understand there are workarounds for it. The library pitched in this thread does it with variadic enum cases as arguments that also support any order. This is still not the same as writing a plain function call, passing arguments directly by their names.

There's a significant mental overhead in using these workarounds (enum cases or trailing closures for attributes like you've shared), especially for newcomers. Imagine you're trying to convert a developer using JSX on daily basis to Swift. With either library you have to explain more advanced concepts than one would need with plain function calls. Well-written Swift macros can hide all of the magic inside.

Static typing of HTML attributes and tags doesn't get you much if it also brings runtime/performance and mental overhead into the project. The JS/HTML/CSS ecosystem it has to compete with has a gazillion of tools that will statically validate your HTML templates. What Swift could bring is performance in rendering a final HTML string, if it puts enough effort in avoiding unnecessary runtime allocations while remaining ergonomic and easy to use at the same time.

5 Likes

I did briefly think about how macros could play role, but I quickly dismissed it because what I wanted to achieve in terms of ergonomics was there in plain swift. Perhaps too quickly?

Maybe I just lack the fantasy, but I failed to see how one could get a similar type-safe (but also extensible) system into a macro without rebuilding half a programming language inside of it.

That is what I set out to do (or start doing - there are many more optimization one could add), but I have definitely focussed on the ergonomics first. The primary use case in mind for me is still serving HTML from a server - saving 0.72 ms in rendering hardly matters when your DB query takes 80 ms.

What I lack is a good intuition about how much of the result builder skirmish actually melts away ofter optimizing and how lean things really condense down with all the generic stuff. IIUC in a perfect world, even with how Elementary it is structured right now, things could boil down to essentially a big generator function that more or less streams out mostly static strings.

If there is some interest from the community I'll be happy to invest in some performance work (PRs welcome ; ) but I think 1) "getting it out the door" 2) "see what happens" 3) "improve it" is the correct order ; )

1 Like

Minor thing, but just for the record: There are no public enums in the interface, and no attribute-specific overloads in Elementary.

The initializer of all elements is more or less this:

init(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: () -> Content) {
    self.attributes = .init(attributes)
    self.content = content()
}

Things like .class or .id are static methods that initialize an HTMLAttribute value this is essentially just stored in a type-erased storage type.

Can you expand on this with an example? I'm not understanding how function body macros in their current form could achieve either of these goals.

i don’t think function body macros in their current state are going to help here, instead we need an additional generalization to closure body macros which are mentioned briefly in SE-0415.

but even if we did have closure body macros, i think it will be a huge challenge to actually “compile” the DSL correctly with no type information. for example, for this snippet the macro would need to understand the for loop and evaluate it to produce the desired list.

@HTML
{
    var x:String = "foo"

    ol
    {
        for i:Int in 0 ..< 10
        {
            li("\(x) \(i)")
        }
    }
}

i’m not seeing an obvious way to implement such a macro without basically building a Swift interpreter.

Sneak peek preview: Swift + HTMX = :fire:

3 Likes

FYI: I just tagged a 0.1.0 version that I expect to stay mostly API stable going forward.

I was able to cut down on more allocations during rendering, and I got back-pressured streaming going that has very little overhead. The default for responding with HTML from vapor or hummingbird is now a streaming API that works fast for both small and large pages.

This also lays the groundwork to enable types like AsyncForEach or AsyncHTML that take an async closure to generate their content. As an example, you can then pass in an AsyncSequence (DB curser for example) directly into your composed HTML hierarchy and have it stream out HTML as the data comes in - all while the browser is already loading the first chunks of your page.

I did think about macros a bit more, and one thing my brain could come up with that seems feasible to do would be a #PrecompiledHTML expression macro. It could take an HTML expression (based on the the existing resultbuilder-based syntax), and have a built-in understanding of "well-known" element types and attributes. Everything that is not a literal or "well-known" would have to be left as an interpolation or similar.

Just thinking out loud here:

#PrecompiledHTML(div { p(.id("foo")) { "bar" } })
// could produce
HTMLRaw(#"<div><p id="foo">bar</p></div>"#)

#PrecompiledHTML(div { p(.myFancyThing("hello") { textInAVariable } })
// could produce
HTMLRaw("<div><p\(HTMLAttribute<Tag.p>.myFancyThing("hello").render())>\(htmlEscaped(textInVariable)</p></div>")

I do question how much this actually moves the needle in terms of performance. My gut says that this would a be a lot of fiddly work for very little benefit.

Thoughts?

1 Like

For a node tree as simple as div { p(.id("foo")) { "bar" } }, you would see at least multiple call instructions emitted for each initializer and function (div/p and .id in this case) and result builder closure, in addition to instructions within those called functions and moving their arguments onto/from the Wasm stack. Finally, you'd need linear memory load/store instructions for each string fragment that doesn't fit into a 32-bit or 64-bit value.

In comparison, I don't expect more than two instructions for HTMLRaw(#"<div><p id="foo">bar</p></div>"#): one to push the address of that static string on the stack, the other is to call HTMLRaw initializer.

Thus even for HTML trees such as this one, a macro-based solution would be multiple times smaller in terms of instructions emitted (read "binary size"), with a comparable boost to performance, even in debug builds. For deeply nested trees one potentially could expect orders of magnitude improvement. Of course, as previously mentioned, handling variable captures would not make such macros trivial, but IMO still worth it for end users.

1 Like

I absolutely agree with your points, that is what I would expect to gain as well.

However, here is why I am a bit skeptical:
For server use cases: Compared to all the other things that are typically going on for every request, this all feels negligible.

For a tight Wasm use case:
I may be wrong, but the main (only?) reason you'd want to do any of this in (client-side) Wasm is to have some sort of state management and updates to the DOM.
In order to get this, you'll need a ton of DOM reference tracking, observation/change tracking, and probably closures all over the place.

Even if you could spit out precompiled "raw HTML" more efficiently, you'd need to reverse-engineer the structure anyway to keep DOM references for updates. So I fear that possibilities for big chunks of precompiled HTML will be rather limited there as well.

All that said, I am still intrigued ; ) If someone wants to give it a go, I am happy to collaborate.

3 Likes

While this is all true, unless you backs your claims with some objective benchmarks, this is all premature optimization.

Adding complexity and increasing the code maintenance cost should be motivated by real performance gain. It would be interesting to profile real use cases first to find what would be worse to optimize.

Speaking as a professional performance engineer, it's premature to assume this is premature optimisation.

This has been well-studied already, so I'll just point to The Fallacy of Premature Optimization for the deeper explanation.

In this particular case, it's not premature optimisation, it's simply a well-reasoned logical evaluation of the likely performance characteristics. It could be wrong - or irrelevant - sure, but then so can any benchmarks.

If you want to choose to not care about performance (to any chosen degree in any given case), that's your option - you don't need to justify that to anyone here. But please let's not use rhetoric to mischaracterise each-other's thoughts.

4 Likes