mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
Add OpenGraph metadata tags and fix canonical URLs
This commit is contained in:
parent
a656a8859d
commit
c18a946dae
26 changed files with 219 additions and 95 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
Title: About me
|
Title: About me
|
||||||
|
Page type: profile
|
||||||
---
|
---
|
||||||
|
|
||||||
I'm Sami Samhuri, a software developer and general technology geek. Sometimes I write my thoughts and post my projects here. I moved to [Victoria, BC][vic] in 2003 to study computer science at the [University of Victoria][uvic], and then dropped out a couple of years later.
|
I'm Sami Samhuri, a software developer and general technology geek. Sometimes I write my thoughts and post my projects here. I moved to [Victoria, BC][vic] in 2003 to study computer science at the [University of Victoria][uvic], and then dropped out a couple of years later.
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 292 KiB |
|
|
@ -9,7 +9,6 @@ import Foundation
|
||||||
|
|
||||||
struct JSONFeed {
|
struct JSONFeed {
|
||||||
let path: String
|
let path: String
|
||||||
let avatarPath: String?
|
|
||||||
let iconPath: String?
|
let iconPath: String?
|
||||||
let faviconPath: String?
|
let faviconPath: String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ private extension JSONFeedWriter {
|
||||||
func buildFeed(site: Site, posts: [Post], renderer: JSONFeedRendering) throws -> Feed {
|
func buildFeed(site: Site, posts: [Post], renderer: JSONFeedRendering) throws -> Feed {
|
||||||
let author = FeedAuthor(
|
let author = FeedAuthor(
|
||||||
name: site.author,
|
name: site.author,
|
||||||
avatar: jsonFeed.avatarPath.map(site.url.appendingPathComponent)?.absoluteString,
|
avatar: site.imageURL?.absoluteString,
|
||||||
url: site.url.absoluteString
|
url: site.url.absoluteString
|
||||||
)
|
)
|
||||||
return Feed(
|
return Feed(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ struct Post {
|
||||||
let scripts: [Script]
|
let scripts: [Script]
|
||||||
let styles: [Stylesheet]
|
let styles: [Stylesheet]
|
||||||
let body: String
|
let body: String
|
||||||
|
let excerpt: String
|
||||||
let path: String
|
let path: String
|
||||||
|
|
||||||
var isLink: Bool {
|
var isLink: Bool {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,31 @@ import Ink
|
||||||
struct RawPost {
|
struct RawPost {
|
||||||
let slug: String
|
let slug: String
|
||||||
let markdown: String
|
let markdown: String
|
||||||
|
|
||||||
|
private static let StripMetadataRegex = try! Regex(#"---\n.*?---\n"#).dotMatchesNewlines()
|
||||||
|
|
||||||
|
private static let TextifyParenthesesLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\([^)]+\)"#)
|
||||||
|
|
||||||
|
private static let TextifyBracketLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\[[^\]]+\]"#)
|
||||||
|
|
||||||
|
private static let StripImagesRegex = try! Regex(#"!\[[\w\s.-_]*\]\([^)]+\)"#)
|
||||||
|
|
||||||
|
private static let WhitespaceRegex = try! Regex(#"\s+"#)
|
||||||
|
|
||||||
|
private static let StripHTMLTagsRegex = try! Regex(#"<[^>]+>"#)
|
||||||
|
|
||||||
|
var excerpt: String {
|
||||||
|
markdown
|
||||||
|
.replacing(Self.StripMetadataRegex, with: "")
|
||||||
|
.replacing(Self.StripImagesRegex, with: "") // must be before links for linked images
|
||||||
|
.replacing(Self.TextifyParenthesesLinksRegex) { match in match.output[1].substring ?? "" }
|
||||||
|
.replacing(Self.TextifyBracketLinksRegex) { match in match.output[1].substring ?? "" }
|
||||||
|
.replacing(Self.StripHTMLTagsRegex, with: "")
|
||||||
|
.replacing(Self.WhitespaceRegex, with: " ")
|
||||||
|
.trimmingPrefix(Self.WhitespaceRegex)
|
||||||
|
.prefix(300)
|
||||||
|
+ "..."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PostRepo {
|
final class PostRepo {
|
||||||
|
|
@ -72,6 +97,7 @@ private extension PostRepo {
|
||||||
scripts: metadata.scripts,
|
scripts: metadata.scripts,
|
||||||
styles: metadata.styles,
|
styles: metadata.styles,
|
||||||
body: result.html,
|
body: result.html,
|
||||||
|
excerpt: rawPost.excerpt,
|
||||||
path: path
|
path: path
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,18 @@ final class PostWriter {
|
||||||
extension PostWriter {
|
extension PostWriter {
|
||||||
func writePosts(_ posts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
func writePosts(_ posts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
||||||
for post in posts {
|
for post in posts {
|
||||||
let postHTML = try renderer.renderPost(post, site: site)
|
let path = [
|
||||||
let postURL = targetURL
|
outputPath,
|
||||||
.appendingPathComponent(outputPath)
|
postPath(date: post.date, slug: post.slug),
|
||||||
.appendingPathComponent(filePath(date: post.date, slug: post.slug))
|
].joined(separator: "/")
|
||||||
try fileWriter.write(string: postHTML, to: postURL)
|
let fileURL = targetURL.appending(path: path).appending(component: "index.html")
|
||||||
|
let postHTML = try renderer.renderPost(post, site: site, path: path)
|
||||||
|
try fileWriter.write(string: postHTML, to: fileURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filePath(date: Date, slug: String) -> String {
|
private func postPath(date: Date, slug: String) -> String {
|
||||||
"/\(date.year)/\(Month(date).padded)/\(slug)/index.html"
|
"\(date.year)/\(Month(date).padded)/\(slug)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +41,7 @@ extension PostWriter {
|
||||||
|
|
||||||
extension PostWriter {
|
extension PostWriter {
|
||||||
func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
||||||
let recentPostsHTML = try renderer.renderRecentPosts(recentPosts, site: site)
|
let recentPostsHTML = try renderer.renderRecentPosts(recentPosts, site: site, path: "/")
|
||||||
let fileURL = targetURL.appendingPathComponent("index.html")
|
let fileURL = targetURL.appendingPathComponent("index.html")
|
||||||
try fileWriter.write(string: recentPostsHTML, to: fileURL)
|
try fileWriter.write(string: recentPostsHTML, to: fileURL)
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +51,7 @@ extension PostWriter {
|
||||||
|
|
||||||
extension PostWriter {
|
extension PostWriter {
|
||||||
func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
||||||
let archiveHTML = try renderer.renderArchive(postsByYear: posts, site: site)
|
let archiveHTML = try renderer.renderArchive(postsByYear: posts, site: site, path: outputPath)
|
||||||
let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html")
|
let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html")
|
||||||
try fileWriter.write(string: archiveHTML, to: archiveURL)
|
try fileWriter.write(string: archiveHTML, to: archiveURL)
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +63,7 @@ extension PostWriter {
|
||||||
func writeYearIndexes(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
func writeYearIndexes(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
|
||||||
for yearPosts in posts.byYear.values {
|
for yearPosts in posts.byYear.values {
|
||||||
let yearDir = targetURL.appendingPathComponent(yearPosts.path)
|
let yearDir = targetURL.appendingPathComponent(yearPosts.path)
|
||||||
let yearHTML = try renderer.renderYearPosts(yearPosts, site: site)
|
let yearHTML = try renderer.renderYearPosts(yearPosts, site: site, path: yearPosts.path)
|
||||||
let yearURL = yearDir.appendingPathComponent("index.html")
|
let yearURL = yearDir.appendingPathComponent("index.html")
|
||||||
try fileWriter.write(string: yearHTML, to: yearURL)
|
try fileWriter.write(string: yearHTML, to: yearURL)
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +77,7 @@ extension PostWriter {
|
||||||
for yearPosts in posts.byYear.values {
|
for yearPosts in posts.byYear.values {
|
||||||
for monthPosts in yearPosts.byMonth.values {
|
for monthPosts in yearPosts.byMonth.values {
|
||||||
let monthDir = targetURL.appendingPathComponent(monthPosts.path)
|
let monthDir = targetURL.appendingPathComponent(monthPosts.path)
|
||||||
let monthHTML = try renderer.renderMonthPosts(monthPosts, site: site)
|
let monthHTML = try renderer.renderMonthPosts(monthPosts, site: site, path: monthPosts.path)
|
||||||
let monthURL = monthDir.appendingPathComponent("index.html")
|
let monthURL = monthDir.appendingPathComponent("index.html")
|
||||||
try fileWriter.write(string: monthHTML, to: monthURL)
|
try fileWriter.write(string: monthHTML, to: monthURL)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@ extension PostsPlugin {
|
||||||
|
|
||||||
func jsonFeed(
|
func jsonFeed(
|
||||||
path: String? = nil,
|
path: String? = nil,
|
||||||
avatarPath: String? = nil,
|
|
||||||
iconPath: String? = nil,
|
iconPath: String? = nil,
|
||||||
faviconPath: String? = nil
|
faviconPath: String? = nil
|
||||||
) -> Self {
|
) -> Self {
|
||||||
precondition(jsonFeed == nil, "JSON feed is already defined")
|
precondition(jsonFeed == nil, "JSON feed is already defined")
|
||||||
jsonFeed = JSONFeed(
|
jsonFeed = JSONFeed(
|
||||||
path: path ?? "feed.json",
|
path: path ?? "feed.json",
|
||||||
avatarPath: avatarPath,
|
|
||||||
iconPath: iconPath,
|
iconPath: iconPath,
|
||||||
faviconPath: faviconPath
|
faviconPath: faviconPath
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol PostsRendering {
|
protocol PostsRendering {
|
||||||
func renderArchive(postsByYear: PostsByYear, site: Site) throws -> String
|
func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String
|
||||||
|
|
||||||
func renderYearPosts(_ yearPosts: YearPosts, site: Site) throws -> String
|
func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String
|
||||||
|
|
||||||
func renderMonthPosts(_ posts: MonthPosts, site: Site) throws -> String
|
func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String
|
||||||
|
|
||||||
func renderPost(_ post: Post, site: Site) throws -> String
|
func renderPost(_ post: Post, site: Site, path: String) throws -> String
|
||||||
|
|
||||||
func renderRecentPosts(_ posts: [Post], site: Site) throws -> String
|
func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ import Plot
|
||||||
|
|
||||||
extension PageRenderer: JSONFeedRendering {
|
extension PageRenderer: JSONFeedRendering {
|
||||||
func renderJSONFeedPost(_ post: Post, site: Site) throws -> String {
|
func renderJSONFeedPost(_ post: Post, site: Site) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: post.title, templateAssets: post.templateAssets)
|
|
||||||
let url = site.url.appendingPathComponent(post.path)
|
let url = site.url.appendingPathComponent(post.path)
|
||||||
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: url,
|
||||||
|
subtitle: post.title,
|
||||||
|
templateAssets: post.templateAssets
|
||||||
|
)
|
||||||
// Turn relative URLs into absolute ones.
|
// Turn relative URLs into absolute ones.
|
||||||
return Node.feedPost(post, url: url, styles: context.styles)
|
return Node.feedPost(post, url: url, styles: context.styles)
|
||||||
.render(indentedBy: .spaces(2))
|
.render(indentedBy: .spaces(2))
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,62 @@ import Foundation
|
||||||
import Plot
|
import Plot
|
||||||
|
|
||||||
extension PageRenderer: PostsRendering {
|
extension PageRenderer: PostsRendering {
|
||||||
func renderArchive(postsByYear: PostsByYear, site: Site) throws -> String {
|
func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: "Archive")
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: "Archive",
|
||||||
|
description: "Archive of all posts"
|
||||||
|
)
|
||||||
return render(.archive(postsByYear), context: context)
|
return render(.archive(postsByYear), context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderYearPosts(_ yearPosts: YearPosts, site: Site) throws -> String {
|
func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: yearPosts.title)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: yearPosts.title,
|
||||||
|
description: "Archive of all posts from \(yearPosts.year)",
|
||||||
|
pageType: "article"
|
||||||
|
)
|
||||||
return render(.yearPosts(yearPosts), context: context)
|
return render(.yearPosts(yearPosts), context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMonthPosts(_ posts: MonthPosts, site: Site) throws -> String {
|
func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String {
|
||||||
|
let subtitle = "\(posts.month.name) \(posts.year)"
|
||||||
let assets = posts.posts.templateAssets
|
let assets = posts.posts.templateAssets
|
||||||
let context = SiteContext(site: site, subtitle: "\(posts.month.name) \(posts.year)", templateAssets: assets)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: subtitle,
|
||||||
|
description: "Archive of all posts from \(subtitle)",
|
||||||
|
pageType: "article",
|
||||||
|
templateAssets: assets
|
||||||
|
)
|
||||||
return render(.monthPosts(posts), context: context)
|
return render(.monthPosts(posts), context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPost(_ post: Post, site: Site) throws -> String {
|
func renderPost(_ post: Post, site: Site, path: String) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: post.title, templateAssets: post.templateAssets)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: post.title,
|
||||||
|
description: post.excerpt,
|
||||||
|
pageType: "article",
|
||||||
|
templateAssets: post.templateAssets
|
||||||
|
)
|
||||||
return render(.post(post, articleClass: "container"), context: context)
|
return render(.post(post, articleClass: "container"), context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderRecentPosts(_ posts: [Post], site: Site) throws -> String {
|
func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: nil, templateAssets: posts.templateAssets)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: nil,
|
||||||
|
description: "Recent posts",
|
||||||
|
pageType: "article",
|
||||||
|
templateAssets: posts.templateAssets
|
||||||
|
)
|
||||||
return render(.recentPosts(posts), context: context)
|
return render(.recentPosts(posts), context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,13 @@ final class ProjectsPlugin: Plugin {
|
||||||
|
|
||||||
let projectsDir = targetURL.appendingPathComponent(outputPath)
|
let projectsDir = targetURL.appendingPathComponent(outputPath)
|
||||||
let projectsURL = projectsDir.appendingPathComponent("index.html")
|
let projectsURL = projectsDir.appendingPathComponent("index.html")
|
||||||
let projectsHTML = try renderer.renderProjects(projects, site: site)
|
let projectsHTML = try renderer.renderProjects(projects, site: site, path: outputPath)
|
||||||
try fileWriter.write(string: projectsHTML, to: projectsURL)
|
try fileWriter.write(string: projectsHTML, to: projectsURL)
|
||||||
|
|
||||||
for project in projects {
|
for project in projects {
|
||||||
let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
|
let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
|
||||||
let projectHTML = try renderer.renderProject(project, site: site, assets: projectAssets)
|
let path = [outputPath, project.title].joined(separator: "/")
|
||||||
|
let projectHTML = try renderer.renderProject(project, site: site, path: path, assets: projectAssets)
|
||||||
try fileWriter.write(string: projectHTML, to: projectURL)
|
try fileWriter.write(string: projectHTML, to: projectURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol ProjectsRenderer {
|
protocol ProjectsRenderer {
|
||||||
func renderProjects(_ projects: [Project], site: Site) throws -> String
|
func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String
|
||||||
|
|
||||||
func renderProject(_ project: Project, site: Site, assets: TemplateAssets) throws -> String
|
func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,25 @@ import Foundation
|
||||||
import Plot
|
import Plot
|
||||||
|
|
||||||
extension PageRenderer: ProjectsRenderer {
|
extension PageRenderer: ProjectsRenderer {
|
||||||
func renderProjects(_ projects: [Project], site: Site) throws -> String {
|
func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String {
|
||||||
let context = SiteContext(site: site, subtitle: "Projects", templateAssets: .empty())
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: "Projects",
|
||||||
|
templateAssets: .empty()
|
||||||
|
)
|
||||||
return render(.projects(projects), context: context)
|
return render(.projects(projects), context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderProject(_ project: Project, site: Site, assets: TemplateAssets) throws -> String {
|
func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String {
|
||||||
let projectContext = ProjectContext(project: project, site: site, templateAssets: assets)
|
let projectContext = ProjectContext(project: project, site: site, templateAssets: assets)
|
||||||
let context = SiteContext(site: site, subtitle: project.title, templateAssets: assets)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: site.url.appending(path: path),
|
||||||
|
subtitle: project.title,
|
||||||
|
description: project.description,
|
||||||
|
templateAssets: assets
|
||||||
|
)
|
||||||
return render(.project(projectContext), context: context)
|
return render(.project(projectContext), context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,16 @@ import Foundation
|
||||||
struct ProjectContext: TemplateContext {
|
struct ProjectContext: TemplateContext {
|
||||||
let site: Site
|
let site: Site
|
||||||
let title: String
|
let title: String
|
||||||
|
let canonicalURL: URL
|
||||||
let description: String
|
let description: String
|
||||||
|
let pageType = "website"
|
||||||
let githubURL: URL
|
let githubURL: URL
|
||||||
let templateAssets: TemplateAssets
|
let templateAssets: TemplateAssets
|
||||||
|
|
||||||
init(project: Project, site: Site, templateAssets: TemplateAssets) {
|
init(project: Project, site: Site, templateAssets: TemplateAssets) {
|
||||||
self.site = site
|
self.site = site
|
||||||
self.title = project.title
|
self.title = project.title
|
||||||
|
self.canonicalURL = site.url.appending(components: "projects", project.title)
|
||||||
self.description = project.description
|
self.description = project.description
|
||||||
self.githubURL = URL(string: "https://github.com/samsonjs/\(title)")!
|
self.githubURL = URL(string: "https://github.com/samsonjs/\(title)")!
|
||||||
self.templateAssets = templateAssets
|
self.templateAssets = templateAssets
|
||||||
|
|
|
||||||
|
|
@ -18,26 +18,31 @@ final class MarkdownRenderer: Renderer {
|
||||||
self.fileWriter = fileWriter
|
self.fileWriter = fileWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
func canRenderFile(named filename: String, withExtension ext: String) -> Bool {
|
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool {
|
||||||
ext == "md"
|
ext == "md"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse Markdown and render it as HTML, running it through a Stencil template.
|
/// Parse Markdown and render it as HTML, running it through a Stencil template.
|
||||||
func render(site: Site, fileURL: URL, targetDir: URL) throws {
|
func render(site: Site, fileURL: URL, targetDir: URL) throws {
|
||||||
let bodyMarkdown = try String(contentsOf: fileURL)
|
|
||||||
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let metadata = try markdownMetadata(from: fileURL)
|
let metadata = try markdownMetadata(from: fileURL)
|
||||||
let pageHTML = try pageRenderer.renderPage(site: site, bodyHTML: bodyHTML, metadata: metadata)
|
|
||||||
|
|
||||||
let mdFilename = fileURL.lastPathComponent
|
let mdFilename = fileURL.lastPathComponent
|
||||||
let showExtension = mdFilename == "index.md" || metadata["Show extension"]?.lowercased() == "yes"
|
let showExtension = mdFilename == "index.md" || metadata["Show extension"]?.lowercased() == "yes"
|
||||||
let htmlPath: String
|
let htmlPath: String = if showExtension {
|
||||||
if showExtension {
|
mdFilename.replacingOccurrences(of: ".md", with: ".html")
|
||||||
htmlPath = mdFilename.replacingOccurrences(of: ".md", with: ".html")
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
htmlPath = mdFilename.replacingOccurrences(of: ".md", with: "/index.html")
|
mdFilename.replacingOccurrences(of: ".md", with: "/index.html")
|
||||||
}
|
}
|
||||||
|
let bodyMarkdown = try String(contentsOf: fileURL)
|
||||||
|
let bodyHTML = markdownParser.html(from: bodyMarkdown).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let url = site.url.appending(path: htmlPath.replacingOccurrences(of: "/index.html", with: ""))
|
||||||
|
let pageHTML = try pageRenderer.renderPage(
|
||||||
|
site: site,
|
||||||
|
url: url,
|
||||||
|
bodyHTML: bodyHTML,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
let htmlURL = targetDir.appendingPathComponent(htmlPath)
|
let htmlURL = targetDir.appendingPathComponent(htmlPath)
|
||||||
try fileWriter.write(string: pageHTML, to: htmlURL)
|
try fileWriter.write(string: pageHTML, to: htmlURL)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol PageRendering {
|
protocol PageRendering {
|
||||||
func renderPage(site: Site, bodyHTML: String, metadata: [String: String]) throws -> String
|
func renderPage(site: Site, url: URL, bodyHTML: String, metadata: [String: String]) throws -> String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol Renderer {
|
protocol Renderer {
|
||||||
func canRenderFile(named filename: String, withExtension ext: String) -> Bool
|
func canRenderFile(named filename: String, withExtension ext: String?) -> Bool
|
||||||
|
|
||||||
func render(site: Site, fileURL: URL, targetDir: URL) throws
|
func render(site: Site, fileURL: URL, targetDir: URL) throws
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ extension Site {
|
||||||
private let title: String
|
private let title: String
|
||||||
private let description: String
|
private let description: String
|
||||||
private let author: String
|
private let author: String
|
||||||
|
private let imageURL: URL?
|
||||||
private let email: String
|
private let email: String
|
||||||
private let url: URL
|
private let url: URL
|
||||||
|
|
||||||
|
|
@ -25,12 +26,20 @@ extension Site {
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
author: String,
|
author: String,
|
||||||
|
imagePath: String?,
|
||||||
email: String,
|
email: String,
|
||||||
url: URL
|
url: URL
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.author = author
|
self.author = author
|
||||||
|
self.imageURL = imagePath.flatMap { path in
|
||||||
|
var imageURL = url
|
||||||
|
for component in path.split(separator: "/") {
|
||||||
|
imageURL = imageURL.appending(component: component)
|
||||||
|
}
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
self.email = email
|
self.email = email
|
||||||
self.url = url
|
self.url = url
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +70,7 @@ extension Site {
|
||||||
email: email,
|
email: email,
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
|
imageURL: imageURL,
|
||||||
url: url,
|
url: url,
|
||||||
scripts: scripts,
|
scripts: scripts,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ struct Site {
|
||||||
let email: String
|
let email: String
|
||||||
let title: String
|
let title: String
|
||||||
let description: String
|
let description: String
|
||||||
|
let imageURL: URL?
|
||||||
let url: URL
|
let url: URL
|
||||||
let scripts: [Script]
|
let scripts: [Script]
|
||||||
let styles: [Stylesheet]
|
let styles: [Stylesheet]
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ final class SiteGenerator {
|
||||||
try fileManager.removeItem(at: targetURL)
|
try fileManager.removeItem(at: targetURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
let ext = String(filename.split(separator: ".").last!)
|
let ext = filename.split(separator: ".").last.flatMap { String($0) }
|
||||||
for renderer in site.renderers {
|
for renderer in site.renderers {
|
||||||
if renderer.canRenderFile(named: filename, withExtension: ext) {
|
if renderer.canRenderFile(named: filename, withExtension: ext) {
|
||||||
try renderer.render(site: site, fileURL: sourceURL, targetDir: targetDir)
|
try renderer.render(site: site, fileURL: sourceURL, targetDir: targetDir)
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,25 @@ import Foundation
|
||||||
import Plot
|
import Plot
|
||||||
|
|
||||||
final class PageRenderer {
|
final class PageRenderer {
|
||||||
func render(_ body: Node<HTML.BodyContext>, context: TemplateContext) -> String {
|
func render<Context: TemplateContext>(_ body: Node<HTML.BodyContext>, context: Context) -> String {
|
||||||
Template.site(body: body, context: context).render(indentedBy: .spaces(2))
|
Template.site(body: body, context: context).render(indentedBy: .spaces(2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PageRenderer: PageRendering {
|
extension PageRenderer: PageRendering {
|
||||||
func renderPage(site: Site, bodyHTML: String, metadata: [String: String]) throws -> String {
|
func renderPage(site: Site, url: URL, bodyHTML: String, metadata: [String: String]) throws -> String {
|
||||||
let pageTitle = metadata["Title"]
|
let pageTitle = metadata["Title"]
|
||||||
|
let pageType = metadata["Page type"]
|
||||||
let scripts = metadata.commaSeparatedList(key: "Scripts").map(Script.init(ref:))
|
let scripts = metadata.commaSeparatedList(key: "Scripts").map(Script.init(ref:))
|
||||||
let styles = metadata.commaSeparatedList(key: "Styles").map(Stylesheet.init(ref:))
|
let styles = metadata.commaSeparatedList(key: "Styles").map(Stylesheet.init(ref:))
|
||||||
let assets = TemplateAssets(scripts: scripts, styles: styles)
|
let assets = TemplateAssets(scripts: scripts, styles: styles)
|
||||||
let context = SiteContext(site: site, subtitle: pageTitle, templateAssets: assets)
|
let context = SiteContext(
|
||||||
|
site: site,
|
||||||
|
canonicalURL: url,
|
||||||
|
subtitle: pageTitle,
|
||||||
|
pageType: pageType,
|
||||||
|
templateAssets: assets
|
||||||
|
)
|
||||||
return render(.page(title: pageTitle ?? "", bodyHTML: bodyHTML), context: context)
|
return render(.page(title: pageTitle ?? "", bodyHTML: bodyHTML), context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,26 @@ import Foundation
|
||||||
|
|
||||||
struct SiteContext: TemplateContext {
|
struct SiteContext: TemplateContext {
|
||||||
let site: Site
|
let site: Site
|
||||||
|
let canonicalURL: URL
|
||||||
let subtitle: String?
|
let subtitle: String?
|
||||||
|
let description: String
|
||||||
|
let pageType: String
|
||||||
let templateAssets: TemplateAssets
|
let templateAssets: TemplateAssets
|
||||||
|
|
||||||
init(site: Site, subtitle: String? = nil, templateAssets: TemplateAssets = .empty()) {
|
init(
|
||||||
|
site: Site,
|
||||||
|
canonicalURL: URL,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
pageType: String? = nil,
|
||||||
|
templateAssets: TemplateAssets = .empty()
|
||||||
|
) {
|
||||||
self.site = site
|
self.site = site
|
||||||
|
self.canonicalURL = canonicalURL
|
||||||
self.subtitle = subtitle
|
self.subtitle = subtitle
|
||||||
|
self.description = description ?? site.description
|
||||||
|
self.pageType = pageType ?? "website"
|
||||||
|
|
||||||
self.templateAssets = templateAssets
|
self.templateAssets = templateAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,15 +40,3 @@ struct SiteContext: TemplateContext {
|
||||||
return "\(site.title): \(subtitle)"
|
return "\(site.title): \(subtitle)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SiteContext {
|
|
||||||
var dictionary: [String: Any] {
|
|
||||||
[
|
|
||||||
"site": site,
|
|
||||||
"title": site.title,
|
|
||||||
"styles": site.styles,
|
|
||||||
"scripts": site.scripts,
|
|
||||||
"currentYear": Date().year,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,35 +8,51 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Plot
|
import Plot
|
||||||
|
|
||||||
|
private extension Node where Context == HTML.DocumentContext {
|
||||||
|
/// Add a `<head>` HTML element within the current context, which
|
||||||
|
/// contains non-visual elements, such as stylesheets and metadata.
|
||||||
|
/// - parameter nodes: The element's attributes and child elements.
|
||||||
|
static func head(_ nodes: [Node<HTML.HeadContext>]) -> Node {
|
||||||
|
.element(named: "head", nodes: nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Template {
|
enum Template {
|
||||||
static func site(body: Node<HTML.BodyContext>, context: TemplateContext) -> HTML {
|
static func site<Context: TemplateContext>(body: Node<HTML.BodyContext>, context: Context) -> HTML {
|
||||||
HTML(
|
// Broken up to fix a build error because Swift can't type-check the varargs version.
|
||||||
|
let headNodes: [Node<HTML.HeadContext>] = [
|
||||||
|
.encoding(.utf8),
|
||||||
|
.title(context.title),
|
||||||
|
.description(context.description),
|
||||||
|
.siteName(context.site.title),
|
||||||
|
.url(context.canonicalURL),
|
||||||
|
.meta(.property("og:image"), .content(context.site.imageURL?.absoluteString ?? "")),
|
||||||
|
.meta(.property("og:type"), .content(context.pageType)),
|
||||||
|
.meta(.property("article:author"), .content(context.site.author)),
|
||||||
|
.meta(.name("twitter:card"), .content("summary")),
|
||||||
|
.rssFeedLink(context.url(for: "feed.xml"), title: context.site.title),
|
||||||
|
.jsonFeedLink(context.url(for: "feed.json"), title: context.site.title),
|
||||||
|
.meta(.name("fediverse:creator"), .content("@sjs@techhub.social")),
|
||||||
|
.link(.rel(.author), .type("text/plain"), .href(context.url(for: "humans.txt"))),
|
||||||
|
.link(.rel(.icon), .type("image/png"), .href(context.imageURL("favicon-32x32.png"))),
|
||||||
|
.link(.rel(.shortcutIcon), .href(context.imageURL("favicon.icon"))),
|
||||||
|
.appleTouchIcon(context.imageURL("apple-touch-icon.png")),
|
||||||
|
.safariPinnedTabIcon(context.imageURL("safari-pinned-tab.svg"), color: "#aa0000"),
|
||||||
|
.link(.attribute(named: "rel", value: "manifest"), .href(context.imageURL("manifest.json"))),
|
||||||
|
.meta(.name("msapplication-config"), .content(context.imageURL("browserconfig.xml").absoluteString)),
|
||||||
|
.meta(.name("theme-color"), .content("#121212")), // matches header
|
||||||
|
.meta(.name("viewport"), .content("width=device-width, initial-scale=1.0, viewport-fit=cover")),
|
||||||
|
.link(.rel(.dnsPrefetch), .href("https://use.typekit.net")),
|
||||||
|
.link(.rel(.dnsPrefetch), .href("https://netdna.bootstrapcdn.com")),
|
||||||
|
.link(.rel(.dnsPrefetch), .href("https://gist.github.com")),
|
||||||
|
.group(context.styles.map { url in
|
||||||
|
.link(.rel(.stylesheet), .type("text/css"), .href(url))
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return HTML(
|
||||||
.lang(.english),
|
.lang(.english),
|
||||||
.comment("meow"),
|
.comment("meow"),
|
||||||
.head(
|
.head(headNodes),
|
||||||
.encoding(.utf8),
|
|
||||||
.title(context.title),
|
|
||||||
.siteName(context.site.title),
|
|
||||||
.url(context.site.url),
|
|
||||||
.rssFeedLink(context.url(for: "feed.xml"), title: context.site.title),
|
|
||||||
.jsonFeedLink(context.url(for: "feed.json"), title: context.site.title),
|
|
||||||
.meta(.name("fediverse:creator"), .content("@sjs@techhub.social")),
|
|
||||||
.link(.rel(.author), .type("text/plain"), .href(context.url(for: "humans.txt"))),
|
|
||||||
.link(.rel(.icon), .type("image/png"), .href(context.imageURL("favicon-32x32.png"))),
|
|
||||||
.link(.rel(.shortcutIcon), .href(context.imageURL("favicon.icon"))),
|
|
||||||
.appleTouchIcon(context.imageURL("apple-touch-icon.png")),
|
|
||||||
.safariPinnedTabIcon(context.imageURL("safari-pinned-tab.svg"), color: "#aa0000"),
|
|
||||||
.link(.attribute(named: "rel", value: "manifest"), .href(context.imageURL("manifest.json"))),
|
|
||||||
.meta(.name("msapplication-config"), .content(context.imageURL("browserconfig.xml").absoluteString)),
|
|
||||||
.meta(.name("theme-color"), .content("#121212")), // matches header
|
|
||||||
.meta(.name("viewport"), .content("width=device-width, initial-scale=1.0, viewport-fit=cover")),
|
|
||||||
.link(.rel(.dnsPrefetch), .href("https://use.typekit.net")),
|
|
||||||
.link(.rel(.dnsPrefetch), .href("https://netdna.bootstrapcdn.com")),
|
|
||||||
.link(.rel(.dnsPrefetch), .href("https://gist.github.com")),
|
|
||||||
.group(context.styles.map { url in
|
|
||||||
.link(.rel(.stylesheet), .type("text/css"), .href(url))
|
|
||||||
})
|
|
||||||
),
|
|
||||||
.body(
|
.body(
|
||||||
.header(.class("primary"),
|
.header(.class("primary"),
|
||||||
.div(.class("title"),
|
.div(.class("title"),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ protocol TemplateContext {
|
||||||
|
|
||||||
var site: Site { get }
|
var site: Site { get }
|
||||||
var title: String { get }
|
var title: String { get }
|
||||||
|
var canonicalURL: URL { get }
|
||||||
|
var description: String { get }
|
||||||
|
var pageType: String { get }
|
||||||
var templateAssets: TemplateAssets { get }
|
var templateAssets: TemplateAssets { get }
|
||||||
|
|
||||||
// These all have default implementations
|
// These all have default implementations
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ public extension samhuri {
|
||||||
let postsPlugin = PostsPlugin.Builder(renderer: renderer)
|
let postsPlugin = PostsPlugin.Builder(renderer: renderer)
|
||||||
.path("posts")
|
.path("posts")
|
||||||
.jsonFeed(
|
.jsonFeed(
|
||||||
avatarPath: "images/me.jpg",
|
|
||||||
iconPath: "images/apple-touch-icon-300.png",
|
iconPath: "images/apple-touch-icon-300.png",
|
||||||
faviconPath: "images/apple-touch-icon-80.png"
|
faviconPath: "images/apple-touch-icon-80.png"
|
||||||
)
|
)
|
||||||
|
|
@ -54,8 +53,9 @@ public extension samhuri {
|
||||||
|
|
||||||
return Site.Builder(
|
return Site.Builder(
|
||||||
title: "samhuri.net",
|
title: "samhuri.net",
|
||||||
description: "just some blog",
|
description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.",
|
||||||
author: "Sami Samhuri",
|
author: "Sami Samhuri",
|
||||||
|
imagePath: "images/me.jpg",
|
||||||
email: "sami@samhuri.net",
|
email: "sami@samhuri.net",
|
||||||
url: siteURLOverride ?? URL(string: "https://samhuri.net")!
|
url: siteURLOverride ?? URL(string: "https://samhuri.net")!
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue