WIP: Add a plugin to render posts, months & years not working yet

This commit is contained in:
Sami Samhuri 2019-12-03 23:17:44 -08:00
parent 947fb4ec3a
commit b00a48b096
23 changed files with 492 additions and 98 deletions

View file

@ -1,14 +0,0 @@
//
// Date+CurrentYear.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
extension Date {
static var currentYear: Int {
Calendar.current.dateComponents([.year], from: Date()).year!
}
}

View file

@ -0,0 +1,22 @@
//
// Date+Sugar.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
extension Date {
var year: Int {
Calendar.current.dateComponents([.year], from: self).year!
}
var month: Int {
Calendar.current.dateComponents([.month], from: self).month!
}
var day: Int {
Calendar.current.dateComponents([.day], from: self).day!
}
}

View file

@ -36,7 +36,7 @@ extension PageContext: TemplateContext {
"metadata": metadata,
"styles": site.styles + page.styles,
"scripts": site.scripts + page.scripts,
"currentYear": Date.currentYear,
"currentYear": Date().year,
]
}
}

View file

@ -24,7 +24,7 @@ extension SiteContext: TemplateContext {
"title": site.title,
"styles": site.styles,
"scripts": site.scripts,
"currentYear": Date.currentYear,
"currentYear": Date().year,
]
}
}

View file

@ -6,13 +6,11 @@
//
import Foundation
import PathKit
import Stencil
public final class Generator: PluginDelegate, RendererDelegate {
public final class Generator {
// Dependencies
let fileManager: FileManager = .default
let templateRenderer: Environment
let templateRenderer: TemplateRenderer
// Site properties
let site: Site
@ -21,13 +19,13 @@ public final class Generator: PluginDelegate, RendererDelegate {
let renderers: [Renderer]
public init(sourceURL: URL, plugins: [Plugin], renderers: [Renderer]) throws {
let templatesURL = sourceURL.appendingPathComponent("templates")
let templatesPath = Path(templatesURL.path)
let loader = FileSystemLoader(paths: [templatesPath])
self.templateRenderer = Environment(loader: loader)
let siteURL = sourceURL.appendingPathComponent("site.json")
self.site = try Site.decode(from: siteURL)
let site = try Site.decode(from: siteURL)
let templatesURL = sourceURL.appendingPathComponent("templates")
self.templateRenderer = SiteTemplateRenderer(site: site, templatesURL: templatesURL)
self.site = site
self.sourceURL = sourceURL
self.plugins = plugins
self.renderers = renderers
@ -39,7 +37,7 @@ public final class Generator: PluginDelegate, RendererDelegate {
public func generate(targetURL: URL) throws {
for plugin in plugins {
try plugin.render(targetURL: targetURL, delegate: self)
try plugin.render(targetURL: targetURL, templateRenderer: templateRenderer)
}
let publicURL = sourceURL.appendingPathComponent("public")
@ -77,7 +75,7 @@ public final class Generator: PluginDelegate, RendererDelegate {
let ext = String(filename.split(separator: ".").last!)
for renderer in renderers {
if renderer.canRenderFile(named: filename, withExtension: ext) {
try renderer.render(fileURL: fileURL, targetDir: targetDir, delegate: self)
try renderer.render(fileURL: fileURL, targetDir: targetDir, templateRenderer: templateRenderer)
return
}
}
@ -86,20 +84,4 @@ public final class Generator: PluginDelegate, RendererDelegate {
let dest = targetDir.appendingPathComponent(filename)
try fileManager.copyItem(at: fileURL, to: dest)
}
// MARK: - PluginDelegate and RendererDelegate
public func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String {
let page = Page(metadata: metadata)
let context = PageContext(site: site, body: bodyHTML, page: page, metadata: metadata)
let pageHTML = try templateRenderer.renderTemplate(name: "\(context.template).html", context: context.dictionary)
return pageHTML
}
public 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 \(contextDict)")
return try templateRenderer.renderTemplate(name: "\(siteContext.template).html", context: contextDict)
}
}

View file

@ -7,13 +7,8 @@
import Foundation
public protocol PluginDelegate: AnyObject {
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String?, context: [String: Any]) throws -> String
}
public protocol Plugin {
func setUp(sourceURL: URL) throws
func render(targetURL: URL, delegate: PluginDelegate) throws
func render(targetURL: URL, templateRenderer: TemplateRenderer) throws
}

View file

@ -7,13 +7,8 @@
import Foundation
public protocol RendererDelegate: AnyObject {
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String?, context: [String: Any]) throws -> String
}
public protocol Renderer {
func canRenderFile(named filename: String, withExtension ext: String) -> Bool
func render(fileURL: URL, targetDir: URL, delegate: RendererDelegate) throws
func render(fileURL: URL, targetDir: URL, templateRenderer: TemplateRenderer) throws
}

View file

@ -0,0 +1,36 @@
//
// SiteTemplateRenderer.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
import PathKit
import Stencil
final class SiteTemplateRenderer: TemplateRenderer {
let site: Site
let stencil: Environment
init(site: Site, templatesURL: URL) {
self.site = site
let templatesPath = Path(templatesURL.path)
let loader = FileSystemLoader(paths: [templatesPath])
self.stencil = Environment(loader: loader)
}
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String {
let page = Page(metadata: metadata)
let context = PageContext(site: site, body: bodyHTML, page: page, metadata: metadata)
let pageHTML = try stencil.renderTemplate(name: "\(context.template).html", context: context.dictionary)
return pageHTML
}
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 \(contextDict)")
return try stencil.renderTemplate(name: "\(siteContext.template).html", context: contextDict)
}
}

View file

@ -0,0 +1,13 @@
//
// templateRenderer.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
public protocol TemplateRenderer: AnyObject {
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String?, context: [String: Any]) throws -> String
}

View file

@ -1,30 +0,0 @@
//
// Post.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
struct Post {
let date: Date
let formattedDate: String
let title: String
let slug: String
let author: String
let tags: [String]
let body: String
var path: String {
let dateComponents = Calendar.current.dateComponents([.year], from: date)
let year = dateComponents.year!
let month = dateComponents.month!
return "/" + [
"posts",
"\(year)",
"\(month)",
"\(slug)",
].joined(separator: "/")
}
}

View file

@ -0,0 +1,30 @@
//
// Month.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
struct Month {
static let names = [
"January", "Februrary", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
]
let number: Int
var padded: String {
String(format: "%02d", number)
}
var name: String {
Month.names[number]
}
var abbreviatedName: String {
String(name.prefix(3))
}
}

View file

@ -0,0 +1,74 @@
//
// Post.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
struct Post {
let slug: String
let title: String
let author: String
let date: Date
let formattedDate: String
let link: URL?
let tags: [String]
let bodyMarkdown: String
var isLink: Bool {
link != nil
}
var path: String {
let dateComponents = Calendar.current.dateComponents([.year, .month], from: date)
let year = dateComponents.year!
let month = dateComponents.month!
return "/" + [
"posts",
"\(year)",
"\(month)",
"\(slug)",
].joined(separator: "/")
}
}
/// 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(bodyMarkdown: String, metadata: [String: String]) throws {
self.bodyMarkdown = bodyMarkdown
let requiredKeys = ["Slug", "Title", "Author", "Date", "Timestamp", "Tags", "Path_deprecated"]
let missingKeys = requiredKeys.filter { metadata[$0] == nil }
guard missingKeys.isEmpty else {
throw Error.deficientMetadata(missingKeys: missingKeys)
}
slug = metadata["Slug"]!
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
}
tags = metadata["Tags"]!.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
let handWrittenPath = metadata["Path_deprecated"]!
assert(path == handWrittenPath, "FUCK: Generated path (\(path)) doesn't match the hand-written one \(handWrittenPath)")
}
}

View file

