diff --git a/samhuri.net/Sources/samhuri.net/Date+Sugar.swift b/samhuri.net/Sources/samhuri.net/Extensions/Date+Sugar.swift similarity index 100% rename from samhuri.net/Sources/samhuri.net/Date+Sugar.swift rename to samhuri.net/Sources/samhuri.net/Extensions/Date+Sugar.swift diff --git a/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift b/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift new file mode 100644 index 0000000..4d6f15f --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift @@ -0,0 +1,18 @@ +// +// DirectoryCreating.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +protocol DirectoryCreating { + func createDirectory(at url: URL) throws +} + +extension FileManager: DirectoryCreating { + func createDirectory(at url: URL) throws { + try createDirectory(at: url, withIntermediateDirectories: true, attributes: [.posixPermissions: FilePermissions.directoryDefault.rawValue]) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift b/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift new file mode 100644 index 0000000..98f3b87 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift @@ -0,0 +1,47 @@ +// +// FilePermissions.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +struct FilePermissions: CustomStringConvertible { + let user: Permissions + let group: Permissions + let other: Permissions + + var description: String { + [user, group, other].map { $0.description }.joined() + } + + static let `default`: FilePermissions = "rw-r--r--" + static let directoryDefault: FilePermissions = "rwxr-xr-x" +} + +extension FilePermissions { + init(string: String) { + user = Permissions(string: String(string.prefix(3))) + group = Permissions(string: String(string.dropFirst(3).prefix(3))) + other = Permissions(string: String(string.dropFirst(6).prefix(3))) + } +} + +extension FilePermissions: RawRepresentable { + var rawValue: Int16 { + user.rawValue << 6 | group.rawValue << 3 | other.rawValue + } + + init(rawValue: Int16) { + user = Permissions(rawValue: rawValue >> 6 & 7) + group = Permissions(rawValue: rawValue >> 3 & 7) + other = Permissions(rawValue: rawValue >> 0 & 7) + } +} + +extension FilePermissions: ExpressibleByStringLiteral { + init(stringLiteral value: String) { + self.init(string: value) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift b/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift new file mode 100644 index 0000000..8ddbbc2 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift @@ -0,0 +1,21 @@ +// +// FilePermissionsSetting.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +protocol FilePermissionsSetting { + func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws +} + +extension FileManager: FilePermissionsSetting { + func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws { + let attributes: [FileAttributeKey: Any] = [ + .posixPermissions: permissions.rawValue, + ] + try setAttributes(attributes, ofItemAtPath: fileURL.path) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift b/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift new file mode 100644 index 0000000..a6bc4f0 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift @@ -0,0 +1,35 @@ +// +// FileWriter.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +/// On Linux umask doesn't seem to be respected and files are written without +/// group and other read permissions by default. This class explicitly sets +/// permissions and then it works properly on macOS and Linux. +final class FileWriter { + typealias FileManager = DirectoryCreating & FilePermissionsSetting + + let fileManager: FileManager + + init(fileManager: FileManager = Foundation.FileManager.default) { + self.fileManager = fileManager + } +} + +extension FileWriter: FileWriting { + func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws { + try fileManager.createDirectory(at: fileURL.deletingLastPathComponent()) + try data.write(to: fileURL, options: .atomic) + try fileManager.setPermissions(permissions, ofItemAt: fileURL) + } + + func write(string: String, to fileURL: URL, permissions: FilePermissions) throws { + try fileManager.createDirectory(at: fileURL.deletingLastPathComponent()) + try string.write(to: fileURL, atomically: true, encoding: .utf8) + try fileManager.setPermissions(permissions, ofItemAt: fileURL) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift b/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift new file mode 100644 index 0000000..00e82a7 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift @@ -0,0 +1,26 @@ +// +// FileWriting.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +protocol FileWriting { + func write(data: Data, to fileURL: URL) throws + func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws + + func write(string: String, to fileURL: URL) throws + func write(string: String, to fileURL: URL, permissions: FilePermissions) throws +} + +extension FileWriting { + func write(data: Data, to fileURL: URL) throws { + try write(data: data, to: fileURL, permissions: .default) + } + + func write(string: String, to fileURL: URL) throws { + try write(string: string, to: fileURL, permissions: .default) + } +} diff --git a/samhuri.net/Sources/samhuri.net/Files/Permissions.swift b/samhuri.net/Sources/samhuri.net/Files/Permissions.swift new file mode 100644 index 0000000..8372b58 --- /dev/null +++ b/samhuri.net/Sources/samhuri.net/Files/Permissions.swift @@ -0,0 +1,49 @@ +// +// Permissions.swift +// samhuri.net +// +// Created by Sami Samhuri on 2019-12-24. +// + +import Foundation + +struct Permissions: OptionSet { + let rawValue: Int16 + + static let execute = Permissions(rawValue: 1 << 0) + static let write = Permissions(rawValue: 1 << 1) + static let read = Permissions(rawValue: 1 << 2) + + init(rawValue: Int16) { + self.rawValue = rawValue + } + + init(string: String) { + self.init(rawValue: 0) + if string[string.startIndex] == "r" { + insert(.read) + } + if string[string.index(string.startIndex, offsetBy: 1)] == "w" { + insert(.write) + } + if string[string.index(string.startIndex, offsetBy: 2)] == "x" { + insert(.execute) + } + } +} + +extension Permissions: CustomStringConvertible { + var description: String { + [ + contains(.read) ? "r" : "-", + contains(.write) ? "w" : "-", + contains(.execute) ? "x" : "-", + ].joined() + } +} + +extension Permissions: ExpressibleByStringLiteral { + init(stringLiteral value: String) { + self.init(string: value) + } +} diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/MarkdownRenderer.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/MarkdownRenderer.swift index 31ad658..efd1dc8 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/MarkdownRenderer.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/MarkdownRenderer.swift @@ -10,11 +10,13 @@ import Ink final class MarkdownRenderer: Renderer { let fileManager: FileManager = .default + let fileWriter: FileWriting let markdownParser = MarkdownParser() let pageRenderer: MarkdownPageRenderer - init(pageRenderer: MarkdownPageRenderer) { + init(pageRenderer: MarkdownPageRenderer, fileWriter: FileWriting = FileWriter()) { self.pageRenderer = pageRenderer + self.fileWriter = fileWriter } func canRenderFile(named filename: String, withExtension ext: String) -> Bool { @@ -37,8 +39,7 @@ final class MarkdownRenderer: Renderer { htmlPath = mdFilename.replacingOccurrences(of: ".md", with: "/index.html") } let htmlURL = targetDir.appendingPathComponent(htmlPath) - try fileManager.createDirectory(at: htmlURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try pageHTML.write(to: htmlURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: pageHTML, to: htmlURL) } func markdownMetadata(from url: URL) throws -> [String: String] { diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/JSONFeedWriter.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/JSONFeedWriter.swift index 357c11d..5c3eaf0 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/JSONFeedWriter.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/JSONFeedWriter.swift @@ -7,6 +7,54 @@ import Foundation +final class JSONFeedWriter { + let fileWriter: FileWriting + let jsonFeed: JSONFeed + + init(jsonFeed: JSONFeed, fileWriter: FileWriting = FileWriter()) { + self.jsonFeed = jsonFeed + self.fileWriter = fileWriter + } + + func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws { + let feed = Feed( + title: site.title, + home_page_url: site.url.absoluteString, + feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString, + author: FeedAuthor( + name: site.author, + avatar: jsonFeed.avatarPath.map(site.url.appendingPathComponent)?.absoluteString, + url: site.url.absoluteString + ), + icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString, + favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString, + items: try posts.map { post in + let url = site.url.appendingPathComponent(post.path) + return FeedItem( + title: post.isLink ? "→ \(post.title)" : post.title, + date_published: post.date, + id: url.absoluteString, + url: url.absoluteString, + external_url: post.link?.absoluteString, + author: FeedAuthor(name: post.author, avatar: nil, url: nil), + content_html: try templateRenderer.renderFeedPost(post, site: site, assets: .none()), + tags: post.tags + ) + } + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 +#if os(Linux) + encoder.outputFormatting = [.prettyPrinted] +#else + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] +#endif + let feedJSON = try encoder.encode(feed) + let feedURL = targetURL.appendingPathComponent(jsonFeed.path) + try fileWriter.write(data: feedJSON, to: feedURL) + } +} + private struct Feed: Codable { let version = "https://jsonfeed.org/version/1" let title: String @@ -34,49 +82,3 @@ private struct FeedItem: Codable { let content_html: String let tags: [String] } - -final class JSONFeedWriter { - let fileManager: FileManager - let jsonFeed: JSONFeed - - init(fileManager: FileManager = .default, feed: JSONFeed) { - self.fileManager = fileManager - self.jsonFeed = feed - } - - func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws { - let items: [FeedItem] = try posts.map { post in - let url = site.url.appendingPathComponent(post.path) - return FeedItem( - title: post.isLink ? "→ \(post.title)" : post.title, - date_published: post.date, - id: url.absoluteString, - url: url.absoluteString, - external_url: post.link?.absoluteString, - author: FeedAuthor(name: post.author, avatar: nil, url: nil), - content_html: try templateRenderer.renderFeedPost(post, site: site, assets: .none()), - tags: post.tags - ) - } - 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(jsonFeed.path).absoluteString, - author: FeedAuthor(name: site.author, avatar: avatar?.absoluteString, url: site.url.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 -#if os(Linux) - encoder.outputFormatting = [.prettyPrinted] -#else - encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] -#endif - let feedJSON = try encoder.encode(feed) - let feedURL = targetURL.appendingPathComponent(jsonFeed.path) - try feedJSON.write(to: feedURL, options: [.atomic]) - } -} diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/RSSFeedWriter.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/RSSFeedWriter.swift index 58eabf4..8c94c1a 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/RSSFeedWriter.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/RSSFeedWriter.swift @@ -8,18 +8,18 @@ import Foundation final class RSSFeedWriter { - let fileManager: FileManager - let feed: RSSFeed + let fileWriter: FileWriting + let rssFeed: RSSFeed - init(fileManager: FileManager = .default, feed: RSSFeed) { - self.fileManager = fileManager - self.feed = feed + init(rssFeed: RSSFeed, fileWriter: FileWriting = FileWriter()) { + self.rssFeed = rssFeed + self.fileWriter = fileWriter } func writeFeed(_ posts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws { - let feedURL = site.url.appendingPathComponent(feed.path) + let feedURL = site.url.appendingPathComponent(rssFeed.path) let feedXML = try templateRenderer.renderRSSFeed(posts: posts, feedURL: feedURL, site: site, assets: .none()) - let feedFileURL = targetURL.appendingPathComponent(feed.path) - try feedXML.write(to: feedFileURL, atomically: true, encoding: .utf8) + let feedFileURL = targetURL.appendingPathComponent(rssFeed.path) + try fileWriter.write(string: feedXML, to: feedFileURL) } } diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/String+EscapeXML.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/String+EscapeXML.swift deleted file mode 100644 index 13155c8..0000000 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/Feeds/String+EscapeXML.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// XMLEscape.swift -// samhuri.net -// -// Created by Sami Samhuri on 2019-12-12. -// - -import Foundation - -extension String { - @available(*, deprecated) - func escapedForXML() -> String { - replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - } -} diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostWriter.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostWriter.swift index 08b9946..f269b88 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostWriter.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostWriter.swift @@ -8,11 +8,11 @@ import Foundation final class PostWriter { - let fileManager: FileManager + let fileWriter: FileWriting let outputPath: String - init(fileManager: FileManager = .default, outputPath: String = "posts") { - self.fileManager = fileManager + init(outputPath: String = "posts", fileWriter: FileWriting = FileWriter()) { + self.fileWriter = fileWriter self.outputPath = outputPath } } @@ -26,8 +26,7 @@ extension PostWriter { let postURL = targetURL .appendingPathComponent(outputPath) .appendingPathComponent(filePath(date: post.date, slug: post.slug)) - try fileManager.createDirectory(at: postURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try postHTML.write(to: postURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: postHTML, to: postURL) } } @@ -42,8 +41,7 @@ extension PostWriter { func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws { let recentPostsHTML = try templateRenderer.renderRecentPosts(recentPosts, site: site, assets: .none()) let fileURL = targetURL.appendingPathComponent("index.html") - try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try recentPostsHTML.write(to: fileURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: recentPostsHTML, to: fileURL) } } @@ -53,8 +51,7 @@ extension PostWriter { func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with templateRenderer: PostsTemplateRenderer) throws { let archiveHTML = try templateRenderer.renderArchive(postsByYear: posts, site: site, assets: .none()) let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html") - try fileManager.createDirectory(at: archiveURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try archiveHTML.write(to: archiveURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: archiveHTML, to: archiveURL) } } @@ -66,8 +63,7 @@ extension PostWriter { let yearDir = targetURL.appendingPathComponent(yearPosts.path) let yearHTML = try templateRenderer.renderYearPosts(yearPosts, site: site, assets: .none()) let yearURL = yearDir.appendingPathComponent("index.html") - try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil) - try yearHTML.write(to: yearURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: yearHTML, to: yearURL) } } } @@ -81,8 +77,7 @@ extension PostWriter { let monthDir = targetURL.appendingPathComponent(monthPosts.path) let monthHTML = try templateRenderer.renderMonthPosts(monthPosts, site: site, assets: .none()) let monthURL = monthDir.appendingPathComponent("index.html") - try fileManager.createDirectory(at: monthDir, withIntermediateDirectories: true, attributes: nil) - try monthHTML.write(to: monthURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: monthHTML, to: monthURL) } } } diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostsPluginBuilder.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostsPluginBuilder.swift index d0680b0..7726b23 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostsPluginBuilder.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/Posts/PostsPluginBuilder.swift @@ -59,7 +59,7 @@ final class PostsPluginBuilder { let jsonFeedWriter: JSONFeedWriter? if let jsonFeed = jsonFeed { - jsonFeedWriter = JSONFeedWriter(feed: jsonFeed) + jsonFeedWriter = JSONFeedWriter(jsonFeed: jsonFeed) } else { jsonFeedWriter = nil @@ -67,7 +67,7 @@ final class PostsPluginBuilder { let rssFeedWriter: RSSFeedWriter? if let rssFeed = rssFeed { - rssFeedWriter = RSSFeedWriter(feed: rssFeed) + rssFeedWriter = RSSFeedWriter(rssFeed: rssFeed) } else { rssFeedWriter = nil diff --git a/samhuri.net/Sources/samhuri.net/SiteGenerator/Projects/ProjectsPlugin.swift b/samhuri.net/Sources/samhuri.net/SiteGenerator/Projects/ProjectsPlugin.swift index 5361bdf..070e4d4 100644 --- a/samhuri.net/Sources/samhuri.net/SiteGenerator/Projects/ProjectsPlugin.swift +++ b/samhuri.net/Sources/samhuri.net/SiteGenerator/Projects/ProjectsPlugin.swift @@ -13,7 +13,7 @@ struct PartialProject { } final class ProjectsPlugin: Plugin { - let fileManager: FileManager = .default + let fileWriter: FileWriting let outputPath: String let partialProjects: [PartialProject] let templateRenderer: ProjectsTemplateRenderer @@ -26,12 +26,14 @@ final class ProjectsPlugin: Plugin { projects: [PartialProject], templateRenderer: ProjectsTemplateRenderer, projectAssets: TemplateAssets, - outputPath: String? = nil + outputPath: String? = nil, + fileWriter: FileWriting = FileWriter() ) { self.partialProjects = projects self.templateRenderer = templateRenderer self.projectAssets = projectAssets self.outputPath = outputPath ?? "projects" + self.fileWriter = fileWriter } // MARK: - Plugin methods @@ -53,16 +55,14 @@ final class ProjectsPlugin: Plugin { } let projectsDir = targetURL.appendingPathComponent(outputPath) - try fileManager.createDirectory(at: projectsDir, withIntermediateDirectories: true, attributes: nil) let projectsURL = projectsDir.appendingPathComponent("index.html") let projectsHTML = try templateRenderer.renderProjects(projects, site: site, assets: .none()) - try projectsHTML.write(to: projectsURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: projectsHTML, to: projectsURL) for project in projects { let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html") let projectHTML = try templateRenderer.renderProject(project, site: site, assets: projectAssets) - try fileManager.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) - try projectHTML.write(to: projectURL, atomically: true, encoding: .utf8) + try fileWriter.write(string: projectHTML, to: projectURL) } } }