iOS Developer. Speaker. Enthusiast. Engineer.

***

The parts missing in the documentation of Publish

Like any other Swift blogger building their blog in Publish, I guess I'll start off with a post about the setup of this blog.

The limited documentation of Publish cost me a lot of time. It's easy to get started and there are a lot of great articles on that. As soon as you try to build something custom, you’re on your own.

This post is not meant to be a complete guide to how Publish works or how to get started with it. Instead I'll focus on all the parts I had a really hard time figuring out. The following sections cover the different concepts I built into this blog but struggled with.

HTMLFactory vs. Theme

I think I had almost finished building my entire blog before I finally realized it wasn’t the Theme I was changing. It was the HTMLFactory. The component responsible for building all the HTML components.

I found plenty of articles on how to use a custom Theme, but I felt that every article failed to mention this: There is no way of adjusting the default theme. You have to build a complete HTMLFactory from scratch to do this. Maybe my expectations of Publish were wrong from the beginning, but at least I spent a huge amount of time figuring out that this actually was the way to go.

So to do this I went to the default HTMLFoundationFactory in the Publish repo and copied all the code in there and pasted into a CustomHTMLFactory struct and started changing the components.

import Plot

public extension Theme {
    static var myCustomTheme: Self {
        Theme(
            htmlFactory: CustomHTMLFactory()
        )
    }
}

private struct CustomHTMLFactory<Site: Website>: HTMLFactory {
    func makeIndexHTML(for index: Index, context: PublishingContext<Site>) throws -> HTML {
       ...
    }
    
    // I copied all the functions from the `HTMLFoundationFactory` in here and started editing them one by one.
}

Then by any other Publish solution I added it with the .publish function:

try MyWebsite()
    .publish(withTheme: .myCustomTheme)

Yeah-yeah, I know, you are about to ask: "Hey, but what did you do about the stylesheet?!"

Keep on reading about the custom <head> section...

Building custom <head>

It was pretty early in the process of styling my blog, that I was going to need some control over the <head> element. And yet again, just as the custom HTMLFactory, there was no help in the documentation. So I started looking into the default .head(for: on:) function that were used in the HTMLFoundationFactory. I found that this implementation was pretty easy to reconstruct in a custom Node extension.

I accomplished the following by doing so:

  • Control over the stylesheet link (more on that in the Plugin section...)
  • Custom font loading from Google Fonts
  • Custom favicon control as svg file
  • Custom JavaScript script for Matomo (analytics)
  • Flexibility of future needs in the <head> section

My implementation ended up looking like this:

public extension Node where Context == HTML.DocumentContext {
    static func customHead<T: Website>(
        for location: Location,
        on site: T,
        titleSeparator: String = " | ",
        stylesheetPaths: [Path] = ["/styles.css"],
        rssFeedPath: Path? = .defaultForRSSFeed,
        rssFeedTitle: String? = nil
    ) -> Node {
        var title = location.title

        if title.isEmpty {
            title = site.name
        } else {
            title.append(titleSeparator + site.name)
        }

        var description = location.description

        if description.isEmpty {
            description = site.description
        }

        return .head(
            .encoding(.utf8),
            .siteName(site.name),
            .url(site.url(for: location)),
            .title(title),
            .description(description),
            .twitterCardType(location.imagePath == nil ? .summary : .summaryLargeImage),
            .forEach(stylesheetPaths, { .stylesheet($0) }),
            .link(.rel(.icon), .type("image/svg+xml"), .href("/images/favicon.svg")),
            .link(.rel(.preconnect), .href("https://fonts.googleapis.com")),
            .link(.rel(.preconnect), .href("https://fonts.gstatic.com"), .attribute(named: "crossorigin", value: "")),
            .link(.rel(.stylesheet), .href("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Gabarito:wght@400..900&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap")),
            .viewport(.accordingToDevice),
            .unwrap(rssFeedPath, { path in
                let title = rssFeedTitle ?? "Subscribe to \(site.name)"
                return .rssFeedLink(path.absoluteString, title: title)
            }),
            .unwrap(location.imagePath ?? site.imagePath, { path in
                let url = site.url(for: path)
                return .socialImageLink(url)
            }),
            .script(.raw(
                """
                <!-- Matomo -->
                  var _paq = window._paq = window._paq || [];
                  _paq.push(["disableCookies"]);
                  _paq.push(['trackPageView']);
                  _paq.push(['enableLinkTracking']);
                  (function() {
                    var u="https://<redacted host>";
                    _paq.push(['setTrackerUrl', u+'matomo.php']);
                    _paq.push(['setSiteId', '<redacted site id>']);
                    var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
                    g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
                  })();
                <!-- End Matomo Code -->
                """
            ))
        )
    }
}

Now that I had a customHead function I could use it in the HTMLFactory for all functions building an HTML site:

private struct CustomHTMLFactory<Site: Website>: HTMLFactory {
    func makeIndexHTML(for index: Index, context: PublishingContext<Site>) throws -> HTML {
        HTML(
            .lang(context.site.language),
            .customHead(for: index, on: context.site), // <-- This!
            .body {
                ...
            }
        )
    }
}

Note: I had to replace all occurrences of the default .head function with my own customHead function in the custom HTMLFactory implementation. It wasn't a big deal, just something to remember.

Plugins

So it sounds tempting with the plugins, but I realized pretty fast that most plugins for Publish isn't maintained very well. So I found a couple of plugins that I wanted to use, but they were not working out of the box. So I had to dig into the source code of the plugins and find branches that were working with the latest version of Publish.

I made it work, but I guess you should be careful with the plugins. You shouldn't build core-features of your website around a plugin that might be abandoned at some point.

So before getting further into plugins I realized something important, that I didn't catch at first. While installing ReadingTimePublishPlugin I found that the documentation told me this:

Note that it must be installed after the Items are created (in this case by addMarkdownFiles() ).

At first I wasn't paying too much attention to this, but boy, I was in for a battle because I didn't keep that in mind.

This thing is that some of these PublishingStep (which a Plugin is) are generating content, that other plugins might be using. So the addMarkdownFiles() actually adds the Markdown content and then you install the readingTime() step. If you do this in the opposite order, all articles gets a reading time of 0, because there are no Markdown files added at that point.

I had a similar issue, where I was sorting some of the articles after I generated the HTML. I know... It sounds like I am an idiot, but I did not realise the meaning of the order of these steps at first.

So... Order matters. I am currently using the following plugins:

The following sections describe some of the problems I stumbled upon using these.

Sass / Scss

As a native app-developer I have always hated CSS. Historically, I found some kind of peace in SCSS, so I needed a solution for that and found the SassPublishPlugin. First of all you need to have the libsass tool installed on your Mac:

brew install libsass

Well that part is in the README of the plugin, so that was pretty straight forward if you read the instructions 🙄

When you install the plugin you need to provide the path of the SCSS file and the destination of the generated CSS file.

try MyWebsite()
    .publish(using: [
        .installPlugin(
            .compileSass(
                sassFilePath: "Resources/MyWebsiteTheme/styles.scss",
                cssFilePath: "/styles.css",
                compressed: true
            )
        )
    ])

The path you pick for the CSS file is important for this to work. The path of the CSS stylesheet is also referenced in the HTMLFactory to link the stylesheet correctly in the head section. Since I am using a customHead function which links the stylesheet, I had to make sure that I aligned these paths.

static func customHead<T: Website>(
    for location: Location,
    on site: T,
    titleSeparator: String = " | ",
    stylesheetPaths: [Path] = ["/styles.css"],
    rssFeedPath: Path? = .defaultForRSSFeed,
    rssFeedTitle: String? = nil
) -> Node

Splash

To get syntax highlighting for code snippets in my blog posts, I used the SplashPublishPlugin. This is an official plugin by John Sundell, and it works great for adding beautiful syntax highlighting to your Swift code.

