mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +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] 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
"<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
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<h3>
|
||||
<a href="{{ month.path }}">{{ monthNames[month.title] }}</a>
|
||||
<a href="{{ month.path }}">{{ month.name }}</a>
|
||||
</h3>
|
||||
|
||||
<ul class="archive">
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
{% else %}
|
||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||
{% endif %}
|
||||
<time>{{ post.day }} {{ monthAbbreviations[month.title] }}</time>
|
||||
<time>{{ post.day }} {{ month.abbreviation }}</time>
|
||||
{% if post.isLink %}
|
||||
<a class="permalink" href="{{ post.path }}">∞</a>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,21 @@
|
|||
|
||||
{% for month in months %}
|
||||
<h2>
|
||||
<a href="/{{ path }}/{{ year }}/{{ month }}">{{ monthNames[month] }}</a>
|
||||
<a href="{{ month.path }}">{{ month.name }}</a>
|
||||
</h2>
|
||||
|
||||
<ul class="archive">
|
||||
{% for post in postsByMonth[month] %}
|
||||
{% for post in month.posts %}
|
||||
<li>
|
||||
{% if post.isLink %}
|
||||
<a href="{{ post.path }}">→ {{ post.title }}</a>
|
||||
{% else %}
|
||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
|||
Loading…
Reference in a new issue