From d69275ce299e112c10fdeff58ce9fef86aba256e Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Tue, 10 Dec 2019 21:06:00 -0800 Subject: [PATCH] Render an RSS feed --- Readme.md | 2 + SiteGenerator/Package.resolved | 9 ++ SiteGenerator/Package.swift | 4 +- .../SiteGenerator/Feeds/PostRepo+Feeds.swift | 16 +++ .../SiteGenerator/Feeds/RSSFeedPlugin.swift | 39 ++++++ .../SiteGenerator/Feeds/RSSWriter.swift | 111 ++++++++++++++++++ .../SiteGenerator/Generator/Generator.swift | 4 +- .../SiteGenerator/Generator/HumanSite.swift | 20 ++-- .../SiteGenerator/Generator/Plugin.swift | 4 +- .../SiteGenerator/Generator/Site.swift | 4 +- .../Generator/SiteTemplateRenderer.swift | 2 +- .../SiteGenerator/Posts/PostWriter.swift | 10 +- .../SiteGenerator/Posts/PostsPlugin.swift | 4 +- .../Projects/ProjectsPlugin.swift | 8 +- .../Sources/SiteGenerator/main.swift | 2 +- site.json | 2 + templates/feed-post.html | 7 ++ templates/feed.xml | 23 ++++ templates/link.feed.html | 7 -- templates/post.feed.html | 6 - 20 files changed, 245 insertions(+), 39 deletions(-) create mode 100644 SiteGenerator/Sources/SiteGenerator/Feeds/PostRepo+Feeds.swift create mode 100644 SiteGenerator/Sources/SiteGenerator/Feeds/RSSFeedPlugin.swift create mode 100644 SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift create mode 100644 templates/feed-post.html create mode 100644 templates/feed.xml delete mode 100644 templates/link.feed.html delete mode 100644 templates/post.feed.html diff --git a/Readme.md b/Readme.md index d881fbe..dbc7ff0 100644 --- a/Readme.md +++ b/Readme.md @@ -105,6 +105,8 @@ Execution, trying TDD for the first time: - [x] Munge HTML files to make them available without an extension (index.html hack, do it in the SiteGenerator) + - [ ] Use perf tools on beta.samhuri.net and compare to samhuri.net to see if inlining css and minifying JS is actually worthwhile + - [ ] Inline CSS? - [ ] Minify JS? Now that we're keeping node, why not ... diff --git a/SiteGenerator/Package.resolved b/SiteGenerator/Package.resolved index 87ad4ee..79f7114 100644 --- a/SiteGenerator/Package.resolved +++ b/SiteGenerator/Package.resolved @@ -36,6 +36,15 @@ "revision": "0e9a78d6584e3812cd9c09494d5c7b483e8f533c", "version": "0.13.1" } + }, + { + "package": "HTMLEntities", + "repositoryURL": "https://github.com/IBM-Swift/swift-html-entities.git", + "state": { + "branch": null, + "revision": "744c094976355aa96ca61b9b60ef0a38e979feb7", + "version": "3.0.14" + } } ] }, diff --git a/SiteGenerator/Package.swift b/SiteGenerator/Package.swift index 7b6e5dd..f8df1cc 100644 --- a/SiteGenerator/Package.swift +++ b/SiteGenerator/Package.swift @@ -9,11 +9,13 @@ let package = Package( .macOS(.v10_15), ], dependencies: [ - .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.13.0"), + .package(url: "https://github.com/IBM-Swift/swift-html-entities.git", from: "3.0.0"), .package(url: "https://github.com/johnsundell/ink.git", from: "0.1.0"), + .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.13.0"), ], targets: [ .target( name: "SiteGenerator", dependencies: [ + "HTMLEntities", "Ink", "Stencil", ]), diff --git a/SiteGenerator/Sources/SiteGenerator/Feeds/PostRepo+Feeds.swift b/SiteGenerator/Sources/SiteGenerator/Feeds/PostRepo+Feeds.swift new file mode 100644 index 0000000..0b043e1 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Feeds/PostRepo+Feeds.swift @@ -0,0 +1,16 @@ +// +// PostRepo+Feeds.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-10. +// + +import Foundation + +extension PostRepo { + var feedPostsCount: Int { 30 } + + var postsForFeed: [Post] { + Array(sortedPosts.prefix(feedPostsCount)) + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Feeds/RSSFeedPlugin.swift b/SiteGenerator/Sources/SiteGenerator/Feeds/RSSFeedPlugin.swift new file mode 100644 index 0000000..cd5bda6 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Feeds/RSSFeedPlugin.swift @@ -0,0 +1,39 @@ +// +// RSSFeedPlugin.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-10. +// + +import Foundation + +final class RSSFeedPlugin: Plugin { + let postRepo: PostRepo + let rssWriter: RSSWriter + + init( + postRepo: PostRepo = PostRepo(), + rssWriter: RSSWriter = RSSWriter() + ) { + self.postRepo = postRepo + self.rssWriter = rssWriter + } + + // MARK: - Plugin methods + + func setUp(site: Site, sourceURL: URL) throws { + guard postRepo.postDataExists(at: sourceURL) else { + return + } + + try postRepo.readPosts(sourceURL: sourceURL, makePath: rssWriter.urlPathForPost) + } + + func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws { + guard !postRepo.isEmpty else { + return + } + + try rssWriter.writeFeed(postRepo.postsForFeed, site: site, to: targetURL, with: templateRenderer) + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift b/SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift new file mode 100644 index 0000000..094a7d2 --- /dev/null +++ b/SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift @@ -0,0 +1,111 @@ +// +// RSSWriter.swift +// SiteGenerator +// +// Created by Sami Samhuri on 2019-12-10. +// + +import HTMLEntities +import Foundation + +struct FeedSite { + let title: String + let description: String? + let url: String + + init(title: String, description: String?, url: URL) { + self.title = title.htmlEscape() + self.description = description?.htmlEscape() + self.url = url.absoluteString.htmlEscape() + } +} + +struct FeedPost { + let title: String + let date: String + let author: String + let isLink: Bool + let link: String + let guid: String + let body: String + + init( + title: String, + date: String, + author: String, + link: URL?, + url: URL, + body: String + ) { + self.title = title.htmlEscape() + self.date = date.htmlEscape() + self.author = author.htmlEscape() + self.isLink = link != nil + self.link = (link ?? url).absoluteString.htmlEscape() + self.guid = url.absoluteString.htmlEscape() + self.body = body.htmlEscape() + } +} + +private let rfc822Formatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "EEE, d MMM yyyy HH:mm:ss ZZZ" + return f +}() + +private extension Date { + var rfc822: String { + rfc822Formatter.string(from: self) + } +} + +final class RSSWriter { + let fileManager: FileManager + let feedPath: String + let postsPath: String + + var baseURL: URL! + + init(fileManager: FileManager = .default, feedPath: String = "feed.xml", postsPath: String = "posts") { + self.fileManager = fileManager + self.feedPath = feedPath + self.postsPath = postsPath + } + + #warning("These urlPath methods were copied from PostsPlugin and should possibly be moved somewhere else") + + func urlPath(year: Int) -> String { + "/\(postsPath)/\(year)" + } + + func urlPath(year: Int, month: Month) -> String { + urlPath(year: year).appending("/\(month.padded)") + } + + func urlPathForPost(date: Date, slug: String) -> String { + urlPath(year: date.year, month: Month(date.month)).appending("/\(slug)") + } + + func writeFeed(_ posts: [Post], site: Site, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { + let renderedPosts: [FeedPost] = try posts.map { post in + return FeedPost( + title: post.title, + date: post.date.rfc822, + author: "\(site.email) (\(post.author))", + link: post.link, + url: site.url.appendingPathComponent(post.path), + body: try templateRenderer.renderTemplate(name: "feed-post.html", context: [ + "post": post, + ]) + ) + } + let feedXML = try templateRenderer.renderTemplate(name: "feed.xml", context: [ + "site": FeedSite(title: site.title, description: site.description, url: site.url), + "feedURL": site.url.appendingPathComponent(feedPath).absoluteString.htmlEscape(), + "posts": renderedPosts, + ]) + let feedURL = targetURL.appendingPathComponent(feedPath) + try feedXML.write(to: feedURL, atomically: true, encoding: .utf8) + } +} diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/Generator.swift b/SiteGenerator/Sources/SiteGenerator/Generator/Generator.swift index 79cc768..82a44c3 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/Generator.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/Generator.swift @@ -33,13 +33,13 @@ public final class Generator { self.renderers = renderers for plugin in plugins { - try plugin.setUp(sourceURL: sourceURL) + try plugin.setUp(site: site, sourceURL: sourceURL) } } public func generate(targetURL: URL) throws { for plugin in plugins { - try plugin.render(targetURL: targetURL, templateRenderer: templateRenderer) + try plugin.render(site: site, targetURL: targetURL, templateRenderer: templateRenderer) } let publicURL = sourceURL.appendingPathComponent("public") diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/HumanSite.swift b/SiteGenerator/Sources/SiteGenerator/Generator/HumanSite.swift index 6a229a4..bf7e308 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/HumanSite.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/HumanSite.swift @@ -10,8 +10,10 @@ 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 url: String + let description: String? + let url: URL let template: String? let styles: [String]? let scripts: [String]? @@ -19,11 +21,15 @@ struct HumanSite: Codable { extension Site { init(humanSite: HumanSite) { - self.author = humanSite.author - self.title = humanSite.title - self.url = humanSite.url - self.template = humanSite.template ?? "page" - self.styles = humanSite.styles ?? [] - self.scripts = humanSite.scripts ?? [] + 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 ?? [] + ) } } diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/Plugin.swift b/SiteGenerator/Sources/SiteGenerator/Generator/Plugin.swift index f7abc81..785527b 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/Plugin.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/Plugin.swift @@ -8,7 +8,7 @@ import Foundation public protocol Plugin { - func setUp(sourceURL: URL) throws + func setUp(site: Site, sourceURL: URL) throws - func render(targetURL: URL, templateRenderer: TemplateRenderer) throws + func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws } diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/Site.swift b/SiteGenerator/Sources/SiteGenerator/Generator/Site.swift index 3a1cae8..a28568d 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/Site.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/Site.swift @@ -9,8 +9,10 @@ import Foundation public struct Site { public let author: String + public let email: String public let title: String - public let url: String + public let description: String? + public let url: URL public let template: String public let styles: [String] public let scripts: [String] diff --git a/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift b/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift index 4bd0a9b..8ea24bb 100644 --- a/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift +++ b/SiteGenerator/Sources/SiteGenerator/Generator/SiteTemplateRenderer.swift @@ -30,6 +30,6 @@ final class SiteTemplateRenderer: TemplateRenderer { func renderTemplate(name: String?, context: [String: Any]) throws -> String { let siteContext = SiteContext(site: site, template: name) let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new }) - return try stencil.renderTemplate(name: "\(siteContext.template).html", context: contextDict) + return try stencil.renderTemplate(name: siteContext.template, context: contextDict) } } diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift index 29276a8..e2d42aa 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostWriter.swift @@ -34,7 +34,7 @@ final class PostWriter { extension PostWriter { func writePosts(_ posts: [Post], to targetURL: URL, with templateRenderer: TemplateRenderer) throws { for post in posts { - let postHTML = try templateRenderer.renderTemplate(name: "post", context: [ + let postHTML = try templateRenderer.renderTemplate(name: "post.html", context: [ "title": post.title, "post": post, ]) @@ -55,7 +55,7 @@ extension PostWriter { extension PostWriter { func writeRecentPosts(_ recentPosts: [Post], to targetURL: URL, with templateRenderer: TemplateRenderer) throws { - let recentPostsHTML = try templateRenderer.renderTemplate(name: "recent-posts", context: [ + let recentPostsHTML = try templateRenderer.renderTemplate(name: "recent-posts.html", context: [ "recentPosts": recentPosts, ]) let fileURL = targetURL.appendingPathComponent("index.html") @@ -69,7 +69,7 @@ extension PostWriter { extension PostWriter { func writeArchive(posts: PostsByYear, to targetURL: URL, with templateRenderer: TemplateRenderer) throws { let allYears = posts.byYear.keys.sorted(by: >) - let archiveHTML = try templateRenderer.renderTemplate(name: "posts-archive", context: [ + let archiveHTML = try templateRenderer.renderTemplate(name: "posts-archive.html", context: [ "title": "Archive", "years": allYears.map { contextDictionaryForYearPosts(posts[$0]) }, ]) @@ -111,7 +111,7 @@ extension PostWriter { "year": year, "months": months.map { contextDictionaryForMonthPosts(posts[year][$0], year: year) }, ] - let yearHTML = try templateRenderer.renderTemplate(name: "posts-year", context: context) + let yearHTML = try templateRenderer.renderTemplate(name: "posts-year.html", context: context) let yearURL = yearDir.appendingPathComponent("index.html") try fileManager.createDirectory(at: yearDir, withIntermediateDirectories: true, attributes: nil) try yearHTML.write(to: yearURL, atomically: true, encoding: .utf8) @@ -126,7 +126,7 @@ extension PostWriter { for (year, yearPosts) in posts.byYear { for month in yearPosts.months { let monthDir = targetURL.appendingPathComponent(urlPath(year: year, month: month)) - let monthHTML = try templateRenderer.renderTemplate(name: "posts-month", context: [ + let monthHTML = try templateRenderer.renderTemplate(name: "posts-month.html", context: [ "title": "\(month.name) \(year)", "posts": yearPosts[month].posts.sorted(by: >), ]) diff --git a/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift b/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift index d6ee7d0..d8d1948 100644 --- a/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift +++ b/SiteGenerator/Sources/SiteGenerator/Posts/PostsPlugin.swift @@ -21,7 +21,7 @@ final class PostsPlugin: Plugin { // MARK: - Plugin methods - func setUp(sourceURL: URL) throws { + func setUp(site: Site, sourceURL: URL) throws { guard postRepo.postDataExists(at: sourceURL) else { return } @@ -29,7 +29,7 @@ final class PostsPlugin: Plugin { try postRepo.readPosts(sourceURL: sourceURL, makePath: postWriter.urlPathForPost) } - func render(targetURL: URL, templateRenderer: TemplateRenderer) throws { + func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws { guard !postRepo.isEmpty else { return } diff --git a/SiteGenerator/Sources/SiteGenerator/Projects/ProjectsPlugin.swift b/SiteGenerator/Sources/SiteGenerator/Projects/ProjectsPlugin.swift index 946c483..ce74912 100644 --- a/SiteGenerator/Sources/SiteGenerator/Projects/ProjectsPlugin.swift +++ b/SiteGenerator/Sources/SiteGenerator/Projects/ProjectsPlugin.swift @@ -28,7 +28,7 @@ final class ProjectsPlugin: Plugin { self.outputPath = outputPath } - func setUp(sourceURL: URL) throws { + func setUp(site: Site, sourceURL: URL) throws { self.sourceURL = sourceURL let projectsURL = sourceURL.appendingPathComponent("projects.json") if fileManager.fileExists(atPath: projectsURL.path) { @@ -38,7 +38,7 @@ final class ProjectsPlugin: Plugin { } } - func render(targetURL: URL, templateRenderer: TemplateRenderer) throws { + func render(site: Site, targetURL: URL, templateRenderer: TemplateRenderer) throws { guard !projects.isEmpty else { return } @@ -46,7 +46,7 @@ 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.renderTemplate(name: "projects", context: [ + let projectsHTML = try templateRenderer.renderTemplate(name: "projects.html", context: [ "title": "Projects", "projects": projects, ]) @@ -54,7 +54,7 @@ final class ProjectsPlugin: Plugin { for project in projects { let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html") - let projectHTML = try templateRenderer.renderTemplate(name: "project", context: [ + let projectHTML = try templateRenderer.renderTemplate(name: "project.html", context: [ "title": "\(project.title)", "project": project, ]) diff --git a/SiteGenerator/Sources/SiteGenerator/main.swift b/SiteGenerator/Sources/SiteGenerator/main.swift index 5057512..840a3f4 100644 --- a/SiteGenerator/Sources/SiteGenerator/main.swift +++ b/SiteGenerator/Sources/SiteGenerator/main.swift @@ -13,7 +13,7 @@ func main(sourcePath: String, targetPath: String) throws { let targetURL = URL(fileURLWithPath: targetPath) let generator = try Generator( sourceURL: sourceURL, - plugins: [ProjectsPlugin(), PostsPlugin()], + plugins: [ProjectsPlugin(), PostsPlugin(), RSSFeedPlugin()], renderers: [LessRenderer(), MarkdownRenderer()] ) try generator.generate(targetURL: targetURL) diff --git a/site.json b/site.json index c6ddbcd..d414198 100644 --- a/site.json +++ b/site.json @@ -1,6 +1,8 @@ { "title": "samhuri.net", + "description": "just some blog", "author": "Sami Samhuri", + "email": "sami@samhuri.net", "url": "https://samhuri.net", "styles": [ "/css/normalize.css", diff --git a/templates/feed-post.html b/templates/feed-post.html new file mode 100644 index 0000000..1c4a394 --- /dev/null +++ b/templates/feed-post.html @@ -0,0 +1,7 @@ +
+

{{ post.date }}

+ {{ post.body }} + {% if post.isLink %} +

+ {% endif %} +
diff --git a/templates/feed.xml b/templates/feed.xml new file mode 100644 index 0000000..a820a8b --- /dev/null +++ b/templates/feed.xml @@ -0,0 +1,23 @@ + + + + + + {{ site.title }} + {{ site.description }} + {{ site.url }} + {{ posts[0].date }} + + + {% for post in posts %} + + {{ post.title }} + {{ post.body }} + {{ post.date }} + {{ post.author }} + {{ post.link }} + {{ post.guid }} + + {% endfor %} + + diff --git a/templates/link.feed.html b/templates/link.feed.html deleted file mode 100644 index 9012b45..0000000 --- a/templates/link.feed.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {{#post}} -

{{date}}

- {{{body}}} -

- {{/post}} -
diff --git a/templates/post.feed.html b/templates/post.feed.html deleted file mode 100644 index d67ac51..0000000 --- a/templates/post.feed.html +++ /dev/null @@ -1,6 +0,0 @@ -
- {{#post}} -

{{date}}

- {{{body}}} - {{/post}} -