First, I installed the plugin by adding it to my publishing steps:

try MyWebsite()
    .publish(using: [
        .installPlugin(.splash(withClassPrefix: "splash-"))
        // ...
    ])

The withClassPrefix parameter allowed me to specify a prefix for the CSS classes that Splash generates. This is useful if you want to avoid conflicts with other CSS on your site. In my case, I used the prefix splash-.

Next, I added the necessary CSS to style the highlighted code. You can find the default Splash CSS in the SplashPublishPlugin repository. I copied this CSS, converted it to SCSS and pasted it my own stylesheet and adjusted it to match my site's design.

$code-block-base-text-color: #a9bcbc;
$code-block-keyword-color: #e73289;
$code-block-type-color: #8281ca;
// ... 

pre {
    // ...

    code {
        color: $code-block-base-text-color;
        // ...

        .splash {
            &-keyword {
                color: $code-block-keyword-color;
            }
            &-type {
                color: $code-block-type-color;
            }
            // ...
        }
    }
}

By configuring the plugin and adding the appropriate SCSS, I was able to achieve consistent and visually appealing syntax highlighting for all my Swift code snippets. As you can see above, it doesn't work well for other languages like SCSS 🙈

Using custom metadata

It became clear to me pretty quickly, that Publish supports YAML Front matter for metadata in the Markdown files, which is parsed and used in the HTMLFactory:

---
title: This my blog post title
date: 2025-01-01 10:00
description: Description of my incredible blog post.
tags: Publish, CSS
---

The README of Publish also stated this:

"... it’s recommended that you build your own website-specific theme — that can make full use of your own custom metadata, ..."

So I must be able to build custom metadata. But how? As all the other stuff the documentation did not help in any way. I found that it actually was pretty straight forward to add custom metadata.

I wanted to have a property to indicate that an article was a draft and shouldn't be published yet, so I introduced a draft property:

---
title: This my blog post title
date: 2025-01-01 10:00
description: Description of my incredible blog post.
tags: Publish, CSS
draft: true
---

To parse this and use in our data, I defined my ItemMetadata like this:

  struct ItemMetadata: WebsiteItemMetadata {
    /// Indicates whether the post may be published or not.
    var draft: Bool?
}

To use that metadata property, I built a Predicate which could be used to remove all Markdown files, with draft: true, in the publishing step.

extension Predicate<Item<MyWebsite>> {
    static var draftPredicate: Publish.Predicate<Item<MyWebsite>> {
        .init(matcher: { item in item.metadata.draft == true })
    }
}

// ...

try MyWebsite()
    .publish(using: [
        // ..
        .addMarkdownFiles(),
        .sortItems(by: \.date, order: .descending),
        .removeAllItems(in: .blog, matching: .draftPredicate),
        .removeAllItems(in: .talks, matching: .draftPredicate),
        //...
    ])

Custom Deploy

As one of the last things I looked into the deployment possibilities of Publish. I noticed that it comes with some default options like .github and .git and I began to wonder. Could I be building my own DeploymentMethod for publishing?

So my ambitions with publishing this website was pretty low. I am hosting most of my content in Docker containers on my own NAS in the basement of our house. Since this is just a static generated website I can easily host it by using a simple nginx Docker container. Thereby, I can connect to my NAS and place the generated files in the mounted volume for this container, and everything is deployed.

This sounds pretty easy and it is. But I also wanted this deployment method to make a backup before overwriting all the old content, so I built custom DeploymentMethod that does this:

  1. Create output folder locally on my Mac.
  2. Create the deployment and backup folders on the NAS, if they doesn't exist.
  3. Remove previous backup and copy current deployment into the backup folder.
  4. Remove all files in the deployment folder.
  5. Copy files from the deployment folder locally on my Mac to the deployment folder on the NAS.
    • If the deployment fails, then move the files from the backup folder back into the deployment folder immediately.
    • If the backup recovery fails, then make that very clear to me, by using an extensive amount of log messages 🔥
