Refactor site generator and add plugin to render projects

This commit is contained in:
Sami Samhuri 2019-12-03 00:38:11 -08:00
parent 9f8c1480ef
commit 72bbc433eb
21 changed files with 413 additions and 140 deletions

View file

@ -79,7 +79,7 @@ Execution, trying TDD for the first time:
- [ ] Generate JSON feed
- [ ] Munge HTML files to make them available without an extension (index.html hack)
- [ ] Munge HTML files to make them available without an extension (index.html hack, do it in the SiteGenerator)
- [ ] Inline CSS?

View file

@ -0,0 +1,14 @@
//
// 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

@ -1,108 +0,0 @@
//
// Generator.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
import Ink
import PathKit
import Stencil
public final class Generator {
private let fileManager: FileManager = .default
let site: Site
let sourceURL: URL
private let lessParser: LessParser
private let mdParser: MarkdownParser
private let templateRenderer: Environment
public init(sourceURL: URL) throws {
let siteURL = sourceURL.appendingPathComponent("site.json")
self.site = try Site.decode(from: siteURL)
self.sourceURL = sourceURL
let templatesURL = sourceURL.appendingPathComponent("templates")
self.templateRenderer = Environment(loader: FileSystemLoader(paths: [Path(templatesURL.path)]))
self.lessParser = LessParser()
self.mdParser = MarkdownParser()
}
public func generate(targetURL: URL) throws {
// Iterate through all files in public recursively and render or copy each one
let publicURL = sourceURL.appendingPathComponent("public")
try renderPath(publicURL.path, to: targetURL)
}
func renderPath(_ path: String, to targetURL: URL) throws {
for filename in try fileManager.contentsOfDirectory(atPath: path) {
// Recurse into subdirectories, updating the target directory as well.
let fileURL = URL(fileURLWithPath: path).appendingPathComponent(filename)
var isDir: ObjCBool = false
fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDir)
guard !isDir.boolValue else {
try renderPath(fileURL.path, to: targetURL.appendingPathComponent(filename))
continue
}
// Make sure this path exists so we can write to it.
try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
// Processes the file, transforming it if necessary.
try renderOrCopyFile(url: fileURL, targetDir: targetURL)
}
}
func renderOrCopyFile(url fileURL: URL, targetDir: URL) throws {
let filename = fileURL.lastPathComponent
guard filename != ".DS_Store", filename != ".gitkeep" else {
print("Ignoring hidden file \(filename)")
return
}
let ext = filename.split(separator: ".").last!
switch ext {
case "less":
let cssURL = targetDir.appendingPathComponent(filename.replacingOccurrences(of: ".less", with: ".css"))
try renderLess(from: fileURL, to: cssURL)
case "md":
let htmlURL = targetDir.appendingPathComponent(filename.replacingOccurrences(of: ".md", with: ".html"))
try renderMarkdown(from: fileURL, to: htmlURL)
default:
// Who knows. Copy the file unchanged.
let dest = targetDir.appendingPathComponent(filename)
try fileManager.copyItem(at: fileURL, to: dest)
}
}
func renderLess(from sourceURL: URL, to targetURL: URL) throws {
let less = try String(contentsOf: sourceURL, encoding: .utf8)
let css = try lessParser.parse(less)
try css.write(to: targetURL, atomically: true, encoding: .utf8)
}
func renderMarkdown(
from sourceURL: URL,
to targetURL: URL
) throws {
let bodyMarkdown = try String(contentsOf: sourceURL, encoding: .utf8)
let bodyHTML = mdParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let metadata = try markdownMetadata(from: sourceURL)
let page = Page(metadata: metadata)
let context = TemplateContext(site: site, page: page, metadata: metadata, body: bodyHTML)
let pageHTML = try templateRenderer.renderTemplate(name: "\(context.template).html", context: context.dictionary)
try pageHTML.write(to: targetURL, 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
}
}

View file

@ -0,0 +1,105 @@
//
// Generator.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
import PathKit
import Stencil
public final class Generator: PluginDelegate, RendererDelegate {
// Dependencies
let fileManager: FileManager = .default
let templateRenderer: Environment
// Site properties
let site: Site
let sourceURL: URL
let plugins: [Plugin]
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)
self.sourceURL = sourceURL
self.plugins = plugins
self.renderers = renderers
for plugin in plugins {
try plugin.setUp(sourceURL: sourceURL)
}
}
public func generate(targetURL: URL) throws {
for plugin in plugins {
try plugin.render(targetURL: targetURL, delegate: self)
}
let publicURL = sourceURL.appendingPathComponent("public")
try renderPath(publicURL.path, to: targetURL)
}
// Recursively copy or render every file in the given path.
func renderPath(_ path: String, to targetURL: URL) throws {
for filename in try fileManager.contentsOfDirectory(atPath: path) {
// Recurse into subdirectories, updating the target directory as well.
let fileURL = URL(fileURLWithPath: path).appendingPathComponent(filename)
var isDir: ObjCBool = false
fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDir)
guard !isDir.boolValue else {
try renderPath(fileURL.path, to: targetURL.appendingPathComponent(filename))
continue
}
// Make sure this path exists so we can write to it.
try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
// Processes the file, transforming it if necessary.
try renderOrCopyFile(url: fileURL, targetDir: targetURL)
}
}
func renderOrCopyFile(url fileURL: URL, targetDir: URL) throws {
let filename = fileURL.lastPathComponent
guard filename != ".DS_Store", filename != ".gitkeep" else {
print("Ignoring hidden file \(filename)")
return
}
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)
return
}
}
// Not handled by any renderer. Copy the file unchanged.
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

@ -1,17 +1,17 @@
//
// TemplateContext.swift
// SiteGenerator
// File.swift
//
// Created by Sami Samhuri on 2019-12-01.
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
struct TemplateContext {
struct PageContext {
let site: Site
let body: String
let page: Page
let metadata: [String: String]
let body: String
var title: String {
guard !page.title.isEmpty else {
@ -20,29 +20,23 @@ struct TemplateContext {
return "\(site.title): \(page.title)"
}
}
extension PageContext: TemplateContext {
var template: String {
page.template ?? site.template
}
var currentYear: Int {
Calendar.current.dateComponents([.year], from: Date()).year!
}
}
// MARK: - Dictionary form
extension TemplateContext {
var dictionary: [String: Any] {
[
"site": site,
"page": page,
"metadata": metadata,
"title": title,
"body": body,
"page": page,
"metadata": metadata,
"styles": site.styles + page.styles,
"scripts": site.scripts + page.scripts,
"currentYear": currentYear,
"currentYear": Date.currentYear,
]
}
}

View file

@ -0,0 +1,19 @@
//
// Plugin.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
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
}

View file

@ -0,0 +1,19 @@
//
// Renderer.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
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
}

View file

@ -0,0 +1,30 @@
//
// SiteContext.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
struct SiteContext {
let site: Site
let template: String
init(site: Site, template: String? = nil) {
self.site = site
self.template = template ?? site.template
}
}
extension SiteContext: TemplateContext {
var dictionary: [String: Any] {
[
"site": site,
"title": site.title,
"styles": site.styles,
"scripts": site.scripts,
"currentYear": Date.currentYear,
]
}
}

View file

@ -0,0 +1,14 @@
//
// TemplateContext.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
protocol TemplateContext {
var template: String { get }
var dictionary: [String : Any] { get }
}

View file

@ -7,13 +7,13 @@
import Foundation
struct Site {
let author: String
let title: String
let url: String
let template: String
let styles: [String]
let scripts: [String]
public struct Site {
public let author: String
public let title: String
public let url: String
public let template: String
public let styles: [String]
public let scripts: [String]
}
extension Site {

View file

@ -0,0 +1,14 @@
//
// Project.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
struct Project: Codable {
let title: String
let description: String
var path: String!
}

View file

@ -0,0 +1,59 @@
//
// ProjectsPlugin.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
private struct Projects: Codable {
let projects: [Project]
static func decode(from url: URL) throws -> Projects {
let json = try Data(contentsOf: url)
let projects = try JSONDecoder().decode(Projects.self, from: json)
return projects
}
}
final class ProjectsPlugin: Plugin {
let fileManager: FileManager = .default
let path: String
var projects: [Project] = []
var sourceURL: URL!
init(path: String = "projects") {
self.path = path
}
func setUp(sourceURL: URL) throws {
self.sourceURL = sourceURL
let projectsURL = sourceURL.appendingPathComponent("projects.json")
if fileManager.fileExists(atPath: projectsURL.path) {
self.projects = try Projects.decode(from: projectsURL).projects.map { project in
Project(title: project.title, description: project.description, path: "/\(path)/\(project.title).html")
}
}
}
func render(targetURL: URL, delegate: PluginDelegate) throws {
guard !projects.isEmpty else {
return
}
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])
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])
try projectHTML.write(to: projectURL, atomically: true, encoding: .utf8)
}
}
}

View file

@ -1,15 +1,26 @@
//
// LessParser.swift
// LessRenderer.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
/// Shells out to lessc on the command line.
final class LessParser {
/// Parses Less and returns CSS.
public final class LessRenderer: Renderer {
public func canRenderFile(named filename: String, withExtension ext: String) -> Bool {
ext == "less"
}
/// Parse Less and render it as CSS.
public func render(fileURL: URL, targetDir: URL, delegate: RendererDelegate) throws {
let filename = fileURL.lastPathComponent
let cssURL = targetDir.appendingPathComponent(filename.replacingOccurrences(of: ".less", with: ".css"))
let less = try String(contentsOf: fileURL, encoding: .utf8)
let css = try parse(less)
try css.write(to: cssURL, atomically: true, encoding: .utf8)
}
func parse(_ less: String) throws -> String {
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
defer {
@ -24,7 +35,8 @@ final class LessParser {
return try String(contentsOf: cssURL, encoding: .utf8)
}
private let lesscPath = URL(fileURLWithPath: #file)
let lesscPath = URL(fileURLWithPath: #file)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("node_modules")
.appendingPathComponent("less")
@ -33,7 +45,7 @@ final class LessParser {
.path
@discardableResult
private func shell(_ args: String...) -> Int32 {
func shell(_ args: String...) -> Int32 {
let task = Process()
task.launchPath = "/usr/bin/env"
task.arguments = args

View file

@ -0,0 +1,34 @@
//
// MarkdownRenderer.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-02.
//
import Foundation
import Ink
public final class MarkdownRenderer: Renderer {
let mdParser = 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 {
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 metadata = try markdownMetadata(from: fileURL)
let pageHTML = try delegate.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
}
}

View file

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

View file

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<title>Nvidia, fuck you!</title>
</head>
<body>
<h1><a href="http://example.net">Nvidia, fuck you!</a></h1>
<ul>
<li><a href="/projects/linux.html">linux</a></li>
</ul>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<title>Nvidia, fuck you!: linux</title>
</head>
<body>
<h1>linux</h1>
<h3>a (free) operating system (just a hobby, won't be big and professional like gnu)</h4>
</body>
</html>

View file

@ -0,0 +1,8 @@
{
"projects": [
{
"title": "linux",
"description": "a (free) operating system (just a hobby, won't be big and professional like gnu)"
}
]
}

View file

@ -0,0 +1,5 @@
{
"author": "Linus Torvalds",
"title": "Nvidia, fuck you!",
"url": "http://example.net"
}

View file

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<title>{{ site.title }}: {{ project.title }}</title>
</head>
<body>
<h1>{{ project.title }}</h1>
<h3>{{ project.description }}</h4>
</body>
</html>

View file

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<title>{{ site.title }}</title>
</head>
<body>
<h1><a href="{{ site.url }}">{{ site.title }}</a></h1>
<ul>
{% for project in projects %}
<li><a href="{{ project.path }}">{{ project.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>