Swift Talk # 273

Static Site Generator: Defining Rules

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We show how we replaced our outdated Ruby website with a SwiftUI-inspired static site generator.

00:06 A while ago, we wanted to change something on our website, but we found the project would no longer compile or run. Because it's a static website, we didn't bother maintaining our dependencies, and as a result, they'd become outdated.

00:45 So, at some point, we decided to bite the bullet and rewrite the website in Swift. This gave us the opportunity to write our own static site generator. It worked out very well, and it only took us around two weeks from start to finish.

01:10 We're going to rebuild the basic skeleton of our static site generator. Since it borrows a lot of ideas from SwiftUI, it could be interesting for others who are used to working with SwiftUI and who need a website for an app or a personal project.

Defining Rules

01:29 Let's get started by writing the simplest example of what a static site generator should be able to do, which is writing a string to a file:

struct MySite: Rule {
    var body: some Rule {
        Write(contents: "Hello world", to: "index.html")
    }
}

02:09 The Rule protocol works a lot like SwiftUI's View. It has a body property that returns some other Rule. This makes it possible to compose rules:

protocol Rule {
    associatedtype Body: Rule
    var body: Body { get }
}

02:44 Then, we need to create the Write struct:

struct Write {
    var contents: String
    var to: String // relative path
}

03:12 The question is, how do we conform Write to Rule?

Executing Rules

03:25 Right now, we've only defined Rule as something that returns another Rule. But at some point, we need a way to execute a rule in such a way that it produces a result:

extension Rule {
    func execute() {
        // ...
    }
}

04:11 We need to distinguish between rules like MySite, which are defined by users of the library, and built-in rules, which are the building blocks defined by the library.

This same idea lies at the heart of SwiftUI's View. Built-in views like HStack and Text are fundamentally different from user-defined views, because they aren't defined in terms of other views. We saw this when we reimplemented parts of SwiftUI.

05:20 The BuiltinRule protocol requires a run function to be implemented:

protocol BuiltinRule {
    func run()
}

05:41 The Write rule is a BuiltinRule; it does its work in a custom run function — for now, we just print the contents to the console. And to be incorporated into other rules, Write also needs to be a Rule. Its body property should never be executed, so we throw a fatal error in it:

struct Write: BuiltinRule, Rule {
    var contents: String
    var to: String // relative path
    
    func run() {
        print("\(contents) — filename: \(to)")
    }
    
    var body: Never {
        fatalError()
    }
}

06:53 If we want to use Never as a body type, we have to conform Never to Rule:

extension Never: Rule {
    var body: Never {
        fatalError()
    }
}

Every built-in rule will have this same error-throwing body property, so we can provide a default implementation and remove it from Write:

extension BuiltinRule {
    var body: Never {
        fatalError()
    }
}

struct Write: BuiltinRule, Rule {
    var contents: String
    var to: String // relative path
    
    func run() {
        print("\(contents) — filename: \(to)")
    }
}

08:07 Next, we need to implement the Rule.execute function. If the rule we call this on conforms to BuiltinRule, we call its run method. Otherwise, we have to somehow execute the body property. We can achieve this by creating an AnyBuiltinRule.

08:26 We initialize AnyBuiltinRule with a Rule. If this rule is a BuiltinRule, we store its run method. Otherwise, we create an AnyBuiltinRule from the rule's body, and we store its run method:

struct AnyBuiltinRule: BuiltinRule {
    let _run: () -> ()
    init<R: Rule>(_ rule: R) {
        if let builtin = rule as? BuiltinRule {
            self._run = builtin.run
        } else {
            self._run = { AnyBuiltinRule(rule.body).run() }
        }
    }
    
    func run() {
        _run()
    }
}

09:46 To execute a rule, we wrap it in an AnyBuiltinRule, and we call run on it:

extension Rule {
    func execute() {
        AnyBuiltinRule(self).run()
    }
}

09:57 We'll need to define more built-in rules. And to have multiple rules in a rule, we'll need to write a RuleBuilder. But we already have the basic infrastructure in place: we can define custom rules using a mix of rules and built-in rules, just like how we create SwiftUI views.

HTML

10:20 One thing a static site generator obviously needs is the ability to produce HTML. We imported Robb Böhnke's Swim library, and we'll use it to write our templates.

10:46 Instead of the "Hello world" string, we want to pass a Node to the Write rule:

import Swim

struct Write: BuiltinRule, Rule {
    var contents: Node
    var to: String // relative path
    
    func run() {
        print("\(contents) — filename: \(to)")
    }
}

11:12 In our example site, we can now construct an HTML node using Swim:

struct MySite: Rule {
    var body: some Rule {
        Write(contents: html {
            HTML.body {
                h1 { "Hello, World!" }
            }
        }, to: "index.html")
    }
}

Coming Up

11:56 When we run our example, formatted HTML output is printed to the console. We'll need to write this output to an actual file:

MySite().execute()

/*

    
        

Hello, World!

- filename: index.html */

But we only specify a relative path for the Write rule, e.g. index.html. So, when we execute the rule, we need to know the base path.

12:20 We want to define this base path — and other configuration details — in an environment. Like in SwiftUI, we want to be able to access and override the environment, but we don't want to manually propagate it down to each rule. Let's tackle this in the next episode.

Resources

  • Sample Code

    Written in Swift 5.5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

40 Episodes · 15h47min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes