Render an RSS feed

This commit is contained in:
Sami Samhuri 2019-12-10 21:06:00 -08:00
parent ed9ad222b2
commit d69275ce29
20 changed files with 245 additions and 39 deletions

View file

@ -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 ...

View file

@ -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"
}
}
]
},

View file

@ -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",
]),

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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")

View file

@ -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 ?? []
)
}
}

View file

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

View file

@ -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]

View file

@ -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)
}
}

View file

@ -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: >),
])

View file

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

View file

@ -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,
])

View file

@ -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)

View file

@ -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",

7
templates/feed-post.html Normal file
View file

@ -0,0 +1,7 @@
<div id="article">
<p class="time">{{ post.date }}</p>
{{ post.body }}
{% if post.isLink %}
<p><a class="permalink" href="{{ post.url }}">&infin;</a></p>
{% endif %}
</div>

23
templates/feed.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="https://samhuri.net/css/normalize.css" type="text/css"?>
<?xml-stylesheet href="https://samhuri.net/css/style.css" type="text/css"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ site.title }}</title>
<description>{{ site.description }}</description>
<link>{{ site.url }}</link>
<pubDate>{{ posts[0].date }}</pubDate>
<atom:link href="{{ feedURL }}" rel="self" type="application/rss+xml" />
{% for post in posts %}
<item>
<title>{{ post.title }}</title>
<description>{{ post.body }}</description>
<pubDate>{{ post.date }}</pubDate>
<author>{{ post.author }}</author>
<link>{{ post.link }}</link>
<guid>{{ post.guid }}</guid>
</item>
{% endfor %}
</channel>
</rss>

View file

@ -1,7 +0,0 @@
<div id="article">
{{#post}}
<p class="time">{{date}}</p>
{{{body}}}
<p><a class="permalink" href="{{url}}">&infin;</a></p>
{{/post}}
</div>

View file

@ -1,6 +0,0 @@
<div id="article">
{{#post}}
<p class="time">{{date}}</p>
{{{body}}}
{{/post}}
</div>