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 experiment with using SwiftUI's infrastructure to build a static site generator on top of it.

00:06 Today, we're going to build another static site generator, as we've done a few times over the years. This time, we’re diving into what might be our "worst" attempt, or at least the most unusual. The static site generator we use for personal projects has a SwiftUI-like interface, and we even added support for the environment, which works well. But as the site grows, builds become slower, taking up to a few seconds, and every change requires a full rebuild.

01:04 It'd be nice if we could get incremental changes, and that's what SwiftUI does really well, as we saw in the attribute graph series: only the parts whose dependencies have changed get rebuilt. We can make use of the attribute graph by building our static site generator on top of SwiftUI.

01:53 SwiftUI also provides other useful mechanisms, like the environment and the preference system. And, at least for us, it's the framework we know very well.

Syntax

02:37 Let's start today by setting up the API we'd like to use for our static site generator; we can think about incremental updates later on. The first step is writing a type alias, Rule, to remind ourselves we're building a static site with rules, instead of a view hierarchy:

import SwiftUI

typealias Rule = View

Then we'd like to create our site with some Write rules. Each Write outputs the given contents to a file:

struct MySite: Rule {
    var body: some Rule {
        Write("index.html", "Hello, world!")
        Write("about.html", "About this site")
    }
}

We could define a default file name so that we can omit the first parameter if we just want to write an index.html file in the current working directory. Let's work on getting this to compile:

struct MySite: Rule {
    var body: some Rule {
        Write("Hello, world!")
    }
}

Write Rule

03:51 Since we're in a view builder, Write has to be a View — or rather, a Rule — as well, and it needs properties for an output directory, a file name, and the file contents. Whenever any of these properties changes, it's going to rewrite the file.

04:29 What we write to a file is data, so we use Data for the type of the contents parameter. In the body view, we can render a text view describing the input parameters, so we can see which file would be written:

struct Write: Rule {
    var name = "index.html"
    var data: Data

    var body: some View {
        Text("Writing \(data) to \(name)")
    }
}

05:42 Now we can call onChange(of:) on the text view to write the data to the file. We want to write the file when either the name or the data changes, but we can't provide a tuple to onChange because tuples aren't Equatable, so we have to wrap the values in a payload struct:

struct WritePayload: Equatable {
    var name: String
    var data: Data
}

06:34 Then we create an initializer for Write that wraps the name and data values in a payload:

struct Write: Rule {
    var payload: WritePayload

    init(name: String = "index.html", data: Data) {
        self.payload = WritePayload(name: name, data: data)
    }

    // ...
}

07:06 To make our API from before work, we add an alternative initializer that takes a string for the data:

struct Write: Rule {
    // ...

    init(name: String = "index.html", _ string: String) {
        self.payload = WritePayload(name: name, data: string.data(using: .utf8)!)
    }

    // ...
}

07:44 Now we can observe changes to the payload using onChange(of:):

struct Write: Rule {
    // ...

    var body: some View {
        Text("Writing \(payload.data) to \(payload.name)")
            .onChange(of: payload, initial: true) {
                print("Writing")
            }
    }
}

08:11 To actually use the sample site, we add MySite to the ContentView — which admittedly looks quite horrible:

struct ContentView: View {
    var body: some View {
        VStack {
            MySite()
        }
        .padding()
    }
}

Writing to File

08:32 Our app now displays the text view describing the data we should write to a file. So now, let's actually write the file. To do so, we can define an output URL in the environment, with the temporary directory as a default value:

extension EnvironmentValues {
    @Entry var outputDir: URL = .temporaryDirectory
}

09:05 We define our output directory in MySite and pass it to the environment:

struct MySite: Rule {
    @State var outputDir: URL = .temporaryDirectory.appending(path: "static_site")

    var body: some View {
        Write("Hello, world!")
            .environment(\.outputDir, outputDir)
    }
}

09:29 We can add a toolbar button to make it easy to open the output directory and check our output. For some reason, we couldn't get the openURL environment value to work, but NSWorkspace.shared.open works fine:

struct MySite: Rule {
    @State var outputDir: URL = .temporaryDirectory.appending(path: "static_site")

    var body: some View {
        Write("Hello, world!")
            .environment(\.outputDir, outputDir)
            .toolbar {
                Button("Open Output Dir") {
                    NSWorkspace.shared.open(outputDir)
                }
            }
    }
}

09:49 Now we can use the output directory URL in our Write rule and construct the URL for the output file by appending the file name to it:

struct Write: Rule {
    @Environment(\.outputDir) var outputDir
    var payload: WritePayload
    
    // ...
    
    var body: some View {
        Text("Writing \(payload.data) to \(payload.name)")
            .onChange(of: payload, initial: true) {
                let fileURL = outputDir.appending(path: payload.name)
                try! payload.data.write(to: fileURL)
            }
    }
}

10:22 This gives us an error because the directory doesn't exist, which we can fix by using FileManager to create the directory before writing the file:

struct Write: Rule {
    @Environment(\.outputDir) var outputDir
    var payload: WritePayload
    
    // ...
    
    var body: some View {
        Text("Writing \(payload.data) to \(payload.name)")
            .onChange(of: payload, initial: true) {
                let fileURL = outputDir.appending(path: payload.name)
                try! FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
                try! payload.data.write(to: fileURL)
            }
    }
}

11:19 Running the app, we see that the index.html file with Hello, world! is created. But there's a problem with our updates. The onChange(of:) modifier looks at the name and the data of the output file, but not the output directory, which comes from the environment. So the body of this Write rule will be reexecuted when the output directory changes, but this won't rerun the onChange closure. We can add another call to also observe changes to the output directory:

struct Write: Rule {
    @Environment(\.outputDir) var outputDir
    var payload: WritePayload

    // ...

    var body: some View {
        Text("Writing \(payload.data) to \(payload.name)")
            .onChange(of: payload, initial: true) {
                run()
            }
            .onChange(of: outputDir) { run() }
    }

    func run() {
        let fileURL = outputDir.appending(path: payload.name)
        try! FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
        try! payload.data.write(to: fileURL)
    }
}

12:24 Alternatively, we could add the output directory to the payload struct and only construct the payload right before we pass it to onChange(of:), when the environment value will be available.

Writing Multiple Files

12:51 Using a VStack, we can have multiple Write rules side by side, creating multiple files at once. The VStack itself doesn't do anything besides laying out the text views describing the writing actions; it's just there to combine multiple rules in a single view. For the second file, we can use transformEnvironment to locally mutate the output path to a subdirectory:

struct MySite: Rule {
    @State var outputDir: URL = .temporaryDirectory.appending(path: "static_site")

    var body: some View {
        VStack {
            Write("Hello, world!")
            Write("About this site")
                .transformEnvironment(\.outputDir) { url in
                    url.append(path: "about")
                }
        }
        .environment(\.outputDir, outputDir)
        .toolbar {
            Button("Open Output Dir") {
                NSWorkspace.shared.open(outputDir)
            }
        }
    }
}

14:18 We see two text views when we run this, and we now have a subdirectory with a second file in it, so everything works as we expect.

14:36 To clean things up a bit, we can write a convenience method in an extension of View to set a subdirectory in a more readable way:

extension View {
    func outputDir(_ name: String) -> some View {
        transformEnvironment(\.outputDir) { url in
            url.append(path: name)
        }
    }
}
Write("About this site")
    .outputDir("about")

Read Rule

15:36 Next, we also want the ability to read files. We can think of two possible APIs that could be nice for this. For one, we could write a property wrapper that gives us the data from the given file:

struct MySite: Rule {
    // ...
    @Read("index.html") var indexData
    // ...
}

15:56 A simpler approach for now would be to just write a view. The Read view could take a path parameter and an input directory environment value and construct a URL to the file to read from those two values:

struct Read: View {
    @Environment(\.inputDir) private var inputDir
    var path: String

    var body: some View {
        let url = inputDir.appending(path: path)

    }
}

extension EnvironmentValues {
    @Entry var outputDir: URL = .temporaryDirectory
    @Entry var inputDir: URL = .temporaryDirectory
}

16:58 Until we implement file observation, we can just try to read the file inside the body of our view and pass the data into a nested view:

struct Read<Nested: View>: View {
    @Environment(\.inputDir) private var inputDir
    var path: String
    var nested: (Data?) -> Nested

    var body: some View {
        let url = inputDir.appending(path: path)
        let contents = try? Data(contentsOf: url)
        nested(contents)
    }
}

17:43 We locate the input directory in our project by using the #file literal, which contains the URL of the current code file:

struct MySite: Rule {
    @State var outputDir: URL = .temporaryDirectory.appending(path: "static_site")
    @State var inputDir: URL = {
        let base = #file
        return URL(fileURLWithPath: base).deletingLastPathComponent().appending(path: "input")
    }()

    // ...
}

19:01 We can now wrap a Write view in a Read view to write the contents of a Markdown file to a file in our static site. We force unwrap the data we receive from Read because we expect the input file to be there:

struct MySite: Rule {
    @State var outputDir: URL = .temporaryDirectory.appending(path: "static_site")
    @State var inputDir: URL = {
        let base = #file
        return URL(fileURLWithPath: base).deletingLastPathComponent().appending(path: "input")
    }()

    var body: some View {
        VStack {
            Read("index.md") { data in
                Write(data: data!)
            }
            Write("About this site")
                .outputDir("about")
        }
        .environment(\.inputDir, inputDir)
        .environment(\.outputDir, outputDir)
        .toolbar {
            Button("Open Output Dir") {
                NSWorkspace.shared.open(outputDir)
            }
        }
    }
}

19:20 To make this compile, we have to write an initializer of Read that removes the label of the first parameter:

struct Read<Nested: View>: View {
    @Environment(\.inputDir) private var inputDir
    var path: String
    var nested: (Data?) -> Nested

    init(_ path: String, nested: @escaping (Data?) -> Nested) {
        self.path = path
        self.nested = nested
    }

    var body: some View {
        let url = inputDir.appending(path: path)
        let contents = try? Data(contentsOf: url)
        nested(contents)
    }
}

19:43 We're now writing two files, and the index file contains the content from the input Markdown file.

Discussion

19:57 The obvious next step would be to not read the input file inside body, but to observe the file system for changes to that file. In a more complex site, where one page is built up from many different parts, we'll then get dependency tracking for free. If, for example, the input file for the site footer changes, the observation mechanism will automatically rewrite everything that's dependent on that input.

20:46 At the same time, if the file changes, but its contents are the same after reading and transforming it, nothing will have to be executed again. At every point in time where SwiftUI can compare dependencies, it'll stop the incremental propagation. So, even though our static site generator is a horrible misuse of SwiftUI, it should be pretty efficient in the end.

21:08 Unlike makefiles, which track file modification dates, our system first needs to build the entire site, and then it's incremental while the app runs. That's a downside, but it's manageable.

21:56 We now have a basic setup for our static site generator. From here, we can build it out with observation and improve ergonomics of the API.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

187 Episodes · 64h55min

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