Shuffle some more code around and clean things up

This commit is contained in:
Sami Samhuri 2020-01-01 22:49:47 -08:00
parent f71c9aabbb
commit 9dfd5080ef
19 changed files with 179 additions and 167 deletions

View file

@ -0,0 +1,16 @@
//
// FileManager+DirectoryExistence.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
extension FileManager {
func directoryExists(at fileURL: URL) -> Bool {
var isDir: ObjCBool = false
_ = fileExists(atPath: fileURL.path, isDirectory: &isDir)
return isDir.boolValue
}
}

View file

@ -49,6 +49,9 @@ extension FilePermissions: RawRepresentable {
extension FilePermissions: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
guard let _ = FilePermissions(string: value) else {
fatalError("Invalid FilePermissions string literal: \(value)")
}
self.init(string: value)!
}
}

View file

@ -23,33 +23,28 @@ struct Permissions: OptionSet {
}
init?(string: String) {
guard string.count == 3 else {
return nil
}
self.init(rawValue: 0)
switch string[string.startIndex] {
case "r":
insert(.read)
case "-":
break
default:
return nil
case "r": insert(.read)
case "-": break
default: return nil
}
switch string[string.index(string.startIndex, offsetBy: 1)] {
case "w":
insert(.write)
case "-":
break
default:
return nil
case "w": insert(.write)
case "-": break
default: return nil
}
switch string[string.index(string.startIndex, offsetBy: 2)] {
case "x":
insert(.execute)
case "-":
break
default:
return nil
case "x": insert(.execute)
case "-": break
default: return nil
}
}
}
@ -66,6 +61,9 @@ extension Permissions: CustomStringConvertible {
extension Permissions: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
guard let _ = Permissions(string: value) else {
fatalError("Invalid Permissions string literal: \(value)")
}
self.init(string: value)!
}
}

View file