@ -0,0 +1,67 @@
//
// Posts.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
struct MonthPosts {
let month: Int
var posts: [Post]
var isEmpty: Bool {
posts.isEmpty
}
}
struct YearPosts {
let year: Int
var byMonth: [Int: MonthPosts]
subscript(month: Int) -> MonthPosts {
get {
byMonth[month, default: MonthPosts(month: month, posts: [])]
}
set {
byMonth[month] = newValue
}
}
var isEmpty: Bool {
byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty }
}
}
struct PostsByYear {
private(set) var byYear: [Int: YearPosts]
init(posts: [Post]) {
byYear = [:]
posts.forEach { add(post: $0) }
}
subscript(year: Int) -> YearPosts {
get {
byYear[year, default: YearPosts(year: year, byMonth: [:])]
}
set {
byYear[year] = newValue
}
}
var isEmpty: Bool {
byYear.isEmpty || byYear.values.allSatisfy { $0.isEmpty }
}
mutating func add(post: Post) {
let (year, month) = (post.date.year, post.date.month)
self[year][month].posts.append(post)
}
/// Returns posts sorted by reverse date.
func flattened() -> [Post] {
byYear.values.flatMap { $0.byMonth.values.flatMap { $0.posts } }.sorted { $1.date < $0.date }
}
}

View file

@ -0,0 +1,146 @@
//
// PostsPlugin.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
import Ink
final class PostsPlugin: Plugin {
let fileManager: FileManager = .default
let markdownParser = MarkdownParser()
let path: String
var posts: PostsByYear!
var sourceURL: URL!
init(path: String = "posts") {
self.path = path
}
func setUp(sourceURL: URL) throws {
self.sourceURL = sourceURL
let postsURL = sourceURL.appendingPathComponent("posts")
guard fileManager.fileExists(atPath: postsURL.path) else {
return
}
let posts = try enumerateMarkdownFiles(directory: postsURL)
.compactMap { (url: URL) -> Post? in
guard let result = (try? String(contentsOf: url)).map(markdownParser.parse) else {
return nil
}
do {
return try Post(bodyMarkdown: "(TEST)", metadata: result.metadata)
}
catch {
print("Cannot create post from markdown file \(url): \(error)")
return nil
}
}
print("posts: \(posts)")
self.posts = PostsByYear(posts: posts)
}
func render(targetURL: URL, templateRenderer: TemplateRenderer) throws {
guard posts != nil, !posts.isEmpty else {
return
}
let postsDir = targetURL.appendingPathComponent(path)
try renderRecentPosts(postsDir: postsDir, templateRenderer: templateRenderer)
try renderYearsAndMonths(postsDir: postsDir, templateRenderer: templateRenderer)
try renderPostsByDate(postsDir: postsDir, templateRenderer: templateRenderer)
}
func renderRecentPosts(postsDir: URL, templateRenderer: TemplateRenderer) throws {
print("renderRecentPosts(postsDir: \(postsDir), templateRenderer: \(templateRenderer)")
let recentPosts = posts.flattened().prefix(10)
try fileManager.createDirectory(at: postsDir, withIntermediateDirectories: true, attributes: nil)
let recentPostsURL = postsDir.appendingPathComponent("index.html")
let recentPostsHTML = try templateRenderer.renderTemplate(name: "recent-posts", context: ["recentPosts": recentPosts])
try recentPostsHTML.write(to: recentPostsURL, atomically: true, encoding: .utf8)
}
func renderPostsByDate(postsDir: URL, templateRenderer: TemplateRenderer) throws {
print("renderPostsByDate(postsDir: \(postsDir), templateRenderer: \(templateRenderer)")
for post in posts.flattened() {
let monthDir = postsDir
.appendingPathComponent(String(format: "%02d", post.date.year))
.appendingPathComponent(String(format: "%02d", post.date.month))
try renderPost(post, monthDir: monthDir, templateRenderer: templateRenderer)
}
}
func renderYearsAndMonths(postsDir: URL, templateRenderer: TemplateRenderer) throws {
print("renderYearsAndMonths(postsDir: \(postsDir), templateRenderer: \(templateRenderer)")
let allMonths = (1 ... 12).reversed().map(Month.init)
for (year, monthPosts) in posts.byYear.sorted(by: { $1.key < $0.key }) {
let yearDir = postsDir.appendingPathComponent("\(year)")
var sortedPostsByMonth: [Int: [RenderedPost]] = [:]
for month in allMonths {
let sortedPosts = monthPosts[month.number].posts.sorted(by: { $1.date < $0.date })
guard !sortedPosts.isEmpty else {
continue
}
let renderedPosts = sortedPosts.map { post -> RenderedPost in
let bodyHTML = markdownParser.html(from: post.bodyMarkdown)
return RenderedPost(post: post, body: bodyHTML)
}
sortedPostsByMonth[month.number] = renderedPosts
let monthDir = yearDir.appendingPathComponent(month.padded)
try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil)
let context: [String: Any] = ["path": path, "month": month, "posts": renderedPosts]
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)
}
try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil)
let context: [String: Any] = [
"path": path,
"year": year,
"months": sortedPostsByMonth.keys.sorted().reversed().map(Month.init),
"postsByMonth": sortedPostsByMonth,
]
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)
}
}
private func renderPost(_ post: Post, monthDir: URL, templateRenderer: TemplateRenderer) throws {
print("renderPost(\(post), monthDir: \(monthDir), templateRenderer: \(templateRenderer)")
try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil)
let filename = "\(post.slug).html"
let postURL = monthDir.appendingPathComponent(filename)
let templateName = self.templateName(for: post)
let bodyHTML = markdownParser.html(from: post.bodyMarkdown)
let renderedPost = RenderedPost(post: post, body: bodyHTML)
let postHTML = try templateRenderer.renderTemplate(name: templateName, context: ["post": renderedPost])
try postHTML.write(to: postURL, atomically: true, encoding: .utf8)
}
private func templateName(for post: Post) -> String {
post.isLink ? "post-link" : "post-text"
}
private func enumerateMarkdownFiles(directory: URL) throws -> [URL] {
print("enumerateMarkdownFiles(directory: \(directory))")
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] : []
}
}
}
}

View file

@ -0,0 +1,27 @@
//
// RenderedPost.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-03.
//
import Foundation
struct RenderedPost {
let post: Post
let body: String
var author: String { post.author }
var title: String { post.title }
var date: Date { post.date }
var formattedDate: String { post.formattedDate }
var isLink: Bool { post.isLink }
var link: URL? { post.link }
var path: String { post.path }
}

View file

@ -38,7 +38,7 @@ final class ProjectsPlugin: Plugin {
}
}
func render(targetURL: URL, delegate: PluginDelegate) throws {
func render(targetURL: URL, templateRenderer: TemplateRenderer) throws {
guard !projects.isEmpty else {
return
}
@ -46,13 +46,13 @@ final class ProjectsPlugin: Plugin {
let projectsDir = targetURL.appendingPathComponent(path)
try fileManager.createDirectory(at: projectsDir, withIntermediateDirectories: true, attributes: nil)
let projectsURL = projectsDir.appendingPathComponent("index.html")
let projectsHTML = try delegate.renderTemplate(name: "projects", context: ["projects": projects])
let projectsHTML = try templateRenderer.renderTemplate(name: "projects", context: ["projects": projects])
try projectsHTML.write(to: projectsURL, atomically: true, encoding: .utf8)
for project in projects {
let filename = "\(project.title).html"
let projectURL = projectsDir.appendingPathComponent(filename)
let projectHTML = try delegate.renderTemplate(name: "project", context: ["project": project])
let projectHTML = try templateRenderer.renderTemplate(name: "project", context: ["project": project])
try projectHTML.write(to: projectURL, atomically: true, encoding: .utf8)
}
}

View file

@ -17,7 +17,7 @@ public final class LessRenderer: Renderer {
}
/// Parse Less and render it as CSS.
public func render(fileURL: URL, targetDir: URL, delegate: RendererDelegate) throws {
public func render(fileURL: URL, targetDir: URL, templateRenderer: TemplateRenderer) throws {
let filename = fileURL.lastPathComponent
let cssURL = targetDir.appendingPathComponent(filename.replacingOccurrences(of: ".less", with: ".css"))
let less = try String(contentsOf: fileURL, encoding: .utf8)

View file

@ -9,26 +9,26 @@ import Foundation
import Ink
public final class MarkdownRenderer: Renderer {
let mdParser = MarkdownParser()
let markdownParser = MarkdownParser()
public func canRenderFile(named filename: String, withExtension ext: String) -> Bool {
ext == "md"
}
/// Parse Markdown and render it as HTML, running it through a Stencil template.
public func render(fileURL: URL, targetDir: URL, delegate: RendererDelegate) throws {
public func render(fileURL: URL, targetDir: URL, templateRenderer: TemplateRenderer) throws {
let mdFilename = fileURL.lastPathComponent
let htmlFilename = mdFilename.replacingOccurrences(of: ".md", with: ".html")
let htmlURL = targetDir.appendingPathComponent(htmlFilename)
let bodyMarkdown = try String(contentsOf: fileURL, encoding: .utf8)
let bodyHTML = mdParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let metadata = try markdownMetadata(from: fileURL)
let pageHTML = try delegate.renderPage(bodyHTML: bodyHTML, metadata: metadata)
let pageHTML = try templateRenderer.renderPage(bodyHTML: bodyHTML, metadata: metadata)
try pageHTML.write(to: htmlURL, atomically: true, encoding: .utf8)
}
func markdownMetadata(from url: URL) throws -> [String: String] {
let md = try String(contentsOf: url, encoding: .utf8)
return mdParser.parse(md).metadata
return markdownParser.parse(md).metadata
}
}

View file

@ -12,7 +12,7 @@ func main(sourcePath: String, targetPath: String) throws {
let targetURL = URL(fileURLWithPath: targetPath)
let generator = try Generator(
sourceURL: sourceURL,
plugins: [ProjectsPlugin()],
plugins: [ProjectsPlugin(), PostsPlugin()],
renderers: [LessRenderer(), MarkdownRenderer()]
)
try generator.generate(targetURL: targetURL)

View file

@ -4,7 +4,7 @@
<article class="container">
<header>
<h1><a href="{{ post.link }}">&rarr; {{ post.title }}</a></h1>
<time>{{ post.date }}</time>
<time>{{ post.formattedDate }}</time>
<a class="permalink" href="{{ post.url }}">&infin;</a>
</header>
{{ post.body }}

View file

@ -4,7 +4,7 @@
<article class="container">
<header>
<h1><a href="{{ post.url }}">{{ post.title }}</a></h1>
<time>{{ post.date }}</time>
<time>{{ post.formattedDate }}</time>
</header>
{{ post.body }}
</article>

View file

@ -0,0 +1,24 @@
{% extends "samhuri.net.html" %}
{% block body %}
{% for post in posts %}
<article class="container">
<header>
{% if post.isLink %}
<h2><a href="{{ post.link }}">&rarr; {{ post.title }}</a></h2>
<time>{{ post.formattedDate }}</time>
<a class="permalink" href="{{ post.path }}">&infin;</a>
{% else %}
<h2><a href="{{ post.path }}">{{ post.title }}</a></h2>
<time>{{ post.formattedDate }}</time>
{% endif %}
</header>
{{ post.body }}
</article>
<div class="row clearfix">
<p class="fin"><i class="fa fa-code"></i></p>
</div>
{% endfor %}
{% endblock %}

27
templates/posts-year.html Normal file
View file

@ -0,0 +1,27 @@
{% extends "samhuri.net.html" %}
{% block body %}
<div class="container">
<h2>{{ year }}</h2>
{% for month in months %}
<h3>
<a href="/{{ path }}/{{ year }}/{{ month.padded }}">{{ month.name }}</a>
</h3>
<ul class="archive">
{% for post in postsByMonth[month.number] %}
<li>
{% if post.isLink %}
<a href="{{ post.path }}">&rarr; {{ post.title }}</a>
{% else %}
<a href="{{ post.path }}">{{ post.title }}</a>
{% endif %}
<time>{{ post.date.day }} {{ month.abbreviatedName }}</time>
</li>
{% endfor %}
</ul>
{% endfor %}
</div>
{% endblock %}