diff --git a/.gitignore b/.gitignore index fd286b6..84c0cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ www Tests/*/actual -bin/gensite diff --git a/pressa/.ruby-version b/.ruby-version similarity index 100% rename from pressa/.ruby-version rename to .ruby-version diff --git a/pressa/Gemfile b/Gemfile similarity index 100% rename from pressa/Gemfile rename to Gemfile diff --git a/pressa/Gemfile.lock b/Gemfile.lock similarity index 100% rename from pressa/Gemfile.lock rename to Gemfile.lock diff --git a/Makefile b/Makefile deleted file mode 100644 index b25386e..0000000 --- a/Makefile +++ /dev/null @@ -1,51 +0,0 @@ -all: debug - -debug: - @echo - bin/build-gensite - bin/gensite . www http://localhost:8000 - -mudge: - @echo - bin/build-gensite - bin/gensite . www http://mudge:8000 - -beta: clean_blog - @echo - bin/build-gensite - bin/gensite . www https://beta.samhuri.net - -release: clean_blog - @echo - bin/build-gensite - bin/gensite . www - -publish: release - @echo - bin/publish --delete www/ - -publish_beta: beta - @echo - bin/publish --beta --delete www/ - -clean: clean_blog - -clean_blog: - @echo - rm -rf www/* www/.htaccess - -clean_swift: - @echo - rm -rf gensite/.build - rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/gensite-* - rm -rf samhuri.net/.build - rm -rf $(HOME)/Library/Developer/Xcode/DerivedData/samhuri-* - -serve: - @echo - cd www && python3 -m http.server --bind localhost - -watch: - bin/watch - -.PHONY: debug beta release publish publish_beta clean clean_blog clean_swift serve watch diff --git a/Readme.md b/Readme.md index 463ade3..4cf1a7f 100644 --- a/Readme.md +++ b/Readme.md @@ -1,252 +1,90 @@ # samhuri.net -The source code for [samhuri.net](https://samhuri.net). +Source code for [samhuri.net](https://samhuri.net), powered by a Ruby static site generator. ## Overview -This is a custom static site generator written in Swift and geared towards blogging, though it's built to be flexible enough to be any kind of static site. As is tradition it gets a lot more attention than my actual writing for the blog. +This repository is now a single integrated Ruby project. The legacy Swift generators (`gensite/` and `samhuri.net/`) have been removed. -If what you want is an artisanal, hand-crafted, static site generator for your personal blog then this might be a decent starting point. If you want a static site generator for other purposes then this has the bones you need to do that too, by ripping out the bundled plugins for posts and projects and writing your own. +- Generator core: `lib/` +- Build tasks: `bake.rb` +- CLI and utilities: `bin/` +- Tests: `spec/` +- Content: `posts/` and `public/` +- Output: `www/` -Some features: +## Requirements -- Plugin-based architecture, including plugins for rendering posts and projects -- Uses Markdown for posts, rendered using [Ink][] and [Plot][] by [@johnsundell][] -- Supports the notion of a link post -- Generates RSS and JSON feeds -- Runs on Linux and macOS, requires Swift 6.0+ +- Ruby `3.4.1` (see `.ruby-version`) +- Bundler +- `rbenv` recommended -If you don't use the posts or projects plugins then what this does at its core is transform and copy files from `public/` to `www/`, and the only transforms that it performs is Markdown to HTML. Everything else is layered on top of this foundation. - -Posts are [organized by year/month directories](https://github.com/samsonjs/samhuri.net/tree/main/posts), there's [an archive page that lists all posts at /posts](https://samhuri.net/posts), plus individual pages for [each year at /posts/2011](https://samhuri.net/posts/2011) and [each month at /posts/2011/12](https://samhuri.net/posts/2011/12). You can throw [any Markdown file](https://github.com/samsonjs/samhuri.net/blob/main/public/about.md) in `public/` and it gets [rendered as HTML using your site's layout](https://samhuri.net/about). - -The main project is in the [samhuri.net directory][], and there's a second project for the command line tool called [gensite][] that uses the samhuri.net package. The entry points to everything live in the Makefile and the bin/ directory so those are good starting points for exploration. This project isn't intended to be a reusable library but rather something that you can fork and make your own without doing a ton of work beyond renaming some things and plugging in your personal info. - -[samhuri.net directory]: https://github.com/samsonjs/samhuri.net/tree/main/samhuri.net -[gensite]: https://github.com/samsonjs/samhuri.net/tree/main/gensite -[Ink]: https://github.com/johnsundell/ink -[Plot]: https://github.com/johnsundell/plot -[@johnsundell]: https://github.com/johnsundell - -### Post format - -Posts are formatted with Markdown, and require this front-matter (build will fail without these fields): - -``` ---- -Title: What's Golden -Author: Chali 2na -Date: 5th June, 2025 -Timestamp: 2025-06-05T09:41:42-07:00 -Tags: Ruby, C, structs, interop -Link: https://example.net/chali-2na/whats-golden # For link posts ---- -``` -## Getting started - -Clone this repo and build my blog: +## Setup ```bash -git clone https://github.com/samsonjs/samhuri.net.git -cd samhuri.net -make debug +bin/bootstrap ``` -Start a local development server: +Or manually: ```bash -make serve # http://localhost:8000 -make watch # Auto-rebuild on file changes (Linux only) +rbenv install -s "$(cat .ruby-version)" +rbenv exec bundle install ``` -## Workflows - -Work on drafts in `public/drafts/` and publish/edit posts in `posts/YYYY/MM/`. The build process renders source files from these directories: - -- posts: Markdown files organized in subdirectories by year and month that are rendered into `www/posts/YYYY/MM/` -- public: static files that are copied directly to the output directory `www/`, rendering Markdown along the way -- public/drafts: by extension this is automatically handled, nothing special for drafts they're just regular pages +## Build And Serve ```bash -bin/new-draft # Create a new empty draft post with frontmatter -bin/new-draft hello # You can pass in a title if you want using any number of args, quotes not needed - -bin/publish-draft public/drafts/hello.md # Publish a draft (updates date and timestamp to current time) - -make debug # Build for local development, browse at http://localhost:8000 after running make serve -make serve # Start local server at http://localhost:8000 - -make beta # Build for staging at https://beta.samhuri.net -make publish_beta # Deploy to staging server -make release # Build for production at https://samhuri.net -make publish # Deploy to production server +rbenv exec bundle exec bake debug # build for http://localhost:8000 +rbenv exec bundle exec bake serve # serve www/ locally ``` -## Customizing for your site +Other targets: -If this seems like a reasonable workflow then you could see what it takes to make it your own. - -### Essential changes - -0. Probably **rename everything** unless you want to impersonate me 🥸 - -1. **Update site configuration** in `samhuri.net/Sources/samhuri.net/samhuri.net.swift`: - - Site title, description, author name - - Base URL for your domain - - RSS/JSON feed metadata - -2. **Modify deployment** in `bin/publish`: - - Update rsync destination to your server - - Adjust staging/production URLs in Makefile - -3. **Customize styling** in `public/css/style.css` - -4. **Replace static assets** in `public/`: - - Favicon, apple-touch-icon - - About page, CV, any personal content or pages you want go in here - -## How it works - -There's a `Site` that contains everything needed to render the site: - -```swift -struct Site { - let author: String - let email: String - let title: String - let description: String - let imageURL: URL? - let url: URL - let scripts: [Script] - let styles: [Stylesheet] - let renderers: [Renderer] - let plugins: [Plugin] -} +```bash +rbenv exec bundle exec bake mudge +rbenv exec bundle exec bake beta +rbenv exec bundle exec bake release +rbenv exec bundle exec bake publish_beta +rbenv exec bundle exec bake publish ``` -There are `Renderer`s that plugins use to transform files, e.g. Markdown to HTML: +## Draft Workflow -```swift -protocol Renderer { - func canRenderFile(named filename: String, withExtension ext: String?) -> Bool - func render(site: Site, fileURL: URL, targetDir: URL) throws -} +```bash +bin/new-draft "Post title" +bin/publish-draft public/drafts/post-title.md ``` -And this is the `Plugin` protocol: +## Post Utilities -```swift -protocol Plugin { - func setUp(site: Site, sourceURL: URL) throws - func render(site: Site, targetURL: URL) throws -} +```bash +bin/convert-frontmatter posts/2025/11/some-post.md ``` -Your site plus its renderers and plugins defines everything that it can do. +## Tests And Lint -```swift -public enum samhuri {} - -public extension samhuri { - struct net { - let siteURLOverride: URL? - - public init(siteURLOverride: URL? = nil) { - self.siteURLOverride = siteURLOverride - } - - public func generate(sourceURL: URL, targetURL: URL) throws { - let renderer = PageRenderer() - let site = makeSite(renderer: renderer) - let generator = try SiteGenerator(sourceURL: sourceURL, site: site) - try generator.generate(targetURL: targetURL) - } - - func makeSite(renderer: PageRenderer) -> Site { - let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer) - .path("projects") - .assets(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 = PostsPlugin.Builder(renderer: renderer) - .path("posts") - .jsonFeed( - iconPath: "images/apple-touch-icon-300.png", - faviconPath: "images/apple-touch-icon-80.png" - ) - .rssFeed() - .build() - - return Site.Builder( - title: "samhuri.net", - description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.", - author: "Sami Samhuri", - imagePath: "images/me.jpg", - email: "sami@samhuri.net", - url: siteURLOverride ?? URL(string: "https://samhuri.net")! - ) - .styles("normalize.css", "style.css", "fontawesome.min.css", "brands.min.css", "solid.min.css") - .renderMarkdown(pageRenderer: renderer) - .plugin(projectsPlugin) - .plugin(postsPlugin) - .build() - } - } -} +```bash +rbenv exec bundle exec rspec +rbenv exec bundle exec standardrb ``` -You can swap out the [posts plugin][PostsPlugin] for something that handles recipes, or photos, or documentation, or whatever. Each plugin defines how to find content files, process them, and where to put the output. So while this is currently set up as a blog generator the underlying architecture doesn't dictate that at all. +Or via bake: -[PostsPlugin]: https://github.com/samsonjs/samhuri.net/blob/main/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift -[ProjectsPlugin]: https://github.com/samsonjs/samhuri.net/blob/main/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift - -Here's what a plugin might look like for generating photo galleries: - -```swift -final class PhotoPlugin: Plugin { - private var galleries: [Gallery] = [] - - func setUp(site: Site, sourceURL: URL) throws { - let photosURL = sourceURL.appendingPathComponent("photos") - let galleryDirs = try FileManager.default.contentsOfDirectory(at: photosURL, ...) - - for galleryDir in galleryDirs { - let imageFiles = try FileManager.default.contentsOfDirectory(at: galleryDir, ...) - .filter { $0.pathExtension.lowercased() == "jpg" } - galleries.append(Gallery(name: galleryDir.lastPathComponent, images: imageFiles)) - } - } - - func render(site: Site, targetURL: URL) throws { - let galleriesURL = targetURL.appendingPathComponent("galleries") - - for gallery in galleries { - let galleryDirectory = galleriesURL.appendingPathComponent(gallery.name) - // Generate HTML in the targetURL directory using Ink and Plot, or whatever else you want - } - } -} +```bash +rbenv exec bundle exec bake test +rbenv exec bundle exec bake lint ``` -## License +## Site Generation CLI -Released under the terms of the [MIT license](https://sjs.mit-license.org). +```bash +bin/pressa SOURCE TARGET [URL] +# example +bin/pressa . www https://samhuri.net +``` + +## Notes + +- `bin/watch` is Linux-only and requires `inotifywait`. +- Deployment uses `rsync` to the configured `mudge` host paths in `bake.rb` and `bin/publish`. diff --git a/pressa/bake.rb b/bake.rb similarity index 91% rename from pressa/bake.rb rename to bake.rb index 7fb3dd4..e5110a0 100644 --- a/pressa/bake.rb +++ b/bake.rb @@ -1,4 +1,4 @@ -# Build tasks for Pressa static site generator +# Build tasks for samhuri.net static site generator # Generate the site in debug mode (localhost:8000) def debug @@ -62,7 +62,7 @@ end # List all available drafts def drafts - Dir.glob('drafts/*.md').sort.each do |draft| + Dir.glob('public/drafts/*.md').sort.each do |draft| puts File.basename(draft) end end @@ -87,6 +87,6 @@ def build(url) puts "Building site for #{url}..." site = Pressa.create_site(url_override: url) generator = Pressa::SiteGenerator.new(site:) - generator.generate(source_path: '..', target_path: 'www') + generator.generate(source_path: '.', target_path: 'www') puts "Site built successfully in www/" end diff --git a/bin/bootstrap b/bin/bootstrap index faa610c..74ec8fa 100755 --- a/bin/bootstrap +++ b/bin/bootstrap @@ -1,50 +1,31 @@ #!/bin/bash -# bail on errors and unset variables set -euo pipefail -SWIFT_VERSION=6.1 -SWIFT_DIR=swift-$SWIFT_VERSION-RELEASE-ubuntu24.04 -SWIFT_FILENAME=$SWIFT_DIR.tar.gz +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +RUBY_VERSION="$(cat "$ROOT_DIR/.ruby-version")" -if [[ $(uname) = "Linux" ]]; then +if [[ "$(uname)" = "Linux" ]]; then + echo "*** installing Linux prerequisites" sudo apt install -y \ - binutils \ - git \ - gnupg2 \ - libc6-dev \ - libcurl4 \ - libedit2 \ - libgcc-s1 \ - libpython3.12 \ - libsqlite3-0 \ - libstdc++-14-dev \ - libxml2 \ - libz3-dev \ - pkg-config \ - tzdata \ - uuid-dev \ - zlib1g-dev + build-essential \ + git \ + inotify-tools \ + libffi-dev \ + libyaml-dev \ + pkg-config \ + zlib1g-dev +fi - if which swift >/dev/null 2>/dev/null && swift --version | grep $SWIFT_VERSION >/dev/null 2>/dev/null; then - echo "*** swift $SWIFT_VERSION is installed" - else - echo "*** installing swift" - if [[ -e $SWIFT_FILENAME ]]; then - echo "*** $SWIFT_FILENAME exists, skipping download" - else - wget https://download.swift.org/swift-$SWIFT_VERSION-release/ubuntu2404/swift-$SWIFT_VERSION-RELEASE/$SWIFT_FILENAME - fi - if [[ -e $SWIFT_DIR ]]; then - echo "*** $SWIFT_DIR exists, skipping extraction" - else - tar xzf $SWIFT_FILENAME - fi - echo "*** add $PWD/$SWIFT_DIR/usr/bin to PATH in your shell's rc file" - fi +cd "$ROOT_DIR" - echo "*** installing inotify-tools for watch script" - sudo apt install -y inotify-tools +if command -v rbenv >/dev/null 2>/dev/null; then + echo "*** using rbenv (ruby $RUBY_VERSION)" + rbenv install -s "$RUBY_VERSION" + rbenv exec bundle install +else + echo "*** rbenv not found, using system Ruby" + bundle install fi echo "*** done" diff --git a/bin/build-gensite b/bin/build-gensite deleted file mode 100755 index 5902b0b..0000000 --- a/bin/build-gensite +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -if [[ $(uname) = "Linux" ]]; then - build_platform_dir="$(arch)-unknown-linux-gnu" -else - build_platform_dir="$(arch)-apple-macosx" -fi - -pushd "gensite" >/dev/null -swift build -cp .build/$build_platform_dir/debug/gensite ../bin/gensite -popd >/dev/null diff --git a/pressa/bin/convert-frontmatter b/bin/convert-frontmatter similarity index 100% rename from pressa/bin/convert-frontmatter rename to bin/convert-frontmatter diff --git a/pressa/bin/pressa b/bin/pressa similarity index 100% rename from pressa/bin/pressa rename to bin/pressa diff --git a/bin/watch b/bin/watch index 0c74d5e..32e55a2 100755 --- a/bin/watch +++ b/bin/watch @@ -1,10 +1,17 @@ #!/bin/bash +set -euo pipefail + BLOG_TARGET=${BLOG_TARGET:-mudge} +if ! command -v inotifywait >/dev/null 2>/dev/null; then + echo "inotifywait is required (install inotify-tools)." + exit 1 +fi + while true; do - inotifywait -e modify,create,delete,move -r drafts -r posts + inotifywait -e modify,create,delete,move -r public -r posts -r lib echo "changed at $(date)" - sleep 5 - make "$TARGET" + sleep 2 + rbenv exec bundle exec bake "$BLOG_TARGET" done diff --git a/gensite/.gitignore b/gensite/.gitignore deleted file mode 100644 index 504eed0..0000000 --- a/gensite/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -/.swiftpm diff --git a/gensite/Package.resolved b/gensite/Package.resolved deleted file mode 100644 index 5e3e272..0000000 --- a/gensite/Package.resolved +++ /dev/null @@ -1,25 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "Ink", - "repositoryURL": "https://github.com/johnsundell/ink.git", - "state": { - "branch": null, - "revision": "bcc9f219900a62c4210e6db726035d7f03ae757b", - "version": "0.6.0" - } - }, - { - "package": "Plot", - "repositoryURL": "https://github.com/johnsundell/plot.git", - "state": { - "branch": null, - "revision": "271926b4413fe868739d99f5eadcf2bd6cd62fb8", - "version": "0.14.0" - } - } - ] - }, - "version": 1 -} diff --git a/gensite/Package.swift b/gensite/Package.swift deleted file mode 100644 index 67142bc..0000000 --- a/gensite/Package.swift +++ /dev/null @@ -1,21 +0,0 @@ -// swift-tools-version:6.1 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "gensite", - platforms: [ - .macOS(.v14), - .iOS(.v17), - ], - dependencies: [ - .package(path: "../samhuri.net"), - ], - targets: [ - .executableTarget( name: "gensite", dependencies: [ - "samhuri.net", - ]), - .testTarget(name: "gensiteTests", dependencies: ["gensite"]), - ] -) diff --git a/gensite/Readme.md b/gensite/Readme.md deleted file mode 100644 index 3e98529..0000000 --- a/gensite/Readme.md +++ /dev/null @@ -1,5 +0,0 @@ -# gensite - -A binary to build [samhuri.net](https://samhuri.net) using SiteGenerator. - -See https://github.com/samsonjs/samhuri.net for details. diff --git a/gensite/Sources/gensite/main.swift b/gensite/Sources/gensite/main.swift deleted file mode 100644 index d6f3ab7..0000000 --- a/gensite/Sources/gensite/main.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// main.swift -// gensite -// -// Created by Sami Samhuri on 2019-12-01. -// - -import Foundation -import samhuri_net - -guard CommandLine.arguments.count >= 3 else { - let name = CommandLine.arguments[0] - FileHandle.standardError.write("Usage: \(name) \n".data(using: .utf8)!) - exit(1) -} - -let sourcePath = CommandLine.arguments[1] -var isDir: ObjCBool = false -let sourceExists = FileManager.default.fileExists(atPath: sourcePath, isDirectory: &isDir) -guard sourceExists, isDir.boolValue else { - FileHandle.standardError.write("error: Site path \(sourcePath) does not exist or is not a directory\n".data(using: .utf8)!) - exit(2) -} - -let targetPath = CommandLine.arguments[2] - -let siteURLOverride: URL? -if CommandLine.argc > 3, CommandLine.arguments[3].isEmpty == false { - let urlString = CommandLine.arguments[3] - guard let url = URL(string: urlString) else { - FileHandle.standardError.write("error: invalid site URL \(urlString)\n".data(using: .utf8)!) - exit(4) - } - siteURLOverride = url -} -else { - siteURLOverride = nil -} - -do { - let sourceURL = URL(fileURLWithPath: sourcePath) - let targetURL = URL(fileURLWithPath: targetPath) - let site = samhuri.net(siteURLOverride: siteURLOverride) - try site.generate(sourceURL: sourceURL, targetURL: targetURL) - exit(0) -} -catch { - FileHandle.standardError.write("error: \(error)\n".data(using: .utf8)!) - exit(-1) -} diff --git a/gensite/Tests/gensiteTests/gensiteTests.swift b/gensite/Tests/gensiteTests/gensiteTests.swift deleted file mode 100644 index f9377c1..0000000 --- a/gensite/Tests/gensiteTests/gensiteTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -@testable import gensite -import Testing - -struct gensiteTests { - @Test func example() { - #expect(true) - } -} diff --git a/pressa/lib/plugin.rb b/lib/plugin.rb similarity index 100% rename from pressa/lib/plugin.rb rename to lib/plugin.rb diff --git a/pressa/lib/posts/json_feed.rb b/lib/posts/json_feed.rb similarity index 100% rename from pressa/lib/posts/json_feed.rb rename to lib/posts/json_feed.rb diff --git a/pressa/lib/posts/metadata.rb b/lib/posts/metadata.rb similarity index 100% rename from pressa/lib/posts/metadata.rb rename to lib/posts/metadata.rb diff --git a/pressa/lib/posts/models.rb b/lib/posts/models.rb similarity index 100% rename from pressa/lib/posts/models.rb rename to lib/posts/models.rb diff --git a/pressa/lib/posts/plugin.rb b/lib/posts/plugin.rb similarity index 100% rename from pressa/lib/posts/plugin.rb rename to lib/posts/plugin.rb diff --git a/pressa/lib/posts/repo.rb b/lib/posts/repo.rb similarity index 100% rename from pressa/lib/posts/repo.rb rename to lib/posts/repo.rb diff --git a/pressa/lib/posts/rss_feed.rb b/lib/posts/rss_feed.rb similarity index 100% rename from pressa/lib/posts/rss_feed.rb rename to lib/posts/rss_feed.rb diff --git a/pressa/lib/posts/writer.rb b/lib/posts/writer.rb similarity index 100% rename from pressa/lib/posts/writer.rb rename to lib/posts/writer.rb diff --git a/pressa/lib/pressa.rb b/lib/pressa.rb similarity index 100% rename from pressa/lib/pressa.rb rename to lib/pressa.rb diff --git a/pressa/lib/projects/models.rb b/lib/projects/models.rb similarity index 100% rename from pressa/lib/projects/models.rb rename to lib/projects/models.rb diff --git a/pressa/lib/projects/plugin.rb b/lib/projects/plugin.rb similarity index 100% rename from pressa/lib/projects/plugin.rb rename to lib/projects/plugin.rb diff --git a/pressa/lib/site.rb b/lib/site.rb similarity index 100% rename from pressa/lib/site.rb rename to lib/site.rb diff --git a/pressa/lib/site_generator.rb b/lib/site_generator.rb similarity index 100% rename from pressa/lib/site_generator.rb rename to lib/site_generator.rb diff --git a/pressa/lib/utils/file_writer.rb b/lib/utils/file_writer.rb similarity index 100% rename from pressa/lib/utils/file_writer.rb rename to lib/utils/file_writer.rb diff --git a/pressa/lib/utils/frontmatter_converter.rb b/lib/utils/frontmatter_converter.rb similarity index 100% rename from pressa/lib/utils/frontmatter_converter.rb rename to lib/utils/frontmatter_converter.rb diff --git a/pressa/lib/utils/html_formatter.rb b/lib/utils/html_formatter.rb similarity index 100% rename from pressa/lib/utils/html_formatter.rb rename to lib/utils/html_formatter.rb diff --git a/pressa/lib/utils/markdown_renderer.rb b/lib/utils/markdown_renderer.rb similarity index 100% rename from pressa/lib/utils/markdown_renderer.rb rename to lib/utils/markdown_renderer.rb diff --git a/pressa/lib/views/archive_view.rb b/lib/views/archive_view.rb similarity index 100% rename from pressa/lib/views/archive_view.rb rename to lib/views/archive_view.rb diff --git a/pressa/lib/views/feed_post_view.rb b/lib/views/feed_post_view.rb similarity index 100% rename from pressa/lib/views/feed_post_view.rb rename to lib/views/feed_post_view.rb diff --git a/pressa/lib/views/icons.rb b/lib/views/icons.rb similarity index 100% rename from pressa/lib/views/icons.rb rename to lib/views/icons.rb diff --git a/pressa/lib/views/layout.rb b/lib/views/layout.rb similarity index 100% rename from pressa/lib/views/layout.rb rename to lib/views/layout.rb diff --git a/pressa/lib/views/month_posts_view.rb b/lib/views/month_posts_view.rb similarity index 100% rename from pressa/lib/views/month_posts_view.rb rename to lib/views/month_posts_view.rb diff --git a/pressa/lib/views/post_view.rb b/lib/views/post_view.rb similarity index 100% rename from pressa/lib/views/post_view.rb rename to lib/views/post_view.rb diff --git a/pressa/lib/views/project_view.rb b/lib/views/project_view.rb similarity index 100% rename from pressa/lib/views/project_view.rb rename to lib/views/project_view.rb diff --git a/pressa/lib/views/projects_view.rb b/lib/views/projects_view.rb similarity index 100% rename from pressa/lib/views/projects_view.rb rename to lib/views/projects_view.rb diff --git a/pressa/lib/views/recent_posts_view.rb b/lib/views/recent_posts_view.rb similarity index 100% rename from pressa/lib/views/recent_posts_view.rb rename to lib/views/recent_posts_view.rb diff --git a/pressa/lib/views/year_posts_view.rb b/lib/views/year_posts_view.rb similarity index 100% rename from pressa/lib/views/year_posts_view.rb rename to lib/views/year_posts_view.rb diff --git a/pressa/README.md b/pressa/README.md deleted file mode 100644 index 6363fdb..0000000 --- a/pressa/README.md +++ /dev/null @@ -1,191 +0,0 @@ -# Pressa - -A Ruby-based static site generator using Phlex for HTML generation. Built to replace the Swift-based generator for samhuri.net. - -## Features - -- **Plugin-based architecture** - Extensible system with PostsPlugin and ProjectsPlugin -- **Hierarchical post organization** - Posts organized by year/month -- **Markdown processing** - Kramdown with Rouge for syntax highlighting -- **Multiple output formats** - Individual posts, homepage, archives, year/month indexes -- **RSS & JSON feeds** - Both feeds with 30 most recent posts -- **Link posts** - Support for posts linking to external URLs -- **Phlex templates** - Type-safe HTML generation -- **dry-struct models** - Immutable data structures - -## Requirements - -- Ruby 3.4.1+ -- Bundler - -## Installation - -```bash -bundle install -``` - -## Usage - -### Build Commands - -```bash -# Development build (localhost:8000) -bundle exec bake debug - -# Start local server -bundle exec bake serve - -# Build for staging -bundle exec bake beta -bundle exec bake publish_beta # build + deploy - -# Build for production -bundle exec bake release -bundle exec bake publish # build + deploy -``` - -### Running Tests - -```bash -# Run all specs -bundle exec bake test - -# Run specs with Guard (auto-run on file changes) -bundle exec bake guard -``` - -### Linting - -```bash -# Check code style -bundle exec bake lint - -# Auto-fix style issues -bundle exec bake lint_fix -``` - -### CLI - -```bash -# Build site -bin/pressa SOURCE TARGET [URL] - -# Example -bin/pressa . www https://samhuri.net -``` - -### Migration Tools - -```bash -# Convert front-matter from custom format to YAML -bin/convert-frontmatter posts/**/*.md - -# Validate output comparison between Swift and Ruby -bin/validate-output www-swift www-ruby -``` - -See [SYNTAX_HIGHLIGHTING.md](SYNTAX_HIGHLIGHTING.md) for details on Rouge syntax highlighting. - -## Project Structure - -``` -pressa/ -├── lib/ -│ ├── pressa.rb # Main entry point -│ ├── site_generator.rb # Orchestrator -│ ├── site.rb # Site model (dry-struct) -│ ├── plugin.rb # Plugin base class -│ ├── posts/ # PostsPlugin -│ │ ├── plugin.rb -│ │ ├── repo.rb # Read/parse posts -│ │ ├── writer.rb # Write HTML -│ │ ├── metadata.rb # YAML front-matter parsing -│ │ ├── models.rb # Post, PostsByYear, etc. -│ │ ├── json_feed.rb -│ │ └── rss_feed.rb -│ ├── projects/ # ProjectsPlugin -│ │ ├── plugin.rb -│ │ └── models.rb -│ ├── views/ # Phlex templates -│ │ ├── layout.rb # Base layout -│ │ ├── post_view.rb -│ │ ├── recent_posts_view.rb -│ │ ├── archive_view.rb -│ │ └── ... -│ └── utils/ -│ ├── file_writer.rb -│ └── markdown_renderer.rb -├── bin/ -│ └── pressa # CLI executable -├── spec/ # RSpec tests -└── bake.rb # Build tasks -``` - -## Content Structure - -### Posts - -Posts must be in `/posts/YYYY/MM/` with YAML front-matter: - -```yaml ---- -Title: Post Title -Author: Author Name -Date: 11th November, 2025 -Timestamp: 2025-11-11T14:00:00-08:00 -Tags: Ruby, Phlex # Optional -Link: https://example.net # Optional (for link posts) -Scripts: highlight.js # Optional -Styles: code.css # Optional ---- - -Post content in Markdown... -``` - -### Output Structure - -``` -www/ -├── index.html # Recent posts (10 most recent) -├── posts/ -│ ├── index.html # Archive page -│ ├── YYYY/ -│ │ ├── index.html # Year index -│ │ └── MM/ -│ │ ├── index.html # Month rollup -│ │ └── slug/ -│ │ └── index.html # Individual post -├── projects/ -│ ├── index.html -│ └── project-name/ -│ └── index.html -├── feed.json # JSON Feed 1.1 -├── feed.xml # RSS 2.0 -└── [static files from public/] -``` - -## Tech Stack - -- **Ruby**: 3.4.1 -- **Phlex**: 2.3 - HTML generation -- **Kramdown**: 2.5 - Markdown parsing -- **kramdown-parser-gfm**: 1.1 - GitHub Flavored Markdown -- **Rouge**: 4.6 - Syntax highlighting -- **dry-struct**: 1.8 - Immutable data models -- **Builder**: 3.3 - XML/RSS generation -- **Bake**: 0.20+ - Task runner -- **RSpec**: 3.13 - Testing -- **StandardRB**: 1.43 - Code linting - -## Differences from Swift Version - -1. **Language**: Ruby 3.4 vs Swift 6.1 -2. **HTML generation**: Phlex vs Plot -3. **Markdown**: Kramdown+Rouge vs Ink -4. **Models**: dry-struct vs Swift structs -5. **Build system**: Bake vs Make -6. **Front-matter**: YAML vs custom format - -## License - -Personal project - not currently licensed for general use. diff --git a/pressa/SYNTAX_HIGHLIGHTING.md b/pressa/SYNTAX_HIGHLIGHTING.md deleted file mode 100644 index c0bf41c..0000000 --- a/pressa/SYNTAX_HIGHLIGHTING.md +++ /dev/null @@ -1,141 +0,0 @@ -# Syntax Highlighting with Rouge - -Pressa uses Rouge for syntax highlighting through Kramdown. Your posts should already work correctly with Rouge if they use standard Markdown code fences. - -## Supported Formats - -### GitHub Flavored Markdown (Recommended) - -````markdown -```ruby -def hello - puts "Hello, World!" -end -``` -```` - -### Kramdown Syntax - -````markdown -~~~ ruby -def hello - puts "Hello, World!" -end -~~~ -```` - -### With Line Numbers (if needed) - -You can enable line numbers in the Kramdown configuration, but by default Pressa has them disabled for cleaner output. - -## Supported Languages - -Rouge supports 200+ languages. Common ones include: - -- `ruby` -- `javascript` / `js` -- `python` / `py` -- `swift` -- `bash` / `shell` -- `html` -- `css` -- `sql` -- `yaml` / `yml` -- `json` -- `markdown` / `md` - -Full list: https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers - -## CSS Styling - -Rouge generates syntax highlighting by wrapping code elements with `` tags that have semantic class names. - -Example output: -```html -
- class Post - end -
-``` - -### CSS Classes - -Common classes used by Rouge: - -- `.k` - Keyword -- `.nc` - Class name -- `.nf` - Function name -- `.s`, `.s1`, `.s2` - Strings -- `.c`, `.c1` - Comments -- `.n` - Name/identifier -- `.o` - Operator -- `.p` - Punctuation - -### Generating CSS - -You can generate Rouge CSS themes with: - -```bash -# List available themes -bundle exec rougify help style - -# Generate CSS for a theme -bundle exec rougify style github > public/css/syntax.css -bundle exec rougify style monokai > public/css/syntax-dark.css -``` - -Popular themes: -- `github` - GitHub's light theme -- `monokai` - Dark theme -- `base16` - Base16 color scheme -- `thankful_eyes` - Easy on the eyes -- `tulip` - Colorful - -## Checking Your Posts - -Your existing posts should work fine if they use: -1. Standard Markdown code fences with language specifiers -2. HTML `
` blocks (will work but won't be highlighted)
-
-To check a specific post:
-
-```bash
-grep -A 5 '```' posts/2025/11/your-post.md
-```
-
-## Configuration in Pressa
-
-Syntax highlighting is configured in `lib/posts/repo.rb` and `lib/utils/markdown_renderer.rb`:
-
-```ruby
-Kramdown::Document.new(
-  markdown,
-  input: 'GFM',
-  syntax_highlighter: 'rouge',
-  syntax_highlighter_opts: {
-    line_numbers: false,  # Change to true if you want line numbers
-    wrap: true            # Leave true so Rouge emits 
 blocks
-  }
-).to_html
-```
-
-## Testing
-
-You can test syntax highlighting with the provided test post:
-
-```bash
-bundle exec bake debug
-bundle exec bake serve
-# Open http://localhost:8000 in browser
-```
-
-The test post at `test-site/posts/2025/11/test-post.md` includes a Ruby code example with syntax highlighting.
-
-## Migration Notes
-
-If you're migrating from Swift/Ink, both use similar Markdown parsers, so your code blocks should "just work." The main difference is:
-
-- **Ink**: Built-in syntax highlighting (uses its own system)
-- **Rouge**: External gem, more themes, more languages, generates semantic HTML
-
-Rouge output is more flexible because it generates plain HTML with classes, allowing you to change themes by just swapping CSS files.
diff --git a/pressa/bin/convert-all-posts b/pressa/bin/convert-all-posts
deleted file mode 100755
index 4d71a5b..0000000
--- a/pressa/bin/convert-all-posts
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/env ruby
-
-require_relative '../lib/utils/frontmatter_converter'
-
-source_dir = ARGV[0] || '../posts'
-dry_run = ARGV.include?('--dry-run')
-
-unless Dir.exist?(source_dir)
-  puts "ERROR: Directory not found: #{source_dir}"
-  exit 1
-end
-
-posts = Dir.glob(File.join(source_dir, '**', '*.md')).sort
-
-puts "Found #{posts.length} posts to convert"
-puts "Mode: #{dry_run ? 'DRY RUN (no changes will be made)' : 'CONVERTING FILES'}"
-puts ""
-
-converted = 0
-errors = []
-
-posts.each do |post_path|
-  relative_path = post_path.sub("#{source_dir}/", '')
-
-  begin
-    if dry_run
-      content = File.read(post_path)
-      Pressa::Utils::FrontmatterConverter.convert_content(content)
-      puts "✓ Would convert: #{relative_path}"
-    else
-      Pressa::Utils::FrontmatterConverter.convert_file(post_path)
-      puts "✓ Converted: #{relative_path}"
-    end
-    converted += 1
-  rescue => e
-    errors << {path: relative_path, error: e.message}
-    puts "✗ Error: #{relative_path}"
-    puts "  #{e.message}"
-  end
-end
-
-puts ""
-puts "="  * 60
-puts "CONVERSION SUMMARY"
-puts "=" * 60
-puts ""
-puts "Total posts: #{posts.length}"
-puts "Converted: #{converted}"
-puts "Errors: #{errors.length}"
-puts ""
-
-if errors.any?
-  puts "Posts with errors:"
-  errors.each do |err|
-    puts "  #{err[:path]}"
-    puts "    #{err[:error]}"
-  end
-  puts ""
-end
-
-if dry_run
-  puts "This was a dry run. Run without --dry-run to actually convert files."
-  puts ""
-end
-
-exit(errors.empty? ? 0 : 1)
diff --git a/pressa/bin/validate-output b/pressa/bin/validate-output
deleted file mode 100755
index 9debfdf..0000000
--- a/pressa/bin/validate-output
+++ /dev/null
@@ -1,285 +0,0 @@
-#!/usr/bin/env ruby
-
-require 'optparse'
-require 'fileutils'
-require 'digest'
-require 'json'
-require 'nokogiri'
-
-begin
-  require 'htmlbeautifier'
-rescue LoadError
-  # Optional dependency used only for nicer diffs.
-end
-
-class OutputValidator
-  def initialize(swift_dir:, ruby_dir:, verbose:, ignore_patterns:, show_details:, dump_dir:)
-    @swift_dir = swift_dir
-    @ruby_dir = ruby_dir
-    @differences = []
-    @missing_in_ruby = []
-    @missing_in_swift = []
-    @identical_count = 0
-    @verbose = verbose
-    @ignore_patterns = ignore_patterns
-    @show_details = show_details
-    @dump_dir = dump_dir
-    prepare_dump_dirs if @dump_dir
-  end
-
-  def validate
-    puts "Comparing outputs:"
-    puts "  Swift: #{@swift_dir}"
-    puts "  Ruby:  #{@ruby_dir}"
-    puts ""
-
-    swift_files = find_html_files(@swift_dir)
-    ruby_files = find_html_files(@ruby_dir)
-
-    puts "Found #{swift_files.length} Swift output files"
-    puts "Found #{ruby_files.length} Ruby output files"
-    puts ""
-
-    compare_files(swift_files, ruby_files)
-    print_summary
-  end
-
-  private
-
-  def find_html_files(dir)
-    Dir.glob(File.join(dir, '**', '*.{html,xml,json,css,js,png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,eot}'))
-       .map { |f| f.sub("#{dir}/", '') }
-       .sort
-  end
-
-  def compare_files(swift_files, ruby_files)
-    all_files = (swift_files + ruby_files).uniq.sort
-
-    all_files.each do |relative_path|
-      next if ignored?(relative_path)
-
-      swift_path = File.join(@swift_dir, relative_path)
-      ruby_path = File.join(@ruby_dir, relative_path)
-
-      if !File.exist?(swift_path)
-        @missing_in_swift << relative_path
-      elsif !File.exist?(ruby_path)
-        @missing_in_ruby << relative_path
-      else
-        compare_file_contents(relative_path, swift_path, ruby_path)
-      end
-    end
-  end
-
-  def compare_file_contents(relative_path, swift_path, ruby_path)
-    if binary_file?(relative_path)
-      swift_content = File.binread(swift_path)
-      ruby_content = File.binread(ruby_path)
-    else
-      swift_content = normalize_content(relative_path, File.read(swift_path))
-      ruby_content = normalize_content(relative_path, File.read(ruby_path))
-      dump_normalized(relative_path, swift_content, ruby_content) if @dump_dir
-    end
-
-    if swift_content == ruby_content
-      @identical_count += 1
-      puts "✓ #{relative_path}" if @verbose
-    else
-      @differences << {
-        path: relative_path,
-        swift_hash: Digest::SHA256.hexdigest(swift_content),
-        ruby_hash: Digest::SHA256.hexdigest(ruby_content),
-        swift_size: swift_content.length,
-        ruby_size: ruby_content.length
-      }
-      puts "✗ #{relative_path} (differs)"
-    end
-  end
-
-  def prepare_dump_dirs
-    FileUtils.rm_rf(@dump_dir)
-    FileUtils.mkdir_p(File.join(@dump_dir, 'swift'))
-    FileUtils.mkdir_p(File.join(@dump_dir, 'ruby'))
-  end
-
-  def dump_normalized(relative_path, swift_content, ruby_content)
-    write_normalized(File.join(@dump_dir, 'swift', relative_path), swift_content)
-    write_normalized(File.join(@dump_dir, 'ruby', relative_path), ruby_content)
-  end
-
-  def write_normalized(path, content)
-    FileUtils.mkdir_p(File.dirname(path))
-    File.write(path, content)
-  end
-
-  def ignored?(path)
-    return false if @ignore_patterns.empty?
-
-    @ignore_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
-  end
-
-  def binary_file?(path)
-    ext = File.extname(path).downcase
-    %w[.png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot].include?(ext)
-  end
-
-  def normalize_content(path, content)
-    normalized = case File.extname(path).downcase
-                 when '.html', '.htm'
-                   normalize_html_dom(content)
-                 when '.xml'
-                   normalize_xml_dom(content)
-                 when '.json'
-                   normalize_json(content)
-                 else
-                   normalize_text(content)
-                 end
-
-    strip_dynamic_values(normalized)
-  end
-
-  def normalize_html_dom(content)
-    doc = Nokogiri::HTML5(content)
-    html = doc.to_html
-    if defined?(HtmlBeautifier) && HtmlBeautifier.respond_to?(:beautify)
-      html = HtmlBeautifier.beautify(html)
-    end
-    html
-  rescue StandardError
-    normalize_text(content)
-  end
-
-  def normalize_xml_dom(content)
-    doc = Nokogiri::XML(content) { |cfg| cfg.noblanks }
-    doc.to_xml(indent: 2)
-  rescue StandardError
-    normalize_text(content)
-  end
-
-  def normalize_json(content)
-    JSON.pretty_generate(JSON.parse(content))
-  rescue StandardError
-    normalize_text(content)
-  end
-
-  def normalize_text(content)
-    content.gsub(/\s+/, ' ').gsub(/>\s+<').strip
-  end
-
-  def strip_dynamic_values(content)
-    content.gsub(/.*?<\/lastBuildDate>/, '')
-  end
-
-  # Legacy helper retained for backwards compatibility if needed elsewhere.
-  def html_save_options
-    Nokogiri::XML::Node::SaveOptions::AS_XHTML |
-      Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
-      Nokogiri::XML::Node::SaveOptions::FORMAT
-  end
-
-  def xml_save_options
-    Nokogiri::XML::Node::SaveOptions::AS_XML |
-      Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
-      Nokogiri::XML::Node::SaveOptions::FORMAT
-  end
-
-  def print_summary
-    puts ""
-    puts "=" * 60
-    puts "VALIDATION SUMMARY"
-    puts "=" * 60
-    puts ""
-    puts "Identical files: #{@identical_count}"
-    puts "Different files: #{@differences.length}"
-    puts "Missing in Ruby: #{@missing_in_ruby.length}"
-    puts "Missing in Swift: #{@missing_in_swift.length}"
-    puts ""
-
-    if @differences.any?
-      puts "Files with differences:"
-      return unless @show_details
-
-      @differences.each do |diff|
-        puts "  #{diff[:path]}"
-        puts "    Swift: #{diff[:swift_size]} bytes (#{diff[:swift_hash][0..7]}...)"
-        puts "    Ruby:  #{diff[:ruby_size]} bytes (#{diff[:ruby_hash][0..7]}...)"
-      end
-      puts ""
-    end
-
-    if @missing_in_ruby.any?
-      puts "Missing in Ruby output:"
-      @missing_in_ruby.each { |path| puts "  #{path}" }
-      puts ""
-    end
-
-    if @missing_in_swift.any?
-      puts "Missing in Swift output:"
-      @missing_in_swift.each { |path| puts "  #{path}" }
-      puts ""
-    end
-
-    success = @differences.empty? && @missing_in_ruby.empty? && @missing_in_swift.empty?
-    puts success ? "✅ VALIDATION PASSED" : "❌ VALIDATION FAILED"
-    puts ""
-
-    exit(success ? 0 : 1)
-  end
-end
-
-options = {
-  verbose: false,
-  ignore_patterns: [],
-  show_details: false,
-  dump_dir: nil
-}
-
-parser = OptionParser.new do |opts|
-  opts.banner = "Usage: validate-output [options] SWIFT_DIR RUBY_DIR"
-
-  opts.on('-v', '--verbose', 'Print a ✓ line for every identical file') do
-    options[:verbose] = true
-  end
-
-  opts.on('-iPATTERN', '--ignore=PATTERN', 'Ignore files matching the glob (may be repeated)') do |pattern|
-    options[:ignore_patterns] << pattern
-  end
-
-  opts.on('--details', 'Include byte counts and hashes for differing files') do
-    options[:show_details] = true
-  end
-
-  opts.on('--dump-normalized=DIR', 'Write normalized Swift/Ruby files to DIR/{swift,ruby}') do |dir|
-    options[:dump_dir] = dir
-  end
-end
-
-parser.parse!
-
-if ARGV.length != 2
-  puts parser
-  exit 1
-end
-
-swift_dir = ARGV[0]
-ruby_dir = ARGV[1]
-
-unless Dir.exist?(swift_dir)
-  puts "ERROR: Swift directory not found: #{swift_dir}"
-  exit 1
-end
-
-unless Dir.exist?(ruby_dir)
-  puts "ERROR: Ruby directory not found: #{ruby_dir}"
-  exit 1
-end
-
-validator = OutputValidator.new(
-  swift_dir: swift_dir,
-  ruby_dir: ruby_dir,
-  verbose: options[:verbose],
-  ignore_patterns: options[:ignore_patterns],
-  show_details: options[:show_details],
-  dump_dir: options[:dump_dir]
-)
-validator.validate
diff --git a/samhuri.net/.gitignore b/samhuri.net/.gitignore
deleted file mode 100644
index 51ef6c2..0000000
--- a/samhuri.net/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-.DS_Store
-/.build
-/Packages
-/*.xcodeproj
-/.swiftpm
-xcuserdata/
diff --git a/samhuri.net/Package.resolved b/samhuri.net/Package.resolved
deleted file mode 100644
index d8b316a..0000000
--- a/samhuri.net/Package.resolved
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-  "originHash" : "1912cd4185c680b826a0cb106effaca327bfc97ee755dd52a44214c989cc02cc",
-  "pins" : [
-    {
-      "identity" : "ink",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/johnsundell/ink.git",
-      "state" : {
-        "revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b",
-        "version" : "0.6.0"
-      }
-    },
-    {
-      "identity" : "plot",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/johnsundell/plot.git",
-      "state" : {
-        "revision" : "271926b4413fe868739d99f5eadcf2bd6cd62fb8",
-        "version" : "0.14.0"
-      }
-    }
-  ],
-  "version" : 3
-}
diff --git a/samhuri.net/Package.swift b/samhuri.net/Package.swift
deleted file mode 100644
index 3564705..0000000
--- a/samhuri.net/Package.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-// swift-tools-version:6.1
-// The swift-tools-version declares the minimum version of Swift required to build this package.
-
-import PackageDescription
-
-let package = Package(
-    name: "samhuri.net",
-    platforms: [
-        .macOS(.v14),
-        .iOS(.v17),
-    ],
-    products: [
-        // Products define the executables and libraries produced by a package, and make them visible to other packages.
-        .library(
-            name: "samhuri.net",
-            targets: ["samhuri.net"]),
-    ],
-    dependencies: [
-        .package(url: "https://github.com/johnsundell/ink.git", exact: "0.6.0"),
-        .package(url: "https://github.com/johnsundell/plot.git", exact: "0.14.0"),
-    ],
-    targets: [
-        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
-        // Targets can depend on other targets in this package, and on products in packages which this package depends on.
-        .target(
-            name: "samhuri.net",
-            dependencies: [
-                .product(name: "Ink", package: "ink"),
-                .product(name: "Plot", package: "plot"),
-            ]
-        ),
-        .testTarget(
-            name: "samhuri.netTests",
-            dependencies: ["samhuri.net"]),
-    ]
-)
diff --git a/samhuri.net/Readme.md b/samhuri.net/Readme.md
deleted file mode 100644
index 847e00a..0000000
--- a/samhuri.net/Readme.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# samhuri.net
-
-A static site generator for [samhuri.net](https://samhuri.net) using SiteGenerator.
-
-See https://github.com/samsonjs/samhuri.net for details.
diff --git a/samhuri.net/Sources/samhuri.net/Dates/Date+Sugar.swift b/samhuri.net/Sources/samhuri.net/Dates/Date+Sugar.swift
deleted file mode 100644
index 1483e3c..0000000
--- a/samhuri.net/Sources/samhuri.net/Dates/Date+Sugar.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-//  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!
-    }
-
-    var month: Int {
-        Calendar.current.dateComponents([.month], from: self).month!
-    }
-
-    var day: Int {
-        Calendar.current.dateComponents([.day], from: self).day!
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift b/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift
deleted file mode 100644
index e0aa098..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/DirectoryCreating.swift
+++ /dev/null
@@ -1,20 +0,0 @@
-//
-//  DirectoryCreating.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-protocol DirectoryCreating {
-    func createDirectory(at url: URL) throws
-}
-
-extension FileManager: DirectoryCreating {
-    func createDirectory(at url: URL) throws {
-        try createDirectory(at: url, withIntermediateDirectories: true, attributes: [
-            .posixPermissions: FilePermissions.directoryDefault.rawValue,
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift b/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift
deleted file mode 100644
index a5014f5..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/FileManager+DirectoryExistence.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-//
-//  FileManager+DirectoryExistence.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-
-extension FileManager {
-    func directoryExists(at fileURL: URL) -> Bool {
-        var isDir: ObjCBool = false
-        _ = fileExists(atPath: fileURL.path, isDirectory: &isDir)
-        return isDir.boolValue
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift b/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift
deleted file mode 100644
index 28bfed9..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/FilePermissions.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-//
-//  FilePermissions.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-struct FilePermissions: Equatable, CustomStringConvertible {
-    let user: Permissions
-    let group: Permissions
-    let other: Permissions
-
-    var description: String {
-        [user, group, other].map { $0.description }.joined()
-    }
-
-    static let fileDefault: FilePermissions = "rw-r--r--"
-    static let directoryDefault: FilePermissions = "rwxr-xr-x"
-}
-
-extension FilePermissions {
-    init?(string: String) {
-        guard let user = Permissions(string: String(string.prefix(3))),
-              let group = Permissions(string: String(string.dropFirst(3).prefix(3))),
-              let other = Permissions(string: String(string.dropFirst(6).prefix(3)))
-        else {
-            return nil
-        }
-
-        self.user = user
-        self.group = group
-        self.other = other
-    }
-}
-
-extension FilePermissions: RawRepresentable {
-    var rawValue: Int16 {
-        user.rawValue << 6 | group.rawValue << 3 | other.rawValue
-    }
-
-    init(rawValue: Int16) {
-        user = Permissions(rawValue: rawValue >> 6 & 0b111)
-        group = Permissions(rawValue: rawValue >> 3 & 0b111)
-        other = Permissions(rawValue: rawValue >> 0 & 0b111)
-    }
-}
-
-extension FilePermissions: ExpressibleByStringLiteral {
-    init(stringLiteral value: String) {
-        guard let permissions = FilePermissions(string: value) else {
-            fatalError("Invalid FilePermissions string literal: \(value)")
-        }
-        self = permissions
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift b/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift
deleted file mode 100644
index 8ddbbc2..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/FilePermissionsSetting.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-//  FilePermissionsSetting.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-protocol FilePermissionsSetting {
-    func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws
-}
-
-extension FileManager: FilePermissionsSetting {
-    func setPermissions(_ permissions: FilePermissions, ofItemAt fileURL: URL) throws {
-        let attributes: [FileAttributeKey: Any] = [
-            .posixPermissions: permissions.rawValue,
-        ]
-        try setAttributes(attributes, ofItemAtPath: fileURL.path)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift b/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift
deleted file mode 100644
index a6bc4f0..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/FileWriter.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-//
-//  FileWriter.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-/// On Linux umask doesn't seem to be respected and files are written without
-/// group and other read permissions by default. This class explicitly sets
-/// permissions and then it works properly on macOS and Linux.
-final class FileWriter {
-    typealias FileManager = DirectoryCreating & FilePermissionsSetting
-
-    let fileManager: FileManager
-
-    init(fileManager: FileManager = Foundation.FileManager.default) {
-        self.fileManager = fileManager
-    }
-}
-
-extension FileWriter: FileWriting {
-    func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws {
-        try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
-        try data.write(to: fileURL, options: .atomic)
-        try fileManager.setPermissions(permissions, ofItemAt: fileURL)
-    }
-
-    func write(string: String, to fileURL: URL, permissions: FilePermissions) throws {
-        try fileManager.createDirectory(at: fileURL.deletingLastPathComponent())
-        try string.write(to: fileURL, atomically: true, encoding: .utf8)
-        try fileManager.setPermissions(permissions, ofItemAt: fileURL)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift b/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift
deleted file mode 100644
index f941abf..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/FileWriting.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-//  FileWriting.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-protocol FileWriting {
-    func write(data: Data, to fileURL: URL) throws
-    func write(data: Data, to fileURL: URL, permissions: FilePermissions) throws
-
-    func write(string: String, to fileURL: URL) throws
-    func write(string: String, to fileURL: URL, permissions: FilePermissions) throws
-}
-
-extension FileWriting {
-    func write(data: Data, to fileURL: URL) throws {
-        try write(data: data, to: fileURL, permissions: .fileDefault)
-    }
-
-    func write(string: String, to fileURL: URL) throws {
-        try write(string: string, to: fileURL, permissions: .fileDefault)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Files/Permissions.swift b/samhuri.net/Sources/samhuri.net/Files/Permissions.swift
deleted file mode 100644
index 2375189..0000000
--- a/samhuri.net/Sources/samhuri.net/Files/Permissions.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-//
-//  Permissions.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-24.
-//
-
-import Foundation
-
-struct Permissions: OptionSet {
-    let rawValue: Int16
-
-    static let none: Permissions = []
-
-    // These raw values match those used by Unix file systems and must not be changed.
-
-    static let execute = Permissions(rawValue: 1 << 0)
-    static let write = Permissions(rawValue: 1 << 1)
-    static let read = Permissions(rawValue: 1 << 2)
-
-    init(rawValue: Int16) {
-        self.rawValue = rawValue
-    }
-
-    init?(string: String) {
-        guard string.count == 3 else {
-            return nil
-        }
-
-        self.init(rawValue: 0)
-
-        switch string[string.startIndex] {
-        case "r": insert(.read)
-        case "-": break
-        default: return nil
-        }
-
-        switch string[string.index(string.startIndex, offsetBy: 1)] {
-        case "w": insert(.write)
-        case "-": break
-        default: return nil
-        }
-
-        switch string[string.index(string.startIndex, offsetBy: 2)] {
-        case "x": insert(.execute)
-        case "-": break
-        default: return nil
-        }
-    }
-}
-
-extension Permissions: CustomStringConvertible {
-    var description: String {
-        [
-            contains(.read) ? "r" : "-",
-            contains(.write) ? "w" : "-",
-            contains(.execute) ? "x" : "-",
-        ].joined()
-    }
-}
-
-extension Permissions: ExpressibleByStringLiteral {
-    init(stringLiteral value: String) {
-        guard let _ = Permissions(string: value) else {
-            fatalError("Invalid Permissions string literal: \(value)")
-        }
-        self.init(string: value)!
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeed.swift b/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeed.swift
deleted file mode 100644
index 4f04169..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeed.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-//  JSONFeed.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-15.
-//
-
-import Foundation
-
-struct JSONFeed {
-    let path: String
-    let iconPath: String?
-    let faviconPath: String?
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeedWriter.swift b/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeedWriter.swift
deleted file mode 100644
index 93b58aa..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/JSONFeed/JSONFeedWriter.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-//
-//  JSONFeedWriter.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-10.
-//
-
-import Foundation
-
-protocol JSONFeedRendering {
-    func renderJSONFeedPost(_ post: Post, site: Site) throws -> String
-}
-
-final class JSONFeedWriter {
-    let fileWriter: FileWriting
-    let jsonFeed: JSONFeed
-
-    init(jsonFeed: JSONFeed, fileWriter: FileWriting = FileWriter()) {
-        self.jsonFeed = jsonFeed
-        self.fileWriter = fileWriter
-    }
-
-    func writeFeed(site: Site, posts: [Post], to targetURL: URL, with renderer: JSONFeedRendering) throws {
-        let feed = try buildFeed(site: site, posts: posts, renderer: renderer)
-        let encoder = JSONEncoder()
-        encoder.dateEncodingStrategy = .iso8601
-#if os(Linux)
-        encoder.outputFormatting = [.prettyPrinted]
-#else
-        encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
-#endif
-        let feedJSON = try encoder.encode(feed)
-        let feedURL = targetURL.appendingPathComponent(jsonFeed.path)
-        try fileWriter.write(data: feedJSON, to: feedURL)
-    }
-}
-
-private extension JSONFeedWriter {
-    func buildFeed(site: Site, posts: [Post], renderer: JSONFeedRendering) throws -> Feed {
-        let author = FeedAuthor(
-            name: site.author,
-            avatar: site.imageURL?.absoluteString,
-            url: site.url.absoluteString
-        )
-        return Feed(
-            title: site.title,
-            home_page_url: site.url.absoluteString,
-            feed_url: site.url.appendingPathComponent(jsonFeed.path).absoluteString,
-            author: author,
-            authors: [author],
-            icon: jsonFeed.iconPath.map(site.url.appendingPathComponent)?.absoluteString,
-            favicon: jsonFeed.faviconPath.map(site.url.appendingPathComponent)?.absoluteString,
-            items: try posts.map { post in
-                let url = site.url.appendingPathComponent(post.path)
-                return FeedItem(
-                    title: post.isLink ? "→ \(post.title)" : post.title,
-                    date_published: post.date,
-                    id: url.absoluteString,
-                    url: url.absoluteString,
-                    external_url: post.link?.absoluteString,
-                    author: FeedAuthor(name: post.author, avatar: nil, url: nil),
-                    content_html: try renderer.renderJSONFeedPost(post, site: site),
-                    tags: post.tags
-                )
-            }
-        )
-    }
-}
-
-private struct Feed: Codable {
-    var version = "https://jsonfeed.org/version/1.1"
-    var language = "en-CA"
-    let title: String
-    let home_page_url: String
-    let feed_url: String
-
-    // `author` has been deprecated in favour of `authors`, but `author` remains for backwards
-    // compatibility.
-    let author: FeedAuthor
-    let authors: [FeedAuthor]
-
-    let icon: String?
-    let favicon: String?
-    let items: [FeedItem]
-}
-
-private struct FeedAuthor: Codable {
-    let name: String
-    let avatar: String?
-    let url: String?
-}
-
-private struct FeedItem: Codable {
-    let title: String
-    let date_published: Date
-    let id: String
-    let url: String
-    let external_url: String?
-    let author: FeedAuthor
-    let content_html: String
-    let tags: [String]
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/HTMLRef.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/HTMLRef.swift
deleted file mode 100644
index c2340e5..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/HTMLRef.swift
+++ /dev/null
@@ -1,37 +0,0 @@
-//
-//  HTMLRef.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-02.
-//
-
-import Foundation
-
-protocol HTMLRef: ExpressibleByStringLiteral {
-    // Concrete requirements, must be implemented
-
-    var ref: String { get }
-
-    // These all have default implementations
-
-    init(ref: String)
-
-    func url(dir: URL) -> URL
-}
-
-extension HTMLRef {
-    init(stringLiteral value: String) {
-        self.init(ref: value)
-    }
-
-    func url(dir: URL) -> URL {
-        // ref is either an absolute HTTP URL or path relative to the given directory.
-        isHTTPURL ? URL(string: ref)! : dir.appendingPathComponent(ref)
-    }
-}
-
-private extension HTMLRef {
-    var isHTTPURL: Bool {
-        ref.hasPrefix("http:") || ref.hasPrefix("https:")
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift
deleted file mode 100644
index 917b199..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/Month.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-//
-//  Month.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-03.
-//
-
-import Foundation
-
-struct Month: Equatable {
-    static let all = names.map(Month.init(_:))
-
-    static let names = [
-        "January", "February", "March", "April",
-        "May", "June", "July", "August",
-        "September", "October", "November", "December"
-    ]
-
-    let number: Int
-
-    init?(_ number: Int) {
-        guard (1 ... Month.all.count).contains(number) else {
-            return nil
-        }
-        self.number = number
-    }
-
-    init?(_ name: String) {
-        guard let index = Month.names.firstIndex(of: name) else {
-            return nil
-        }
-        self.number = index + 1
-    }
-
-    init(_ date: Date) {
-        self.init(date.month)!
-    }
-
-    var padded: String {
-        String(format: "%02d", number)
-    }
-
-    var name: String {
-        Month.names[number - 1]
-    }
-
-    var abbreviation: String {
-        String(name.prefix(3))
-    }
-}
-
-extension Month: Hashable {
-    func hash(into hasher: inout Hasher) {
-        hasher.combine(number)
-    }
-}
-
-extension Month: Comparable {
-    static func <(lhs: Month, rhs: Month) -> Bool {
-        lhs.number < rhs.number
-    }
-}
-
-extension Month: ExpressibleByIntegerLiteral {
-    init(integerLiteral value: Int) {
-        guard let _ = Month(value) else {
-            fatalError("Invalid month number in string literal: \(value)")
-        }
-        self.init(value)!
-    }
-}
-
-extension Month: ExpressibleByStringLiteral {
-    init(stringLiteral value: String) {
-        guard let _ = Month(value) else {
-            fatalError("Invalid month name in string literal: \(value)")
-        }
-        self.init(value)!
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift
deleted file mode 100644
index 0be6509..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/MonthPosts.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-//
-//  MonthPosts.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-
-struct MonthPosts {
-    let month: Month
-    private(set) var posts: [Post]
-    let path: String
-
-    var title: String {
-        month.padded
-    }
-
-    var isEmpty: Bool {
-        posts.isEmpty
-    }
-
-    var year: Int {
-        posts[0].date.year
-    }
-
-    mutating func add(post: Post) {
-        posts.append(post)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/Post.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/Post.swift
deleted file mode 100644
index 271b042..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/Post.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-//
-//  Post.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-01.
-//
-
-import Foundation
-
-struct Post {
-    let slug: String
-    let title: String
-    let author: String
-    let date: Date
-    let formattedDate: String
-    let link: URL?
-    let tags: [String]
-    let scripts: [Script]
-    let styles: [Stylesheet]
-    let body: String
-    let excerpt: String
-    let path: String
-
-    var isLink: Bool {
-        link != nil
-    }
-
-    var templateAssets: TemplateAssets {
-        TemplateAssets(scripts: scripts, styles: styles)
-    }
-}
-
-extension Post: Comparable {
-    static func <(lhs: Self, rhs: Self) -> Bool {
-        lhs.date < rhs.date
-    }
-}
-
-extension Post: CustomDebugStringConvertible {
-    var debugDescription: String {
-        ""
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift
deleted file mode 100644
index 39c8da7..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/PostsByYear.swift
+++ /dev/null
@@ -1,46 +0,0 @@
-//
-//  Posts.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-03.
-//
-
-import Foundation
-
-struct PostsByYear {
-    private(set) var byYear: [Int: YearPosts]
-    let path: String
-
-    init(posts: [Post], path: String) {
-        byYear = [:]
-        self.path = path
-        posts.forEach { add(post: $0) }
-    }
-
-    var isEmpty: Bool {
-        byYear.isEmpty || byYear.values.allSatisfy { $0.isEmpty }
-    }
-
-    var years: [Int] {
-        Array(byYear.keys)
-    }
-
-    subscript(year: Int) -> YearPosts {
-        get {
-            byYear[year, default: YearPosts(year: year, byMonth: [:], path: "\(path)/\(year)")]
-        }
-        set {
-            byYear[year] = newValue
-        }
-    }
-
-    mutating func add(post: Post) {
-        let (year, month) = (post.date.year, Month(post.date))
-        self[year].add(post: post, to: month)
-    }
-
-    /// Returns an array of all posts.
-    func flattened() -> [Post] {
-        byYear.values.flatMap { $0.flattened() }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/Script.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/Script.swift
deleted file mode 100644
index 6083f20..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/Script.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-//
-//  Script.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-02.
-//
-
-import Foundation
-
-struct Script: HTMLRef, Equatable {
-    let ref: String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/Stylesheet.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/Stylesheet.swift
deleted file mode 100644
index a0e91ba..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/Stylesheet.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-//
-//  Stylesheet.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-02.
-//
-
-import Foundation
-
-struct Stylesheet: HTMLRef, Equatable {
-    let ref: String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift b/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift
deleted file mode 100644
index 2ae89d5..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Model/YearPosts.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-//
-//  YearPosts.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-
-struct YearPosts {
-    let year: Int
-    var byMonth: [Month: MonthPosts]
-    let path: String
-
-    var title: String {
-        "\(year)"
-    }
-
-    var isEmpty: Bool {
-        byMonth.isEmpty || byMonth.values.allSatisfy { $0.isEmpty }
-    }
-
-    var months: [Month] {
-        Array(byMonth.keys)
-    }
-
-    subscript(month: Month) -> MonthPosts {
-        get {
-            byMonth[month, default: MonthPosts(month: month, posts: [], path: "\(path)/\(month.padded)")]
-        }
-        set {
-            byMonth[month] = newValue
-        }
-    }
-
-    mutating func add(post: Post, to month: Month) {
-        self[month].add(post: post)
-    }
-
-    /// Returns an array of all posts.
-    func flattened() -> [Post] {
-        byMonth.values.flatMap { $0.posts }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift b/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift
deleted file mode 100644
index 70028f8..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostMetadata.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-//
-//  PostMetadata.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-
-struct PostMetadata {
-    let title: String
-    let author: String
-    let date: Date
-    let formattedDate: String
-    let link: URL?
-    let tags: [String]
-    let scripts: [Script]
-    let styles: [Stylesheet]
-}
-
-extension PostMetadata {
-    enum Error: Swift.Error {
-        case deficientMetadata(slug: String, missingKeys: [String], metadata: [String: String])
-        case invalidTimestamp(String?)
-    }
-
-    nonisolated(unsafe) private static let iso8601Formatter = ISO8601DateFormatter()
-
-    init(dictionary: [String: String], slug: String) throws {
-        let requiredKeys = ["Title", "Author", "Date", "Timestamp"]
-        let missingKeys = requiredKeys.filter { dictionary[$0] == nil }
-        guard missingKeys.isEmpty else {
-            throw Error.deficientMetadata(slug: slug, missingKeys: missingKeys, metadata: dictionary)
-        }
-        guard let timestamp = dictionary["Timestamp"],
-              let date = Self.iso8601Formatter.date(from: timestamp)
-        else {
-            throw Error.invalidTimestamp(dictionary["Timestamp"])
-        }
-
-        self.init(
-            title: dictionary["Title"]!,
-            author: dictionary["Author"]!,
-            date: date,
-            formattedDate: dictionary["Date"]!,
-            link: dictionary["Link"].flatMap { URL(string: $0) },
-            tags: dictionary.commaSeparatedList(key: "Tags"),
-            scripts: dictionary.commaSeparatedList(key: "Scripts").map(Script.init(ref:)),
-            styles: dictionary.commaSeparatedList(key: "Styles").map(Stylesheet.init(ref:))
-        )
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift b/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift
deleted file mode 100644
index e9a167e..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostRepo.swift
+++ /dev/null
@@ -1,148 +0,0 @@
-//
-//  PostRepo.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-09.
-//
-
-import Foundation
-import Ink
-
-struct RawPost {
-    let slug: String
-    let markdown: String
-
-    private nonisolated(unsafe) static let StripMetadataRegex = try! Regex(#"---\n.*?---\n"#).dotMatchesNewlines()
-
-    private nonisolated(unsafe) static let TextifyParenthesesLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\([^)]+\)"#)
-
-    private nonisolated(unsafe) static let TextifyBracketLinksRegex = try! Regex(#"\[([\w\s.-_]*)\]\[[^\]]+\]"#)
-
-    private nonisolated(unsafe) static let StripImagesRegex = try! Regex(#"!\[[\w\s.-_]*\]\([^)]+\)"#)
-
-    private nonisolated(unsafe) static let WhitespaceRegex = try! Regex(#"\s+"#)
-
-    private nonisolated(unsafe) 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 {
-    let postsPath = "posts"
-    let recentPostsCount = 10
-    let feedPostsCount = 30
-
-    let fileManager: FileManager
-    let markdownParser: MarkdownParser
-
-    private(set) var posts: PostsByYear!
-
-    init(fileManager: FileManager = .default, markdownParser: MarkdownParser = MarkdownParser()) {
-        self.fileManager = fileManager
-        self.markdownParser = markdownParser
-    }
-
-    var isEmpty: Bool {
-        posts == nil || posts.isEmpty
-    }
-
-    var sortedPosts: [Post] {
-        posts.flattened().sorted(by: >)
-    }
-
-    var recentPosts: [Post] {
-        Array(sortedPosts.prefix(recentPostsCount))
-    }
-
-    var postsForFeed: [Post] {
-        Array(sortedPosts.prefix(feedPostsCount))
-    }
-
-    func postDataExists(at sourceURL: URL) -> Bool {
-        let postsURL = sourceURL.appendingPathComponent(postsPath)
-        return fileManager.fileExists(atPath: postsURL.path)
-    }
-
-    func readPosts(sourceURL: URL, outputPath: String) throws {
-        let posts = try readRawPosts(sourceURL: sourceURL)
-            .map { try makePost(from: $0, outputPath: outputPath) }
-        self.posts = PostsByYear(posts: posts, path: "/\(outputPath)")
-    }
-}
-
-private extension PostRepo {
-    func makePost(from rawPost: RawPost, outputPath: String) throws -> Post {
-        let result = markdownParser.parse(rawPost.markdown)
-        let metadata = try PostMetadata(dictionary: result.metadata, slug: rawPost.slug)
-        let path = pathForPost(root: outputPath, date: metadata.date, slug: rawPost.slug)
-        return Post(
-            slug: rawPost.slug,
-            title: metadata.title,
-            author: metadata.author,
-            date: metadata.date,
-            formattedDate: metadata.formattedDate,
-            link: metadata.link,
-            tags: metadata.tags,
-            scripts: metadata.scripts,
-            styles: metadata.styles,
-            body: result.html,
-            excerpt: rawPost.excerpt,
-            path: path
-        )
-    }
-
-    func pathForPost(root: String, date: Date, slug: String) -> String {
-        // format: /{root}/{year}/{month}/{slug}
-        //    e.g. /posts/2019/12/first-post
-        [
-            "", // leading slash
-            root,
-            "\(date.year)",
-            Month(date).padded,
-            slug,
-        ].joined(separator: "/")
-    }
-
-    func readRawPosts(sourceURL: URL) throws -> [RawPost] {
-        let postsURL = sourceURL.appendingPathComponent(postsPath)
-        return try enumerateMarkdownFiles(directory: postsURL)
-            .compactMap { url in
-                do {
-                    return try readRawPost(url: url)
-                }
-                catch {
-                    print("error: Cannot read post from \(url): \(error)")
-                    return nil
-                }
-            }
-    }
-
-    func readRawPost(url: URL) throws -> RawPost {
-        let slug = url.deletingPathExtension().lastPathComponent
-        let markdown = try String(contentsOf: url)
-        return RawPost(slug: slug, markdown: markdown)
-    }
-
-    func enumerateMarkdownFiles(directory: URL) throws -> [URL] {
-        return try fileManager.contentsOfDirectory(atPath: directory.path).flatMap { (name: String) -> [URL] in
-            let url = directory.appendingPathComponent(name)
-            if fileManager.directoryExists(at: url) {
-                return try enumerateMarkdownFiles(directory: url)
-            }
-            else {
-                return url.pathExtension == "md" ? [url] : []
-            }
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift b/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift
deleted file mode 100644
index 377fceb..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostWriter.swift
+++ /dev/null
@@ -1,86 +0,0 @@
-//
-//  PostWriter.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-09.
-//
-
-import Foundation
-
-final class PostWriter {
-    let fileWriter: FileWriting
-    let outputPath: String
-
-    init(outputPath: String = "posts", fileWriter: FileWriting = FileWriter()) {
-        self.fileWriter = fileWriter
-        self.outputPath = outputPath
-    }
-}
-
-// MARK: - Post pages
-
-extension PostWriter {
-    func writePosts(_ posts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
-        for post in posts {
-            let path = [
-                outputPath,
-                postPath(date: post.date, slug: post.slug),
-            ].joined(separator: "/")
-            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 postPath(date: Date, slug: String) -> String {
-        "\(date.year)/\(Month(date).padded)/\(slug)"
-    }
-}
-
-// MARK: - Recent posts page
-
-extension PostWriter {
-    func writeRecentPosts(_ recentPosts: [Post], for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
-        let recentPostsHTML = try renderer.renderRecentPosts(recentPosts, site: site, path: "/")
-        let fileURL = targetURL.appendingPathComponent("index.html")
-        try fileWriter.write(string: recentPostsHTML, to: fileURL)
-    }
-}
-
-// MARK: - Post archive page
-
-extension PostWriter {
-    func writeArchive(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
-        let archiveHTML = try renderer.renderArchive(postsByYear: posts, site: site, path: outputPath)
-        let archiveURL = targetURL.appendingPathComponent(outputPath).appendingPathComponent("index.html")
-        try fileWriter.write(string: archiveHTML, to: archiveURL)
-    }
-}
-
-// MARK: - Yearly post index pages
-
-extension PostWriter {
-    func writeYearIndexes(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
-        for yearPosts in posts.byYear.values {
-            let yearDir = targetURL.appendingPathComponent(yearPosts.path)
-            let yearHTML = try renderer.renderYearPosts(yearPosts, site: site, path: yearPosts.path)
-            let yearURL = yearDir.appendingPathComponent("index.html")
-            try fileWriter.write(string: yearHTML, to: yearURL)
-        }
-    }
-}
-
-// MARK: - Monthly post roll-up pages
-
-extension PostWriter {
-    func writeMonthRollups(posts: PostsByYear, for site: Site, to targetURL: URL, with renderer: PostsRendering) throws {
-        for yearPosts in posts.byYear.values {
-            for monthPosts in yearPosts.byMonth.values {
-                let monthDir = targetURL.appendingPathComponent(monthPosts.path)
-                let monthHTML = try renderer.renderMonthPosts(monthPosts, site: site, path: monthPosts.path)
-                let monthURL = monthDir.appendingPathComponent("index.html")
-                try fileWriter.write(string: monthHTML, to: monthURL)
-            }
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift
deleted file mode 100644
index 2e4b1b4..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin+Builder.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-//
-//  PostsPlugin+Builder.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-15.
-//
-
-import Foundation
-
-extension PostsPlugin {
-    final class Builder {
-        private let renderer: Renderer
-        private var path: String?
-        private var jsonFeed: JSONFeed?
-        private var rssFeed: RSSFeed?
-
-        init(renderer: Renderer) {
-            self.renderer = renderer
-        }
-
-        func path(_ path: String) -> Self {
-            precondition(self.path == nil, "path is already defined")
-            self.path = path
-            return self
-        }
-
-        func jsonFeed(
-            path: String? = nil,
-            iconPath: String? = nil,
-            faviconPath: String? = nil
-        ) -> Self {
-            precondition(jsonFeed == nil, "JSON feed is already defined")
-            jsonFeed = JSONFeed(
-                path: path ?? "feed.json",
-                iconPath: iconPath,
-                faviconPath: faviconPath
-            )
-            return self
-        }
-
-        func rssFeed(path: String? = nil) -> Self {
-            precondition(rssFeed == nil, "RSS feed is already defined")
-            rssFeed = RSSFeed(path: path ?? "feed.xml")
-            return self
-        }
-
-        func build() -> PostsPlugin {
-            let postWriter: PostWriter
-            if let outputPath = path {
-                postWriter = PostWriter(outputPath: outputPath)
-            }
-            else {
-                postWriter = PostWriter()
-            }
-
-            let jsonFeedWriter: JSONFeedWriter?
-            if let jsonFeed = jsonFeed {
-                jsonFeedWriter = JSONFeedWriter(jsonFeed: jsonFeed)
-            }
-            else {
-                jsonFeedWriter = nil
-            }
-
-            let rssFeedWriter: RSSFeedWriter?
-            if let rssFeed = rssFeed {
-                rssFeedWriter = RSSFeedWriter(rssFeed: rssFeed)
-            }
-            else {
-                rssFeedWriter = nil
-            }
-
-            return PostsPlugin(
-                renderer: renderer,
-                postRepo: PostRepo(),
-                postWriter: postWriter,
-                jsonFeedWriter: jsonFeedWriter,
-                rssFeedWriter: rssFeedWriter
-            )
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift b/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift
deleted file mode 100644
index 9da4b83..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostsPlugin.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-//
-//  PostsPlugin.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-03.
-//
-
-import Foundation
-
-final class PostsPlugin: Plugin {
-    typealias Renderer = PostsRendering & JSONFeedRendering & RSSFeedRendering
-
-    let renderer: Renderer
-    let postRepo: PostRepo
-    let postWriter: PostWriter
-    let jsonFeedWriter: JSONFeedWriter?
-    let rssFeedWriter: RSSFeedWriter?
-
-    init(
-        renderer: Renderer,
-        postRepo: PostRepo = PostRepo(),
-        postWriter: PostWriter = PostWriter(),
-        jsonFeedWriter: JSONFeedWriter?,
-        rssFeedWriter: RSSFeedWriter?
-    ) {
-        self.renderer = renderer
-        self.postRepo = postRepo
-        self.postWriter = postWriter
-        self.jsonFeedWriter = jsonFeedWriter
-        self.rssFeedWriter = rssFeedWriter
-    }
-
-    // MARK: - Plugin methods
-
-    func setUp(site: Site, sourceURL: URL) throws {
-        guard postRepo.postDataExists(at: sourceURL) else {
-            return
-        }
-
-        try postRepo.readPosts(sourceURL: sourceURL, outputPath: postWriter.outputPath)
-    }
-
-    func render(site: Site, targetURL: URL) throws {
-        guard !postRepo.isEmpty else {
-            return
-        }
-
-        try postWriter.writeRecentPosts(postRepo.recentPosts, for: site, to: targetURL, with: renderer)
-        try postWriter.writePosts(postRepo.sortedPosts, for: site, to: targetURL, with: renderer)
-        try postWriter.writeArchive(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
-        try postWriter.writeYearIndexes(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
-        try postWriter.writeMonthRollups(posts: postRepo.posts, for: site, to: targetURL, with: renderer)
-        try jsonFeedWriter?.writeFeed(site: site, posts: postRepo.postsForFeed, to: targetURL, with: renderer)
-        try rssFeedWriter?.writeFeed(site: site, posts: postRepo.postsForFeed, to: targetURL, with: renderer)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/PostsRendering.swift b/samhuri.net/Sources/samhuri.net/Posts/PostsRendering.swift
deleted file mode 100644
index 28f02ad..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/PostsRendering.swift
+++ /dev/null
@@ -1,20 +0,0 @@
-//
-//  PostsRendering.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-17.
-//
-
-import Foundation
-
-protocol PostsRendering {
-    func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String
-
-    func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String
-
-    func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String
-
-    func renderPost(_ post: Post, site: Site, path: String) throws -> String
-
-    func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeed.swift b/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeed.swift
deleted file mode 100644
index b41b928..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeed.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-//
-//  RSSFeed.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-15.
-//
-
-import Foundation
-
-struct RSSFeed {
-    let path: String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeedWriter.swift b/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeedWriter.swift
deleted file mode 100644
index 8c40d58..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/RSSFeed/RSSFeedWriter.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-//  RSSFeedWriter.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-10.
-//
-
-import Foundation
-
-protocol RSSFeedRendering {
-    func renderRSSFeed(posts: [Post], feedURL: URL, site: Site) throws -> String
-}
-
-final class RSSFeedWriter {
-    let fileWriter: FileWriting
-    let rssFeed: RSSFeed
-
-    init(rssFeed: RSSFeed, fileWriter: FileWriting = FileWriter()) {
-        self.rssFeed = rssFeed
-        self.fileWriter = fileWriter
-    }
-
-    func writeFeed(site: Site, posts: [Post], to targetURL: URL, with renderer: RSSFeedRendering) throws {
-        let feedURL = site.url.appendingPathComponent(rssFeed.path)
-        let feedXML = try renderer.renderRSSFeed(posts: posts, feedURL: feedURL, site: site)
-        let feedFileURL = targetURL.appendingPathComponent(rssFeed.path)
-        try fileWriter.write(string: feedXML, to: feedFileURL)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/FeedPostTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/FeedPostTemplate.swift
deleted file mode 100644
index 7ec54ae..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/FeedPostTemplate.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-//
-//  FeedPostTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-private extension Node where Context == HTML.BodyContext {
-    static func link(_ attributes: Attribute...) -> Self {
-        .element(named: "link", attributes: attributes)
-    }
-}
-
-extension Node where Context == HTML.BodyContext {
-    static func feedPost(_ post: Post, url: URL, styles: [URL]) -> Self {
-        .group([
-            .group(styles.map { style in
-                .link(.rel(.stylesheet), .href(style), .type("text/css"))
-            }),
-            .div(
-                .p(.class("time"), .text(post.formattedDate)),
-                .raw(post.body),
-                .p(.a(.class("permalink"), .href(url), "∞"))
-            ),
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/MonthPostsTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/MonthPostsTemplate.swift
deleted file mode 100644
index d04f7c0..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/MonthPostsTemplate.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-//  MonthPostsTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func monthPosts(_ posts: MonthPosts) -> Self {
-        .group([
-            .div(.class("container"),
-                 .h1("\(posts.month.name) \(posts.year)")
-            ),
-            .group(posts.posts.sorted(by: >).map { post in
-                .div(.class("container"), self.post(post))
-            })
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+JSONFeed.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+JSONFeed.swift
deleted file mode 100644
index 149e7cd..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+JSONFeed.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-//  PageRenderer+JSONFeed.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-import Plot
-
-extension PageRenderer: JSONFeedRendering {
-    func renderJSONFeedPost(_ post: Post, site: Site) throws -> String {
-        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.
-        return Node.feedPost(post, url: url, styles: context.styles)
-            .render(indentedBy: .spaces(2))
-            .replacingOccurrences(of: "href=\"/", with: "href=\"\(site.url)/")
-            .replacingOccurrences(of: "src=\"/", with: "src=\"\(site.url)/")
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+Posts.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+Posts.swift
deleted file mode 100644
index 3a1bd3b..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+Posts.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-//
-//  PageRenderer+Posts.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-extension PageRenderer: PostsRendering {
-    func renderArchive(postsByYear: PostsByYear, site: Site, path: String) throws -> String {
-        let context = SiteContext(
-            site: site,
-            canonicalURL: site.url.appending(path: path),
-            subtitle: "Archive",
-            description: "Archive of all posts"
-        )
-        return render(.archive(postsByYear), context: context)
-    }
-
-    func renderYearPosts(_ yearPosts: YearPosts, site: Site, path: String) throws -> String {
-        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)
-    }
-
-    func renderMonthPosts(_ posts: MonthPosts, site: Site, path: String) throws -> String {
-        let subtitle = "\(posts.month.name) \(posts.year)"
-        let assets = posts.posts.templateAssets
-        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)
-    }
-
-    func renderPost(_ post: Post, site: Site, path: String) throws -> String {
-        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)
-    }
-
-    func renderRecentPosts(_ posts: [Post], site: Site, path: String) throws -> String {
-        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)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift
deleted file mode 100644
index 538b521..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PageRenderer+RSSFeed.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-//
-//  PageRenderer+RSSFeed.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2020-01-01.
-//
-
-import Foundation
-import Plot
-
-extension PageRenderer: RSSFeedRendering {
-    func renderRSSFeed(posts: [Post], feedURL: URL, site: Site) throws -> String {
-        try RSS(
-            .title(site.title),
-            .description(site.description),
-            .link(site.url),
-            .pubDate(posts[0].date),
-            .atomLink(feedURL),
-            .group(posts.map { post in
-                let url = site.url.appendingPathComponent(post.path)
-                return .item(
-                    .title(post.isLink ? "→ \(post.title)" : post.title),
-                    .pubDate(post.date),
-                    .element(named: "author", text: post.author),
-                    .link(url),
-                    .guid(.text(url.absoluteString), .isPermaLink(true)),
-                    .content(try renderJSONFeedPost(post, site: site))
-                )
-            })
-        ).render(indentedBy: .spaces(2))
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PostTemplate.swift
deleted file mode 100644
index 973fa43..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostTemplate.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-//
-//  PostTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func post(_ post: Post, articleClass: String = "") -> Self {
-        .group([
-            .article(.class(articleClass),
-                .header(
-                    .h2(postTitleLink(post)),
-                    .time(.text(post.formattedDate)),
-                    .a(.class("permalink"), .href(post.path), "∞")
-                ),
-                .raw(post.body)
-            ),
-            .div(.class("row clearfix"),
-                .p(.class("fin"), Icons.code())
-            )
-        ])
-    }
-
-    static func postTitleLink(_ post: Post) -> Self {
-        .if(post.isLink,
-            .a(.href(post.link?.absoluteString ?? post.path), "→ \(post.title)"),
-            else: .a(.href(post.path), .text(post.title))
-        )
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsArchiveTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsArchiveTemplate.swift
deleted file mode 100644
index bbbe2c8..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsArchiveTemplate.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-//  ArchiveTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-21.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func archive(_ postsByYear: PostsByYear) -> Self {
-        .group([
-            .div(.class("container"),
-                 .h1("Archive")
-            ),
-            .group(postsByYear.years.sorted(by: >).map { year in
-                .yearPosts(postsByYear[year])
-            }),
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsAssets.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsAssets.swift
deleted file mode 100644
index 37f7555..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/PostsAssets.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-//  PostsAssets.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-31.
-//
-
-import Foundation
-
-extension Collection where Element == Post {
-    var templateAssets: TemplateAssets {
-        reduce(into: TemplateAssets.empty()) { assets, post in
-            assets.scripts.append(contentsOf: post.scripts)
-            assets.styles.append(contentsOf: post.styles)
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/RecentPostsTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/RecentPostsTemplate.swift
deleted file mode 100644
index 58e791d..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/RecentPostsTemplate.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-//  RecentPostsTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func recentPosts(_ posts: [Post]) -> Self {
-        .div(.class("container"),
-             .group(posts.map { self.post($0) })
-        )
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Posts/Templates/YearPostsTemplate.swift b/samhuri.net/Sources/samhuri.net/Posts/Templates/YearPostsTemplate.swift
deleted file mode 100644
index 449f468..0000000
--- a/samhuri.net/Sources/samhuri.net/Posts/Templates/YearPostsTemplate.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-//
-//  YearPostsTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-21.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func yearPosts(_ posts: YearPosts) -> Self {
-        .div(.class("container"),
-            .h2(.class("year"),
-                .a(.href(posts.path), .text(posts.title))
-            ),
-
-            .group(posts.months.sorted(by: >).map { month in
-                .monthTitles(posts[month])
-            })
-        )
-    }
-
-    static func monthTitles(_ posts: MonthPosts) -> Self {
-        .group([
-            .h3(.class("month"),
-                .a(.href(posts.path), "\(posts.month.name)")
-            ),
-            .ul(.class("archive"),
-                .group(posts.posts.sorted(by: >).map { post in
-                    .postItem(post, date: "\(post.date.day) \(posts.month.abbreviation)")
-                })
-            ),
-        ])
-    }
-}
-
-extension Node where Context == HTML.ListContext {
-    static func postItem(_ post: Post, date: Node) -> Self {
-        .if(post.isLink, .li(
-                .a(.href(post.link?.absoluteString ?? post.path), "→ \(post.title)"),
-                .time(date),
-                .a(.class("permalink"), .href(post.path), "∞")
-            ),
-            else: .li(
-                .a(.href(post.path), .text(post.title)),
-                .time(date)
-            )
-        )
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/Project.swift b/samhuri.net/Sources/samhuri.net/Projects/Project.swift
deleted file mode 100644
index 09d67a6..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/Project.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-//  Project.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-02.
-//
-
-import Foundation
-
-struct Project {
-    let title: String
-    let description: String
-    let url: URL
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin+Builder.swift b/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin+Builder.swift
deleted file mode 100644
index 6a8dc4f..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin+Builder.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-//
-//  ProjectsPlugin+Builder.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-
-extension ProjectsPlugin {
-    final class Builder {
-        let renderer: ProjectsRenderer
-        private var path: String?
-        private var projects: [PartialProject] = []
-        private var assets: TemplateAssets?
-
-        init(renderer: ProjectsRenderer) {
-            self.renderer = renderer
-        }
-
-        func path(_ path: String) -> Self {
-            precondition(self.path == nil, "path is already defined")
-            self.path = path
-            return self
-        }
-
-        func assets(_ assets: TemplateAssets) -> Self {
-            precondition(self.assets == nil, "assets are already defined")
-            self.assets = assets
-            return self
-        }
-
-        func add(_ title: String, description: String) -> Self {
-            let project = PartialProject(title: title, description: description)
-            projects.append(project)
-            return self
-        }
-
-        func build() -> ProjectsPlugin {
-            if projects.isEmpty {
-                print("WARNING: No projects have been added")
-            }
-            return ProjectsPlugin(
-                projects: projects,
-                renderer: renderer,
-                projectAssets: assets ?? .empty(),
-                outputPath: path
-            )
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift b/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift
deleted file mode 100644
index c4df27a..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/ProjectsPlugin.swift
+++ /dev/null
@@ -1,67 +0,0 @@
-//
-//  ProjectsPlugin.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-02.
-//
-
-import Foundation
-
-struct PartialProject {
-    let title: String
-    let description: String
-}
-
-final class ProjectsPlugin: Plugin {
-    let fileWriter: FileWriting
-    let outputPath: String
-    let partialProjects: [PartialProject]
-    let renderer: ProjectsRenderer
-    let projectAssets: TemplateAssets
-
-    var projects: [Project] = []
-
-    init(
-        projects: [PartialProject],
-        renderer: ProjectsRenderer,
-        projectAssets: TemplateAssets,
-        outputPath: String? = nil,
-        fileWriter: FileWriting = FileWriter()
-    ) {
-        self.partialProjects = projects
-        self.renderer = renderer
-        self.projectAssets = projectAssets
-        self.outputPath = outputPath ?? "projects"
-        self.fileWriter = fileWriter
-    }
-
-    // MARK: - Plugin methods
-
-    func setUp(site: Site, sourceURL: URL) throws {
-        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 {
-        guard !projects.isEmpty else {
-            return
-        }
-
-        let projectsDir = targetURL.appendingPathComponent(outputPath)
-        let projectsURL = projectsDir.appendingPathComponent("index.html")
-        let projectsHTML = try renderer.renderProjects(projects, site: site, path: outputPath)
-        try fileWriter.write(string: projectsHTML, to: projectsURL)
-
-        for project in projects {
-            let projectURL = projectsDir.appendingPathComponent("\(project.title)/index.html")
-            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)
-        }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/ProjectsRenderer.swift b/samhuri.net/Sources/samhuri.net/Projects/ProjectsRenderer.swift
deleted file mode 100644
index f06c793..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/ProjectsRenderer.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-//  ProjectsTemplateRenderer.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-17.
-//
-
-import Foundation
-
-protocol ProjectsRenderer {
-    func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String
-
-    func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/Templates/PageRenderer+Projects.swift b/samhuri.net/Sources/samhuri.net/Projects/Templates/PageRenderer+Projects.swift
deleted file mode 100644
index 485a109..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/Templates/PageRenderer+Projects.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-//  PageRenderer+Projects.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-22.
-//
-
-import Foundation
-import Plot
-
-extension PageRenderer: ProjectsRenderer {
-    func renderProjects(_ projects: [Project], site: Site, path: String) throws -> String {
-        let context = SiteContext(
-            site: site,
-            canonicalURL: site.url.appending(path: path),
-            subtitle: "Projects",
-            templateAssets: .empty()
-        )
-        return render(.projects(projects), context: context)
-    }
-
-    func renderProject(_ project: Project, site: Site, path: String, assets: TemplateAssets) throws -> String {
-        let projectContext = ProjectContext(project: project, site: site, 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)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectContext.swift b/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectContext.swift
deleted file mode 100644
index 3e0fd88..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectContext.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-//
-//  ProjectContext.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-
-struct ProjectContext: TemplateContext {
-    let site: Site
-    let title: String
-    let canonicalURL: URL
-    let description: String
-    let pageType = "website"
-    let githubURL: URL
-    let templateAssets: TemplateAssets
-
-    init(project: Project, site: Site, templateAssets: TemplateAssets) {
-        self.site = site
-        self.title = project.title
-        self.canonicalURL = site.url.appending(components: "projects", 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")
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectTemplate.swift b/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectTemplate.swift
deleted file mode 100644
index 5fad87d..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectTemplate.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-//
-//  ProjectTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func project(_ context: ProjectContext) -> Self {
-        .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"), Icons.code())
-            ),
-
-            .group(context.scripts.map { url in
-                .script(.attribute(named: "defer"), .src(url))
-            })
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectsTemplate.swift b/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectsTemplate.swift
deleted file mode 100644
index 97ebde1..0000000
--- a/samhuri.net/Sources/samhuri.net/Projects/Templates/ProjectsTemplate.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-//
-//  ProjectsTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func projects(_ projects: [Project]) -> Self {
-        .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"), Icons.code())
-            )
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift b/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift
deleted file mode 100644
index 2343ab3..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/MarkdownRenderer.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-//  MarkdownRenderer.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-02.
-//
-
-import Foundation
-import Ink
-
-final class MarkdownRenderer: Renderer {
-    let fileWriter: FileWriting
-    let markdownParser = MarkdownParser()
-    let pageRenderer: PageRendering
-
-    init(pageRenderer: PageRendering, fileWriter: FileWriting = FileWriter()) {
-        self.pageRenderer = pageRenderer
-        self.fileWriter = fileWriter
-    }
-
-    func canRenderFile(named filename: String, withExtension ext: String?) -> Bool {
-        ext == "md"
-    }
-
-    /// Parse Markdown and render it as HTML, running it through a Stencil template.
-    func render(site: Site, fileURL: URL, targetDir: URL) throws {
-        let metadata = try markdownMetadata(from: fileURL)
-        let mdFilename = fileURL.lastPathComponent
-        let showExtension = mdFilename == "index.md" || metadata["Show extension"]?.lowercased() == "yes"
-        let htmlPath: String = if showExtension {
-            mdFilename.replacingOccurrences(of: ".md", with: ".html")
-        }
-        else {
-            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)
-        try fileWriter.write(string: pageHTML, to: htmlURL)
-    }
-
-    func markdownMetadata(from url: URL) throws -> [String: String] {
-        let md = try String(contentsOf: url)
-        return markdownParser.parse(md).metadata
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/PageRendering.swift b/samhuri.net/Sources/samhuri.net/Site/PageRendering.swift
deleted file mode 100644
index e00adb3..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/PageRendering.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-//
-//  PageRendering.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-03.
-//
-
-import Foundation
-
-protocol PageRendering {
-    func renderPage(site: Site, url: URL, bodyHTML: String, metadata: [String: String]) throws -> String
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Plugin.swift b/samhuri.net/Sources/samhuri.net/Site/Plugin.swift
deleted file mode 100644
index 5f02ef2..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Plugin.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-//  Plugin.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-02.
-//
-
-import Foundation
-
-protocol Plugin {
-    func setUp(site: Site, sourceURL: URL) throws
-
-    func render(site: Site, targetURL: URL) throws
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Renderer.swift b/samhuri.net/Sources/samhuri.net/Site/Renderer.swift
deleted file mode 100644
index abbae38..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Renderer.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-//  Renderer.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-02.
-//
-
-import Foundation
-
-protocol Renderer {
-    func canRenderFile(named filename: String, withExtension ext: String?) -> Bool
-
-    func render(site: Site, fileURL: URL, targetDir: URL) throws
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift b/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift
deleted file mode 100644
index ae5f648..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Site+Builder.swift
+++ /dev/null
@@ -1,90 +0,0 @@
-//
-//  Site+Builder.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-15.
-//
-
-import Foundation
-
-extension Site {
-    final class Builder {
-        private let title: String
-        private let description: String
-        private let author: String
-        private let imageURL: URL?
-        private let email: String
-        private let url: URL
-
-        private var scripts: [Script] = []
-        private var styles: [Stylesheet] = []
-
-        private var plugins: [Plugin] = []
-        private var renderers: [Renderer] = []
-
-        init(
-            title: String,
-            description: String,
-            author: String,
-            imagePath: String?,
-            email: String,
-            url: URL
-        ) {
-            self.title = title
-            self.description = description
-            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.url = url
-        }
-
-        func scripts(_ scripts: String...) -> Self {
-            self.scripts.append(contentsOf: scripts.map(Script.init(ref:)))
-            return self
-        }
-
-        func styles(_ styles: String...) -> Self {
-            self.styles.append(contentsOf: styles.map(Stylesheet.init(ref:)))
-            return self
-        }
-
-        func plugin(_ plugin: Plugin) -> Self {
-            plugins.append(plugin)
-            return self
-        }
-
-        func renderer(_ renderer: Renderer) -> Self {
-            renderers.append(renderer)
-            return self
-        }
-
-        func build() -> Site {
-            Site(
-                author: author,
-                email: email,
-                title: title,
-                description: description,
-                imageURL: imageURL,
-                url: url,
-                scripts: scripts,
-                styles: styles,
-                renderers: renderers,
-                plugins: plugins
-            )
-        }
-    }
-}
-
-// MARK: - Markdown
-
-extension Site.Builder {
-    func renderMarkdown(pageRenderer: PageRendering) -> Self {
-        renderer(MarkdownRenderer(pageRenderer: pageRenderer))
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Site.swift b/samhuri.net/Sources/samhuri.net/Site/Site.swift
deleted file mode 100644
index 3ef2f75..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Site.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-//  Site.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-01.
-//
-
-import Foundation
-
-struct Site {
-    let author: String
-    let email: String
-    let title: String
-    let description: String
-    let imageURL: URL?
-    let url: URL
-    let scripts: [Script]
-    let styles: [Stylesheet]
-    let renderers: [Renderer]
-    let plugins: [Plugin]
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift b/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift
deleted file mode 100644
index 5265c55..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/SiteGenerator.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-//
-//  samhuri.net.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-01.
-//
-
-import Foundation
-
-final class SiteGenerator {
-    // Dependencies
-    let fileManager: FileManager = .default
-
-    // Site properties
-    let site: Site
-    let sourceURL: URL
-
-    let ignoredFilenames = [".DS_Store", ".gitkeep"]
-
-    init(sourceURL: URL, site: Site) throws {
-        self.site = site
-        self.sourceURL = sourceURL
-
-        try initializePlugins()
-    }
-
-    private func initializePlugins() throws {
-        for plugin in site.plugins {
-            try plugin.setUp(site: site, sourceURL: sourceURL)
-        }
-    }
-
-    func generate(targetURL: URL) throws {
-        for plugin in site.plugins {
-            try plugin.render(site: site, targetURL: targetURL)
-        }
-
-        let publicURL = sourceURL.appendingPathComponent("public")
-        try renderPath(publicURL.path, to: targetURL)
-    }
-
-    // Recursively copy or render every file in the given path.
-    func renderPath(_ path: String, to targetURL: URL) throws {
-        for name in try fileManager.contentsOfDirectory(atPath: path) {
-            guard !ignoredFilenames.contains(name) else {
-                continue
-            }
-
-            // Recurse into subdirectories, updating the target directory as well.
-            let url = URL(fileURLWithPath: path).appendingPathComponent(name)
-            guard !fileManager.directoryExists(at: url) else {
-                try renderPath(url.path, to: targetURL.appendingPathComponent(name))
-                continue
-            }
-
-            // Make sure this path exists so we can write to it.
-            try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
-
-            // Process the file, transforming it if necessary.
-            try renderOrCopyFile(url: url, targetDir: targetURL)
-        }
-    }
-
-    func renderOrCopyFile(url sourceURL: URL, targetDir: URL) throws {
-        let filename = sourceURL.lastPathComponent
-        let targetURL = targetDir.appendingPathComponent(filename)
-
-        // Clear the way so write operations don't fail later on.
-        if fileManager.fileExists(atPath: targetURL.path) {
-            try fileManager.removeItem(at: targetURL)
-        }
-
-        let ext = filename.split(separator: ".").last.flatMap { String($0) }
-        for renderer in site.renderers {
-            if renderer.canRenderFile(named: filename, withExtension: ext) {
-                try renderer.render(site: site, fileURL: sourceURL, targetDir: targetDir)
-                return
-            }
-        }
-
-        // Not handled by any renderer. Copy the file unchanged.
-        try fileManager.copyItem(at: sourceURL, to: targetURL)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/HTMLElements.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/HTMLElements.swift
deleted file mode 100644
index 311950e..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/HTMLElements.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-//  HTMLElements.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-18.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.HeadContext {
-    static func jsonFeedLink(_ url: URLRepresentable, title: String) -> Self {
-        .link(.rel(.alternate), .href(url), .type("application/json"), .attribute(named: "title", value: title))
-    }
-}
-
-extension Node where Context == HTML.HeadContext {
-    static func appleTouchIcon(_ url: URLRepresentable) -> Self {
-        .link(.attribute(named: "rel", value: "apple-touch-icon"), .href(url))
-    }
-
-    static func safariPinnedTabIcon(_ url: URLRepresentable, color: String) -> Self {
-        .link(.attribute(named: "rel", value: "mask-icon"), .attribute(named: "color", value: color), .href(url))
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/Icons.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/Icons.swift
deleted file mode 100644
index a00e4b5..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/Icons.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-//
-//  Icons.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2026-02-03.
-//
-
-import Foundation
-import Plot
-
-enum Icons {
-    static func mastodon() -> Node {
-        .raw(svg(className: "icon icon-mastodon", viewBox: "0 0 448 512", path: IconPath.mastodon))
-    }
-
-    static func github() -> Node {
-        .raw(svg(className: "icon icon-github", viewBox: "0 0 496 512", path: IconPath.github))
-    }
-
-    static func rss() -> Node {
-        .raw(svg(className: "icon icon-rss", viewBox: "0 0 448 512", path: IconPath.rss))
-    }
-
-    static func code() -> Node {
-        .raw(svg(className: "icon icon-code", viewBox: "0 0 640 512", path: IconPath.code))
-    }
-
-    private static func svg(className: String, viewBox: String, path: String) -> String {
-        ""
-    }
-}
-
-private enum IconPath {
-    static let mastodon = "M433 268.89c0 0 0.799805 -71.6992 -9 -121.5c-6.23047 -31.5996 -55.1104 -66.1992 -111.23 -72.8994c-20.0996 -2.40039 -93.1191 -14.2002 -178.75 6.7002c0 -0.116211 -0.00390625 -0.119141 -0.00390625 -0.235352c0 -4.63281 0.307617 -9.19434 0.904297 -13.665 c6.62988 -49.5996 49.2197 -52.5996 89.6299 -54c40.8105 -1.2998 77.1201 10.0996 77.1201 10.0996l1.7002 -36.8994s-28.5098 -15.2998 -79.3203 -18.1006c-28.0098 -1.59961 -62.8193 0.700195 -103.33 11.4004c-112.229 29.7002 -105.63 173.4 -105.63 289.1 c0 97.2002 63.7197 125.7 63.7197 125.7c61.9209 28.4004 227.96 28.7002 290.48 0c0 0 63.71 -28.5 63.71 -125.7zM357.88 143.69c0 122 5.29004 147.71 -18.4199 175.01c-25.71 28.7002 -79.7197 31 -103.83 -6.10059l-11.5996 -19.5l-11.6006 19.5 c-24.0098 36.9004 -77.9297 35 -103.83 6.10059c-23.6094 -27.1006 -18.4092 -52.9004 -18.4092 -175h46.7295v114.2c0 49.6992 64 51.5996 64 -6.90039v-62.5098h46.3301v62.5c0 58.5 64 56.5996 64 6.89941v-114.199h46.6299z"
-    static let github = "M165.9 50.5996c0 -2 -2.30078 -3.59961 -5.2002 -3.59961c-3.2998 -0.299805 -5.60059 1.2998 -5.60059 3.59961c0 2 2.30078 3.60059 5.2002 3.60059c3 0.299805 5.60059 -1.2998 5.60059 -3.60059zM134.8 55.0996c0.700195 2 3.60059 3 6.2002 2.30078 c3 -0.900391 4.90039 -3.2002 4.2998 -5.2002c-0.599609 -2 -3.59961 -3 -6.2002 -2c-3 0.599609 -5 2.89941 -4.2998 4.89941zM179 56.7998c2.90039 0.299805 5.59961 -1 5.90039 -2.89941c0.299805 -2 -1.7002 -3.90039 -4.60059 -4.60059 c-3 -0.700195 -5.59961 0.600586 -5.89941 2.60059c-0.300781 2.2998 1.69922 4.19922 4.59961 4.89941zM244.8 440c138.7 0 251.2 -105.3 251.2 -244c0 -110.9 -67.7998 -205.8 -167.8 -239c-12.7002 -2.2998 -17.2998 5.59961 -17.2998 12.0996 c0 8.2002 0.299805 49.9004 0.299805 83.6006c0 23.5 -7.7998 38.5 -17 46.3994c55.8994 6.30078 114.8 14 114.8 110.5c0 27.4004 -9.7998 41.2002 -25.7998 58.9004c2.59961 6.5 11.0996 33.2002 -2.60059 67.9004c-20.8994 6.59961 -69 -27 -69 -27 c-20 5.59961 -41.5 8.5 -62.7998 8.5s-42.7998 -2.90039 -62.7998 -8.5c0 0 -48.0996 33.5 -69 27c-13.7002 -34.6006 -5.2002 -61.4004 -2.59961 -67.9004c-16 -17.5996 -23.6006 -31.4004 -23.6006 -58.9004c0 -96.1992 56.4004 -104.3 112.3 -110.5 c-7.19922 -6.59961 -13.6992 -17.6992 -16 -33.6992c-14.2998 -6.60059 -51 -17.7002 -72.8994 20.8994c-13.7002 23.7998 -38.6006 25.7998 -38.6006 25.7998c-24.5 0.300781 -1.59961 -15.3994 -1.59961 -15.3994c16.4004 -7.5 27.7998 -36.6006 27.7998 -36.6006 c14.7002 -44.7998 84.7002 -29.7998 84.7002 -29.7998c0 -21 0.299805 -55.2002 0.299805 -61.3994c0 -6.5 -4.5 -14.4004 -17.2998 -12.1006c-99.7002 33.4004 -169.5 128.3 -169.5 239.2c0 138.7 106.1 244 244.8 244zM97.2002 95.0996 c1.2998 1.30078 3.59961 0.600586 5.2002 -1c1.69922 -1.89941 2 -4.19922 0.699219 -5.19922c-1.2998 -1.30078 -3.59961 -0.600586 -5.19922 1c-1.7002 1.89941 -2 4.19922 -0.700195 5.19922zM86.4004 103.2c0.699219 1 2.2998 1.2998 4.2998 0.700195 c2 -1 3 -2.60059 2.2998 -3.90039c-0.700195 -1.40039 -2.7002 -1.7002 -4.2998 -0.700195c-2 1 -3 2.60059 -2.2998 3.90039zM118.8 67.5996c1.2998 1.60059 4.2998 1.30078 6.5 -1c2 -1.89941 2.60059 -4.89941 1.2998 -6.19922 c-1.2998 -1.60059 -4.19922 -1.30078 -6.5 1c-2.2998 1.89941 -2.89941 4.89941 -1.2998 6.19922zM107.4 82.2998c1.59961 1.2998 4.19922 0.299805 5.59961 -2c1.59961 -2.2998 1.59961 -4.89941 0 -6.2002c-1.2998 -1 -4 0 -5.59961 2.30078 c-1.60059 2.2998 -1.60059 4.89941 0 5.89941z"
-    static let rss = "M128.081 32.041c0 -35.3691 -28.6719 -64.041 -64.041 -64.041s-64.04 28.6719 -64.04 64.041s28.6719 64.041 64.041 64.041s64.04 -28.6729 64.04 -64.041zM303.741 -15.209c0.494141 -9.13477 -6.84668 -16.791 -15.9951 -16.79h-48.0693 c-8.41406 0 -15.4707 6.49023 -16.0176 14.8867c-7.29883 112.07 -96.9404 201.488 -208.772 208.772c-8.39648 0.545898 -14.8867 7.60254 -14.8867 16.0176v48.0693c0 9.14746 7.65625 16.4883 16.791 15.9941c154.765 -8.36328 278.596 -132.351 286.95 -286.95z M447.99 -15.4971c0.324219 -9.03027 -6.97168 -16.5029 -16.0049 -16.5039h-48.0684c-8.62598 0 -15.6455 6.83496 -15.999 15.4531c-7.83789 191.148 -161.286 344.626 -352.465 352.465c-8.61816 0.354492 -15.4531 7.37402 -15.4531 15.999v48.0684 c0 9.03418 7.47266 16.3301 16.5029 16.0059c234.962 -8.43555 423.093 -197.667 431.487 -431.487z"
-    static let code = "M278.9 -63.5l-61 17.7002c-6.40039 1.7998 -10 8.5 -8.2002 14.8994l136.5 470.2c1.7998 6.40039 8.5 10 14.8994 8.2002l61 -17.7002c6.40039 -1.7998 10 -8.5 8.2002 -14.8994l-136.5 -470.2c-1.89941 -6.40039 -8.5 -10.1006 -14.8994 -8.2002zM164.9 48.7002 c-4.5 -4.90039 -12.1006 -5.10059 -17 -0.5l-144.101 135.1c-5.09961 4.7002 -5.09961 12.7998 0 17.5l144.101 135c4.89941 4.60059 12.5 4.2998 17 -0.5l43.5 -46.3994c4.69922 -4.90039 4.2998 -12.7002 -0.800781 -17.2002l-90.5996 -79.7002l90.5996 -79.7002 c5.10059 -4.5 5.40039 -12.2998 0.800781 -17.2002zM492.1 48.0996c-4.89941 -4.5 -12.5 -4.2998 -17 0.600586l-43.5 46.3994c-4.69922 4.90039 -4.2998 12.7002 0.800781 17.2002l90.5996 79.7002l-90.5996 79.7998c-5.10059 4.5 -5.40039 12.2998 -0.800781 17.2002 l43.5 46.4004c4.60059 4.7998 12.2002 5 17 0.5l144.101 -135.2c5.09961 -4.7002 5.09961 -12.7998 0 -17.5z"
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/MetadataList.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/MetadataList.swift
deleted file mode 100644
index 8962593..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/MetadataList.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-//
-//  MetadataList.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-31.
-//
-
-import Foundation
-
-extension Dictionary where Key == String, Value == String {
-    func commaSeparatedList(key: String) -> [String] {
-        self[key, default: ""]
-            .split(separator: ",")
-            .map { $0.trimmingCharacters(in: .whitespaces) }
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/PageRenderer.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/PageRenderer.swift
deleted file mode 100644
index ffc653f..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/PageRenderer.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-//  PageRenderer.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-17.
-//
-
-import Foundation
-import Plot
-
-final class PageRenderer {
-    func render(_ body: Node, context: Context) -> String {
-        Template.site(body: body, context: context).render(indentedBy: .spaces(2))
-    }
-}
-
-extension PageRenderer: PageRendering {
-    func renderPage(site: Site, url: URL, bodyHTML: String, metadata: [String: String]) throws -> String {
-        let pageTitle = metadata["Title"]
-        let pageType = metadata["Page type"]
-        let scripts = metadata.commaSeparatedList(key: "Scripts").map(Script.init(ref:))
-        let styles = metadata.commaSeparatedList(key: "Styles").map(Stylesheet.init(ref:))
-        let assets = TemplateAssets(scripts: scripts, styles: styles)
-        let context = SiteContext(
-            site: site,
-            canonicalURL: url,
-            subtitle: pageTitle,
-            pageType: pageType,
-            templateAssets: assets
-        )
-        return render(.page(title: pageTitle ?? "", bodyHTML: bodyHTML), context: context)
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/PageTemplate.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/PageTemplate.swift
deleted file mode 100644
index 7d63664..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/PageTemplate.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-//
-//  PageTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-import Plot
-
-extension Node where Context == HTML.BodyContext {
-    static func page(title: String, bodyHTML: String) -> Self {
-        .group([
-            .article(.class("container"),
-                .h1(.text(title)),
-                .raw(bodyHTML)
-            ),
-            .div(.class("row clearfix"),
-                .p(.class("fin"), Icons.code())
-            )
-        ])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/SiteContext.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/SiteContext.swift
deleted file mode 100644
index 080ed69..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/SiteContext.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-//
-//  SiteContext.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-01.
-//
-
-import Foundation
-
-struct SiteContext: TemplateContext {
-    let site: Site
-    let canonicalURL: URL
-    let subtitle: String?
-    let description: String
-    let pageType: String
-    let templateAssets: TemplateAssets
-
-    init(
-        site: Site,
-        canonicalURL: URL,
-        subtitle: String? = nil,
-        description: String? = nil,
-        pageType: String? = nil,
-        templateAssets: TemplateAssets = .empty()
-    ) {
-        self.site = site
-        self.canonicalURL = canonicalURL
-        self.subtitle = subtitle
-        self.description = description ?? site.description
-        self.pageType = pageType ?? "website"
-
-        self.templateAssets = templateAssets
-    }
-
-    var title: String {
-        guard let subtitle = subtitle else {
-            return site.title
-        }
-
-        return "\(site.title): \(subtitle)"
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/SiteTemplate.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/SiteTemplate.swift
deleted file mode 100644
index 915b7f3..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/SiteTemplate.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-//
-//  SiteTemplate.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-19.
-//
-
-import Foundation
-import Plot
-
-private extension Node where Context == HTML.DocumentContext {
-    /// Add a `` 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]) -> Node {
-        .element(named: "head", nodes: nodes)
-    }
-}
-
-enum Template {
-    static func site(body: Node, context: Context) -> HTML {
-        // Broken up to fix a build error because Swift can't type-check the varargs version.
-        let headNodes: [Node] = [
-            .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://gist.github.com")),
-            .group(context.styles.map { url in
-                    .link(.rel(.stylesheet), .type("text/css"), .href(url))
-            }),
-        ]
-        return HTML(
-            .lang(.english),
-            .comment("meow"),
-            .head(headNodes),
-            .body(
-                .header(.class("primary"),
-                    .div(.class("title"),
-                         .h1(.a(.href(context.site.url), .text(context.site.title))),
-                         .br(),
-                         .h4(.text("By "), .a(.href(context.url(for: "about")), .text(context.site.author)))
-                    ),
-                    .nav(.class("remote"),
-                        .ul(
-                            .li(.class("mastodon"),
-                                .a(
-                                    .attribute(named: "rel", value: "me"),
-                                    .attribute(named: "aria-label", value: "Mastodon"),
-                                    .href("https://techhub.social/@sjs"),
-                                    Icons.mastodon()
-                                )
-                            ),
-                            .li(.class("github"),
-                                .a(
-                                    .attribute(named: "aria-label", value: "GitHub"),
-                                    .href("https://github.com/samsonjs"),
-                                    Icons.github()
-                                )
-                            ),
-                            .li(.class("rss"),
-                                .a(
-                                    .attribute(named: "aria-label", value: "RSS"),
-                                    .href(context.url(for: "feed.xml")),
-                                    Icons.rss()
-                                )
-                            )
-                        )
-                    ),
-                    .nav(.class("local"),
-                        .ul(
-                            .li(.a(.href(context.url(for: "about")), "About")),
-                            .li(.a(.href(context.url(for: "posts")), "Archive")),
-                            .li(.a(.href(context.url(for: "projects")), "Projects"))
-                        )
-                    ),
-                    .div(.class("clearfix"))
-                ),
-
-                body,
-
-                .footer(
-                    "© 2006 - \(context.currentYear)",
-                    .a(.href(context.url(for: "about")), .text(context.site.author))
-                ),
-
-                .group(context.scripts.map { script in
-                    .script(.attribute(named: "defer"), .src(script))
-                })
-            )
-        )
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateAssets.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateAssets.swift
deleted file mode 100644
index 1c770ad..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateAssets.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-//
-//  TemplateAssets.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-20.
-//
-
-import Foundation
-
-struct TemplateAssets {
-    var scripts: [Script]
-    var styles: [Stylesheet]
-
-    static func empty() -> TemplateAssets {
-        TemplateAssets(scripts: [], styles: [])
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateContext.swift b/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateContext.swift
deleted file mode 100644
index 4dbf2df..0000000
--- a/samhuri.net/Sources/samhuri.net/Site/Templates/TemplateContext.swift
+++ /dev/null
@@ -1,79 +0,0 @@
-//
-//  TemplateContext.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-18.
-//
-
-import Foundation
-
-protocol TemplateContext {
-    // Concrete requirements, must be implemented
-
-    var site: Site { get }
-    var title: String { get }
-    var canonicalURL: URL { get }
-    var description: String { get }
-    var pageType: String { 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 scripts: [URL] {
-        let allScripts = site.scripts + templateAssets.scripts
-        return allScripts.map { script in
-            script.url(dir: scriptDir)
-        }
-    }
-
-    var styles: [URL] {
-        let allStyles = site.styles + templateAssets.styles
-        return allStyles.map { style in
-            style.url(dir: styleDir)
-        }
-    }
-
-    var currentYear: Int {
-        Date().year
-    }
-
-    func url(for path: String) -> URL {
-        site.url.appendingPathComponent(path)
-    }
-
-    func imageURL(_ filename: String) -> URL {
-        site.url
-            .appendingPathComponent("images")
-            .appendingPathComponent(filename)
-    }
-
-    func scriptURL(_ filename: String) -> URL {
-        scriptDir.appendingPathComponent(filename)
-    }
-
-    func styleURL(_ filename: String) -> URL {
-        styleDir.appendingPathComponent(filename)
-    }
-}
-
-private extension TemplateContext {
-    var scriptDir: URL {
-        site.url.appendingPathComponent("js")
-    }
-
-    var styleDir: URL {
-        site.url.appendingPathComponent("css")
-    }
-}
diff --git a/samhuri.net/Sources/samhuri.net/samhuri.net.swift b/samhuri.net/Sources/samhuri.net/samhuri.net.swift
deleted file mode 100644
index 5965ccf..0000000
--- a/samhuri.net/Sources/samhuri.net/samhuri.net.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-import Foundation
-
-public enum samhuri {}
-
-public extension samhuri {
-    struct net {
-        let siteURLOverride: URL?
-
-        public init(siteURLOverride: URL? = nil) {
-            self.siteURLOverride = siteURLOverride
-        }
-
-        public func generate(sourceURL: URL, targetURL: URL) throws {
-            let renderer = PageRenderer()
-            let site = makeSite(renderer: renderer)
-            let generator = try SiteGenerator(sourceURL: sourceURL, site: site)
-            try generator.generate(targetURL: targetURL)
-        }
-
-        func makeSite(renderer: PageRenderer) -> Site {
-            let projectsPlugin = ProjectsPlugin.Builder(renderer: renderer)
-                .path("projects")
-                .assets(TemplateAssets(scripts: [
-                    "https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js",
-                    "gitter.js",
-                    "store.js",
-                    "projects.js",
-                ], styles: []))
-                .add("samhuri.net", description: "this site")
-                .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("AsyncMonitor", description: "easily monitor async sequences using Swift concurrency")
-                .add("NotificationSmuggler", description: "embed strongly-typed values in notifications on Apple platforms")
-                .add("strftime", description: "strftime for JavaScript")
-                .add("format", description: "printf for JavaScript")
-                .add("gitter", description: "a GitHub client for Node (v3 API)")
-                .add("cheat.el", description: "cheat from emacs")
-                .build()
-
-            let postsPlugin = PostsPlugin.Builder(renderer: renderer)
-                .path("posts")
-                .jsonFeed(
-                    iconPath: "images/apple-touch-icon-300.png",
-                    faviconPath: "images/apple-touch-icon-80.png"
-                )
-                .rssFeed()
-                .build()
-
-            return Site.Builder(
-                title: "samhuri.net",
-                description: "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days.",
-                author: "Sami Samhuri",
-                imagePath: "images/me.jpg",
-                email: "sami@samhuri.net",
-                url: siteURLOverride ?? URL(string: "https://samhuri.net")!
-            )
-                .styles("normalize.css", "style.css")
-                .renderMarkdown(pageRenderer: renderer)
-                .plugin(projectsPlugin)
-                .plugin(postsPlugin)
-                .build()
-        }
-    }
-}
diff --git a/samhuri.net/Tests/samhuri.netTests/Files/FilePermissionsTests.swift b/samhuri.net/Tests/samhuri.netTests/Files/FilePermissionsTests.swift
deleted file mode 100644
index 8fb0fe8..0000000
--- a/samhuri.net/Tests/samhuri.netTests/Files/FilePermissionsTests.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-//
-//  FilePermissionsTests.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-31.
-//
-
-@testable import samhuri_net
-import Testing
-
-struct FilePermissionsTests {
-    @Test func description() {
-        #expect(FilePermissions(user: "---", group: "---", other: "---").description == "---------")
-        #expect(FilePermissions(user: "r--", group: "r--", other: "r--").description == "r--r--r--")
-        #expect(FilePermissions(user: "-w-", group: "-w-", other: "-w-").description == "-w--w--w-")
-        #expect(FilePermissions(user: "--x", group: "--x", other: "--x").description == "--x--x--x")
-        #expect(FilePermissions(user: "rwx", group: "r-x", other: "r--").description == "rwxr-xr--")
-    }
-
-    @Test func initFromString() {
-        #expect(FilePermissions(user: "---", group: "---", other: "---") == FilePermissions(string: "---------"))
-        #expect(FilePermissions(user: "r--", group: "r--", other: "r--") == FilePermissions(string: "r--r--r--"))
-        #expect(FilePermissions(user: "-w-", group: "-w-", other: "-w-") == FilePermissions(string: "-w--w--w-"))
-        #expect(FilePermissions(user: "--x", group: "--x", other: "--x") == FilePermissions(string: "--x--x--x"))
-        #expect(FilePermissions(user: "rwx", group: "r-x", other: "r--") == FilePermissions(string: "rwxr-xr--"))
-
-        // Refuses to initialize with nonsense.
-        #expect(FilePermissions(string: "abcdefghi") == nil)
-        #expect(FilePermissions(string: "abcrwxrwx") == nil)
-        #expect(FilePermissions(string: "rwxabcrwx") == nil)
-        #expect(FilePermissions(string: "rwxrwxabc") == nil)
-    }
-
-    @Test func initFromRawValue() {
-        #expect(FilePermissions(rawValue: 0o000) == FilePermissions(string: "---------"))
-        #expect(FilePermissions(rawValue: 0o755) == FilePermissions(string: "rwxr-xr-x"))
-        #expect(FilePermissions(rawValue: 0o644) == FilePermissions(string: "rw-r--r--"))
-        #expect(FilePermissions(rawValue: 0o600) == FilePermissions(string: "rw-------"))
-        #expect(FilePermissions(rawValue: 0o777) == FilePermissions(string: "rwxrwxrwx"))
-    }
-
-    @Test func rawValue() {
-        #expect(FilePermissions(string: "---------")!.rawValue == 0o000)
-        #expect(FilePermissions(string: "rwxr-xr-x")!.rawValue == 0o755)
-        #expect(FilePermissions(string: "rw-r--r--")!.rawValue == 0o644)
-        #expect(FilePermissions(string: "rw-------")!.rawValue == 0o600)
-        #expect(FilePermissions(string: "rwxrwxrwx")!.rawValue == 0o777)
-    }
-
-    @Test func expressibleByStringLiteral() {
-        #expect(FilePermissions(user: "rwx", group: "r-x", other: "r-x") == "rwxr-xr-x")
-    }
-}
diff --git a/samhuri.net/Tests/samhuri.netTests/Files/PermissionsTests.swift b/samhuri.net/Tests/samhuri.netTests/Files/PermissionsTests.swift
deleted file mode 100644
index 58384be..0000000
--- a/samhuri.net/Tests/samhuri.netTests/Files/PermissionsTests.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-//
-//  PermissionsTests.swift
-//  samhuri.net
-//
-//  Created by Sami Samhuri on 2019-12-31.
-//
-
-@testable import samhuri_net
-import Testing
-
-struct PermissionsTests {
-    @Test func optionsAreMutuallyExclusive() {
-        // If any of the bits overlap then the `or` value will be less than the sum of the raw values.
-        let allValues = [Permissions.execute, Permissions.write, Permissions.read].map { $0.rawValue }
-        #expect(allValues.reduce(0, +) == allValues.reduce(0, |))
-    }
-
-    @Test func rawValuesAreUnixy() {
-        #expect(Permissions.none.rawValue == 0o0)
-        #expect(Permissions.read.rawValue == 0o4)
-        #expect(Permissions.write.rawValue == 0o2)
-        #expect(Permissions.execute.rawValue == 0o1)
-    }
-
-    @Test func initFromString() {
-        #expect(Permissions(string: "---") == [.none])
-        #expect(Permissions(string: "--x") == [.execute])
-        #expect(Permissions(string: "-w-") == [.write])
-        #expect(Permissions(string: "r--") == [.read])
-
-        #expect(Permissions(string: "rw-") == [.read, .write])
-        #expect(Permissions(string: "r-x") == [.read, .execute])
-        #expect(Permissions(string: "-wx") == [.write, .execute])
-        #expect(Permissions(string: "rwx") == [.read, .write, .execute])
-
-        // Refuses to initialize with nonsense.
-        #expect(Permissions(string: "abc") == nil)
-        #expect(Permissions(string: "awx") == nil)
-        #expect(Permissions(string: "rax") == nil)
-        #expect(Permissions(string: "rwa") == nil)
-    }
-
-    @Test func description() {
-        #expect(Permissions.none.description == "---")
-        #expect(Permissions.read.description == "r--")
-        #expect(Permissions.write.description == "-w-")
-        #expect(Permissions.execute.description == "--x")
-        #expect(Permissions(arrayLiteral: [.read, .write]).description == "rw-")
-        #expect(Permissions(arrayLiteral: [.read, .execute]).description == "r-x")
-        #expect(Permissions(arrayLiteral: [.write, .execute]).description == "-wx")
-        #expect(Permissions(arrayLiteral: [.read, .write, .execute]).description == "rwx")
-    }
-
-    @Test func expressibleByStringLiteral() {
-        #expect(Permissions.read == "r--")
-    }
-}
diff --git a/samhuri.net/Tests/samhuri.netTests/samhuri.netTests.swift b/samhuri.net/Tests/samhuri.netTests/samhuri.netTests.swift
deleted file mode 100644
index 911f189..0000000
--- a/samhuri.net/Tests/samhuri.netTests/samhuri.netTests.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-import Testing
-@testable import samhuri_net
-
-struct samhuri_net_Tests {
-    @Test func example() {
-        #expect(true)
-    }
-}
diff --git a/pressa/spec/examples.txt b/spec/examples.txt
similarity index 89%
rename from pressa/spec/examples.txt
rename to spec/examples.txt
index a9f0bc0..322aa5d 100644
--- a/pressa/spec/examples.txt
+++ b/spec/examples.txt
@@ -1,20 +1,20 @@
 example_id                                        | status | run_time        |
 ------------------------------------------------- | ------ | --------------- |
-./spec/posts/metadata_spec.rb[1:1:1]              | passed | 0.00025 seconds |
-./spec/posts/metadata_spec.rb[1:1:2]              | passed | 0.00089 seconds |
-./spec/posts/metadata_spec.rb[1:1:3]              | passed | 0.00068 seconds |
-./spec/posts/repo_spec.rb[1:1:1]                  | passed | 0.0172 seconds  |
-./spec/posts/repo_spec.rb[1:1:2]                  | passed | 0.0009 seconds  |
-./spec/utils/frontmatter_converter_spec.rb[1:1:1] | passed | 0.0002 seconds  |
+./spec/posts/metadata_spec.rb[1:1:1]              | passed | 0.00051 seconds |
+./spec/posts/metadata_spec.rb[1:1:2]              | passed | 0.00066 seconds |
+./spec/posts/metadata_spec.rb[1:1:3]              | passed | 0.00467 seconds |
+./spec/posts/repo_spec.rb[1:1:1]                  | passed | 0.00124 seconds |
+./spec/posts/repo_spec.rb[1:1:2]                  | passed | 0.02703 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:1:1] | passed | 0.00057 seconds |
 ./spec/utils/frontmatter_converter_spec.rb[1:1:2] | passed | 0.00004 seconds |
 ./spec/utils/frontmatter_converter_spec.rb[1:1:3] | passed | 0.00003 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:1:4] | passed | 0.00056 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:1:5] | passed | 0.00003 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:1:6] | passed | 0.00032 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:1:7] | passed | 0.00003 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:1:4] | passed | 0.00003 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:1:5] | passed | 0.00006 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:1:6] | passed | 0.00005 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:1:7] | passed | 0.00004 seconds |
 ./spec/utils/frontmatter_converter_spec.rb[1:1:8] | passed | 0.00004 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:2:1] | passed | 0.00004 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:2:2] | passed | 0.00003 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:2:3] | passed | 0.00002 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:2:4] | passed | 0.00003 seconds |
-./spec/utils/frontmatter_converter_spec.rb[1:2:5] | passed | 0.00002 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:2:1] | passed | 0.00005 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:2:2] | passed | 0.0002 seconds  |
+./spec/utils/frontmatter_converter_spec.rb[1:2:3] | passed | 0.00004 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:2:4] | passed | 0.00006 seconds |
+./spec/utils/frontmatter_converter_spec.rb[1:2:5] | passed | 0.00003 seconds |
diff --git a/pressa/spec/posts/metadata_spec.rb b/spec/posts/metadata_spec.rb
similarity index 100%
rename from pressa/spec/posts/metadata_spec.rb
rename to spec/posts/metadata_spec.rb
diff --git a/pressa/spec/posts/repo_spec.rb b/spec/posts/repo_spec.rb
similarity index 100%
rename from pressa/spec/posts/repo_spec.rb
rename to spec/posts/repo_spec.rb
diff --git a/pressa/spec/spec_helper.rb b/spec/spec_helper.rb
similarity index 100%
rename from pressa/spec/spec_helper.rb
rename to spec/spec_helper.rb
diff --git a/pressa/spec/utils/frontmatter_converter_spec.rb b/spec/utils/frontmatter_converter_spec.rb
similarity index 100%
rename from pressa/spec/utils/frontmatter_converter_spec.rb
rename to spec/utils/frontmatter_converter_spec.rb