diff --git a/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift b/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift new file mode 100644 index 0000000..a5014f5 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift @@ -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 + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift b/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift index e72faa4..aa38a59 100644 --- a/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift +++ b/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift @@ -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)! } } diff --git a/samhuri.net/Sources/samhuri.net/Files/Permissions.swift b/samhuri.net/Sources/samhuri.net/Files/Permissions.swift index 8380c5a..c88fbf1 100644 --- a/samhuri.net/Sources/samhuri.net/Files/Permissions.swift +++ b/samhuri.net/Sources/samhuri.net/Files/Permissions.swift @@ -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)! } } diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift index 19140a0..2820d9f 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift @@ -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)! } } diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift index 4e57cd8..0be6509 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift @@ -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) + } } diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift index 3731800..39c8da7 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift @@ -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. diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift index 919d7b9..2ae89d5 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift @@ -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 } diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift b/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift new file mode 100644 index 0000000..2b96602 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift @@ -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") + ) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift b/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift index 535aad9..157e970 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift @@ -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] : [] } } } diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostTransformer.swift b/samhuri.net/Sources/samhuri.net/Posts/PostTransformer.swift deleted file mode 100644 index 7b7739f..0000000 --- a/samhuri.net/Sources/samhuri.net/Posts/PostTransformer.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift b/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift index dc0ffac..c016d69 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift @@ -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" } } diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift index 431e305..8029bb9 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift @@ -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 diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift index b11bef3..9da4b83 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift @@ -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 { diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift index 7155d6c..538b521 100644 --- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift +++ b/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift @@ -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), diff --git a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift b/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift index 407d946..40a0bb5 100644 --- a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift +++ b/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift @@ -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, diff --git a/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift b/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift index c4556c3..658a5a4 100644 --- a/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift +++ b/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift @@ -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 } } diff --git a/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift b/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift index b87a175..1625df4 100644 --- a/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift +++ b/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift @@ -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 diff --git a/samhuri.net/Sources/samhuri.net/Site/Site.swift b/samhuri.net/Sources/samhuri.net/Site/Site.swift index 4ea98b7..5fc5880 100644 --- a/samhuri.net/Sources/samhuri.net/Site/Site.swift +++ b/samhuri.net/Sources/samhuri.net/Site/Site.swift @@ -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] diff --git a/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift b/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift index 2c441cb..c38139a 100644 --- a/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift +++ b/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift @@ -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) } }