diff --git a/Readme.md b/Readme.md index 4368679..5ba8492 100644 --- a/Readme.md +++ b/Readme.md @@ -69,7 +69,7 @@ Execution, trying TDD for the first time: - [x] 301 redirect /archive to /posts, and update the header link - - [x] Check and delete _data.json filse + - [x] Check and delete _data.json files - [x] Search for other _data.json and .ejs files and eliminate any that are found @@ -79,9 +79,21 @@ Execution, trying TDD for the first time: - [x] Find a way to add the site name to HTML titles rendered by plugins -- [ ] Clean up the posts plugin +- [x] Clean up the posts plugin - - [ ] Why don't plain data structures always work with Stencil? Maybe computed properties are a no-go but we can at least use structs instead of dictionaries for the actual rendering + - [x] Why don't plain data structures always work with Stencil? Maybe computed properties are a no-go but we can at least use structs instead of dictionaries for the actual rendering + + - [x] Separate I/O from transformations + + - [x] Factor the core logic out of PostsPlugin ... separate I/O from transformations? Is that an improvement or does it obscure what's happening? + + - [x] Stop validating metadata in Post, do that when rendering markdown + + - [x] Remove RenderedPost + + - [x] Move all dictionary conversions for use in template contexts to extensions + + - [x] Stop using dictionaries for template contexts, use structs w/ computed properties - [ ] Consider using Swift for samhuri.net as well, and then making SiteGenerator a package that it uses ... then we can use Plot or pointfree.co's swift-html diff --git a/SiteGenerator/Sources/SiteGenerator/FunctionComposition.swift b/SiteGenerator/Sources/SiteGenerator/FunctionComposition.swift new file mode 100644 index 0000000..9e6b8d3 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/FunctionComposition.swift @@ -0,0 +1,34 @@ +// +// FunctionComposition.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-11-18. +// + +import Foundation + +infix operator |> :AdditionPrecedence + +// MARK: Synchronous + +public func |> ( + f: @escaping (A) -> B, + g: @escaping (B) -> C +) -> (A) -> C { + return { a in + let b = f(a) + let c = g(b) + return c + } +} + +public func |> ( + f: @escaping (A) throws -> B, + g: @escaping (B) throws -> C +) -> (A) throws -> C { + return { a in + let b = try f(a) + let c = try g(b) + return c + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/Page.swift b/SiteGenerator/Sources/SiteGenerator/Generator/Page.swift index d2df94c..23929ec 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/Page.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/Page.swift @@ -17,8 +17,12 @@ struct Page { extension Page { init(metadata: [String: String]) { let template = metadata["Template"] - let styles = metadata["Styles", default: ""].split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - let scripts = metadata["Scripts", default: ""].split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + let styles = metadata["Styles", default: ""] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + let scripts = metadata["Scripts", default: ""] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } let title = metadata["Title", default: ""] self.init(title: title, template: template, styles: styles, scripts: scripts) } diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift b/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift index e5b04fd..4bd0a9b 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift @@ -30,7 +30,6 @@ final class SiteTemplateRenderer: TemplateRenderer { func renderTemplate(name: String?, context: [String: Any]) throws -> String { let siteContext = SiteContext(site: site, template: name) let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new }) - print("Rendering \(siteContext.template) with context keys: \(contextDict.keys.sorted().joined(separator: ", "))") return try stencil.renderTemplate(name: "\(siteContext.template).html", context: contextDict) } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/Month.swift b/SiteGenerator/Sources/SiteGenerator/Posts/Month.swift index 0f1fd16..6d6e92a 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/Month.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/Month.swift @@ -8,6 +8,8 @@ import Foundation struct Month: Equatable { + static let all = (1 ... 12).map(Month.init(_:)) + static let names = [ "January", "Februrary", "March", "April", "May", "June", "July", "August", @@ -34,7 +36,7 @@ struct Month: Equatable { Month.names[number - 1] } - var abbreviatedName: String { + var abbreviation: String { String(name.prefix(3)) } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/Post.swift b/SiteGenerator/Sources/SiteGenerator/Posts/Post.swift index 3c95fb2..f492048 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/Post.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/Post.swift @@ -15,76 +15,39 @@ struct Post { let formattedDate: String let link: URL? let tags: [String] - let bodyMarkdown: String + let body: String + let path: String - var dictionary: [String: Any] { - var result: [String: Any] = [ - "slug": slug, - "title": title, - "author": author, - "day": date.day, - "month": date.month, - "year": date.year, - "formattedDate": formattedDate, - "tags": tags - ] - if let link = link { - result["isLink"] = true - result["link"] = link - } - return result - } + // These are computed properties but are computed eagerly because + // Stencil is unable to use real computed properties at this time. + let isLink: Bool + let day: Int - func dictionary(withPath path: String) -> [String: Any] { - var dict = dictionary - dict["path"] = path - return dict - } -} - -/// Posts are sorted in reverse date order. -extension Post: Comparable { - static func < (lhs: Self, rhs: Self) -> Bool { - rhs.date < lhs.date - } -} - -extension Post { - enum Error: Swift.Error { - case deficientMetadata(missingKeys: [String]) - } - - init(slug: String, bodyMarkdown: String, metadata: [String: String]) throws { + init(slug: String, title: String, author: String, date: Date, formattedDate: String, link: URL?, tags: [String], body: String, path: String) { self.slug = slug - self.bodyMarkdown = bodyMarkdown + self.title = title + self.author = author + self.date = date + self.formattedDate = formattedDate + self.link = link + self.tags = tags + self.body = body + self.path = path - let requiredKeys = ["Title", "Author", "Date", "Timestamp"] - let missingKeys = requiredKeys.filter { metadata[$0] == nil } - guard missingKeys.isEmpty else { - throw Error.deficientMetadata(missingKeys: missingKeys) - } + // Eagerly computed properties + self.isLink = link != nil + self.day = date.day + } +} - title = metadata["Title"]! - author = metadata["Author"]! - date = Date(timeIntervalSince1970: TimeInterval(metadata["Timestamp"]!)!) - formattedDate = metadata["Date"]! - if let urlString = metadata["Link"] { - link = URL(string: urlString)! - } - else { - link = nil - } - if let string = metadata["Tags"] { - tags = string.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) - } - else { - tags = [] - } +extension Post: Comparable { + static func <(lhs: Self, rhs: Self) -> Bool { + lhs.date < rhs.date } } extension Post: CustomDebugStringConvertible { var debugDescription: String { - "" + "" } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostRepo.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostRepo.swift new file mode 100644 index 0000000..29d0193 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostRepo.swift @@ -0,0 +1,85 @@ +// +// PostRepo.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-09. +// + +import Foundation + +struct RawPost { + let slug: String + let markdown: String +} + +final class PostRepo { + let postsPath = "posts" + let recentPostsCount = 10 + + let fileManager: FileManager + let postTransformer: PostTransformer + + private(set) var posts: PostsByYear! + + init(fileManager: FileManager = .default, postTransformer: PostTransformer = PostTransformer()) { + self.fileManager = fileManager + self.postTransformer = postTransformer + } + + var isEmpty: Bool { + posts == nil || posts.isEmpty + } + + var sortedPosts: [Post] { + posts?.flattened().sorted(by: >) ?? [] + } + + var recentPosts: [Post] { + Array(sortedPosts.prefix(recentPostsCount)) + } + + func postDataExists(at sourceURL: URL) -> Bool { + let postsURL = sourceURL.appendingPathComponent(postsPath) + return fileManager.fileExists(atPath: postsURL.path) + } + + func readPosts(sourceURL: URL, makePath: (Date, _ slug: String) -> String) throws { + let posts = try readRawPosts(sourceURL: sourceURL) + .map { try postTransformer.makePost(from: $0, makePath: makePath) } + self.posts = PostsByYear(posts: posts) + } + + private func readRawPosts(sourceURL: URL) throws -> [RawPost] { + let postsURL = sourceURL.appendingPathComponent(postsPath) + return try enumerateMarkdownFiles(directory: postsURL) + .compactMap { url in + do { + return try readRawPost(url: url) + } + catch { + print("error: Cannot read post from \(url): \(error)") + return nil + } + } + } + + private func readRawPost(url: URL) throws -> RawPost { + let slug = url.deletingPathExtension().lastPathComponent + let markdown = try String(contentsOf: url) + return RawPost(slug: slug, markdown: markdown) + } + + private func enumerateMarkdownFiles(directory: URL) throws -> [URL] { + return try fileManager.contentsOfDirectory(atPath: directory.path).flatMap { (filename: String) -> [URL] in + let fileURL = directory.appendingPathComponent(filename) + var isDir: ObjCBool = false + fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDir) + if isDir.boolValue { + return try enumerateMarkdownFiles(directory: fileURL) + } + else { + return fileURL.pathExtension == "md" ? [fileURL] : [] + } + } + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostTransformer.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostTransformer.swift new file mode 100644 index 0000000..89380cd --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostTransformer.swift @@ -0,0 +1,84 @@ +// +// PostTransformer.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-09. +// + +import Foundation +import Ink + +final class PostTransformer { + let markdownParser: MarkdownParser + + init(markdownParser: MarkdownParser = MarkdownParser()) { + self.markdownParser = markdownParser + } + + func makePost(from rawPost: RawPost, makePath: (Date, _ slug: String) -> String) throws -> Post { + let result = markdownParser.parse(rawPost.markdown) + let metadata = try parseMetadata(result.metadata) + let path = makePath(metadata.date, rawPost.slug) + return Post( + slug: rawPost.slug, + title: metadata.title, + author: metadata.author, + date: metadata.date, + formattedDate: metadata.formattedDate, + link: metadata.link, + tags: metadata.tags, + body: result.html, + path: path + ) + } +} + +struct ParsedMetadata { + let title: String + let author: String + let date: Date + let formattedDate: String + let link: URL? + let tags: [String] +} + +extension PostTransformer { + enum Error: Swift.Error { + case deficientMetadata(missingKeys: [String]) + case invalidTimestamp(String) + } + + func parseMetadata(_ metadata: [String: String]) throws -> ParsedMetadata { + let requiredKeys = ["Title", "Author", "Date", "Timestamp"] + let missingKeys = requiredKeys.filter { metadata[$0] == nil } + guard missingKeys.isEmpty else { + throw Error.deficientMetadata(missingKeys: missingKeys) + } + guard let timeInterval = TimeInterval(metadata["Timestamp"]!) else { + throw Error.invalidTimestamp(metadata["Timestamp"]!) + } + + let title = metadata["Title"]! + let author = metadata["Author"]! + let date = Date(timeIntervalSince1970: timeInterval) + let formattedDate = metadata["Date"]! + + let link: URL? + if let urlString = metadata["Link"] { + link = URL(string: urlString)! + } + else { + link = nil + } + + let tags: [String] + if let string = metadata["Tags"] { + tags = string.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) + } + else { + tags = [] + } + + return ParsedMetadata(title: title, author: author, date: date, formattedDate: formattedDate, link: link, tags: tags) + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift new file mode 100644 index 0000000..5fc2fb0 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift @@ -0,0 +1,139 @@ +// +// PostWriter.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-09. +// + +import Foundation + +final class PostWriter { + let fileManager: FileManager + let outputPath: String + + init(fileManager: FileManager = .default, outputPath: String = "posts") { + self.fileManager = fileManager + self.outputPath = outputPath + } + + func urlPath(year: Int) -> String { + "/\(outputPath)/\(year)" + } + + func urlPath(year: Int, month: Month) -> String { + urlPath(year: year).appending("/\(month.padded)") + } + + func urlPathForPost(date: Date, slug: String) -> String { + urlPath(year: date.year, month: Month(date.month)).appending("/\(slug).html") + } +} + +// MARK: - Post pages + +extension PostWriter { + func writePosts(_ posts: [Post], to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + for post in posts { + let postHTML = try templateRenderer.renderTemplate(name: "post", context: [ + "title": post.title, + "post": post, + ]) + let postURL = targetURL + .appendingPathComponent(outputPath) + .appendingPathComponent(filePath(date: post.date, slug: post.slug)) + try fileManager.createDirectory(at: postURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + try postHTML.write(to: postURL, atomically: true, encoding: .utf8) + } + } + + private func filePath(date: Date, slug: String) -> String { + "/\(date.year)/\(Month(date.month).padded)/\(slug).html" + } +} + +// MARK: - Recent posts page + +extension PostWriter { + func writeRecentPosts(_ recentPosts: [Post], to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + let recentPostsHTML = try templateRenderer.renderTemplate(name: "recent-posts", context: [ + "recentPosts": recentPosts, + ]) + let fileURL = targetURL.appendingPathComponent("index.html") + try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + try recentPostsHTML.write(to: fileURL, atomically: true, encoding: .utf8) + } +} + +// MARK: - Post archive page + +extension PostWriter { + func writeArchive(posts: PostsByYear, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + let allYears = posts.byYear.keys.sorted(by: >) + let archiveHTML = try templateRenderer.renderTemplate(name: "posts-archive", context: [ + "title": "Archive", + "years": allYears.map { contextDictionaryForYearPosts(posts[$0]) }, + ]) + let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html") + try fileManager.createDirectory(at: archiveURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + try archiveHTML.write(to: archiveURL, atomically: true, encoding: .utf8) + } + + private func contextDictionaryForYearPosts(_ posts: YearPosts) -> [String: Any] { + [ + "path": urlPath(year: posts.year), + "title": posts.title, + "months": posts.months.sorted(by: >).map { month in + contextDictionaryForMonthPosts(posts[month], year: posts.year) + }, + ] + } + + private func contextDictionaryForMonthPosts(_ posts: MonthPosts, year: Int) -> [String: Any] { + [ + "path": urlPath(year: year, month: posts.month), + "name": posts.month.name, + "abbreviation": posts.month.abbreviation, + "posts": posts.posts.sorted(by: >), + ] + } +} + +// MARK: - Yearly post index pages + +extension PostWriter { + func writeYearIndexes(posts: PostsByYear, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + for (year, yearPosts) in posts.byYear { + let months = yearPosts.months.sorted(by: >) + let yearDir = targetURL.appendingPathComponent(urlPath(year: year)) + let context: [String: Any] = [ + "title": yearPosts.title, + "path": urlPath(year: year), + "year": year, + "months": months.map { contextDictionaryForMonthPosts(posts[year][$0], year: year) }, + ] + let yearHTML = try templateRenderer.renderTemplate(name: "posts-year", context: context) + let yearURL = yearDir.appendingPathComponent("index.html") + try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil) + try yearHTML.write(to: yearURL, atomically: true, encoding: .utf8) + } + } +} + +// MARK: - Monthly post roll-up pages + +extension PostWriter { + func writeMonthRollups(posts: PostsByYear, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + for (year, yearPosts) in posts.byYear { + for month in yearPosts.months { + let monthDir = targetURL.appendingPathComponent(urlPath(year: year, month: month)) + let monthHTML = try templateRenderer.renderTemplate(name: "posts-month", context: [ + "title": "\(month.name) \(year)", + "posts": yearPosts[month].posts.sorted(by: >), + ]) + let monthURL = monthDir.appendingPathComponent("index.html") + try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil) + try monthHTML.write(to: monthURL, atomically: true, encoding: .utf8) + } + } + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostsByYear.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostsByYear.swift index ce06227..726284d 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/PostsByYear.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostsByYear.swift @@ -11,6 +11,10 @@ struct MonthPosts { let month: Month var posts: [Post] + var title: String { + month.padded + } + var isEmpty: Bool { posts.isEmpty } @@ -20,6 +24,18 @@ struct YearPosts { let year: Int var byMonth: [Month: MonthPosts] + var title: String { + "\(year)" + } + + var isEmpty: Bool { + byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty } + } + + var months: [Month] { + Array(byMonth.keys) + } + subscript(month: Month) -> MonthPosts { get { byMonth[month, default: MonthPosts(month: month, posts: [])] @@ -28,10 +44,6 @@ struct YearPosts { byMonth[month] = newValue } } - - var isEmpty: Bool { - byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty } - } } struct PostsByYear { @@ -60,8 +72,8 @@ struct PostsByYear { self[year][month].posts.append(post) } - /// Returns posts sorted by reverse date. + /// Returns an array of all posts. func flattened() -> [Post] { - byYear.values.flatMap { $0.byMonth.values.flatMap { $0.posts } }.sorted { $1.date < $0.date } + byYear.values.flatMap { $0.byMonth.values.flatMap { $0.posts } } } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift index f63b9c0..d6ee7d0 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift @@ -6,225 +6,38 @@ // import Foundation -import Ink final class PostsPlugin: Plugin { - let fileManager: FileManager = .default - let markdownParser = MarkdownParser() - let postsPath: String - let recentPostsPath: String + let postRepo: PostRepo + let postWriter: PostWriter - var posts: PostsByYear! - var sourceURL: URL! - - init(postsPath: String = "posts", recentPostsPath: String = "index.html") { - self.postsPath = postsPath - self.recentPostsPath = recentPostsPath + init( + postRepo: PostRepo = PostRepo(), + postWriter: PostWriter = PostWriter() + ) { + self.postRepo = postRepo + self.postWriter = postWriter } + // MARK: - Plugin methods + func setUp(sourceURL: URL) throws { - self.sourceURL = sourceURL - let postsURL = sourceURL.appendingPathComponent("posts") - guard fileManager.fileExists(atPath: postsURL.path) else { + guard postRepo.postDataExists(at: sourceURL) else { return } - let posts = try enumerateMarkdownFiles(directory: postsURL) - .compactMap { (url: URL) -> Post? in - do { - let markdown = try String(contentsOf: url) - let result = markdownParser.parse(markdown) - let slug = url.deletingPathExtension().lastPathComponent - return try Post(slug: slug, bodyMarkdown: result.html, metadata: result.metadata) - } - catch { - print("Cannot create post from markdown file \(url): \(error)") - return nil - } - } - self.posts = PostsByYear(posts: posts) + try postRepo.readPosts(sourceURL: sourceURL, makePath: postWriter.urlPathForPost) } func render(targetURL: URL, templateRenderer: TemplateRenderer) throws { - guard posts != nil, !posts.isEmpty else { + guard !postRepo.isEmpty else { return } - let postsDir = targetURL.appendingPathComponent(postsPath) - try renderPostsByDate(postsDir: postsDir, templateRenderer: templateRenderer) - try renderYears(postsDir: postsDir, templateRenderer: templateRenderer) - try renderMonths(postsDir: postsDir, templateRenderer: templateRenderer) - try renderArchive(postsDir: postsDir, templateRenderer: templateRenderer) - try renderRecentPosts(targetURL: targetURL, templateRenderer: templateRenderer) - } - - func renderPostsByDate(postsDir: URL, templateRenderer: TemplateRenderer) throws { - print("renderPostsByDate(postsDir: \(postsDir), templateRenderer: \(templateRenderer)") - for post in posts.flattened() { - let monthDir = postsDir - .appendingPathComponent("\(post.date.year)") - .appendingPathComponent(Month(post.date.month).padded) - try renderPost(post, monthDir: monthDir, templateRenderer: templateRenderer) - } - } - - func renderRecentPosts(targetURL: URL, templateRenderer: TemplateRenderer) throws { - print("renderRecentPosts(targetURL: \(targetURL), templateRenderer: \(templateRenderer)") - let recentPosts = posts.flattened().prefix(10) - let renderedRecentPosts: [[String: Any]] = recentPosts.map { post in - let html = markdownParser.html(from: post.bodyMarkdown) - let path = self.path(for: post) - return RenderedPost(path: path, post: post, body: html).dictionary - } - let recentPostsHTML = try templateRenderer.renderTemplate(name: "recent-posts", context: [ - "recentPosts": renderedRecentPosts, - ]) - let fileURL = targetURL.appendingPathComponent(recentPostsPath) - try recentPostsHTML.write(to: fileURL, atomically: true, encoding: .utf8) - } - - func renderArchive(postsDir: URL, templateRenderer: TemplateRenderer) throws { - print("renderArchive(postsDir: \(postsDir), templateRenderer: \(templateRenderer)") - let allYears = posts.byYear.keys.sorted(by: >) - let allMonths = (1 ... 12).map(Month.init(_:)) - let yearsWithPostsByMonthForContext: [[String: Any]] = allYears.map { year in - [ - "path": self.path(year: year), - "title": "\(year)", - "months": posts[year].byMonth.keys.sorted(by: >).map { (month: Month) -> [String: Any] in - let sortedPosts = posts[year][month].posts.sorted(by: { $0.date > $1.date }) - return [ - "path": self.path(year: year, month: month), - "title": month.padded, - "posts": sortedPosts.map { $0.dictionary(withPath: self.path(for: $0)) }, - ] - }, - ] - } - let context: [String: Any] = [ - "title": "Archive", - "years": yearsWithPostsByMonthForContext, - "monthNames": allMonths.reduce(into: [String: String](), { dict, month in - dict[month.padded] = month.name - }), - "monthAbbreviations": allMonths.reduce(into: [String: String](), { dict, month in - dict[month.padded] = month.abbreviatedName - }), - ] - let archiveHTML = try templateRenderer.renderTemplate(name: "posts-archive", context: context) - let archiveURL = postsDir.appendingPathComponent("index.html") - try archiveHTML.write(to: archiveURL, atomically: true, encoding: .utf8) - } - - func renderYears(postsDir: URL, templateRenderer: TemplateRenderer) throws { - print("renderYears(postsDir: \(postsDir), templateRenderer: \(templateRenderer)") - let allMonths = (1 ... 12).map(Month.init(_:)) - for (year, monthPosts) in posts.byYear.sorted(by: { $1.key < $0.key }) { - let yearDir = postsDir.appendingPathComponent("\(year)") - var sortedPostsByMonth: [Month: [Post]] = [:] - for month in allMonths { - let sortedPosts = monthPosts[month].posts.sorted(by: { $1.date < $0.date }) - if !sortedPosts.isEmpty { - sortedPostsByMonth[month] = sortedPosts - } - } - - try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil) - let months = Array(sortedPostsByMonth.keys.sorted().reversed()) - let postsByMonthForContext: [String: [[String: Any]]] = sortedPostsByMonth.reduce(into: [:]) { dict, pair in - let (month, posts) = pair - dict[month.padded] = posts.map { $0.dictionary(withPath: self.path(for: $0)) } - } - let context: [String: Any] = [ - "title": "\(year)", - "path": postsPath, - "year": year, - "months": months.map { $0.padded }, - "monthNames": months.reduce(into: [String: String](), { dict, month in - dict[month.padded] = month.name - }), - "monthAbbreviations": months.reduce(into: [String: String](), { dict, month in - dict[month.padded] = month.abbreviatedName - }), - "postsByMonth": postsByMonthForContext, - ] - let yearHTML = try templateRenderer.renderTemplate(name: "posts-year", context: context) - let yearURL = yearDir.appendingPathComponent("index.html") - try yearHTML.write(to: yearURL, atomically: true, encoding: .utf8) - } - } - - func renderMonths(postsDir: URL, templateRenderer: TemplateRenderer) throws { - print("renderMonths(postsDir: \(postsDir), templateRenderer: \(templateRenderer)") - let allMonths = (1 ... 12).map(Month.init(_:)) - for (year, monthPosts) in posts.byYear.sorted(by: { $1.key < $0.key }) { - let yearDir = postsDir.appendingPathComponent("\(year)") - for month in allMonths { - let sortedPosts = monthPosts[month].posts.sorted(by: { $1.date < $0.date }) - guard !sortedPosts.isEmpty else { - continue - } - - let renderedPosts = sortedPosts.map { post -> RenderedPost in - let path = self.path(for: post) - let bodyHTML = markdownParser.html(from: post.bodyMarkdown) - return RenderedPost(path: path, post: post, body: bodyHTML) - } - let monthDir = yearDir.appendingPathComponent(month.padded) - try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil) - let context: [String: Any] = [ - "title": "\(month.name) \(year)", - "posts": renderedPosts.map { $0.dictionary }, - ] - let monthHTML = try templateRenderer.renderTemplate(name: "posts-month", context: context) - let monthURL = monthDir.appendingPathComponent("index.html") - try monthHTML.write(to: monthURL, atomically: true, encoding: .utf8) - } - } - } - - private func renderPost(_ post: Post, monthDir: URL, templateRenderer: TemplateRenderer) throws { - print("renderPost(\(post.debugDescription), monthDir: \(monthDir), templateRenderer: \(templateRenderer)") - try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil) - let filename = "\(post.slug).html" - let path = self.path(for: post) - let postURL = monthDir.appendingPathComponent(filename) - let bodyHTML = markdownParser.html(from: post.bodyMarkdown) - let renderedPost = RenderedPost(path: path, post: post, body: bodyHTML) - let postHTML = try templateRenderer.renderTemplate(name: "post", context: [ - "title": "\(renderedPost.post.title)", - "post": renderedPost.dictionary, - ]) - try postHTML.write(to: postURL, atomically: true, encoding: .utf8) - } - - private func enumerateMarkdownFiles(directory: URL) throws -> [URL] { - return try fileManager.contentsOfDirectory(atPath: directory.path).flatMap { (filename: String) -> [URL] in - let fileURL = directory.appendingPathComponent(filename) - var isDir: ObjCBool = false - fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDir) - if isDir.boolValue { - return try enumerateMarkdownFiles(directory: fileURL) - } - else { - return fileURL.pathExtension == "md" ? [fileURL] : [] - } - } - } - - private func path(for post: Post) -> String { - path(year: post.date.year, month: Month(post.date.month), filename: "\(post.slug).html") - } - - private func path(year: Int) -> String { - "/\(postsPath)/\(year)" - } - - private func path(year: Int, month: Month) -> String { - path(year: year).appending("/\(month.padded)") - } - - private func path(year: Int, month: Month, filename: String) -> String { - path(year: year, month: month).appending("/\(filename)") + try postWriter.writeRecentPosts(postRepo.recentPosts, to: targetURL, with: templateRenderer) + try postWriter.writePosts(postRepo.sortedPosts, to: targetURL, with: templateRenderer) + try postWriter.writeArchive(posts: postRepo.posts, to: targetURL, with: templateRenderer) + try postWriter.writeYearIndexes(posts: postRepo.posts, to: targetURL, with: templateRenderer) + try postWriter.writeMonthRollups(posts: postRepo.posts, to: targetURL, with: templateRenderer) } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/RenderedPost.swift b/SiteGenerator/Sources/SiteGenerator/Posts/RenderedPost.swift deleted file mode 100644 index 245b603..0000000 --- a/SiteGenerator/Sources/SiteGenerator/Posts/RenderedPost.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// RenderedPost.swift -// SiteGenerator -// -// Created by Sami Samhuri on 2019-12-03. -// - -import Foundation - -struct RenderedPost { - let path: String - let post: Post - let body: String - - var dictionary: [String: Any] { - [ - "author": post.author, - "title": post.title, - "date": post.date, - "day": post.date.day, - "formattedDate": post.formattedDate, - "link": post.link as Any, - "path": path, - "body": body, - ] - } -} diff --git a/templates/posts-archive.html b/templates/posts-archive.html index 6e4b9c0..7e20452 100644 --- a/templates/posts-archive.html +++ b/templates/posts-archive.html @@ -12,7 +12,7 @@ {% for month in year.months %}

- {{ monthNames[month.title] }} + {{ month.name }}

    @@ -23,7 +23,7 @@ {% else %} {{ post.title }} {% endif %} - + {% if post.isLink %} {% endif %} diff --git a/templates/posts-year.html b/templates/posts-year.html index c5bf75f..c3206e6 100644 --- a/templates/posts-year.html +++ b/templates/posts-year.html @@ -7,18 +7,21 @@ {% for month in months %}

    - {{ monthNames[month] }} + {{ month.name }}

      - {% for post in postsByMonth[month] %} + {% for post in month.posts %}
    • {% if post.isLink %} → {{ post.title }} {% else %} {{ post.title }} {% endif %} - + + {% if post.isLink %} + + {% endif %}
    • {% endfor %}