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
- [ ] Replace site.json with Swift code
- [x] Replace site.json 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 {
var template: String {
page.template ?? site.template
}
var dictionary: [String: Any] {
[
"site": site,

View file

@ -9,11 +9,9 @@ import Foundation
struct SiteContext {
let site: Site
let template: String
init(site: Site, template: String? = nil) {
init(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 title: String
public let description: String?
public var url: URL
public let template: String
public let url: URL
public let styles: [String]
public let scripts: [String]
public let renderers: [Renderer]
public let plugins: [Plugin]
// Used for JSON feed
public let avatarPath: String?
public let iconPath: String?
public let faviconPath: String?
public let plugins: [SitePlugin: [String: Any]]
}
extension Site {
static func decode(from url: URL) throws -> Site {
let json = try Data(contentsOf: url)
let humanSite = try JSONDecoder().decode(HumanSite.self, from: json)
return Site(humanSite: humanSite)
public init(
author: String,
email: String?,
title: String,
description: String?,
url: URL,
styles: [String],
scripts: [String],
renderers: [Renderer],
plugins: [Plugin]
) {
self.author = author
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)
}
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 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
}
func renderTemplate(name: String?, context: [String: Any]) throws -> String {
let siteContext = SiteContext(site: site, template: name)
func renderTemplate(name: String, context: [String: Any]) throws -> String {
let siteContext = SiteContext(site: site)
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
public protocol TemplateRenderer: AnyObject {
func renderPage(bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String?, context: [String: Any]) throws -> String
func renderPage(template: String, bodyHTML: String, metadata: [String: String]) throws -> String
func renderTemplate(name: String, context: [String: Any]) throws -> String
}

View file

@ -37,11 +37,11 @@ private struct FeedItem: Codable {
final class JSONFeedWriter {
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.feedPath = feedPath
self.jsonFeed = feed
}
func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws {
@ -60,21 +60,21 @@ final class JSONFeedWriter {
tags: post.tags
)
}
let avatar = site.avatarPath.map(site.url.appendingPathComponent)
let avatar = jsonFeed.avatarPath.map(site.url.appendingPathComponent)
let feed: Feed = Feed(
title: site.title,
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),
icon: site.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
favicon: site.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
items: items
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
let feedJSON = try encoder.encode(feed)
let feedURL = targetURL.appendingPathComponent(feedPath)
let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
try feedJSON.write(to: feedURL, options: [.atomic])
}
}

View file

@ -37,11 +37,11 @@ private extension Date {
final class RSSFeedWriter {
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.feedPath = feedPath
self.feed = feed
}
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: [
"site": feedSite,
"feedURL": site.url.appendingPathComponent(feedPath).absoluteString.escapedForXML(),
"feedURL": site.url.appendingPathComponent(feed.path).absoluteString.escapedForXML(),
"posts": renderedPosts,
])
let feedURL = targetURL.appendingPathComponent(feedPath)
let feedURL = targetURL.appendingPathComponent(feed.path)
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
final class PostsPlugin: Plugin {
public final class PostsPlugin: Plugin {
let postRepo: PostRepo
let postWriter: PostWriter
let jsonFeedWriter: JSONFeedWriter?
@ -41,7 +41,8 @@ final class PostsPlugin: Plugin {
let jsonFeedWriter: JSONFeedWriter?
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 {
jsonFeedWriter = nil
@ -49,7 +50,8 @@ final class PostsPlugin: Plugin {
let rssFeedWriter: RSSFeedWriter?
if let rssFeedPath = options["rss_feed"] as? String {
rssFeedWriter = RSSFeedWriter(feedPath: rssFeedPath)
let rssFeed = RSSFeed(path: rssFeedPath)
rssFeedWriter = RSSFeedWriter(feed: rssFeed)
}
else {
rssFeedWriter = nil
@ -58,7 +60,7 @@ final class PostsPlugin: Plugin {
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 {
return
}
@ -66,7 +68,7 @@ final class PostsPlugin: Plugin {
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 {
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 sourceURL: URL!
init(outputPath: String = "projects") {
self.outputPath = outputPath
init(outputPath: String? = nil) {
self.outputPath = outputPath ?? "projects"
}
convenience init(options: [String: Any]) {

View file

@ -11,8 +11,11 @@ import Ink
public final class MarkdownRenderer: Renderer {
let fileManager: FileManager = .default
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 {
ext == "md"
@ -23,7 +26,7 @@ public final class MarkdownRenderer: Renderer {
let bodyMarkdown = try String(contentsOf: fileURL, encoding: .utf8)
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
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 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
public let site: Site
public let sourceURL: URL
public private(set) var plugins: [Plugin] = []
public let renderers: [Renderer]
let ignoredFilenames = [".DS_Store", ".gitkeep"]
public init(sourceURL: URL, siteURLOverride: URL? = nil, renderers: [Renderer]) throws {
let siteURL = sourceURL.appendingPathComponent("site.json")
var site = try Site.decode(from: siteURL)
if let url = siteURLOverride {
print("Overriding site URL \(site.url) with \(url)")
site.url = url
}
public init(sourceURL: URL, site: Site) throws {
self.site = site
self.sourceURL = sourceURL
let templatesURL = sourceURL.appendingPathComponent("templates")
self.templateRenderer = SiteTemplateRenderer(site: site, templatesURL: templatesURL)
self.site = site
self.sourceURL = sourceURL
self.renderers = renderers
try initializePlugins()
}
private func initializePlugins() throws {
plugins = site.plugins.map { pair in
let (sitePlugin, options) = pair
return sitePlugin.construct(options: options)
for plugin in site.plugins {
try plugin.setUp(site: site, sourceURL: sourceURL)
}
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 {
for plugin in plugins {
for plugin in site.plugins {
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 {
let filename = fileURL.lastPathComponent
let ext = String(filename.split(separator: ".").last!)
for renderer in renderers {
for renderer in site.renderers {
if renderer.canRenderFile(named: filename, withExtension: ext) {
try renderer.render(fileURL: fileURL, targetDir: targetDir, templateRenderer: templateRenderer)
return

View file

@ -5,11 +5,28 @@ public struct samhuri_net {
public init() {}
public func generate(sourceURL: URL, targetURL: URL, siteURLOverride: URL? = nil) throws {
let generator = try SiteGenerator(
sourceURL: sourceURL,
siteURLOverride: siteURLOverride,
renderers: [MarkdownRenderer()]
let postsPlugin = PostsPluginBuilder()
.path("posts")
.jsonFeed(
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)
}
}

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"
}
}
}