Migrate projects plugin from Stencil and JSON to Swift

This commit is contained in:
Sami Samhuri 2019-12-20 22:27:24 -08:00
parent 640c76d967
commit 0c17d5c543
21 changed files with 317 additions and 245 deletions

View file

@ -121,9 +121,9 @@ Execution, trying TDD for the first time:
- [x] Replace page template with Swift code
- [ ] Replace projects.json with Swift code
- [x] Replace projects.json with Swift code
- [ ] Replace project templates with Swift code
- [x] Replace project templates with Swift code
- [ ] Replace post templates with Swift code

View file

@ -0,0 +1,8 @@
//
// File.swift
//
//
// Created by Sami Samhuri on 2019-12-20.
//
import Foundation

View file

@ -7,8 +7,14 @@
import Foundation
struct Project: Codable {
let title: String
let description: String
var path: String!
public struct Project {
public let title: String
public let description: String
public let url: URL
public init(title: String, description: String, url: URL) {
self.title = title
self.description = description
self.url = url
}
}

View file

@ -7,42 +7,47 @@
import Foundation
private struct Projects: Codable {
let projects: [Project]
static func decode(from url: URL) throws -> Projects {
let json = try Data(contentsOf: url)
let projects = try JSONDecoder().decode(Projects.self, from: json)
return projects
}
struct PartialProject {
let title: String
let description: String
}
final class ProjectsPlugin: Plugin {
public final class ProjectsPlugin: Plugin {
let fileManager: FileManager = .default
let outputPath: String
let partialProjects: [PartialProject]
let templateRenderer: ProjectsTemplateRenderer
let projectAssets: TemplateAssets
var projects: [Project] = []
var sourceURL: URL!
init(templateRenderer: ProjectsTemplateRenderer, outputPath: String? = nil) {
init(
projects: [PartialProject],
templateRenderer: ProjectsTemplateRenderer,
projectAssets: TemplateAssets,
outputPath: String? = nil
) {
self.partialProjects = projects
self.templateRenderer = templateRenderer
self.projectAssets = projectAssets
self.outputPath = outputPath ?? "projects"
}
// MARK: - Plugin methods
func setUp(site: Site, sourceURL: URL) throws {
public func setUp(site: Site, sourceURL: URL) throws {
self.sourceURL = sourceURL
let projectsURL = sourceURL.appendingPathComponent("projects.json")
if fileManager.fileExists(atPath: projectsURL.path) {
self.projects = try Projects.decode(from: projectsURL).projects.map { project in
Project(title: project.title, description: project.description, path: "/\(outputPath)/\(project.title)")
}
projects = partialProjects.map { partial in
Project(
title: partial.title,
description: partial.description,
url: site.url.appendingPathComponent("\(outputPath)/\(partial.title)")
)
}
}
func render(site: Site, targetURL: URL) throws {
public func render(site: Site, targetURL: URL) throws {
guard !projects.isEmpty else {
return
}
@ -50,18 +55,12 @@ 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(.projects, site: site, context: [
"title": "Projects",
"projects": projects,
])
let projectsHTML = try templateRenderer.renderProjects(projects, site: site, assets: .none())
try projectsHTML.write(to: projectsURL, atomically: true, encoding: .utf8)
for project in projects {
let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
let projectHTML = try templateRenderer.renderTemplate(.project, site: site, context: [
"title": "\(project.title)",
"project": project,
])
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)
}

View file

@ -0,0 +1,49 @@
//
// ProjectsPluginBuilder.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
public final class ProjectsPluginBuilder {
let templateRenderer: ProjectsTemplateRenderer
private var path: String?
private var projects: [PartialProject] = []
private var projectAssets: TemplateAssets?
public init(templateRenderer: ProjectsTemplateRenderer) {
self.templateRenderer = templateRenderer
}
public func path(_ path: String) -> ProjectsPluginBuilder {
precondition(self.path == nil, "path is already defined")
self.path = path
return self
}
public func projectAssets(_ projectAssets: TemplateAssets) -> ProjectsPluginBuilder {
precondition(self.projectAssets == nil, "projectAssets are already defined")
self.projectAssets = projectAssets
return self
}
public func add(_ title: String, description: String) -> ProjectsPluginBuilder {
let project = PartialProject(title: title, description: description)
projects.append(project)
return self
}
public func build() -> ProjectsPlugin {
if projects.isEmpty {
print("WARNING: No projects have been added")
}
return ProjectsPlugin(
projects: projects,
templateRenderer: templateRenderer,
projectAssets: projectAssets ?? .none(),
outputPath: path
)
}
}

View file

@ -7,11 +7,7 @@
import Foundation
public enum ProjectTemplate {
case project
case projects
}
public protocol ProjectsTemplateRenderer {
func renderTemplate(_ template: ProjectTemplate, site: Site, context: [String: Any]) throws -> String
func renderProjects(_ projects: [Project], site: Site, assets: TemplateAssets) throws -> String
func renderProject(_ project: Project, site: Site, assets: TemplateAssets) throws -> String
}

View file

@ -76,17 +76,3 @@ public extension SiteBuilder {
renderer(MarkdownRenderer(pageRenderer: pageRenderer))
}
}
// MARK: - Projects
public extension SiteBuilder {
func projects(templateRenderer: ProjectsTemplateRenderer, path: String? = nil) -> SiteBuilder {
plugin(ProjectsPlugin(templateRenderer: templateRenderer, outputPath: path))
}
}
// MARK: - Posts
public extension SiteBuilder {
// anything nice we can do there?
}

View file

@ -0,0 +1,22 @@
//
// TemplateAssets.swift
// SiteGenerator
//
// Created by Sami Samhuri on 2019-12-20.
//
import Foundation
public struct TemplateAssets {
public let scripts: [String]
public let styles: [String]
public init(scripts: [String], styles: [String]) {
self.scripts = scripts
self.styles = styles
}
public static func none() -> TemplateAssets {
TemplateAssets(scripts: [], styles: [])
}
}

View file

@ -1,60 +0,0 @@
{
"projects": [
{
"title": "bin",
"description": "my collection of scripts in ~/bin"
},
{
"title": "config",
"description": "important dot files (zsh, emacs, vim, screen)"
},
{
"title": "compiler",
"description": "a compiler targeting x86 in Ruby"
},
{
"title": "lake",
"description": "a simple implementation of Scheme in C"
},
{
"title": "strftime",
"description": "strftime for JavaScript"
},
{
"title": "format",
"description": "printf for JavaScript"
},
{
"title": "gitter",
"description": "a GitHub client for Node (v3 API)"
},
{
"title": "mojo.el",
"description": "turn emacs into a sweet mojo editor"
},
{
"title": "ThePusher",
"description": "Github post-receive hook router"
},
{
"title": "NorthWatcher",
"description": "cron for filesystem changes"
},
{
"title": "repl-edit",
"description": "edit Node repl commands with your text editor"
},
{
"title": "cheat.el",
"description": "cheat from emacs"
},
{
"title": "batteries",
"description": "a general purpose node library"
},
{
"title": "samhuri.net",
"description": "this site"
}
]
}

View file

@ -0,0 +1,14 @@
//
// Date+Sugar.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
extension Date {
var year: Int {
Calendar.current.dateComponents([.year], from: self).year!
}
}

View file

@ -6,11 +6,11 @@
//
import Foundation
import SiteGenerator
struct Page {
let title: String
let styles: [String]
let scripts: [String]
let templateAssets: TemplateAssets
}
extension Page {
@ -22,6 +22,6 @@ extension Page {
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
let title = metadata["Title", default: ""]
self.init(title: title, styles: styles, scripts: scripts)
self.init(title: title, templateAssets: TemplateAssets(scripts: scripts, styles: styles))
}
}

View file

@ -22,15 +22,17 @@ final class PageRenderer {
let loader = FileSystemLoader(paths: [templatesPath])
self.stencil = Environment(loader: loader)
}
func render(_ body: Node<HTML.BodyContext>, context: TemplateContext) -> String {
Template.site(body: body, context: context).render(indentedBy: .spaces(2))
}
}
extension PageRenderer: MarkdownPageRenderer {
func renderPage(site: Site, bodyHTML: String, metadata: [String: String]) throws -> String {
let page = Page(metadata: metadata)
let context = PageContext(site: site, body: bodyHTML, page: page, metadata: metadata)
let body: Node<HTML.BodyContext> = .page(title: page.title, bodyHTML: bodyHTML)
return Template.site(body: body, context: context)
.render(indentedBy: .spaces(2))
let pageTitle = metadata["Title", default: ""]
let context = SiteContext(site: site, subtitle: pageTitle, templateAssets: .none())
return render(.page(title: pageTitle, bodyHTML: bodyHTML), context: context)
}
}
@ -58,34 +60,21 @@ extension PostTemplate {
extension PageRenderer: PostsTemplateRenderer {
func renderTemplate(_ template: PostTemplate, site: Site, context: [String : Any]) throws -> String {
let siteContext = SiteContext(site: site)
let siteContext = SiteContext(site: site, subtitle: nil, templateAssets: .none())
let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new })
return try stencil.renderTemplate(name: template.htmlFilename, context: contextDict)
}
}
extension ProjectTemplate {
@available(*, deprecated)
var htmlFilename: String {
switch self {
case .project:
return "project.html"
case .projects:
return "projects.html"
}
}
}
extension PageRenderer: ProjectsTemplateRenderer {
func renderTemplate(_ template: ProjectTemplate, site: Site, context: [String : Any]) throws -> String {
let siteContext = SiteContext(site: site)
let contextDict = siteContext.dictionary.merging(context, uniquingKeysWith: { _, new in new })
return try stencil.renderTemplate(name: template.htmlFilename, context: contextDict)
func renderProjects(_ projects: [Project], site: Site, assets: TemplateAssets) throws -> String {
let context = SiteContext(site: site, subtitle: "Projects", templateAssets: assets)
return render(.projects(projects), context: context)
}
}
extension Date {
var year: Int {
Calendar.current.dateComponents([.year], from: self).year!
func renderProject(_ project: Project, site: Site, assets: TemplateAssets) throws -> String {
let projectContext = ProjectContext(project: project, site: site, templateAssets: assets)
let context = SiteContext(site: site, subtitle: project.title, templateAssets: assets)
return render(.project(projectContext), context: context)
}
}

View file

@ -0,0 +1,33 @@
//
// ProjectContext.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
import SiteGenerator
struct ProjectContext: TemplateContext {
let site: Site
let title: String
let description: String
let githubURL: URL
let templateAssets: TemplateAssets
init(project: Project, site: Site, templateAssets: TemplateAssets) {
self.site = site
self.title = project.title
self.description = project.description
self.githubURL = URL(string: "https://github.com/samsonjs/\(title)")!
self.templateAssets = templateAssets
}
var stargazersURL: URL {
githubURL.appendingPathComponent("stargazers")
}
var networkURL: URL {
githubURL.appendingPathComponent("network/members")
}
}

View file

@ -0,0 +1,71 @@
//
// ProjectTemplates.swift
// samhuri.net
//
// Created by Sami Samhuri on 2019-12-19.
//
import Foundation
import Plot
import SiteGenerator
extension Node where Context == HTML.BodyContext {
static func projects(_ projects: [Project]) -> Node<HTML.BodyContext> {
.group([
.article(.class("container"),
.h1("Projects"),
.group(projects.map { project in
.div(.class("project-listing"),
.h4(.a(.href(project.url), .text(project.title))),
.p(.class("description"), .text(project.description))
)
})
),
.div(.class("row clearfix"),
.p(.class("fin"), .i(.class("fa fa-code")))
)
])
}
static func project(_ context: ProjectContext) -> Node<HTML.BodyContext> {
.group([
.article(.class("container project"),
// projects.js picks up this data-title attribute and uses it to render all the Github stuff
.h1(.id("project"), .data(named: "title", value: context.title), .text(context.title)),
.h4(.text(context.description)),
.div(.class("project-stats"),
.p(
.a(.href(context.githubURL), "GitHub"),
"",
.a(.id("nstar"), .href(context.stargazersURL)),
"",
.a(.id("nfork"), .href(context.networkURL))
),
.p("Last updated on ", .span(.id("updated")))
),
.div(.class("project-info row clearfix"),
.div(.class("column half"),
.h3("Contributors"),
.div(.id("contributors"))
),
.div(.class("column half"),
.h3("Languages"),
.div(.id("langs"))
)
)
),
.div(.class("row clearfix"),
.p(.class("fin"), .i(.class("fa fa-code")))
),
.group(context.scripts.map { url in
.script(.attribute(named: "defer"), .src(url))
})
])
}
}

View file

@ -18,16 +18,8 @@ struct PageContext: TemplateContext {
"\(site.title): \(page.title)"
}
var styles: [URL] {
(site.styles + page.styles).map { style in
style.hasPrefix("http") ? URL(string: style)! : url(for: style)
}
}
var scripts: [URL] {
(site.scripts + page.scripts).map { script in
script.hasPrefix("http") ? URL(string: script)! : url(for: script)
}
var templateAssets: TemplateAssets {
page.templateAssets
}
}
@ -39,8 +31,8 @@ extension PageContext {
"body": body,
"page": page,
"metadata": metadata,
"styles": site.styles + page.styles,
"scripts": site.scripts + page.scripts,
"styles": site.styles + templateAssets.styles,
"scripts": site.scripts + templateAssets.scripts,
"currentYear": Date().year,
]
}

View file

@ -11,11 +11,7 @@ import SiteGenerator
struct SiteContext: TemplateContext {
let site: Site
let subtitle: String?
init(site: Site, subtitle: String? = nil) {
self.site = site
self.subtitle = subtitle
}
let templateAssets: TemplateAssets
var title: String {
guard let subtitle = subtitle else {
@ -24,18 +20,6 @@ struct SiteContext: TemplateContext {
return "\(site.title): \(subtitle)"
}
var styles: [URL] {
site.styles.map { style in
style.hasPrefix("http") ? URL(string: style)! : url(for: style)
}
}
var scripts: [URL] {
site.scripts.map { script in
script.hasPrefix("http") ? URL(string: script)! : url(for: script)
}
}
}
extension SiteContext {

View file

@ -13,18 +13,36 @@ protocol TemplateContext {
var site: Site { get }
var title: String { get }
var styles: [URL] { get }
var scripts: [URL] { get }
var templateAssets: TemplateAssets { get }
// These all have default implementations
var styles: [URL] { get }
var scripts: [URL] { get }
var currentYear: Int { get }
func url(for path: String) -> URL
func imageURL(_ filename: String) -> URL
func scriptURL(_ filename: String) -> URL
func styleURL(_ filename: String) -> URL
}
extension TemplateContext {
var styles: [URL] {
let allStyles = site.styles + templateAssets.styles
return allStyles.map { style in
style.hasPrefix("http") ? URL(string: style)! : styleURL(style)
}
}
var scripts: [URL] {
let allScripts = site.scripts + templateAssets.scripts
return allScripts.map { script in
script.hasPrefix("http") ? URL(string: script)! : scriptURL(script)
}
}
var currentYear: Int {
Date().year
}
@ -38,4 +56,16 @@ extension TemplateContext {
.appendingPathComponent("images")
.appendingPathComponent(filename)
}
func scriptURL(_ filename: String) -> URL {
site.url
.appendingPathComponent("js")
.appendingPathComponent(filename)
}
func styleURL(_ filename: String) -> URL {
site.url
.appendingPathComponent("css")
.appendingPathComponent(filename)
}
}

View file

@ -12,6 +12,30 @@ public extension samhuri {
}
func buildSite(renderer: PageRenderer) -> Site {
let projectsPlugin = ProjectsPluginBuilder(templateRenderer: renderer)
.path("projects")
.projectAssets(TemplateAssets(scripts: [
"https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js",
"gitter.js",
"store.js",
"projects.js",
], styles: []))
.add("bin", description: "my collection of scripts in ~/bin")
.add("config", description: "important dot files (zsh, emacs, vim, screen)")
.add("compiler", description: "a compiler targeting x86 in Ruby")
.add("lake", description: "a simple implementation of Scheme in C")
.add("strftime", description: "strftime for JavaScript")
.add("format", description: "printf for JavaScript")
.add("gitter", description: "a GitHub client for Node (v3 API)")
.add("mojo.el", description: "turn emacs into a sweet mojo editor")
.add("ThePusher", description: "Github post-receive hook router")
.add("NorthWatcher", description: "cron for filesystem changes")
.add("repl-edit", description: "edit Node repl commands with your text editor")
.add("cheat.el", description: "cheat from emacs")
.add("batteries", description: "a general purpose node library")
.add("samhuri.net", description: "this site")
.build()
let postsPlugin = PostsPluginBuilder(templateRenderer: renderer)
.path("posts")
.jsonFeed(
@ -29,10 +53,10 @@ public extension samhuri {
email: "sami@samhuri.net",
url: siteURLOverride ?? URL(string: "https://samhuri.net")!
)
.styles("/css/normalize.css", "/css/style.css")
.styles("normalize.css", "style.css")
.styles("https://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css")
.renderMarkdown(pageRenderer: renderer)
.projects(templateRenderer: renderer)
.plugin(projectsPlugin)
.plugin(postsPlugin)
.build()
}

View file

@ -1,42 +0,0 @@
{% extends "samhuri.net.html" %}
{% block body %}
<article class="container project">
<!-- projects.js picks up this data-title attribute and uses it to render all the Github stuff -->
<h1 id="project" data-title="{{ project.title }}">{{ project.title }}</h1>
<h4>{{ project.description }}</h4>
<div class="project-stats">
<p>
<a href="https://github.com/samsonjs/{{ project.title }}">GitHub</a>
&bull;
<a id="nstar" href="https://github.com/samsonjs/{{ project.title }}/stargazers"></a>
&bull;
<a id="nfork" href="https://github.com/samsonjs/{{ project.title }}/network/members"></a>
</p>
<p>
Last updated on <span id="updated"></span>
</p>
</div>
<div class="project-info row clearfix">
<div class="column half">
<h3>Contributors</h3>
<div id="contributors"></div>
</div>
<div class="column half">
<h3>Languages</h3>
<div id="langs"></div>
</div>
</div>
</article>
<div class="row clearfix">
<p class="fin"><i class="fa fa-code"></i></p>
</div>
<script defer src="https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js"></script>
<script defer src="/js/gitter.js"></script>
<script defer src="/js/store.js"></script>
<script defer src="/js/projects.js"></script>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "samhuri.net.html" %}
{% block body %}
<article class="container">
<h1>Projects</h1>
{% for project in projects %}
<div class="project-listing">
<h4><a href="{{ project.path }}">{{ project.title }}</a></h4>
<p class="description">{{ project.description }}</p>
</div>
{% endfor %}
</article>
<div class="row clearfix">
<p class="fin"><i class="fa fa-code"></i></p>
</div>
{% endblock %}

View file

@ -64,7 +64,7 @@
<script type="text/javascript">
(function() {
var css = document.createElement('link');
css.href = '{{ style }}';
css.href = '/css/{{ style }}';
css.rel = 'stylesheet';
css.type = 'text/css';
document.getElementsByTagName('head')[0].appendChild(css);
@ -72,16 +72,6 @@
</script>
{% endfor %}
<script type="text/javascript">
(function() {
var css = document.createElement('link');
css.href = '//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css';
css.rel = 'stylesheet';
css.type = 'text/css';
document.getElementsByTagName('head')[0].appendChild(css);
})();
</script>
<script src="https://use.typekit.net/tcm1whv.js" crossorigin="anonymous"></script>
<script>try{Typekit.load({ async: true });}catch(e){}</script>