@ -8,7 +8,7 @@
import Foundation
struct Month: Equatable {
static let all = (1 ... 12).map(Month.init(_:))
static let all = names.map(Month.init(_:))
static let names = [
"January", "February", "March", "April",
@ -18,14 +18,22 @@ struct Month: Equatable {
let number: Int
init(_ number: Int) {
precondition((1 ... 12).contains(number), "Month number must be from 1 to 12, got \(number)")
init?(_ number: Int) {
guard number < Month.all.count else {
return nil
}
self.number = number
}
init(_ name: String) {
precondition(Month.names.contains(name), "Month name is unknown: \(name)")
self.number = 1 + Month.names.firstIndex(of: name)!
init?(_ name: String) {
guard let index = Month.names.firstIndex(of: name) else {
return nil
}
self.number = index + 1
}
init(_ date: Date) {
self.init(date.month)!
}
var padded: String {
@ -55,12 +63,18 @@ extension Month: Comparable {
extension Month: ExpressibleByIntegerLiteral {
init(integerLiteral value: Int) {
self.init(value)
guard let _ = Month(value) else {
fatalError("Invalid month number in string literal: \(value)")
}
self.init(value)!
}
}
extension Month: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.init(value)
guard let _ = Month(value) else {
fatalError("Invalid month name in string literal: \(value)")
}
self.init(value)!
}
}

View file

@ -9,7 +9,7 @@ import Foundation
struct MonthPosts {
let month: Month
var posts: [Post]
private(set) var posts: [Post]
let path: String
var title: String {
@ -23,4 +23,8 @@ struct MonthPosts {
var year: Int {
posts[0].date.year
}
mutating func add(post: Post) {
posts.append(post)
}
}

View file

@ -35,8 +35,8 @@ struct PostsByYear {
}
mutating func add(post: Post) {
let (year, month) = (post.date.year, Month(post.date.month))
self[year][month].posts.append(post)
let (year, month) = (post.date.year, Month(post.date))
self[year].add(post: post, to: month)
}
/// Returns an array of all posts.

View file

@ -33,6 +33,10 @@ struct YearPosts {
}
}
mutating func add(post: Post, to month: Month) {
self[month].add(post: post)
}
/// Returns an array of all posts.
func flattened() -> [Post] {
byMonth.values.flatMap { $0.posts }

View file

@ -0,0 +1,48 @@
//
// PostMetadata.swift
// samhuri.net
//
// Created by Sami Samhuri on 2020-01-01.
//
import Foundation
struct PostMetadata {
let title: String
let author: String
let date: Date
let formattedDate: String
let link: URL?
let tags: [String]
let scripts: [String]
let styles: [String]
}
extension PostMetadata {
enum Error: Swift.Error {
case deficientMetadata(slug: String, missingKeys: [String], metadata: [String: String])
case invalidTimestamp(String)
}
init(dictionary: [String: String], slug: String) throws {
let requiredKeys = ["Title", "Author", "Date", "Timestamp"]
let missingKeys = requiredKeys.filter { dictionary[$0] == nil }
guard missingKeys.isEmpty else {
throw Error.deficientMetadata(slug: slug, missingKeys: missingKeys, metadata: dictionary)
}
guard let timestamp = dictionary["Timestamp"], let timeInterval = TimeInterval(timestamp) else {
throw Error.invalidTimestamp(dictionary["Timestamp"]!)
}
self.init(
title: dictionary["Title"]!,
author: dictionary["Author"]!,
date: Date(timeIntervalSince1970: timeInterval),
formattedDate: dictionary["Date"]!,
link: dictionary["Link"].flatMap { URL(string: $0) },
tags: dictionary.commaSeparatedList(key: "Tags"),
scripts: dictionary.commaSeparatedList(key: "Scripts"),
styles: dictionary.commaSeparatedList(key: "Styles")
)
}
}

View file

@ -6,6 +6,7 @@
//
import Foundation
import Ink
struct RawPost {
let slug: String
@ -18,13 +19,13 @@ final class PostRepo {
let feedPostsCount = 30
let fileManager: FileManager
let outputPath: String
let markdownParser: MarkdownParser
private(set) var posts: PostsByYear!
init(fileManager: FileManager = .default, outputPath: String = "posts") {
init(fileManager: FileManager = .default, markdownParser: MarkdownParser = MarkdownParser()) {
self.fileManager = fileManager
self.outputPath = outputPath
self.markdownParser = markdownParser
}
var isEmpty: Bool {
@ -32,7 +33,7 @@ final class PostRepo {
}
var sortedPosts: [Post] {
posts?.flattened().sorted(by: >) ?? []
posts.flattened().sorted(by: >)
}
var recentPosts: [Post] {
@ -48,14 +49,46 @@ final class PostRepo {
return fileManager.fileExists(atPath: postsURL.path)
}
func readPosts(sourceURL: URL) throws {
let postTransformer = PostTransformer(outputPath: outputPath)
func readPosts(sourceURL: URL, outputPath: String) throws {
let posts = try readRawPosts(sourceURL: sourceURL)
.map(postTransformer.makePost)
.map { try makePost(from: $0, outputPath: outputPath) }
self.posts = PostsByYear(posts: posts, path: "/\(outputPath)")
}
}
private func readRawPosts(sourceURL: URL) throws -> [RawPost] {
private extension PostRepo {
func makePost(from rawPost: RawPost, outputPath: String) throws -> Post {
let result = markdownParser.parse(rawPost.markdown)
let metadata = try PostMetadata(dictionary: result.metadata, slug: rawPost.slug)
let path = pathForPost(root: outputPath, date: metadata.date, slug: 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,
scripts: metadata.scripts,
styles: metadata.styles,
body: result.html,
path: path
)
}
func pathForPost(root: String, date: Date, slug: String) -> String {
// format: /{root}/{year}/{month}/{slug}
// e.g. /posts/2019/12/first-post
[
"",
root,
"\(date.year)",
Month(date).padded,
slug,
].joined(separator: "/")
}
func readRawPosts(sourceURL: URL) throws -> [RawPost] {
let postsURL = sourceURL.appendingPathComponent(postsPath)
return try enumerateMarkdownFiles(directory: postsURL)
.compactMap { url in
@ -69,22 +102,20 @@ final class PostRepo {
}
}
private func readRawPost(url: URL) throws -> RawPost {
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)
func enumerateMarkdownFiles(directory: URL) throws -> [URL] {
return try fileManager.contentsOfDirectory(atPath: directory.path).flatMap { (name: String) -> [URL] in
let url = directory.appendingPathComponent(name)
if fileManager.directoryExists(at: url) {
return try enumerateMarkdownFiles(directory: url)
}
else {
return fileURL.pathExtension == "md" ? [fileURL] : []
return url.pathExtension == "md" ? [url] : []
}
}
}

View file

@ -1,98 +0,0 @@
//
// PostTransformer.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-09.
//
import Foundation
import Ink
final class PostTransformer {
let markdownParser: MarkdownParser
let outputPath: String
init(markdownParser: MarkdownParser = MarkdownParser(), outputPath: String = "posts") {
self.markdownParser = markdownParser
self.outputPath = outputPath
}
func makePost(from rawPost: RawPost) throws -> Post {
let result = markdownParser.parse(rawPost.markdown)
let metadata = try parseMetadata(result.metadata, slug: rawPost.slug)
let path = pathForPost(date: metadata.date, slug: 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,
scripts: metadata.scripts,
styles: metadata.styles,
body: result.html,
path: path
)
}
func pathForPost(date: Date, slug: String) -> String {
// format: /posts/2019/12/first-post
[
"",
outputPath,
"\(date.year)",
Month(date.month).padded,
slug,
].joined(separator: "/")
}
}
private struct ParsedMetadata {
let title: String
let author: String
let date: Date
let formattedDate: String
let link: URL?
let tags: [String]
let scripts: [String]
let styles: [String]
}
private extension PostTransformer {
enum Error: Swift.Error {
case deficientMetadata(slug: String, missingKeys: [String], metadata: [String: String])
case invalidTimestamp(String)
}
func parseMetadata(_ metadata: [String: String], slug: String) throws -> ParsedMetadata {
let requiredKeys = ["Title", "Author", "Date", "Timestamp"]
let missingKeys = requiredKeys.filter { metadata[$0] == nil }
guard missingKeys.isEmpty else {
throw Error.deficientMetadata(slug: slug, missingKeys: missingKeys, metadata: metadata)
}
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 = metadata["Link"].flatMap { URL(string: $0) }
let tags = metadata.commaSeparatedList(key: "Tags")
let scripts = metadata.commaSeparatedList(key: "Scripts")
let styles = metadata.commaSeparatedList(key: "Styles")
return ParsedMetadata(
title: title,
author: author,
date: date,
formattedDate: formattedDate,
link: link,
tags: tags,
scripts: scripts,
styles: styles
)
}
}

View file

@ -31,7 +31,7 @@ extension PostWriter {
}
private func filePath(date: Date, slug: String) -> String {
"/\(date.year)/\(Month(date.month).padded)/\(slug)/index.html"
"/\(date.year)/\(Month(date).padded)/\(slug)/index.html"
}
}

View file

@ -47,14 +47,11 @@ extension PostsPlugin {
}
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()
}
@ -76,7 +73,7 @@ extension PostsPlugin {
return PostsPlugin(
renderer: renderer,
postRepo: postRepo,
postRepo: PostRepo(),
postWriter: postWriter,
jsonFeedWriter: jsonFeedWriter,
rssFeedWriter: rssFeedWriter

View file

@ -37,7 +37,7 @@ final class PostsPlugin: Plugin {
return
}
try postRepo.readPosts(sourceURL: sourceURL)
try postRepo.readPosts(sourceURL: sourceURL, outputPath: postWriter.outputPath)
}
func render(site: Site, targetURL: URL) throws {

View file

@ -12,7 +12,7 @@ extension PageRenderer: RSSFeedRendering {
func renderRSSFeed(posts: [Post], feedURL: URL, site: Site) throws -> String {
try RSS(
.title(site.title),
.if(site.description != nil, .description(site.description!)),
.description(site.description),
.link(site.url),
.pubDate(posts[0].date),
.atomLink(feedURL),

View file

@ -20,7 +20,6 @@ final class ProjectsPlugin: Plugin {
let projectAssets: TemplateAssets
var projects: [Project] = []
var sourceURL: URL!
init(
projects: [PartialProject],
@ -39,7 +38,6 @@ final class ProjectsPlugin: Plugin {
// MARK: - Plugin methods
func setUp(site: Site, sourceURL: URL) throws {
self.sourceURL = sourceURL
projects = partialProjects.map { partial in
Project(
title: partial.title,

View file

@ -9,7 +9,6 @@ import Foundation
import Ink
final class MarkdownRenderer: Renderer {
let fileManager: FileManager = .default
let fileWriter: FileWriting
let markdownParser = MarkdownParser()
let pageRenderer: PageRendering
@ -25,7 +24,7 @@ final class MarkdownRenderer: Renderer {
/// Parse Markdown and render it as HTML, running it through a Stencil template.
func render(site: Site, fileURL: URL, targetDir: URL) throws {
let bodyMarkdown = try String(contentsOf: fileURL, encoding: .utf8)
let bodyMarkdown = try String(contentsOf: fileURL)
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
let metadata = try markdownMetadata(from: fileURL)
let pageHTML = try pageRenderer.renderPage(site: site, bodyHTML: bodyHTML, metadata: metadata)
@ -43,7 +42,7 @@ final class MarkdownRenderer: Renderer {
}
func markdownMetadata(from url: URL) throws -> [String: String] {
let md = try String(contentsOf: url, encoding: .utf8)
let md = try String(contentsOf: url)
return markdownParser.parse(md).metadata
}
}

View file

@ -10,7 +10,7 @@ import Foundation
extension Site {
final class Builder {
private let title: String
private let description: String?
private let description: String
private let author: String
private let email: String
private let url: URL
@ -23,7 +23,7 @@ extension Site {
init(
title: String,
description: String? = nil,
description: String,
author: String,
email: String,
url: URL

View file

@ -11,7 +11,7 @@ struct Site {
let author: String
let email: String
let title: String
let description: String?
let description: String
let url: URL
let styles: [String]
let scripts: [String]

View file

@ -41,17 +41,15 @@ final class SiteGenerator {
// 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) {
guard !ignoredFilenames.contains(filename) else {
for name in try fileManager.contentsOfDirectory(atPath: path) {
guard !ignoredFilenames.contains(name) else {
continue
}
// 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))
let url = URL(fileURLWithPath: path).appendingPathComponent(name)
guard !fileManager.directoryExists(at: url) else {
try renderPath(url.path, to: targetURL.appendingPathComponent(name))
continue
}
@ -59,7 +57,7 @@ final class SiteGenerator {
try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
// Process the file, transforming it if necessary.
try renderOrCopyFile(url: fileURL, targetDir: targetURL)
try renderOrCopyFile(url: url, targetDir: targetURL)
}
}