27 December 2021

27 December 2021, 12:01 (Published)

Extend Swift Publish with item statuses and build targets

This blog is built using Swift Publish, customised such that the Markdown front matter of every post and page must specify a status for the item (one of published, draft or hidden. The status of each item determines whether a staging or public build of the website will include that particular post or page.

This post describes the changes that item statuses and build targets require on top of vanilla Swift Publish. Additionally, you can read about the rationale on why I did this.

The Blog type

The Website implementation driving this blog is, shockingly, called Blog.

It defines a Target enum for the build targets, and an ItemMetadata.Status enum for the known status values:

struct Blog: Website {
    …
    enum Target: String {
        case staging
        case `public`
        
        var itemStatuses: [ItemMetadata.Status] {
            switch self {
            case .public:
                return [.published]
            case .staging:
                return [.published, .draft]
            }
        }
        
        var folderName: String {
            switch self {
            case .staging:
                return "Build/Staging"
            case .public:
                return "Build/Public"
            }
        }
    }
    
    struct ItemMetadata: WebsiteItemMetadata {
        enum Status: String, Codable, Hashable {
            case hidden
            case draft
            case published
        }

        var status: Status
    }
    …

Note: implemeting item status this way ensures, without futher ado, that if you omit the status field from the Markdown front matter of a post, Publish will fail to build the website, printing out the cause to the console like so:

[path] posts/swift-publish-build-targets-item-statuses.md
[info] Missing metadata value for key 'status'

The item status filter plugin

Ensuring that only the items with the correct status are included in the built is achieved by a simple plugin:

extension Plugin where Site == Blog {
    static func filterItemsByStatus(_ keeperStatuses: [Blog.ItemMetadata.Status]) -> Self {
        Plugin(name: "FilterItemsByStatus") { context in
            context.mutateAllSections { section in
                section.removeItems(matching: Predicate<Item<Blog>>(matcher: { item in
                    !keeperStatuses.contains(item.metadata.status)
                }))
            }
        }
    }
}

Assuming you have the desired Blog.Target in a target argument, the plugin is inserted between the standard fare Markdown and sorting build steps like so:

     try Blog().publish(
    …
        using: [
            …
            .copyResources(),
            .addMarkdownFiles(),
            .installPlugin(.filterItemsByStatus(target.itemStatuses)),
            .sortItems(by: \.date, order: .descending),
            …
        ] 
    … 

Note:

Build targets

Finally, to have Publish output saved in a target-specific directory for the staging or public target, requires modifying a for of Publish itself. This particular detail, unfortunately, goes a few layers deep into the implementation, such that you will want to:

  1. Make outputFolderName an argument of the PublishingPipeline.setUpFolders() method (rather than a local constant with the value "Output" assigned in the method body:
     func setUpFolders(withExplicitRootPath path: Path?,
                      outputFolderName: String,
                      shouldEmptyOutputFolder: Bool) throws -> Folder.Group {
  1. Pass the argument through PublishingPipeline.execute():
extension PublishingPipeline {
    func execute(for site: Site, at path: Path?, outputFolderName: String) throws -> PublishedWebsite<Site> {
        let stepKind = resolveStepKind()

        let folders = try setUpFolders(
            withExplicitRootPath: path,
            outputFolderName: outputFolderName,
            shouldEmptyOutputFolder: stepKind == .generation
        )
    … 

…as well as Website.publish()

     @discardableResult
    func publish(at path: Path? = nil,
                 outputFolderName: String = "Output",
                 using steps: [PublishingStep<Self>],
                 file: StaticString = #file) throws -> PublishedWebsite<Self> {
        let pipeline = PublishingPipeline(
            steps: steps,
            originFilePath: Path("\(file)")
        )

        return try pipeline.execute(for: self, at: path, outputFolderName: outputFolderName)
    }

…and, finally, make use of it, along the lines of:

     try Blog().publish(
        at: nil,
        outputFolderName: target.folderName,
        …