mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
Render an RSS feed
This commit is contained in:
parent
ed9ad222b2
commit
d69275ce29
20 changed files with 245 additions and 39 deletions
|
|
@ -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 ...
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
111
SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift
Normal file
111
SiteGenerator/Sources/SiteGenerator/Feeds/RSSWriter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: >),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
7
templates/feed-post.html
Normal 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 }}">∞</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
23
templates/feed.xml
Normal file
23
templates/feed.xml
Normal 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>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<div id="article">
|
||||
{{#post}}
|
||||
<p class="time">{{date}}</p>
|
||||
{{{body}}}
|
||||
<p><a class="permalink" href="{{url}}">∞</a></p>
|
||||
{{/post}}
|
||||
</div>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<div id="article">
|
||||
{{#post}}
|
||||
<p class="time">{{date}}</p>
|
||||
{{{body}}}
|
||||
{{/post}}
|
||||
</div>
|
||||
Loading…
Reference in a new issue