Friday, February 28, 2025 · 17 min read
How I transformed Paul Hudson’s Swift static site generator into SwiftUI for the web.
I’m no arsonist. But when a project is named Ignite, how can you help but set it on fire?
Conceived by @twostraws, Ignite is a Swift static-site generator that “aims to use SwiftUI-like syntax to help you build great websites.” And I encountered it like most other Swift developers: with a foredoomed Google search to uncover an enjoyable way of creating websites—one devoid of the messy babble of HTML/CSS or the overpriced and restrictive walled gardens of modern web-design platforms.
And this time, to my surprise, the internet delivered.
Rest assured, I didn’t torch Ignite; I tempered it. Because at only six months old, with an API that nodded as much to UIKit as to SwiftUI, Ignite was still maturing into a framework whose power matched its ease.
Fortunately, I was privileged to have Paul as the consummate collaborator—fanning my fire’s flames when they illuminated promising avenues, and containing them when they raged into runaway pull requests.
What I had expected to be a three-week burn ended up being three magnitudes greater, and over the past three months, I’ve touched just about every file in the codebase. I authored the Theme
protocol, Style
protocol, and Animation
; introduced an assortment of new elements, modifiers, and types; and updated the project to Swift 6.
And while I hope to write about those things in the future, in this article I’ll spotlight the meat and potatoes of my contributions: the HTML
protocol and the @Environment
.
Before I dive into those implementations, I’d like to give you a brief introduction to Ignite. But first things first, some rules of the road:
The code used throughout this article is abridged. In particular, assume that type definitions show only the relevant properties and methods.
Between the 0.2.2 tag—our start point—and the current release, several of Ignite’s types have donned new names. When talking about the framework in general, I’ll favor the new names.
With that out of the way, what better teaser of the fun ahead than a comparison of the core APIs, past and present?
struct FeedLink: Component {
func body(context: PublishingContext) -> [any PageElement] {
if context.isFeedEnabled {
Link("RSS Feed", target: context.site.feedConfiguration.path)
}
}
}
And now:
struct FeedLink: HTML {
@Environment(\.feedConfiguration) private var feedConfig
var body: some HTML {
if let feedConfig {
Link("RSS Feed", target: feedConfig.path)
}
}
}
In the words of Prof. G: let’s light this candle.
Our entry point into Ignite is Site
, a protocol that allows us to define everything from a website’s title and pages to its syntax highlighters and RSS configuration.
struct MySite: Site {
var name: String = "My Site"
var author: String = "J.P. Toro"
var url: URL = URL(static: "https://example.com")
var layout: any Layout = MainLayout()
var lightTheme: (any Theme)? = MainTheme()
}
And for nonessential metadata, Ignite supplies sensible defaults.
Every site requires a Layout
, which defines how any page, static or dynamic, is structured. The placement of a page’s navigation bar and footer, the injection of required <head>
elements—your layout handles it, ready to receive the actual page content from a given build’s PublishingContext
when the site is published. And if our pages don’t want to use the website’s default layout, they can override it.
Each page type is composed of HTML
elements that behave just like SwiftUI views—some are “primitive” types defined by the framework itself; others are “custom” types defined by the user. Regardless, the HTML
protocol requires a render() -> String
method, which converts that element’s content into an HTML string.
During the build process, Ignite calls a site’s publish(from:buildDirectoryPath:) async throws
method to initialize the publishing context singleton, which transforms all of that site’s information into a collection of HTML and CSS files that render a website.
You can see this all in action in Ignite Samples.
HTML
ProtocolAlthough CSS assumed the mantle of specifying an HTML element’s display value, elements historically fell into two categories:
Block-level, which start on a new line, expand to the available width, have default vertical margins, and can contain both inline and block-level elements—e.g., a <div>
.
Inline elements, which flow within text, take up no more space than needed, lack enclosing line breaks, and can contain only inline elements—e.g., a <span>
.
Likewise, to enforce valid HTML composition, Ignite’s primitive types originally conformed to one of the following core protocols:
public protocol BlockElement: PageElement {
var columnWidth: ColumnWidth { get set }
}
public protocol InlineElement: PageElement {}
Which conformed to:
public protocol PageElement: BaseElement {
var attributes: CoreAttributes { get set }
}
public protocol BaseElement {
func render(context: PublishingContext) -> String
}
Custom elements like FeedLink()
, on the other hand, conformed to Component
:
public protocol Component: BaseElement {
@PageElementBuilder func body(context: PublishingContext) -> [PageElement]
}
But in SwiftUI, primitive views and custom views conform to the same protocol, and to follow in its declarative footsteps, Ignite too should have one unified protocol.
For Ignite to prohibit invalid HTML, InlineElement
must remain a distinct, specialized type, leaving PageElement
, BaseElement
, and Component
as the key protocols to consolidate:
public protocol HTML: CustomStringConvertible, Sendable {
/// The type of HTML content this element contains.
associatedtype Body: HTML
/// The content of this HTML element.
@HTMLBuilder var body: Body { get }
/// The HTML attributes for this element.
var attributes: CoreAttributes { get set }
/// Whether this HTML belongs to the framework.
var isPrimitive: Bool { get }
/// Converts this element into an HTML string.
func render() -> String
}
I’m sure each member of HTML
ignites at least one burning question, so let’s start with the hottest property: body
, kindled by @HTMLBuilder
.
Result builders transform an input—the body of a computed property or method—into a desired output.
To address the unique syntax pattern of an input—whether it be an optional expression, control flow statement, or empty block—result builders comprise build methods, which both implement the logic of the transformation and specify the return type, which can vary from handler to handler.
But result builders aren’t new to Ignite—the framework’s defunct Component
protocol employed a result builder:
@PageElementBuilder func body(context: PublishingContext) -> [any PageElement]
So what’s so special about HTML
’s implementation?
Forget that body(context:) -> [any PageElement]
returns an array of any PageElement
, and let’s focus on the element type itself, an existential using the any
keyword.
An existential type is simply any type that conforms to a given protocol.
Unsurprisingly, the array returned by body(context:) -> [any PageElement]
can include any type that conforms to PageElement
.
And at compilation, even though it has to work harder, the compiler can avoid knowing anything about the concrete types inside.
By contrast, body
returns an associated type that conforms to HTML
.
Specific to protocols, associated types mirror generics in that they hold the place of unknown types. But with associated types, a conforming class, struct, or enum specifies the protocol’s associated type, whereas with generics, an instance specifies the indefinite type.
Even so, rather than having a concrete implementation define this unknown type explicitly, we can instruct the compiler to derive it implicitly—all thanks to the some
keyword.
When used with a protocol, the some
keyword constitutes an opaque type—a guarantee to the compiler that a member will always return the same conforming type of that protocol, and that—to the benefit of the code’s performance—enough context exists for the compiler to know which.
Ignite’s primitive types demonstrate this process at its simplest. Let’s use Text
as an example:
public struct Text: HTML {
public var body: some HTML { self }
}
When the result builder attached to HTML
’s body
property detects a single build component, it routes body
’s content through the build method designated to handle that content’s structure:
@resultBuilder
public struct HTMLBuilder {
public static func buildBlock<Content: HTML>(_ content: Content) -> Content {
content
}
}
Because buildBlock
simply returns the untransformed input as the output, what you see is what you get: the type returned byText
’s body
property is Text
.
With that information, the compiler understands that for this concrete implementation of HTML
, Body
—the associated type describing the return value of body
—is one and the same as Text
.
In other words, the type returned by @HTMLBuilder
defines Body
.
While primitive types color this process to be straightforward, custom types, whose body
supports myriad syntax patterns, paint a much more complex picture.
Say we have a custom element that shows an optional date, if it exists, as a string:
struct MyDate: HTML {
var date: Date?
var body: some HTML {
if let date {
Text(date.formatted())
}
}
}
When MyDate
’s body
property is called, @HTMLBuilder
will direct its content to buildOptional
:
@resultBuilder
public struct HTMLBuilder {
public static func buildOptional<Content: HTML>(_ component: Content?) -> some HTML {
if let component {
// Handle component
} else {
// Handle nil
}
}
}
But since this method—like all of @HTMLBuilder
’s build handlers—must return a single, non-optional conforming type of HTML
, the body of this method needs an else
branch that handles nil
—and that else
branch must return the same type as the if
branch.
But how is such a result possible? How can we hoodwink buildOptional
into believing two unrelated results are of the same type?
The answer lies in one of a programmer’s most powerful tools: type erasure.
Type erasure is the technique of concealing one of a protocol’s concrete implementations with another. This specialized type wraps its sibling types in a uniform exterior to placate the compiler, but provides mechanisms to access its wrapped content.
Just as SwiftUI handles type erasure with EmptyView
and AnyView
, so too does Ignite with EmptyHTML
and AnyHTML
.
EmptyHTML
represents the absence of an HTML
element, and its render() -> String
method simply returns an empty string.
By wrapping an instance of it in AnyHTML
inside our else
branch, and wrapping component
in AnyHTML
inside our if
branch, we ensure that buildOptional
always returns one conforming type of HTML
:
@resultBuilder
public struct HTMLBuilder {
public static func buildOptional<Content: HTML>(_ component: Content?) -> some HTML {
if let component
AnyHTML(component)
} else {
AnyHTML(EmptyHTML())
}
}
}
And AnyHTML
’s utility doesn’t end there.
When a modifier is applied to an HTML
element:
Text("Blue")
.style(.color, "blue")
It can directly:
public extension HTML {
func style(_ property: Property, value: String) -> some HTML {
var copy = content
copy.attributes.add(styles: .init(property, value: value)
return copy
}
}
Or indirectly:
public extension HTML {
func clipped() -> some HTML {
self.style(.overflow, "hidden")
}
}
manipulate that element’s underlying attributes
.
And these attributes comprise an element’s distinct behavior—like font, height, and color.
But a custom type’s need for concrete storage sparks a problem: because a protocol extension’s properties are computed, not stored, custom types cannot rely on a default implementation for attribute persistence. They must implement attributes
manually—a pattern at odds with the SwiftUI paradigm.
Fortunately, the solution is surprisingly straightforward: before modification, wrap Ignite’s custom types in a primitive wrapper with concrete storage.
And rather than imitate SwiftUI by introducing an HTMLModifier
protocol and a specialized type eraser like ModifiedHTML
, we can simply use AnyHTML
.
When a modifier changes an element’s attributes, no matter the implementation, it ultimately resolves to some combination of five core modifiers:
public extension HTML {
func style(_ property: Property, _ value: String) -> some HTML { ... }
func class(_ className: String) -> some HTML { ... }
func id(_ id: String) -> some HTML { ... }
func data(_ name: String, _ value: String) -> some HTML { ... }
func attribute(_ name: String, _ value: String) -> some HTML { ... }
}
All of which resemble this implementation:
public extension HTML {
func style(_ property: Property, _ value: String) -> some HTML {
AnyHTML(inlineStyleModifier(.init(property, value: value)))
}
}
private extension HTML {
func inlineStyleModifier(_ style: InlineStyle) -> any HTML {
var copy: any HTML = self.isPrimitive ? self : Section(self)
copy.attributes.add(styles: style)
return copy
}
}
If the modified element is primitive, the modifier amends its attributes directly. Otherwise, the modifier wraps its custom content in a Section
—a basic <div>
container—to which it applies attributes.
Because self
and Section
aren’t guaranteed to be the same type, in style(_:_:) -> some HTML
, we wrap the result of inlineStyleModifier(_:) -> any HTML
in AnyHTML
.
In the view hierarchy, AnyHTML
acts as the definitive reference point for its wrapped content’s attributes:
public struct AnyHTML: HTML {
public init(_ content: any HTML) {
var content = content
attributes.merge(content.attributes)
content.attributes.clear()
if let anyHTML = content as? AnyHTML {
wrapped = anyHTML.wrapped
} else {
wrapped = content
}
}
}
It both prohibits nested instances of AnyHTML
and eliminates the need to cast unknown types of HTML
to AnyHTML
to query its wrapped
property, offering friendly ergonomics.
When it comes time to render markup or pass its content to another view, AnyHTML
transfers its attributes to wrapped
before calling it:
public struct AnyHTML: HTML {
var attributedContent: any HTML {
var wrapped = wrapped
wrapped.attributes.merge(attributes)
return wrapped
}
public func render() -> String {
var wrapped = wrapped
wrapped.attributes.merge(attributes)
return wrapped.render()
}
}
And just like that, without going up in flames, our custom types can now track attributes.
@Environment
: Retrieving Site DataOf course, while transmuting a custom element’s body
requirement from a method:
public protocol Component: BaseElement {
@PageElementBuilder func body(context: PublishingContext) -> [PageElement]
}
To a property:
public protocol HTML {
associatedtype Body: HTML
@HTMLBuilder var body: Body { get }
}
We relinquished a vital source of site data: a given build’s PublishingContext
instance.
Not only does a PublishingContext
vend resources to an element—like Markdown files or methods for decoding JSON—it also bridges an HTML element to Site
.
For example, to know if Bootstrap’s RSS icon is available, FeedLink
checks Site
’s builtInIconsEnabled
member.
The obvious fix is converting PublishingContext
into a singleton. But as you may recall, another crucial method relies on the build’s publishing context:
public protocol BaseElement {
func render(content: PublishingContext) -> String
}
Would our dependencies even support a singleton pattern?
As it turns out, they do.
During the build process, by calling publish(from:buildDirectoryPath:) async throws
, Site
instantiates a publishing context, which in turn invokes its own publish()
method:
public func publish(from file: StaticString = #file, buildDirectoryPath: String = "Build") async throws {
let context = try PublishingContext(for: self, from: file, buildDirectoryPath: buildDirectoryPath)
try await context.publish()
if context.warnings.isEmpty == false {
print("Publish completed with warnings:")
print(context.warnings.map { "\t- \($0)" }.joined(separator: "\n"))
}
}
publish()
generates everything from Robots.txt to the final HTML markup of a site’s pages, and during that latter process, it injects itself into each HTML
element’s render(context:) -> String
method.
Surprisingly, to render valid HTML, only a few elements depend on the publishing context. And even if throughout the build process there existed a legitimate use case for passing distinct contexts into our HTML
views—of which I see none—these dependent elements rely on information so fundamental to a site’s identity that the information would have to remain constant across all contexts.
In fact, although our new API doesn’t necessitate removing the context
parameter from render(content:) -> String
, doing so bestows the HTML
protocol with tremendous power.
Since HTML
elements can access the publishing context anywhere, they can now generate their HTML markup anywhere.
As a result, by implementing conformance to CustomStringConvertible
, we can “synonymize” our HTML
elements with their HTML markup strings:
@MainActor
public protocol HTML: CustomStringConvertible {
// Implementation
}
public extension HTML {
nonisolated var description: String {
MainActor.assumeIsolated {
self.render()
}
}
}
And support string interpolation among HTML
types—just as SwiftUI does with Text
and Image
:
Text("\(Image(systemName: "rss-fill")) RSS Feed")
Suffice it say, adopting the singleton pattern for PublishingContext
solves our API migration conundrum. Well, at least logistically.
Because here’s the rub: creating a shared context requires publicizing PublishingContext
. And despite containing a few members with public relevance, PublishingContext
almost exclusively serves internal processes.
Exposing it feels like overkill. And even more importantly, doing so makes for a clunky API:
let feedPath = PublishingContext.shared.site.feedConfiguration.path
Luckily, these concerns warrant straightforward solutions.
First, let’s project PublishingContext
’s externally pertinent root properties and nested properties into a new composite type, EnvironmentValues
:
public struct EnvironmentValues {
public let site: SiteMetadata
public var decode: DecodeAction
public let author: String
public var feedConfiguration: FeedConfiguration
public var themes: [any Theme] = []
var pageContent: any HTML = EmptyHTML()
let article: Content
let category: any Category
}
And store an instance of that type right inside PublishingContext
itself:
final class PublishingContext {
var environment = EnvironmentValues()
}
To access this property, we’ll also need a public interface:
@propertyWrapper
public struct Environment<Value> {
private let keyPath: KeyPath<EnvironmentValues, Value>
public var wrappedValue: Value {
PublishingContext.shared.environment[keyPath: keyPath]
}
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
self.keyPath = keyPath
}
}
Together these additions encapsulate Ignite’s site-level environment.
I qualify this environment as “site-level” because like SwiftUI, Ignite supports “view-level” environments—albeit on a much more limited scale.
For example, when we encounter OuterText
:
struct OuterText: HTML {
var body: some HTML {
Group {
Text("Outer")
SomeText()
}
.foregroundStyle(.blue)
}
}
struct InnerText: HTML {
var body: some HTML {
Text("Green")
.foregroundStyle(.green)
Text("Inner")
}
}
We expect that Text("Inner")
will inherit foregroundStyle(.blue)
from its containing Group
, and indeed it does. But this transference leverages CSS inheritance; it goes without saying our site-level environment works much differently.
During the publishing process, within each method that renders a page’s HTML, an instance of EnvironmentValues
is initialized with sources specific to that page:
func render(_ page: any Page, isHomePage: Bool = false) {
let path = isHomePage ? "" : page.path
currentRenderingPath = isHomePage ? "/" : page.path
let metadata = PageMetadata(
title: page.title,
description: page.description,
url: site.url.appending(path: path),
image: page.image)
let values = EnvironmentValues(
sourceDirectory: sourceDirectory,
site: site,
allContent: allContent,
pageMetadata: metadata,
pageContent: page)
let outputString = withEnvironment(values) {
staticLayout.parentLayout.body.render()
}
let outputDirectory = buildDirectory.appending(path: path)
write(outputString, to: outputDirectory, priority: isHomePage ? 1 : 0.9)
}
With the entire webpage—top-level layout to page content—rendered inside withEnvironment<T>(_:operation:) -> T
:
func withEnvironment<T>(_ environment: EnvironmentValues, operation: () -> T) -> T {
let previous = self.environment
self.environment = environment
defer { self.environment = previous }
return operation()
}
So that when the body
of each element in the view hierarchy is invoked, any calls to @Environment
—which encapsulates PublishingContext.shared
’s environment
property—has the correct values.
And because we inject the page content itself into the environment, any primitive type that formerly required that content for instantiation—like Head
and Body
:
struct MyTheme: Theme {
func render(page: Page, context: PublishingContext) async -> Document {
Document {
Head(for: page, in: context)
Body(for: page)
}
}
}
Can remove it as an initialization parameter:
struct MainLayout: Layout {
var body: some HTML {
Body {
content
IgniteFooter()
}
}
}
Streamlined, these types now feel akin to SwiftUI scenes, and in the case of Head
, the framework has enough information to infer a default implementation when an explicit instance is absent.
Now, if you’re wondering why Layout
’s implicit content
is excluded from @Environment
, well, it’s a unique case. Through content
, Layout
supplies the body content of a page to its conforming types:
public extension Layout {
var content: some HTML {
Section(PublishingContext.shared.environment.pageContent)
}
}
But outside of Layout
, that data is an irrelevant vector of confusion, and Swift lacks a mechanism for hiding properties in inappropriate contexts—like concealing @Environment(\.content)
within a regular HTML
element.
And with that, although Ignite has ceased to combust, its future shines brighter than ever.
If you’d like me to continue this series—or to publicly disavow metaphors—let me know by shouting out this article on X or Mastodon.
And if you’d like to see how I used Ignite to build this website, you can find the source code here.