mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-04-26 14:47:44 +00:00
Factor most of the code out of PostsPlugin
This commit is contained in:
parent
5fac69542c
commit
4b3dee6706
14 changed files with 434 additions and 311 deletions
18
Readme.md
18
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] 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
|
- [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
|
- [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
|
- [ ] 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// FunctionComposition.swift
|
||||||
|
// SiteGenerator
|
||||||
|
//
|
||||||
|
// Created by Sami Samhuri on 2019-11-18.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
infix operator |> :AdditionPrecedence
|
||||||
|
|
||||||
|
// MARK: Synchronous
|
||||||
|
|
||||||
|
public func |> <A, B, C> (
|
||||||
|
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 |> <A, B, C> (
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,12 @@ struct Page {
|
||||||
extension Page {
|
extension Page {
|
||||||
init(metadata: [String: String]) {
|
init(metadata: [String: String]) {
|
||||||
let template = metadata["Template"]
|
let template = metadata["Template"]
|
||||||
let styles = metadata["Styles", default: ""].split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
let styles = metadata["Styles", default: ""]
|
||||||
let scripts = metadata["Scripts", default: ""].split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
let scripts = metadata["Scripts", default: ""]
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
let title = metadata["Title", default: ""]
|
let title = metadata["Title", default: ""]
|
||||||
self.init(title: title, template: template, styles: styles, scripts: scripts)
|
self.init(title: title, template: template, styles: styles, scripts: scripts)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ final class SiteTemplateRenderer: TemplateRenderer {
|
||||||
func renderTemplate(name: String?, context: [String: Any]) throws -> String {
|
func renderTemplate(name: String?, context: [String: Any]) throws -> String {
|
||||||
let siteContext = SiteContext(site: site, template: name)
|
let siteContext = SiteContext(site: site, template: name)
|
||||||
let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new })
|
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)
|
return try stencil.renderTemplate(name: "\(siteContext.template).html", context: contextDict)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Month: Equatable {
|
struct Month: Equatable {
|
||||||
|
static let all = (1 ... 12).map(Month.init(_:))
|
||||||
|
|
||||||
static let names = [
|
static let names = [
|
||||||
"January", "Februrary", "March", "April",
|
"January", "Februrary", "March", "April",
|
||||||
"May", "June", "July", "August",
|
"May", "June", "July", "August",
|
||||||
|
|
@ -34,7 +36,7 @@ struct Month: Equatable {
|
||||||
Month.names[number - 1]
|
Month.names[number - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
var abbreviatedName: String {
|
var abbreviation: String {
|
||||||
String(name.prefix(3))
|
String(name.prefix(3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,76 +15,39 @@ struct Post {
|
||||||
let formattedDate: String
|
let formattedDate: String
|
||||||
let link: URL?
|
let link: URL?
|
||||||
let tags: [String]
|
let tags: [String]
|
||||||
let bodyMarkdown: String
|
let body: String
|
||||||
|
let path: String
|
||||||
|
|
||||||
var dictionary: [String: Any] {
|
// These are computed properties but are computed eagerly because
|
||||||
var result: [String: Any] = [
|
// Stencil is unable to use real computed properties at this time.
|
||||||
"slug": slug,
|
let isLink: Bool
|
||||||
"title": title,
|
let day: Int
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
func dictionary(withPath path: String) -> [String: Any] {
|
init(slug: String, title: String, author: String, date: Date, formattedDate: String, link: URL?, tags: [String], body: String, path: String) {
|
||||||
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 {
|
|
||||||
self.slug = slug
|
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"]
|
// Eagerly computed properties
|
||||||
let missingKeys = requiredKeys.filter { metadata[$0] == nil }
|
self.isLink = link != nil
|
||||||
guard missingKeys.isEmpty else {
|
self.day = date.day
|
||||||
throw Error.deficientMetadata(missingKeys: missingKeys)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title = metadata["Title"]!
|
extension Post: Comparable {
|
||||||
author = metadata["Author"]!
|
static func <(lhs: Self, rhs: Self) -> Bool {
|
||||||
date = Date(timeIntervalSince1970: TimeInterval(metadata["Timestamp"]!)!)
|
lhs.date < rhs.date
|
||||||
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: CustomDebugStringConvertible {
|
extension Post: CustomDebugStringConvertible {
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
"<Post slug=\(slug) title=\"\(title)\" date=\"\(formattedDate)\" link=\(link?.absoluteString ?? "no")>"
|
"<Post path=\(path) title=\"\(title)\" date=\"\(formattedDate)\" link=\(link?.absoluteString ?? "no")>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
SiteGenerator/Sources/SiteGenerator/Posts/PostRepo.swift
Normal file
85
SiteGenerator/Sources/SiteGenerator/Posts/PostRepo.swift
Normal file
|
|
@ -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] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
139
SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift
Normal file
139
SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,10 @@ struct MonthPosts {
|
||||||
let month: Month
|
let month: Month
|
||||||
var posts: [Post]
|
var posts: [Post]
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
month.padded
|
||||||
|
}
|
||||||
|
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
posts.isEmpty
|
posts.isEmpty
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +24,18 @@ struct YearPosts {
|
||||||
let year: Int
|
let year: Int
|
||||||
var byMonth: [Month: MonthPosts]
|
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 {
|
subscript(month: Month) -> MonthPosts {
|
||||||
get {
|
get {
|
||||||
byMonth[month, default: MonthPosts(month: month, posts: [])]
|
byMonth[month, default: MonthPosts(month: month, posts: [])]
|
||||||
|
|
@ -28,10 +44,6 @@ struct YearPosts {
|
||||||
byMonth[month] = newValue
|
byMonth[month] = newValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PostsByYear {
|
struct PostsByYear {
|
||||||
|
|
@ -60,8 +72,8 @@ struct PostsByYear {
|
||||||
self[year][month].posts.append(post)
|
self[year][month].posts.append(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns posts sorted by reverse date.
|
/// Returns an array of all posts.
|
||||||
func flattened() -> [Post] {
|
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 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,225 +6,38 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Ink
|
|
||||||
|
|
||||||
final class PostsPlugin: Plugin {
|
final class PostsPlugin: Plugin {
|
||||||
let fileManager: FileManager = .default
|
let postRepo: PostRepo
|
||||||
let markdownParser = MarkdownParser()
|
let postWriter: PostWriter
|
||||||
let postsPath: String
|
|
||||||
let recentPostsPath: String
|
|
||||||
|
|
||||||
var posts: PostsByYear!
|
init(
|
||||||
var sourceURL: URL!
|
postRepo: PostRepo = PostRepo(),
|
||||||
|
postWriter: PostWriter = PostWriter()
|
||||||
init(postsPath: String = "posts", recentPostsPath: String = "index.html") {
|
) {
|
||||||
self.postsPath = postsPath
|
self.postRepo = postRepo
|
||||||
self.recentPostsPath = recentPostsPath
|
self.postWriter = postWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Plugin methods
|
||||||
|
|
||||||
func setUp(sourceURL: URL) throws {
|
func setUp(sourceURL: URL) throws {
|
||||||
self.sourceURL = sourceURL
|
guard postRepo.postDataExists(at: sourceURL) else {
|
||||||
let postsURL = sourceURL.appendingPathComponent("posts")
|
|
||||||
guard fileManager.fileExists(atPath: postsURL.path) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let posts = try enumerateMarkdownFiles(directory: postsURL)
|
try postRepo.readPosts(sourceURL: sourceURL, makePath: postWriter.urlPathForPost)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(targetURL: URL, templateRenderer: TemplateRenderer) throws {
|
func render(targetURL: URL, templateRenderer: TemplateRenderer) throws {
|
||||||
guard posts != nil, !posts.isEmpty else {
|
guard !postRepo.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let postsDir = targetURL.appendingPathComponent(postsPath)
|
try postWriter.writeRecentPosts(postRepo.recentPosts, to: targetURL, with: templateRenderer)
|
||||||
try renderPostsByDate(postsDir: postsDir, templateRenderer: templateRenderer)
|
try postWriter.writePosts(postRepo.sortedPosts, to: targetURL, with: templateRenderer)
|
||||||
try renderYears(postsDir: postsDir, templateRenderer: templateRenderer)
|
try postWriter.writeArchive(posts: postRepo.posts, to: targetURL, with: templateRenderer)
|
||||||
try renderMonths(postsDir: postsDir, templateRenderer: templateRenderer)
|
try postWriter.writeYearIndexes(posts: postRepo.posts, to: targetURL, with: templateRenderer)
|
||||||
try renderArchive(postsDir: postsDir, templateRenderer: templateRenderer)
|
try postWriter.writeMonthRollups(posts: postRepo.posts, to: targetURL, with: 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)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
{% for month in year.months %}
|
{% for month in year.months %}
|
||||||
<h3>
|
<h3>
|
||||||
<a href="{{ month.path }}">{{ monthNames[month.title] }}</a>
|
<a href="{{ month.path }}">{{ month.name }}</a>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<ul class="archive">
|
<ul class="archive">
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<time>{{ post.day }} {{ monthAbbreviations[month.title] }}</time>
|
<time>{{ post.day }} {{ month.abbreviation }}</time>
|
||||||
{% if post.isLink %}
|
{% if post.isLink %}
|
||||||
<a class="permalink" href="{{ post.path }}">∞</a>
|
<a class="permalink" href="{{ post.path }}">∞</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,21 @@
|
||||||
|
|
||||||
{% for month in months %}
|
{% for month in months %}
|
||||||
<h2>
|
<h2>
|
||||||
<a href="/{{ path }}/{{ year }}/{{ month }}">{{ monthNames[month] }}</a>
|
<a href="{{ month.path }}">{{ month.name }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="archive">
|
<ul class="archive">
|
||||||
{% for post in postsByMonth[month] %}
|
{% for post in month.posts %}
|
||||||
<li>
|
<li>
|
||||||
{% if post.isLink %}
|
{% if post.isLink %}
|
||||||
<a href="{{ post.path }}">→ {{ post.title }}</a>
|
<a href="{{ post.path }}">→ {{ post.title }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<time>{{ post.day }} {{ monthAbbreviations[month] }}</time>
|
<time>{{ post.day }} {{ month.abbreviation }}</time>
|
||||||
|
{% if post.isLink %}
|
||||||
|
<a class="permalink" href="{{ post.path }}">∞</a>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue