Port site.json to Swift code in samhuri_net module

This commit is contained in:
Sami Samhuri 2019-12-17 10:29:28 -08:00
parent 487875098a
commit 44fef3fb78
20 changed files with 319 additions and 152 deletions

View file

@ -115,7 +115,7 @@ Execution, trying TDD for the first time:
- [x] Create new packages and distribute the code accordingly - [x] Create new packages and distribute the code accordingly
- [ ] Replace site.json with Swift code - [x] Replace site.json with Swift code
- [ ] Replace page template with Swift code - [ ] Replace page template with Swift code

View file

@ -0,0 +1,20 @@
//
// BuiltSite.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
public struct BuiltSite {
public let site: Site
public let plugins: [Plugin]
public let renderers: [Renderer]
init(site: Site, plugins: [Plugin], renderers: [Renderer]) {
self.site = site
self.plugins = plugins
self.renderers = renderers
}
}

View file

@ -15,10 +15,6 @@ struct PageContext {
} }
extension PageContext { extension PageContext {
var template: String {
page.template ?? site.template
}
var dictionary: [String: Any] { var dictionary: [String: Any] {
[ [
"site": site, "site": site,

View file

@ -9,11 +9,9 @@ import Foundation
struct SiteContext { struct SiteContext {
let site: Site let site: Site
let template: String
init(site: Site, template: String? = nil) { init(site: Site) {
self.site = site self.site = site
self.template = template ?? site.template
} }
} }

View file

@ -1,50 +0,0 @@
//
// HumanSite.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-01.
//
import Foundation
/// This is used to make the JSON simpler to write with optionals.
struct HumanSite: Codable {
let author: String
let email: String?
let title: String
let description: String?
let url: URL
let template: String?
let styles: [String]?
let scripts: [String]?
let avatar: String?
let icon: String?
let favicon: String?
let plugins: [String: [String: String]]?
}
extension Site {
init(humanSite: HumanSite) {
self.init(
author: humanSite.author,
email: humanSite.email,
title: humanSite.title,
description: humanSite.description,
url: humanSite.url,
template: humanSite.template ?? "page",
styles: humanSite.styles ?? [],
scripts: humanSite.scripts ?? [],
avatarPath: humanSite.avatar,
iconPath: humanSite.icon,
faviconPath: humanSite.favicon,
plugins: (humanSite.plugins ?? [:]).reduce(into: [:], { dict, pair in
let (name, options) = pair
guard let sitePlugin = SitePlugin(rawValue: name) else {
print("warning: unknown site plugin \"\(name)\"")
return
}
dict[sitePlugin] = options
})
)
}
}

View file

@ -12,23 +12,31 @@ public struct Site {
public let email: String? public let email: String?
public let title: String public let title: String
public let description: String? public let description: String?
public var url: URL public let url: URL
public let template: String
public let styles: [String] public let styles: [String]
public let scripts: [String] public let scripts: [String]
public let renderers: [Renderer]
public let plugins: [Plugin]
// Used for JSON feed public init(
public let avatarPath: String? author: String,
public let iconPath: String? email: String?,
public let faviconPath: String? title: String,
description: String?,
public let plugins: [SitePlugin: [String: Any]] url: URL,
} styles: [String],
scripts: [String],
extension Site { renderers: [Renderer],
static func decode(from url: URL) throws -> Site { plugins: [Plugin]
let json = try Data(contentsOf: url) ) {
let humanSite = try JSONDecoder().decode(HumanSite.self, from: json) self.author = author
return Site(humanSite: humanSite) self.email = email
self.title = title
self.description = description
self.url = url
self.styles = styles
self.scripts = scripts
self.renderers = renderers
self.plugins = plugins
} }
} }

View file

@ -20,16 +20,16 @@ final class SiteTemplateRenderer: TemplateRenderer {
self.stencil = Environment(loader: loader) self.stencil = Environment(loader: loader)
} }
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String { func renderPage(template: String, bodyHTML: String, metadata: [String: String]) throws -> String {
let page = Page(metadata: metadata) let page = Page(metadata: metadata)
let context = PageContext(site: site, body: bodyHTML, 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) let pageHTML = try stencil.renderTemplate(name: template, context: context.dictionary)
return pageHTML return pageHTML
} }
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)
let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new }) let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new })
return try stencil.renderTemplate(name: siteContext.template, context: contextDict) return try stencil.renderTemplate(name: name, context: contextDict)
} }
} }

View file

@ -8,6 +8,6 @@
import Foundation import Foundation
public protocol TemplateRenderer: AnyObject { public protocol TemplateRenderer: AnyObject {
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String func renderPage(template: String, bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String?, context: [String: Any]) throws -> String func renderTemplate(name: String, context: [String: Any]) throws -> String
} }

View file

@ -37,11 +37,11 @@ private struct FeedItem: Codable {
final class JSONFeedWriter { final class JSONFeedWriter {
let fileManager: FileManager let fileManager: FileManager
let feedPath: String let jsonFeed: JSONFeed
init(fileManager: FileManager = .default, feedPath: String = "feed.json") { init(fileManager: FileManager = .default, feed: JSONFeed) {
self.fileManager = fileManager self.fileManager = fileManager
self.feedPath = feedPath self.jsonFeed = feed
} }
func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws {
@ -60,21 +60,21 @@ final class JSONFeedWriter {
tags: post.tags tags: post.tags
) )
} }
let avatar = site.avatarPath.map(site.url.appendingPathComponent) let avatar = jsonFeed.avatarPath.map(site.url.appendingPathComponent)
let feed: Feed = Feed( let feed: Feed = Feed(
title: site.title, title: site.title,
home_page_url: site.url.absoluteString, home_page_url: site.url.absoluteString,
feed_url: site.url.appendingPathComponent(feedPath).absoluteString, feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString,
author: FeedAuthor(name: site.author, avatar: avatar?.absoluteString, url: site.url.absoluteString), author: FeedAuthor(name: site.author, avatar: avatar?.absoluteString, url: site.url.absoluteString),
icon: site.iconPath.map(site.url.appendingPathComponent)?.absoluteString, icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
favicon: site.faviconPath.map(site.url.appendingPathComponent)?.absoluteString, favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
items: items items: items
) )
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
let feedJSON = try encoder.encode(feed) let feedJSON = try encoder.encode(feed)
let feedURL = targetURL.appendingPathComponent(feedPath) let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
try feedJSON.write(to: feedURL, options: [.atomic]) try feedJSON.write(to: feedURL, options: [.atomic])
} }
} }

View file

@ -37,11 +37,11 @@ private extension Date {
final class RSSFeedWriter { final class RSSFeedWriter {
let fileManager: FileManager let fileManager: FileManager
let feedPath: String let feed: RSSFeed
init(fileManager: FileManager = .default, feedPath: String = "feed.xml") { init(fileManager: FileManager = .default, feed: RSSFeed) {
self.fileManager = fileManager self.fileManager = fileManager
self.feedPath = feedPath self.feed = feed
} }
func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws {
@ -74,10 +74,10 @@ final class RSSFeedWriter {
} }
let feedXML = try templateRenderer.renderTemplate(name: "feed.xml", context: [ let feedXML = try templateRenderer.renderTemplate(name: "feed.xml", context: [
"site": feedSite, "site": feedSite,
"feedURL": site.url.appendingPathComponent(feedPath).absoluteString.escapedForXML(), "feedURL": site.url.appendingPathComponent(feed.path).absoluteString.escapedForXML(),
"posts": renderedPosts, "posts": renderedPosts,
]) ])
let feedURL = targetURL.appendingPathComponent(feedPath) let feedURL = targetURL.appendingPathComponent(feed.path)
try feedXML.write(to: feedURL, atomically: true, encoding: .utf8) try feedXML.write(to: feedURL, atomically: true, encoding: .utf8)
} }
} }

View file

@ -0,0 +1,15 @@
//
// JSONFeed.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
struct JSONFeed {
let path: String
let avatarPath: String?
let iconPath: String?
let faviconPath: String?
}

View file

@ -7,7 +7,7 @@
import Foundation import Foundation
final class PostsPlugin: Plugin { public final class PostsPlugin: Plugin {
let postRepo: PostRepo let postRepo: PostRepo
let postWriter: PostWriter let postWriter: PostWriter
let jsonFeedWriter: JSONFeedWriter? let jsonFeedWriter: JSONFeedWriter?
@ -41,7 +41,8 @@ final class PostsPlugin: Plugin {
let jsonFeedWriter: JSONFeedWriter? let jsonFeedWriter: JSONFeedWriter?
if let jsonFeedPath = options["json_feed"] as? String { if let jsonFeedPath = options["json_feed"] as? String {
jsonFeedWriter = JSONFeedWriter(feedPath: jsonFeedPath) let jsonFeed = JSONFeed(path: jsonFeedPath, avatarPath: nil, iconPath: nil, faviconPath: nil)
jsonFeedWriter = JSONFeedWriter(feed: jsonFeed)
} }
else { else {
jsonFeedWriter = nil jsonFeedWriter = nil
@ -49,7 +50,8 @@ final class PostsPlugin: Plugin {
let rssFeedWriter: RSSFeedWriter? let rssFeedWriter: RSSFeedWriter?
if let rssFeedPath = options["rss_feed"] as? String { if let rssFeedPath = options["rss_feed"] as? String {
rssFeedWriter = RSSFeedWriter(feedPath: rssFeedPath) let rssFeed = RSSFeed(path: rssFeedPath)
rssFeedWriter = RSSFeedWriter(feed: rssFeed)
} }
else { else {
rssFeedWriter = nil rssFeedWriter = nil
@ -58,7 +60,7 @@ final class PostsPlugin: Plugin {
self.init(postRepo: postRepo, postWriter: postWriter, jsonFeedWriter: jsonFeedWriter, rssFeedWriter: rssFeedWriter) self.init(postRepo: postRepo, postWriter: postWriter, jsonFeedWriter: jsonFeedWriter, rssFeedWriter: rssFeedWriter)
} }
func setUp(site: Site, sourceURL: URL) throws { public func setUp(site: Site, sourceURL: URL) throws {
guard postRepo.postDataExists(at: sourceURL) else { guard postRepo.postDataExists(at: sourceURL) else {
return return
} }
@ -66,7 +68,7 @@ final class PostsPlugin: Plugin {
try postRepo.readPosts(sourceURL: sourceURL) try postRepo.readPosts(sourceURL: sourceURL)
} }
func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws { public func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws {
guard !postRepo.isEmpty else { guard !postRepo.isEmpty else {
return return
} }

View file

@ -0,0 +1,80 @@
//
// PostsPluginBuilder.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
public final class PostsPluginBuilder {
private var path: String?
private var jsonFeed: JSONFeed?
private var rssFeed: RSSFeed?
public init() {}
public func path(_ path: String) -> PostsPluginBuilder {
precondition(self.path == nil, "path is already defined")
self.path = path
return self
}
public func jsonFeed(
path: String? = nil,
avatarPath: String? = nil,
iconPath: String? = nil,
faviconPath: String? = nil
) -> PostsPluginBuilder {
precondition(jsonFeed == nil, "JSON feed is already defined")
jsonFeed = JSONFeed(
path: path ?? "feed.json",
avatarPath: avatarPath,
iconPath: iconPath,
faviconPath: faviconPath
)
return self
}
public func rssFeed(path: String? = nil) -> PostsPluginBuilder {
precondition(rssFeed == nil, "RSS feed is already defined")
rssFeed = RSSFeed(path: path ?? "feed.xml")
return self
}
public func build() -> PostsPlugin {
let postRepo: PostRepo
let postWriter: PostWriter
if let outputPath = path {
postRepo = PostRepo(outputPath: outputPath)
postWriter = PostWriter(outputPath: outputPath)
}
else {
postRepo = PostRepo()
postWriter = PostWriter()
}
let jsonFeedWriter: JSONFeedWriter?
if let jsonFeed = jsonFeed {
jsonFeedWriter = JSONFeedWriter(feed: jsonFeed)
}
else {
jsonFeedWriter = nil
}
let rssFeedWriter: RSSFeedWriter?
if let rssFeed = rssFeed {
rssFeedWriter = RSSFeedWriter(feed: rssFeed)
}
else {
rssFeedWriter = nil
}
return PostsPlugin(
postRepo: postRepo,
postWriter: postWriter,
jsonFeedWriter: jsonFeedWriter,
rssFeedWriter: rssFeedWriter
)
}
}

View file

@ -0,0 +1,12 @@
//
// RSSFeed.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
struct RSSFeed {
let path: String
}

View file

@ -24,8 +24,8 @@ final class ProjectsPlugin: Plugin {
var projects: [Project] = [] var projects: [Project] = []
var sourceURL: URL! var sourceURL: URL!
init(outputPath: String = "projects") { init(outputPath: String? = nil) {
self.outputPath = outputPath self.outputPath = outputPath ?? "projects"
} }
convenience init(options: [String: Any]) { convenience init(options: [String: Any]) {

View file

@ -11,8 +11,11 @@ import Ink
public final class MarkdownRenderer: Renderer { public final class MarkdownRenderer: Renderer {
let fileManager: FileManager = .default let fileManager: FileManager = .default
let markdownParser = MarkdownParser() let markdownParser = MarkdownParser()
let defaultTemplate: String
public init() {} public init(defaultTemplate: String) {
self.defaultTemplate = defaultTemplate
}
public func canRenderFile(named filename: String, withExtension ext: String) -> Bool { public func canRenderFile(named filename: String, withExtension ext: String) -> Bool {
ext == "md" ext == "md"
@ -23,7 +26,7 @@ public final class MarkdownRenderer: Renderer {
let bodyMarkdown = try String(contentsOf: fileURL, encoding: .utf8) let bodyMarkdown = try String(contentsOf: fileURL, encoding: .utf8)
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines) let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let metadata = try markdownMetadata(from: fileURL) let metadata = try markdownMetadata(from: fileURL)
let pageHTML = try templateRenderer.renderPage(bodyHTML: bodyHTML, metadata: metadata) let pageHTML = try templateRenderer.renderPage(template: defaultTemplate, bodyHTML: bodyHTML, metadata: metadata)
let mdFilename = fileURL.lastPathComponent let mdFilename = fileURL.lastPathComponent
let htmlPath: String let htmlPath: String

View file

@ -0,0 +1,105 @@
//
// SiteBuilder.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-15.
//
import Foundation
public final class SiteBuilder {
private let author: String
private let title: String
private let url: URL
private var email: String?
private var description: String?
private var styles: [String] = []
private var scripts: [String] = []
private var plugins: [Plugin] = []
private var renderers: [Renderer] = []
public init(
author: String,
email: String? = nil,
title: String,
description: String? = nil,
url: URL
) {
self.author = author
self.email = email
self.title = title
self.description = description
self.url = url
}
public func email(_ email: String) -> SiteBuilder {
precondition(self.email == nil, "email is already defined")
self.email = email
return self
}
public func description(_ description: String) -> SiteBuilder {
precondition(self.description == nil, "description is already defined")
self.description = description
return self
}
public func styles(_ styles: String...) -> SiteBuilder {
self.styles.append(contentsOf: styles)
return self
}
public func scripts(_ scripts: String...) -> SiteBuilder {
self.scripts.append(contentsOf: scripts)
return self
}
public func plugin(_ plugin: Plugin) -> SiteBuilder {
plugins.append(plugin)
return self
}
public func renderer(_ renderer: Renderer) -> SiteBuilder {
renderers.append(renderer)
return self
}
public func build() -> Site {
Site(
author: author,
email: email,
title: title,
description: description,
url: url,
styles: styles,
scripts: scripts,
renderers: renderers,
plugins: plugins
)
}
}
// MARK: - Markdown
public extension SiteBuilder {
func renderMarkdown(defaultTemplate: String) -> SiteBuilder {
renderer(MarkdownRenderer(defaultTemplate: defaultTemplate))
}
}
// MARK: - Projects
public extension SiteBuilder {
func projects(path: String? = nil) -> SiteBuilder {
plugin(ProjectsPlugin(outputPath: path))
}
}
// MARK: - Posts
public extension SiteBuilder {
// anything nice we can do there?
}

View file

@ -15,44 +15,27 @@ public final class SiteGenerator {
// Site properties // Site properties
public let site: Site public let site: Site
public let sourceURL: URL public let sourceURL: URL
public private(set) var plugins: [Plugin] = []
public let renderers: [Renderer]
let ignoredFilenames = [".DS_Store", ".gitkeep"] let ignoredFilenames = [".DS_Store", ".gitkeep"]
public init(sourceURL: URL, siteURLOverride: URL? = nil, renderers: [Renderer]) throws { public init(sourceURL: URL, site: Site) throws {
let siteURL = sourceURL.appendingPathComponent("site.json") self.site = site
var site = try Site.decode(from: siteURL) self.sourceURL = sourceURL
if let url = siteURLOverride {
print("Overriding site URL \(site.url) with \(url)")
site.url = url
}
let templatesURL = sourceURL.appendingPathComponent("templates") let templatesURL = sourceURL.appendingPathComponent("templates")
self.templateRenderer = SiteTemplateRenderer(site: site, templatesURL: templatesURL) self.templateRenderer = SiteTemplateRenderer(site: site, templatesURL: templatesURL)
self.site = site
self.sourceURL = sourceURL
self.renderers = renderers
try initializePlugins() try initializePlugins()
} }
private func initializePlugins() throws { private func initializePlugins() throws {
plugins = site.plugins.map { pair in for plugin in site.plugins {
let (sitePlugin, options) = pair try plugin.setUp(site: site, sourceURL: sourceURL)
return sitePlugin.construct(options: options)
} }
try plugins.forEach(addPlugin)
}
public func addPlugin(_ plugin: Plugin) throws {
try plugin.setUp(site: site, sourceURL: sourceURL)
plugins.append(plugin)
} }
public func generate(targetURL: URL) throws { public func generate(targetURL: URL) throws {
for plugin in plugins { for plugin in site.plugins {
try plugin.render(site: site, targetURL: targetURL, templateRenderer: templateRenderer) try plugin.render(site: site, targetURL: targetURL, templateRenderer: templateRenderer)
} }
@ -87,7 +70,7 @@ public final class SiteGenerator {
func renderOrCopyFile(url fileURL: URL, targetDir: URL) throws { func renderOrCopyFile(url fileURL: URL, targetDir: URL) throws {
let filename = fileURL.lastPathComponent let filename = fileURL.lastPathComponent
let ext = String(filename.split(separator: ".").last!) let ext = String(filename.split(separator: ".").last!)
for renderer in renderers { for renderer in site.renderers {
if renderer.canRenderFile(named: filename, withExtension: ext) { if renderer.canRenderFile(named: filename, withExtension: ext) {
try renderer.render(fileURL: fileURL, targetDir: targetDir, templateRenderer: templateRenderer) try renderer.render(fileURL: fileURL, targetDir: targetDir, templateRenderer: templateRenderer)
return return

View file

@ -5,11 +5,28 @@ public struct samhuri_net {
public init() {} public init() {}
public func generate(sourceURL: URL, targetURL: URL, siteURLOverride: URL? = nil) throws { public func generate(sourceURL: URL, targetURL: URL, siteURLOverride: URL? = nil) throws {
let generator = try SiteGenerator( let postsPlugin = PostsPluginBuilder()
sourceURL: sourceURL, .path("posts")
siteURLOverride: siteURLOverride, .jsonFeed(
renderers: [MarkdownRenderer()] avatarPath: "images/me.jpg",
iconPath: "images/apple-touch-icon-300.png",
faviconPath: "images/apple-touch-icon-80.png"
)
.rssFeed()
.build()
let site = SiteBuilder(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "just some blog",
url: siteURLOverride ?? URL(string: "https://samhuri.net")!
) )
.styles("css/normalize.css", "css/style.css")
.renderMarkdown(defaultTemplate: "page.html")
.projects()
.plugin(postsPlugin)
.build()
let generator = try SiteGenerator(sourceURL: sourceURL, site: site)
try generator.generate(targetURL: targetURL) try generator.generate(targetURL: targetURL)
} }
} }

View file

@ -1,22 +0,0 @@
{
"title": "samhuri.net",
"description": "just some blog",
"author": "Sami Samhuri",
"email": "sami@samhuri.net",
"url": "https://samhuri.net",
"styles": [
"/css/normalize.css",
"/css/style.css"
],
"scripts": [],
"icon": "images/apple-touch-icon-300.png",
"favicon": "images/apple-touch-icon-80.png",
"avatar": "images/me.jpg",
"plugins": {
"projects": {},
"posts": {
"json_feed": "feed.json",
"rss_feed": "feed.xml"
}
}
}