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:
- Make
outputFolderName
an argument of thePublishingPipeline.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 {
- 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,
…