import Publish
import AppKit
import Foundation
import Files
import ShellOut

extension DeploymentMethod {
    private static func deployLog(_ message: String) {
        fputs("\(message)\n", stdout)
    }
    
    static var moveToNAS: Self {
        DeploymentMethod(name: "Move to NAS") { context in
            deployLog("🔥 Warming up for deploy...\n")
            // Get the output folder (where Publish generates files)
            let outputFolder = try context.createDeploymentFolder(withPrefix: "NAS") { folder in
                // Clear previous deploy
                try folder.empty()
            }
            
            let baseDeploymentPath = "/Volumes/docker/websites/dev.kalhoej.com"

            let baseDeploymentFolder = try Folder(path: baseDeploymentPath)
            let deploymentFolder = try baseDeploymentFolder.createSubfolderIfNeeded(withName: "www")
            let backupFolder = try baseDeploymentFolder.createSubfolderIfNeeded(withName: "backup")
            
            // Backup
            deployLog("🔨 Preparing backup folder...")
            do {
                try cleanDestinationAndCopyFiles(source: deploymentFolder, destination: backupFolder)
            } catch {
                deployLog("💥 Failed to create backup - aborting deployment!")
                throw error
            }
            deployLog(
                """
                💾 Created backup before starting deployment
                Backup located at: '\(backupFolder.path)'
                """
            )
            
            // Deployment
            deployLog("🔨 Preparing deployment folder...")
            try deploymentFolder.empty()
            
            // Move all files from the output folder to the destination folder
            do {
                try cleanDestinationAndCopyFiles(source: outputFolder, destination: deploymentFolder)
            } catch {
                deployLog("🚨 !!DEPLOYMENT FAILED!! 🚨 ")
                deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                deployLog("🚒💨 Initiating deployment from backup...")
                do {
                    try cleanDestinationAndCopyFiles(source: backupFolder, destination: deploymentFolder)
                    deployLog("🧯👨🏻‍🚒 Successfully recovered from backup.")
                    return
                } catch {
                    deployLog("🚨 !!RECOVERY FAILED!! 🚨 ")
                    deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                    deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                    deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                    deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                    deployLog("🔥🔥🔥🔥🔥🔥🔥🔥")
                    deployLog("THIS DEPLOYMENT IS GETTING OUT OF CONTROL")
                    deployLog("The website is expected to be down or in a broken state now.")
                    deployLog("🚨😱 You will have to recover from this manually.")
                }
            }
            
            deployLog(
                """
                🚀 Deployment completed.
                Deployment located at: '\(deploymentFolder.path)'
                """
            )
            
            let url = URL(string: "https://dev.kalhoej.com")!
            deployLog("🌍 Opening website - \(url)")
            NSWorkspace.shared.open(url)
        }
    }
    
    private static func cleanDestinationAndCopyFiles(source: Folder, destination: Folder) throws {
        deployLog("🧹 Cleaning destination folder: '\(destination.path)'")
        try destination.empty()
        
        deployLog("🗄️ Copying files from '\(source.path)' to '\(destination.path)'")
        try source.files.recursive.forEach { file in
            do {
                let fileDestinationPath: Folder
                if let parentPath = file.parent?.path(relativeTo: source) {
                    fileDestinationPath = try destination.createSubfolderIfNeeded(at: parentPath)
                } else {
                    fileDestinationPath = destination
                }
                try file.copy(to: fileDestinationPath)
            } catch {
                deployLog("❌ Failed to copy file: \(file.path)\nERROR: \(error.localizedDescription)")
                throw error
            }
        }
    }
}

Conclusion

Building a website with Publish has been both rewarding and challenging. While its flexibility allows for extensive customization, the lack of documentation often made the process frustrating. Through trial and error, I learned to navigate its quirks, from creating custom themes and metadata to managing plugins and deployment. Despite the hurdles, the end result is a highly tailored website that meets my needs.

Tags: