From 71a3bd17ae1fcff20d285f72d17742c131e2ae31 Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Thu, 23 Nov 2023 02:26:05 +0100 Subject: [PATCH] feat: Simplify API by getting rid of SiteMetadata Saga doesn't use the site metadata at all, and instead of Saga passing it around and having to deal with the generic types, your site's code can just create whatever struct or enum holding whatever values you want to use in your templates. --- Example/Package.resolved | 12 +++--- Example/Package.swift | 2 +- Example/Sources/Example/run.swift | 20 ++++----- Example/Sources/Example/templates.swift | 56 +++++++++++-------------- Sources/Saga/ProcessingStep.swift | 10 ++--- Sources/Saga/RenderingContexts.swift | 11 +++-- Sources/Saga/Saga.swift | 13 ++---- Sources/Saga/Writer.swift | 38 ++++++++--------- 8 files changed, 71 insertions(+), 91 deletions(-) diff --git a/Example/Package.resolved b/Example/Package.resolved index 056a3df..2d02558 100644 --- a/Example/Package.resolved +++ b/Example/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/loopwerk/Parsley", "state": { "branch": null, - "revision": "10da1efa3ea278a4828b8c87fd430bdfa326ffbc", - "version": "0.8.0" + "revision": "3240bdfee97f3bbde5c4ec150a29b0abcb0d3d21", + "version": "0.9.0" } }, { @@ -33,8 +33,8 @@ "repositoryURL": "https://github.com/loopwerk/SagaParsleyMarkdownReader", "state": { "branch": null, - "revision": "dff2d3fa3f4eb83b74ec026cdf9b2f62df0383af", - "version": "0.5.0" + "revision": "d4fe9fca9829c1fb294af0160c23d5fc4bcf5fe4", + "version": "0.6.0" } }, { @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/loopwerk/SagaSwimRenderer", "state": { "branch": null, - "revision": "b4b833d20846c19581f4a0328cfdefa8d4e564ed", - "version": "0.6.1" + "revision": "f53481e5c9972b83ca2d16ca2b962c63d460e23f", + "version": "0.7.0" } }, { diff --git a/Example/Package.swift b/Example/Package.swift index 83ccba0..3266986 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -10,7 +10,7 @@ let package = Package( dependencies: [ .package(path: "../"), .package(url: "https://github.com/loopwerk/SagaParsleyMarkdownReader", from: "0.5.0"), - .package(url: "https://github.com/loopwerk/SagaSwimRenderer", from: "0.6.1"), + .package(url: "https://github.com/loopwerk/SagaSwimRenderer", from: "0.7.0"), ], targets: [ .executableTarget( diff --git a/Example/Sources/Example/run.swift b/Example/Sources/Example/run.swift index a43e1fb..6980641 100644 --- a/Example/Sources/Example/run.swift +++ b/Example/Sources/Example/run.swift @@ -4,6 +4,12 @@ import PathKit import SagaParsleyMarkdownReader import SagaSwimRenderer +enum SiteMetadata { + static let url = URL(string: "http://www.example.com")! + static let name = "Example website" + static let author = "Kevin Renskers" +} + struct ArticleMetadata: Metadata { let tags: [String] var summary: String? @@ -15,18 +21,6 @@ struct AppMetadata: Metadata { let images: [String]? } -// SiteMetadata is given to every template. -// You can put whatever you want in here, as long as it's Decodable. -struct SiteMetadata: Metadata { - let url: URL - let name: String -} - -let siteMetadata = SiteMetadata( - url: URL(string: "http://www.example.com")!, - name: "Example website" -) - // An easy way to only get public articles, since ArticleMetadata.public is optional extension Item where M == ArticleMetadata { var `public`: Bool { @@ -63,7 +57,7 @@ struct Run { }() static func main() async throws { - try await Saga(input: "content", output: "deploy", siteMetadata: siteMetadata) + try await Saga(input: "content", output: "deploy") // All markdown files within the "articles" subfolder will be parsed to html, // using ArticleMetadata as the Item's metadata type. // Furthermore we are only interested in public articles. diff --git a/Example/Sources/Example/templates.swift b/Example/Sources/Example/templates.swift index a9c8419..7802a5a 100644 --- a/Example/Sources/Example/templates.swift +++ b/Example/Sources/Example/templates.swift @@ -3,10 +3,10 @@ import Saga import SagaSwimRenderer import Foundation -func baseHtml(siteMetadata: SiteMetadata, title pageTitle: String, @NodeBuilder children: () -> NodeConvertible) -> Node { +func baseHtml(title pageTitle: String, @NodeBuilder children: () -> NodeConvertible) -> Node { html(lang: "en-US") { head { - title { siteMetadata.name+": "+pageTitle } + title { SiteMetadata.name+": "+pageTitle } link(href: "/static/style.css", rel: "stylesheet") link(href: "/static/prism.css", rel: "stylesheet") } @@ -34,8 +34,8 @@ extension Date { } } -func renderArticle(context: ItemRenderingContext) -> Node { - return baseHtml(siteMetadata: context.siteMetadata, title: context.item.title) { +func renderArticle(context: ItemRenderingContext) -> Node { + return baseHtml(title: context.item.title) { div(id: "article") { h1 { context.item.title } h2 { @@ -85,24 +85,24 @@ func renderPagination(_ paginator: Paginator?) -> Node { } } -func renderArticles(context: ItemsRenderingContext) -> Node { - baseHtml(siteMetadata: context.siteMetadata, title: "Articles") { +func renderArticles(context: ItemsRenderingContext) -> Node { + baseHtml(title: "Articles") { h1 { "Articles" } context.items.map(articleInList) renderPagination(context.paginator) } } -func renderPartition(context: PartitionedRenderingContext) -> Node { - baseHtml(siteMetadata: context.siteMetadata, title: "Articles in \(context.key)") { +func renderPartition(context: PartitionedRenderingContext) -> Node { + baseHtml(title: "Articles in \(context.key)") { h1 { "Articles in \(context.key)" } context.items.map(articleInList) renderPagination(context.paginator) } } -func renderPage(context: ItemRenderingContext) -> Node { - baseHtml(siteMetadata: context.siteMetadata, title: context.item.title) { +func renderPage(context: ItemRenderingContext) -> Node { + baseHtml(title: context.item.title) { div(id: "page") { h1 { context.item.title } Node.raw(context.item.body) @@ -117,8 +117,8 @@ func renderPage(context: ItemRenderingContext) -> N } } -func renderApps(context: ItemsRenderingContext) -> Node { - baseHtml(siteMetadata: context.siteMetadata, title: "Apps") { +func renderApps(context: ItemsRenderingContext) -> Node { + baseHtml(title: "Apps") { h1 { "Apps" } context.items.map { app in div(class: "app") { @@ -151,36 +151,28 @@ extension Item where M == ArticleMetadata { } } -func renderFeed(context: ItemsRenderingContext) -> Node { +func renderFeed(context: ItemsRenderingContext) -> Node { AtomFeed( - title: context.siteMetadata.name, - author: "Kevin Renskers", - baseURL: context.siteMetadata.url, - pagePath: "articles/", - feedPath: "articles/feed.xml", + title: SiteMetadata.name, + author: SiteMetadata.author, + baseURL: SiteMetadata.url, + feedPath: context.outputPath.string, items: Array(context.items.prefix(20)), summary: { item in - if let article = item as? Item { - return article.summary - } - return nil + return item.summary } ).node() } -func renderTagFeed(context: PartitionedRenderingContext) -> Node { +func renderTagFeed(context: PartitionedRenderingContext) -> Node { AtomFeed( - title: context.siteMetadata.name, - author: "Kevin Renskers", - baseURL: context.siteMetadata.url, - pagePath: "articles/tag/\(context.key)/", - feedPath: "articles/tag/\(context.key)/feed.xml", + title: SiteMetadata.name, + author: SiteMetadata.author, + baseURL: SiteMetadata.url, + feedPath: context.outputPath.string, items: Array(context.items.prefix(20)), summary: { item in - if let article = item as? Item { - return article.summary - } - return nil + return item.summary } ).node() } diff --git a/Sources/Saga/ProcessingStep.swift b/Sources/Saga/ProcessingStep.swift index 4ec21d2..a7969d3 100644 --- a/Sources/Saga/ProcessingStep.swift +++ b/Sources/Saga/ProcessingStep.swift @@ -1,14 +1,14 @@ import Foundation import PathKit -internal class ProcessStep { +internal class ProcessStep { let folder: Path? let readers: [Reader] let filter: (Item) -> Bool - let writers: [Writer] + let writers: [Writer] var items: [Item] - init(folder: Path?, readers: [Reader], filter: @escaping (Item) -> Bool, writers: [Writer]) { + init(folder: Path?, readers: [Reader], filter: @escaping (Item) -> Bool, writers: [Writer]) { self.folder = folder self.readers = readers self.filter = filter @@ -21,7 +21,7 @@ internal class AnyProcessStep { let runReaders: () async throws -> () let runWriters: () throws -> () - init(step: ProcessStep, fileStorage: [FileContainer], inputPath: Path, outputPath: Path, itemWriteMode: ItemWriteMode, siteMetadata: SiteMetadata, fileIO: FileIO) { + init(step: ProcessStep, fileStorage: [FileContainer], inputPath: Path, outputPath: Path, itemWriteMode: ItemWriteMode, fileIO: FileIO) { runReaders = { var items = [Item]() @@ -71,7 +71,7 @@ internal class AnyProcessStep { .sorted(by: { left, right in left.date > right.date }) for writer in step.writers { - try writer.run(step.items, allItems, siteMetadata, outputPath, step.folder ?? "", fileIO) + try writer.run(step.items, allItems, outputPath, step.folder ?? "", fileIO) } } } diff --git a/Sources/Saga/RenderingContexts.swift b/Sources/Saga/RenderingContexts.swift index 0dc937a..c16accf 100644 --- a/Sources/Saga/RenderingContexts.swift +++ b/Sources/Saga/RenderingContexts.swift @@ -1,26 +1,25 @@ import PathKit -public struct ItemRenderingContext { +public struct ItemRenderingContext { public let item: Item public let items: [Item] public let allItems: [AnyItem] - public let siteMetadata: SiteMetadata } -public struct ItemsRenderingContext { +public struct ItemsRenderingContext { public let items: [Item] public let allItems: [AnyItem] - public let siteMetadata: SiteMetadata public let paginator: Paginator? + public let outputPath: Path } public typealias ContextKey = CustomStringConvertible & Comparable -public struct PartitionedRenderingContext { +public struct PartitionedRenderingContext { public let key: T public let items: [Item] public let allItems: [AnyItem] - public let siteMetadata: SiteMetadata public let paginator: Paginator? + public let outputPath: Path } /// A model representing a paginator. diff --git a/Sources/Saga/Saga.swift b/Sources/Saga/Saga.swift index 22ab772..557f99d 100644 --- a/Sources/Saga/Saga.swift +++ b/Sources/Saga/Saga.swift @@ -7,7 +7,7 @@ import PathKit /// @main /// struct Run { /// static func main() async throws { -/// try await Saga(input: "content", output: "deploy", siteMetadata: EmptyMetadata()) +/// try await Saga(input: "content", output: "deploy") /// // All files in the input folder will be parsed to html, and written to the output folder. /// .register( /// metadata: EmptyMetadata.self, @@ -26,7 +26,7 @@ import PathKit /// } /// } /// ``` -public class Saga { +public class Saga { /// The root working path. This is automatically set to the same folder that holds `Package.swift`. public let rootPath: Path @@ -36,21 +36,17 @@ public class Saga { /// The path that Saga will write the rendered website to, relative to the `rootPath`. For example "deploy". public let outputPath: Path - /// The metadata used to hold site-wide information, such as the website name or URL. This will be included in all rendering contexts. - public let siteMetadata: SiteMetadata - /// An array of all file containters. public let fileStorage: [FileContainer] internal var processSteps = [AnyProcessStep]() internal let fileIO: FileIO - public init(input: Path, output: Path = "deploy", siteMetadata: SiteMetadata, fileIO: FileIO = .diskAccess, originFilePath: StaticString = #file) throws { + public init(input: Path, output: Path = "deploy", fileIO: FileIO = .diskAccess, originFilePath: StaticString = #file) throws { let originFile = Path("\(originFilePath)") rootPath = try fileIO.resolveSwiftPackageFolder(originFile) inputPath = rootPath + input outputPath = rootPath + output - self.siteMetadata = siteMetadata self.fileIO = fileIO // 1. Find all files in the source folder @@ -75,7 +71,7 @@ public class Saga { /// - writers: The writers that will be used by this step. /// - Returns: The Saga instance itself, so you can chain further calls onto it. @discardableResult - public func register(folder: Path? = nil, metadata: M.Type, readers: [Reader], itemWriteMode: ItemWriteMode = .moveToSubfolder, filter: @escaping ((Item) -> Bool) = { _ in true }, writers: [Writer]) throws -> Self { + public func register(folder: Path? = nil, metadata: M.Type, readers: [Reader], itemWriteMode: ItemWriteMode = .moveToSubfolder, filter: @escaping ((Item) -> Bool) = { _ in true }, writers: [Writer]) throws -> Self { let step = ProcessStep(folder: folder, readers: readers, filter: filter, writers: writers) self.processSteps.append( .init( @@ -84,7 +80,6 @@ public class Saga { inputPath: inputPath, outputPath: outputPath, itemWriteMode: itemWriteMode, - siteMetadata: siteMetadata, fileIO: fileIO )) return self diff --git a/Sources/Saga/Writer.swift b/Sources/Saga/Writer.swift index 2a56e3e..e01a3b1 100644 --- a/Sources/Saga/Writer.swift +++ b/Sources/Saga/Writer.swift @@ -6,8 +6,8 @@ import Foundation /// To turn an ``Item`` into a `String`, a `Writer` uses a "renderer"; a function that knows how to turn a rendering context such as ``ItemRenderingContext`` into a `String`. /// /// > Note: Saga does not come bundled with any renderers out of the box, instead you should install one such as [SagaSwimRenderer](https://github.com/loopwerk/SagaSwimRenderer) or [SagaStencilRenderer](https://github.com/loopwerk/SagaStencilRenderer). -public struct Writer { - let run: (_ items: [Item], _ allItems: [AnyItem], _ siteMetadata: SiteMetadata, _ outputRoot: Path, _ outputPrefix: Path, _ fileIO: FileIO) throws -> Void +public struct Writer { + let run: (_ items: [Item], _ allItems: [AnyItem], _ outputRoot: Path, _ outputPrefix: Path, _ fileIO: FileIO) throws -> Void } private extension Array { @@ -20,10 +20,10 @@ private extension Array { public extension Writer { /// Writes a single ``Item`` to a single output file, using `Item.destination` as the destination path. - static func itemWriter(_ renderer: @escaping (ItemRenderingContext) throws -> String) -> Self { - Writer { items, allItems, siteMetadata, outputRoot, outputPrefix, fileIO in + static func itemWriter(_ renderer: @escaping (ItemRenderingContext) throws -> String) -> Self { + Writer { items, allItems, outputRoot, outputPrefix, fileIO in for item in items { - let context = ItemRenderingContext(item: item, items: items, allItems: allItems, siteMetadata: siteMetadata) + let context = ItemRenderingContext(item: item, items: items, allItems: allItems) let stringToWrite = try renderer(context) try fileIO.write(outputRoot + item.relativeDestination, stringToWrite) } @@ -31,10 +31,10 @@ public extension Writer { } /// Writes an array of items into a single output file. - static func listWriter(_ renderer: @escaping (ItemsRenderingContext) throws -> String, output: Path = "index.html", paginate: Int? = nil, paginatedOutput: Path = "page/[page]/index.html") -> Self { - return Self { items, allItems, siteMetadata, outputRoot, outputPrefix, fileIO in - try writePages(renderer: renderer, items: items, allItems: allItems, siteMetadata: siteMetadata, outputRoot: outputRoot, outputPrefix: outputPrefix, output: output, paginate: paginate, paginatedOutput: paginatedOutput, fileIO: fileIO) { - return ItemsRenderingContext(items: $0, allItems: $1, siteMetadata: $2, paginator: $3) + static func listWriter(_ renderer: @escaping (ItemsRenderingContext) throws -> String, output: Path = "index.html", paginate: Int? = nil, paginatedOutput: Path = "page/[page]/index.html") -> Self { + return Self { items, allItems, outputRoot, outputPrefix, fileIO in + try writePages(renderer: renderer, items: items, allItems: allItems, outputRoot: outputRoot, outputPrefix: outputPrefix, output: output, paginate: paginate, paginatedOutput: paginatedOutput, fileIO: fileIO) { + return ItemsRenderingContext(items: $0, allItems: $1, paginator: $2, outputPath: $3) } } } @@ -45,22 +45,22 @@ public extension Writer { /// /// The `output` path is a template where `[key]` will be replaced with the key used for the partition. /// Example: `articles/[key]/index.html` - static func partitionedWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "[key]/page/[page]/index.html", partitioner: @escaping ([Item]) -> [T: [Item]]) -> Self { - return Self { items, allItems, siteMetadata, outputRoot, outputPrefix, fileIO in + static func partitionedWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "[key]/page/[page]/index.html", partitioner: @escaping ([Item]) -> [T: [Item]]) -> Self { + return Self { items, allItems, outputRoot, outputPrefix, fileIO in let partitions = partitioner(items) for (key, itemsInPartition) in Array(partitions).sorted(by: {$0.0 < $1.0}) { let finishedOutputPath = Path(output.string.replacingOccurrences(of: "[key]", with: "\(key.slugified)")) let finishedPaginatedOutputPath = Path(paginatedOutput.string.replacingOccurrences(of: "[key]", with: "\(key.slugified)")) - try writePages(renderer: renderer, items: itemsInPartition, allItems: allItems, siteMetadata: siteMetadata, outputRoot: outputRoot, outputPrefix: outputPrefix, output: finishedOutputPath, paginate: paginate, paginatedOutput: finishedPaginatedOutputPath, fileIO: fileIO) { - return PartitionedRenderingContext(key: key, items: $0, allItems: $1, siteMetadata: $2, paginator: $3) + try writePages(renderer: renderer, items: itemsInPartition, allItems: allItems, outputRoot: outputRoot, outputPrefix: outputPrefix, output: finishedOutputPath, paginate: paginate, paginatedOutput: finishedPaginatedOutputPath, fileIO: fileIO) { + return PartitionedRenderingContext(key: key, items: $0, allItems: $1, paginator: $2, outputPath: $3) } } } } /// A convenience version of `partitionedWriter` that splits items based on year. - static func yearWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "[key]/page/[page]/index.html") -> Self { + static func yearWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "[key]/page/[page]/index.html") -> Self { let partitioner: ([Item]) -> [Int: [Item]] = { items in var itemsPerYear = [Int: [Item]]() @@ -83,7 +83,7 @@ public extension Writer { /// A convenience version of `partitionedWriter` that splits items based on tags. /// /// Tags can be any `[String]` array. - static func tagWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "tag/[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "tag/[key]/page/[page]/index.html", tags: @escaping (Item) -> [String]) -> Self { + static func tagWriter(_ renderer: @escaping (PartitionedRenderingContext) throws -> String, output: Path = "tag/[key]/index.html", paginate: Int? = nil, paginatedOutput: Path = "tag/[key]/page/[page]/index.html", tags: @escaping (Item) -> [String]) -> Self { let partitioner: ([Item]) -> [String: [Item]] = { items in var itemsPerTag = [String: [Item]]() @@ -106,7 +106,7 @@ public extension Writer { } private extension Writer { - static func writePages(renderer: @escaping (Context) throws -> String, items: [Item], allItems: [AnyItem], siteMetadata: SiteMetadata, outputRoot: Path, outputPrefix: Path, output: Path, paginate: Int?, paginatedOutput: Path, fileIO: FileIO, getContext: ([Item], [AnyItem], SiteMetadata, Paginator?) -> Context) throws { + static func writePages(renderer: @escaping (Context) throws -> String, items: [Item], allItems: [AnyItem], outputRoot: Path, outputPrefix: Path, output: Path, paginate: Int?, paginatedOutput: Path, fileIO: FileIO, getContext: ([Item], [AnyItem], Paginator?, Path) -> Context) throws { if let perPage = paginate { let ranges = items.chunked(into: perPage) let numberOfPages = ranges.count @@ -123,7 +123,7 @@ private extension Writer { next: numberOfPages > 1 ? (outputPrefix + nextPage) : nil ) - let context = getContext(firstItems, allItems, siteMetadata, paginator) + let context = getContext(firstItems, allItems, paginator, outputPrefix + output) let stringToWrite = try renderer(context) try fileIO.write(outputRoot + outputPrefix + output, stringToWrite) } @@ -143,12 +143,12 @@ private extension Writer { ) let finishedOutputPath = Path(paginatedOutput.string.replacingOccurrences(of: "[page]", with: "\(currentPage)")) - let context = getContext(items, allItems, siteMetadata, paginator) + let context = getContext(items, allItems, paginator, outputPrefix + finishedOutputPath) let stringToWrite = try renderer(context) try fileIO.write(outputRoot + outputPrefix + finishedOutputPath, stringToWrite) } } else { - let context = getContext(items, allItems, siteMetadata, nil) + let context = getContext(items, allItems, nil, outputPrefix + output) let stringToWrite = try renderer(context) try fileIO.write(outputRoot + outputPrefix + output, stringToWrite) }