diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ef21dc6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ coverage:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+ bundler-cache: true
+
+ - name: Bootstrap
+ run: bin/bootstrap
+
+ - name: Coverage
+ run: bundle exec bake coverage
+
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+ bundler-cache: true
+
+ - name: Bootstrap
+ run: bin/bootstrap
+
+ - name: Lint
+ run: bundle exec bake lint
+
+ debug:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: .ruby-version
+ bundler-cache: true
+
+ - name: Bootstrap
+ run: bin/bootstrap
+
+ - name: Debug Build
+ run: bundle exec bake debug
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/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..1454f6e
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4.0.1
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..23ee274
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,75 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+This repository is a single Ruby static-site generator project (the legacy Swift generators were removed).
+
+- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
+- Build/deploy/draft tasks: `bake.rb`
+- Tests: `test/`
+- Site config: `site.toml`, `projects.toml`
+- Published posts: `posts/YYYY/MM/*.md`
+- Static and renderable public content: `public/`
+- Draft posts: `public/drafts/`
+- Generated output: `www/` (safe to delete/regenerate)
+
+Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
+
+## Setup, Build, Test, and Development Commands
+- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
+- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
+- `rbenv exec bundle exec bake debug`: build for `http://localhost:8000` into `www/`.
+- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
+- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
+- `rbenv exec bundle exec bake mudge|beta|release`: build with environment-specific base URLs.
+- `rbenv exec bundle exec bake publish_beta|publish`: build and rsync `www/` to remote host.
+- `rbenv exec bundle exec bake clean`: remove `www/`.
+- `rbenv exec bundle exec bake test`: run test suite.
+- `rbenv exec bundle exec bake lint`: lint code.
+- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
+- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
+- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
+
+## Draft Workflow
+- `rbenv exec bundle exec bake new_draft "Post Title"` creates `public/drafts/.md`.
+- `rbenv exec bundle exec bake drafts` lists available drafts.
+- `rbenv exec bundle exec bake publish_draft public/drafts/.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
+
+## Content and Metadata Requirements
+Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are:
+
+- `Title`
+- `Author`
+- `Date`
+- `Timestamp`
+
+Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
+
+## Coding Style & Naming Conventions
+- Ruby (see `.ruby-version`).
+- Follow idiomatic Ruby style and keep code `bake lint`-clean.
+- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
+- Prefer small, focused classes for plugins, views, renderers, and config loaders.
+- Do not hand-edit generated files in `www/`.
+
+## Testing Guidelines
+- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
+- Add regression tests for parser, rendering, feed, and generator behavior changes.
+- Before submitting, run:
+ - `rbenv exec bundle exec bake test`
+ - `rbenv exec bundle exec bake coverage`
+ - `rbenv exec bundle exec bake lint`
+ - `rbenv exec bundle exec bake debug`
+
+## Commit & Pull Request Guidelines
+- Use concise, imperative commit subjects (history examples: `Fix internal permalink regression in archives`).
+- Keep commits scoped to one concern (generator logic, content, or deployment changes).
+- In PRs, include motivation, verification commands run, and deployment impact.
+- Include screenshots when changing rendered layout/CSS output.
+
+## Deployment & Security Notes
+- Deployment is defined in `bake.rb` via rsync over SSH.
+- Current publish host is `mudge` with:
+ - production: `/var/www/samhuri.net/public`
+ - beta: `/var/www/beta.samhuri.net/public`
+- Validate `www/` before publishing to avoid shipping stale assets.
+- Never commit credentials, SSH keys, or other secrets.
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..906017c
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,15 @@
+source "https://rubygems.org"
+
+gem "phlex", "~> 2.3"
+gem "kramdown", "~> 2.5"
+gem "kramdown-parser-gfm", "~> 1.1"
+gem "rouge", "~> 4.6"
+gem "dry-struct", "~> 1.8"
+gem "builder", "~> 3.3"
+gem "bake", "~> 0.20"
+
+group :development, :test do
+ gem "guard", "~> 2.18"
+ gem "minitest", "~> 6.0"
+ gem "standard", "~> 1.43"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..5ea8741
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,178 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ ast (2.4.3)
+ bake (0.24.1)
+ bigdecimal
+ samovar (~> 2.1)
+ bigdecimal (4.0.1)
+ builder (3.3.0)
+ coderay (1.1.3)
+ concurrent-ruby (1.3.6)
+ console (1.34.2)
+ fiber-annotation
+ fiber-local (~> 1.1)
+ json
+ dry-core (1.2.0)
+ concurrent-ruby (~> 1.0)
+ logger
+ zeitwerk (~> 2.6)
+ dry-inflector (1.3.1)
+ dry-logic (1.6.0)
+ bigdecimal
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.1)
+ zeitwerk (~> 2.6)
+ dry-struct (1.8.0)
+ dry-core (~> 1.1)
+ dry-types (~> 1.8, >= 1.8.2)
+ ice_nine (~> 0.11)
+ zeitwerk (~> 2.6)
+ dry-types (1.9.1)
+ bigdecimal (>= 3.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0)
+ dry-inflector (~> 1.0)
+ dry-logic (~> 1.4)
+ zeitwerk (~> 2.6)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm-linux-gnu)
+ ffi (1.17.3-arm-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86-linux-gnu)
+ ffi (1.17.3-x86-linux-musl)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
+ fiber-annotation (0.2.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (1.0.1)
+ formatador (1.2.3)
+ reline
+ guard (2.20.1)
+ formatador (>= 0.2.4)
+ listen (>= 2.7, < 4.0)
+ logger (~> 1.6)
+ lumberjack (>= 1.0.12, < 2.0)
+ nenv (~> 0.1)
+ notiffany (~> 0.0)
+ pry (>= 0.13.0)
+ shellany (~> 0.0)
+ thor (>= 0.18.1)
+ ice_nine (0.11.2)
+ io-console (0.8.2)
+ json (2.18.1)
+ kramdown (2.5.2)
+ rexml (>= 3.4.4)
+ kramdown-parser-gfm (1.1.0)
+ kramdown (~> 2.0)
+ language_server-protocol (3.17.0.5)
+ lint_roller (1.1.0)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ lumberjack (1.4.2)
+ mapping (1.1.3)
+ method_source (1.1.0)
+ minitest (6.0.1)
+ prism (~> 1.5)
+ nenv (0.3.0)
+ notiffany (0.1.3)
+ nenv (~> 0.1)
+ shellany (~> 0.0)
+ parallel (1.27.0)
+ parser (3.3.10.1)
+ ast (~> 2.4.1)
+ racc
+ phlex (2.4.1)
+ refract (~> 1.0)
+ zeitwerk (~> 2.7)
+ prism (1.9.0)
+ pry (0.16.0)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ reline (>= 0.6.0)
+ racc (1.8.1)
+ rainbow (3.1.1)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ refract (1.1.0)
+ prism
+ zeitwerk
+ regexp_parser (2.11.3)
+ reline (0.6.3)
+ io-console (~> 0.5)
+ rexml (3.4.4)
+ rouge (4.7.0)
+ rubocop (1.82.1)
+ json (~> 2.3)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.1.0)
+ parallel (~> 1.10)
+ parser (>= 3.3.0.2)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.48.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.49.0)
+ parser (>= 3.3.7.2)
+ prism (~> 1.7)
+ rubocop-performance (1.26.1)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.47.1, < 2.0)
+ ruby-progressbar (1.13.0)
+ samovar (2.4.1)
+ console (~> 1.0)
+ mapping (~> 1.0)
+ shellany (0.0.1)
+ standard (1.53.0)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.0)
+ rubocop (~> 1.82.0)
+ standard-custom (~> 1.0.0)
+ standard-performance (~> 1.8)
+ standard-custom (1.0.2)
+ lint_roller (~> 1.0)
+ rubocop (~> 1.50)
+ standard-performance (1.9.0)
+ lint_roller (~> 1.1)
+ rubocop-performance (~> 1.26.0)
+ thor (1.5.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ zeitwerk (2.7.4)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86-linux-gnu
+ x86-linux-musl
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ bake (~> 0.20)
+ builder (~> 3.3)
+ dry-struct (~> 1.8)
+ guard (~> 2.18)
+ kramdown (~> 2.5)
+ kramdown-parser-gfm (~> 1.1)
+ minitest (~> 6.0)
+ phlex (~> 2.3)
+ rouge (~> 4.6)
+ standard (~> 1.43)
+
+BUNDLED WITH
+ 4.0.6
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..e262e04 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,252 +1,111 @@
# 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 contains the Ruby static-site generator and site content for samhuri.net.
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.
-Some features:
+- Generator core: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
+- Build tasks and utility workflows: `bake.rb`
+- Tests: `test/`
+- Config: `site.toml` and `projects.toml`
+- Content: `posts/` and `public/`
+- Output: `www/`
-- 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+
+## Requirements
-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.
+- Ruby (see `.ruby-version`)
+- Bundler
+- `rbenv` recommended
-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)"
+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
+bake debug # build for http://localhost:8000
+bake serve # serve www/ locally
```
+## Configuration
+
+Site metadata and project data are configured with TOML files at the repository root:
+
+- `site.toml`: site identity, default scripts/styles, and a `plugins` list (for example `["posts", "projects"]`), plus `projects_plugin` assets when that plugin is enabled.
+- `projects.toml`: project listing entries using `[[projects]]`.
+
+`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
+
## Customizing for your site
-If this seems like a reasonable workflow then you could see what it takes to make it your own.
+If this workflow seems like a good fit, here is the minimum to make it your own:
-### Essential changes
+- Update `site.toml` with your site identity (`author`, `email`, `title`, `description`, `url`) and any global `scripts` / `styles`.
+- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
+- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
+- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
+- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
+- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
-0. Probably **rename everything** unless you want to impersonate me 🥸
+Other targets:
-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
+bake mudge
+bake beta
+bake release
+bake watch target=debug
+bake clean
+bake publish_beta
+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
+bake new_draft "Post title"
+bake drafts
+bake publish_draft public/drafts/post-title.md
```
-And this is the `Plugin` protocol:
+Published posts in `posts/YYYY/MM/*.md` require YAML front matter keys:
-```swift
-protocol Plugin {
- func setUp(site: Site, sourceURL: URL) throws
- func render(site: Site, targetURL: URL) throws
-}
+- `Title`
+- `Author`
+- `Date`
+- `Timestamp`
+
+## Tests And Lint
+
+```bash
+bake test
+standardrb
```
-Your site plus its renderers and plugins defines everything that it can do.
+Or via bake:
-```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
+bake test
+bake lint
+bake lint_fix
```
-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.
+## Notes
-[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
- }
- }
-}
-```
-
-## License
-
-Released under the terms of the [MIT license](https://sjs.mit-license.org).
+- `bake watch` is Linux-only and requires `inotifywait`.
+- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
+ - production: `/var/www/samhuri.net/public`
+ - beta: `/var/www/beta.samhuri.net/public`
diff --git a/bake.rb b/bake.rb
new file mode 100644
index 0000000..be19b18
--- /dev/null
+++ b/bake.rb
@@ -0,0 +1,484 @@
+# Build tasks for samhuri.net static site generator
+
+require "etc"
+require "fileutils"
+require "open3"
+require "tmpdir"
+
+LIB_PATH = File.expand_path("lib", __dir__).freeze
+$LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH)
+
+DRAFTS_DIR = "public/drafts".freeze
+PUBLISH_HOST = "mudge".freeze
+PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
+BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
+WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
+LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
+BUILD_TARGETS = %w[debug mudge beta release].freeze
+
+# Generate the site in debug mode (localhost:8000)
+def debug
+ build("http://localhost:8000")
+end
+
+# Generate the site for the mudge development server
+def mudge
+ build("http://mudge:8000")
+end
+
+# Generate the site for beta/staging
+def beta
+ build("https://beta.samhuri.net")
+end
+
+# Generate the site for production
+def release
+ build("https://samhuri.net")
+end
+
+# Start local development server
+def serve
+ require "webrick"
+ server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: "www")
+ trap("INT") { server.shutdown }
+ puts "Server running at http://localhost:8000"
+ server.start
+end
+
+# Create a new draft in public/drafts/.
+# @parameter title_parts [Array] Optional title words; defaults to Untitled.
+def new_draft(*title_parts)
+ title, filename =
+ if title_parts.empty?
+ ["Untitled", next_available_draft]
+ else
+ given_title = title_parts.join(" ")
+ slug = slugify(given_title)
+ abort "Error: title cannot be converted to a filename." if slug.empty?
+
+ filename = "#{slug}.md"
+ path = draft_path(filename)
+ abort "Error: draft already exists at #{path}" if File.exist?(path)
+
+ [given_title, filename]
+ end
+
+ FileUtils.mkdir_p(DRAFTS_DIR)
+ path = draft_path(filename)
+ content = render_draft_template(title)
+ File.write(path, content)
+
+ puts "Created new draft at #{path}"
+ puts ">>> Contents below <<<"
+ puts
+ puts content
+end
+
+# Publish a draft by moving it to posts/YYYY/MM and updating dates.
+# @parameter input_path [String] Draft path or filename in public/drafts.
+def publish_draft(input_path = nil)
+ if input_path.nil? || input_path.strip.empty?
+ puts "Usage: bake publish_draft "
+ puts
+ puts "Available drafts:"
+ drafts = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) }
+ if drafts.empty?
+ puts " (no drafts found)"
+ else
+ drafts.each { |draft| puts " #{draft}" }
+ end
+ abort
+ end
+
+ draft_path_value, draft_file = resolve_draft_input(input_path)
+ abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value)
+
+ now = Time.now
+ content = File.read(draft_path_value)
+ content.sub!(/^Date:.*$/, "Date: #{ordinal_date(now)}")
+ content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}")
+
+ target_dir = "posts/#{now.strftime("%Y/%m")}"
+ FileUtils.mkdir_p(target_dir)
+ target_path = "#{target_dir}/#{draft_file}"
+
+ File.write(target_path, content)
+ FileUtils.rm_f(draft_path_value)
+
+ puts "Published draft: #{draft_path_value} -> #{target_path}"
+end
+
+# Watch content directories and rebuild on every change.
+# @parameter target [String] One of debug, mudge, beta, or release.
+def watch(target: "debug")
+ unless command_available?("inotifywait")
+ abort "inotifywait is required (install inotify-tools)."
+ end
+
+ loop do
+ abort "Error: watch failed." unless system("inotifywait", "-e", "modify,create,delete,move", *watch_paths)
+ puts "changed at #{Time.now}"
+ sleep 2
+ run_build_target(target)
+ end
+end
+
+# Publish to beta/staging server
+def publish_beta
+ beta
+ run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
+end
+
+# Publish to production server
+def publish
+ release
+ run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
+end
+
+# Clean generated files
+def clean
+ FileUtils.rm_rf("www")
+ puts "Cleaned www/ directory"
+end
+
+# Default task: run coverage and lint.
+def default
+ coverage
+ lint
+end
+
+# Run Minitest tests
+def test
+ run_test_suite(test_file_list)
+end
+
+# Run Guard for continuous testing
+def guard
+ exec "bundle exec guard"
+end
+
+# List all available drafts
+def drafts
+ Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft|
+ puts File.basename(draft)
+ end
+end
+
+# Run StandardRB linter
+def lint
+ run_standardrb
+end
+
+# Auto-fix StandardRB issues
+def lint_fix
+ run_standardrb("--fix")
+end
+
+# Measure line coverage for files under lib/.
+# @parameter lowest [Integer] Number of lowest-covered files to print (default: 10, use 0 to hide).
+def coverage(lowest: 10)
+ lowest_count = Integer(lowest)
+ abort "Error: lowest must be >= 0." if lowest_count.negative?
+
+ run_coverage(test_files: test_file_list, lowest_count:)
+end
+
+# Compare line coverage for files under lib/ against a baseline and fail on regression.
+# @parameter baseline [String] Baseline ref, or "merge-base" (default) to compare against merge-base with remote default branch.
+# @parameter lowest [Integer] Number of lowest-covered files to print for the current checkout (default: 10, use 0 to hide).
+def coverage_regression(baseline: "merge-base", lowest: 10)
+ lowest_count = Integer(lowest)
+ abort "Error: lowest must be >= 0." if lowest_count.negative?
+
+ baseline_ref = resolve_coverage_baseline_ref(baseline)
+ baseline_commit = capture_command("git", "rev-parse", "--short", baseline_ref).strip
+
+ puts "Running coverage for current checkout..."
+ current_output = capture_coverage_output(test_files: test_file_list, lowest_count:, chdir: Dir.pwd)
+ print current_output
+ current_percent = parse_coverage_percent(current_output)
+
+ puts "Running coverage for baseline #{baseline_ref} (#{baseline_commit})..."
+ baseline_percent = with_temporary_worktree(ref: baseline_ref) do |worktree_path|
+ baseline_tests = test_file_list(chdir: worktree_path)
+ baseline_output = capture_coverage_output(test_files: baseline_tests, lowest_count: 0, chdir: worktree_path)
+ parse_coverage_percent(baseline_output)
+ end
+
+ delta = current_percent - baseline_percent
+ puts format("Baseline coverage (%s %s): %.2f%%", baseline_ref, baseline_commit, baseline_percent)
+ puts format("Coverage delta: %+0.2f%%", delta)
+
+ return unless delta.negative?
+
+ abort format("Error: coverage regressed by %.2f%% against %s (%s).", -delta, baseline_ref, baseline_commit)
+end
+
+private
+
+def run_test_suite(test_files)
+ run_command("ruby", "-Ilib", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files)
+end
+
+def run_coverage(test_files:, lowest_count:)
+ output = capture_coverage_output(test_files:, lowest_count:, chdir: Dir.pwd)
+ print output
+end
+
+def test_file_list(chdir: Dir.pwd)
+ test_files = Dir.chdir(chdir) { Dir.glob("test/**/*_test.rb").sort }
+ abort "Error: no tests found in test/**/*_test.rb under #{chdir}" if test_files.empty?
+
+ test_files
+end
+
+def coverage_script(lowest_count:)
+ <<~RUBY
+ require "coverage"
+
+ root = Dir.pwd
+ lib_root = File.join(root, "lib") + "/"
+ Coverage.start(lines: true)
+
+ at_exit do
+ result = Coverage.result
+ rows = result.keys
+ .select { |file| file.start_with?(lib_root) && file.end_with?(".rb") }
+ .sort
+ .map do |file|
+ lines = result[file][:lines] || []
+ total = 0
+ covered = 0
+ lines.each do |line_count|
+ next if line_count.nil?
+ total += 1
+ covered += 1 if line_count.positive?
+ end
+ percent = total.zero? ? 100.0 : (covered.to_f / total * 100)
+ [file, covered, total, percent]
+ end
+
+ covered_lines = rows.sum { |row| row[1] }
+ total_lines = rows.sum { |row| row[2] }
+ overall_percent = total_lines.zero? ? 100.0 : (covered_lines.to_f / total_lines * 100)
+ puts format("Coverage (lib): %.2f%% (%d / %d lines)", overall_percent, covered_lines, total_lines)
+
+ unless #{lowest_count}.zero? || rows.empty?
+ puts "Lowest covered files:"
+ rows.sort_by { |row| row[3] }.first(#{lowest_count}).each do |file, covered, total, percent|
+ relative_path = file.delete_prefix(root + "/")
+ puts format(" %6.2f%% %d/%d %s", percent, covered, total, relative_path)
+ end
+ end
+ end
+
+ ARGV.each { |file| require File.expand_path(file) }
+ RUBY
+end
+
+def capture_coverage_output(test_files:, lowest_count:, chdir:)
+ capture_command("ruby", "-Ilib", "-Itest", "-e", coverage_script(lowest_count:), *test_files, chdir:)
+end
+
+def parse_coverage_percent(output)
+ match = output.match(/Coverage \(lib\):\s+([0-9]+\.[0-9]+)%/)
+ abort "Error: unable to parse coverage output." unless match
+
+ Float(match[1])
+end
+
+def resolve_coverage_baseline_ref(baseline)
+ baseline_name = baseline.to_s.strip
+ abort "Error: baseline cannot be empty." if baseline_name.empty?
+
+ return coverage_merge_base_ref if baseline_name == "merge-base"
+
+ baseline_name
+end
+
+def coverage_merge_base_ref
+ remote = preferred_remote
+ remote_head_ref = remote_default_branch_ref(remote)
+ merge_base = capture_command("git", "merge-base", "HEAD", remote_head_ref).strip
+ abort "Error: could not resolve merge-base with #{remote_head_ref}." if merge_base.empty?
+
+ merge_base
+end
+
+def preferred_remote
+ upstream = capture_command_optional("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").strip
+ upstream_remote = upstream.split("/").first unless upstream.empty?
+ return upstream_remote if upstream_remote && !upstream_remote.empty?
+
+ remotes = capture_command("git", "remote").lines.map(&:strip).reject(&:empty?)
+ abort "Error: no git remotes configured; pass baseline=[." if remotes.empty?
+
+ remotes.include?("origin") ? "origin" : remotes.first
+end
+
+def remote_default_branch_ref(remote)
+ symbolic = capture_command_optional("git", "symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD").strip
+ if symbolic.empty?
+ fallback = "#{remote}/main"
+ capture_command("git", "rev-parse", "--verify", fallback)
+ return fallback
+ end
+
+ symbolic.sub("refs/remotes/", "")
+end
+
+def with_temporary_worktree(ref:)
+ temp_root = Dir.mktmpdir("coverage-baseline-")
+ worktree_path = File.join(temp_root, "worktree")
+
+ run_command("git", "worktree", "add", "--detach", worktree_path, ref)
+ begin
+ yield worktree_path
+ ensure
+ system("git", "worktree", "remove", "--force", worktree_path)
+ FileUtils.rm_rf(temp_root)
+ end
+end
+
+def capture_command(*command, chdir: Dir.pwd)
+ stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
+ output = +""
+ output << stdout unless stdout.empty?
+ output << stderr unless stderr.empty?
+ abort "Error: command failed: #{command.join(" ")}\n#{output}" unless status.success?
+
+ output
+end
+
+def capture_command_optional(*command, chdir: Dir.pwd)
+ stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
+ return stdout if status.success?
+ return "" if stderr.include?("no upstream configured") || stderr.include?("is not a symbolic ref")
+
+ ""
+end
+
+# Build the site with specified URL
+# @parameter url [String] The site URL to use
+def build(url)
+ require "pressa"
+
+ puts "Building site for #{url}..."
+ site = Pressa.create_site(source_path: ".", url_override: url)
+ generator = Pressa::SiteGenerator.new(site:)
+ generator.generate(source_path: ".", target_path: "www")
+ puts "Site built successfully in www/"
+end
+
+def run_build_target(target)
+ target_name = target.to_s
+ unless BUILD_TARGETS.include?(target_name)
+ abort "Error: invalid target '#{target_name}'. Use one of: #{BUILD_TARGETS.join(", ")}"
+ end
+
+ public_send(target_name)
+end
+
+def watch_paths
+ WATCHABLE_DIRECTORIES.flat_map { |path| ["-r", path] }
+end
+
+def standardrb_command(*extra_args)
+ ["bundle", "exec", "standardrb", *extra_args, *LINT_TARGETS]
+end
+
+def run_standardrb(*extra_args)
+ run_command(*standardrb_command(*extra_args))
+end
+
+def run_command(*command)
+ abort "Error: command failed: #{command.join(" ")}" unless system(*command)
+end
+
+def run_rsync(local_paths:, publish_dir:, dry_run:, delete:)
+ command = ["rsync", "-aKv", "-e", "ssh -4"]
+ command << "--dry-run" if dry_run
+ command << "--delete" if delete
+ command.concat(local_paths)
+ command << "#{PUBLISH_HOST}:#{publish_dir}"
+ abort "Error: rsync failed." unless system(*command)
+end
+
+def resolve_draft_input(input_path)
+ if input_path.include?("/")
+ if input_path.start_with?("posts/")
+ abort "Error: '#{input_path}' is already published in posts/ directory"
+ end
+
+ [input_path, File.basename(input_path)]
+ else
+ [draft_path(input_path), input_path]
+ end
+end
+
+def draft_path(filename)
+ File.join(DRAFTS_DIR, filename)
+end
+
+def slugify(title)
+ title.downcase
+ .gsub(/[^a-z0-9\s-]/, "")
+ .gsub(/\s+/, "-").squeeze("-")
+ .gsub(/^-|-$/, "")
+end
+
+def next_available_draft(base_filename = "untitled.md")
+ return base_filename unless File.exist?(draft_path(base_filename))
+
+ name_without_ext = File.basename(base_filename, ".md")
+ counter = 1
+ loop do
+ numbered_filename = "#{name_without_ext}-#{counter}.md"
+ return numbered_filename unless File.exist?(draft_path(numbered_filename))
+
+ counter += 1
+ end
+end
+
+def render_draft_template(title)
+ now = Time.now
+ <<~FRONTMATTER
+ ---
+ Author: #{current_author}
+ Title: #{title}
+ Date: unpublished
+ Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}
+ Tags:
+ ---
+
+ # #{title}
+
+ TKTK
+ FRONTMATTER
+end
+
+def current_author
+ Etc.getlogin || ENV["USER"] || `whoami`.strip
+rescue
+ ENV["USER"] || `whoami`.strip
+end
+
+def ordinal_date(time)
+ day = time.day
+ suffix = case day
+ when 1, 21, 31
+ "st"
+ when 2, 22
+ "nd"
+ when 3, 23
+ "rd"
+ else
+ "th"
+ end
+
+ time.strftime("#{day}#{suffix} %B, %Y")
+end
+
+def command_available?(command)
+ system("which", command, out: File::NULL, err: File::NULL)
+end
diff --git a/bin/bootstrap b/bin/bootstrap
index faa610c..0067969 100755
--- a/bin/bootstrap
+++ b/bin/bootstrap
@@ -1,50 +1,37 @@
#!/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"
+cd "$ROOT_DIR"
+
+if command -v rbenv >/dev/null 2>/dev/null; then
+ echo "*** using rbenv (ruby $RUBY_VERSION)"
+ rbenv install -s "$RUBY_VERSION"
+ if ! rbenv exec gem list -i bundler >/dev/null 2>/dev/null; then
+ rbenv exec gem install bundler
fi
-
- echo "*** installing inotify-tools for watch script"
- sudo apt install -y inotify-tools
+ rbenv exec bundle install
+else
+ echo "*** rbenv not found, using system Ruby"
+ if ! gem list -i bundler >/dev/null 2>/dev/null; then
+ gem install bundler
+ fi
+ 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/bin/new-draft b/bin/new-draft
deleted file mode 100755
index 5e93aa7..0000000
--- a/bin/new-draft
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env ruby -w
-
-require 'fileutils'
-
-DRAFTS_DIR = File.expand_path("../public/drafts", __dir__).freeze
-
-def usage
- puts "Usage: #{$0} [title]"
- puts
- puts "Examples:"
- puts " #{$0} Top 5 Ways to Write Clickbait # using a title without quotes"
- puts " #{$0} 'Something with punctuation?!' # fancy chars need quotes"
- puts " #{$0} working-with-databases # using a slug"
- puts " #{$0} # Creates untitled.md (or untitled-2.md, etc.)"
- puts
- puts "Creates a new draft in public/drafts/ directory with proper frontmatter."
-end
-
-def draft_path(filename)
- File.join(DRAFTS_DIR, filename)
-end
-
-def main
- if ARGV.include?('-h') || ARGV.include?('--help')
- usage
- exit 0
- end
-
- title, filename =
- if ARGV.empty?
- ['Untitled', next_available_draft]
- else
- given_title = ARGV.join(' ')
- filename = "#{slugify(given_title)}.md"
- path = draft_path(filename)
- if File.exist?(path)
- puts "Error: draft already exists at #{path}"
- exit 1
- end
-
- [given_title, filename]
- end
-
- FileUtils.mkdir_p(DRAFTS_DIR)
- path = draft_path(filename)
- content = render_template(title)
- File.write(path, content)
-
- puts "Created new draft at #{path}"
- puts '>>> Contents below <<<'
- puts
- puts content
-end
-
-def slugify(title)
- title.downcase
- .gsub(/[^a-z0-9\s-]/, '')
- .gsub(/\s+/, '-')
- .gsub(/-+/, '-')
- .gsub(/^-|-$/, '')
-end
-
-def next_available_draft(base_filename = 'untitled.md')
- return base_filename unless File.exist?(draft_path(base_filename))
-
- name_without_ext = File.basename(base_filename, '.md')
- counter = 1
- loop do
- numbered_filename = "#{name_without_ext}-#{counter}.md"
- return numbered_filename unless File.exist?(draft_path(numbered_filename))
- counter += 1
- end
-end
-
-def render_template(title)
- now = Time.now
- iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z')
-
- <<~FRONTMATTER
- ---
- Author: #{`whoami`.strip}
- Title: #{title}
- Date: unpublished
- Timestamp: #{iso_timestamp}
- Tags:
- ---
-
- # #{title}
-
- TKTK
- FRONTMATTER
-end
-
-main if $0 == __FILE__
diff --git a/bin/publish b/bin/publish
deleted file mode 100755
index af294d3..0000000
--- a/bin/publish
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/bin/bash
-
-# exit on errors
-set -e
-
-PUBLISH_HOST="mudge"
-PUBLISH_DIR="/var/www/samhuri.net/public"
-ECHO=0
-RSYNC_OPTS=""
-
-BREAK_WHILE=0
-while [[ $# > 0 ]]; do
- ARG="$1"
- case "$ARG" in
-
- -b|--beta)
- PUBLISH_DIR="/var/www/beta.samhuri.net/public"
- shift
- ;;
-
- -t|--test)
- ECHO=1
- RSYNC_OPTS="$RSYNC_OPTS --dry-run"
- shift
- ;;
-
- -d|--delete)
- RSYNC_OPTS="$RSYNC_OPTS --delete"
- shift
- ;;
-
- # we're at the paths, no more options
- *)
- BREAK_WHILE=1
- break
- ;;
-
- esac
-
- [[ $BREAK_WHILE -eq 1 ]] && break
-done
-
-declare -a CMD
-if [[ $# -eq 0 ]]; then
- CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS www/ $PUBLISH_HOST:$PUBLISH_DIR)
-else
- CMD=(rsync -aKv -e "ssh -4" $RSYNC_OPTS $@ $PUBLISH_HOST:$PUBLISH_DIR)
-fi
-
-if [[ $ECHO -eq 1 ]]; then
- echo "${CMD[@]}"
-fi
-
-"${CMD[@]}"
diff --git a/bin/publish-draft b/bin/publish-draft
deleted file mode 100755
index b754287..0000000
--- a/bin/publish-draft
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env ruby -w
-
-require 'fileutils'
-
-def usage
- puts "Usage: #{$0} ]"
- puts
- puts "Examples:"
- puts " #{$0} public/drafts/reverse-engineering-photo-urls.md"
- puts
- puts "Available drafts:"
- drafts = Dir.glob('public/drafts/*.md').map { |f| File.basename(f) }
- if drafts.empty?
- puts " (no drafts found)"
- else
- drafts.each { |d| puts " #{d}" }
- end
-end
-
-if ARGV.empty?
- usage
- abort
-end
-
-input_path = ARGV.first
-
-# Handle both full paths and just filenames
-if input_path.include?('/')
- draft_path = input_path
- draft_file = File.basename(input_path)
- if input_path.start_with?('posts/')
- abort "Error: '#{input_path}' is already published in posts/ directory"
- end
-else
- draft_file = input_path
- draft_path = "public/drafts/#{draft_file}"
-end
-
-abort "Error: File not found: #{draft_path}" unless File.exist?(draft_path)
-
-# Update display date timestamp to current time
-def ordinal_date(time)
- day = time.day
- suffix = case day
- when 1, 21, 31 then 'st'
- when 2, 22 then 'nd'
- when 3, 23 then 'rd'
- else 'th'
- end
- time.strftime("#{day}#{suffix} %B, %Y")
-end
-now = Time.now
-iso_timestamp = now.strftime('%Y-%m-%dT%H:%M:%S%:z')
-human_date = ordinal_date(now)
-content = File.read(draft_path)
-content.sub!(/^Date:.*$/, "Date: #{human_date}")
-content.sub!(/^Timestamp:.*$/, "Timestamp: #{iso_timestamp}")
-
-# Use current year/month for directory, pad with strftime
-year_month = now.strftime('%Y-%m')
-year, month = year_month.split('-')
-
-target_dir = "posts/#{year}/#{month}"
-FileUtils.mkdir_p(target_dir)
-target_path = "#{target_dir}/#{draft_file}"
-
-File.write(target_path, content)
-FileUtils.rm_f(draft_path)
-
-puts "Published draft: #{draft_path} → #{target_path}"
diff --git a/bin/watch b/bin/watch
deleted file mode 100755
index 0c74d5e..0000000
--- a/bin/watch
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/bash
-
-BLOG_TARGET=${BLOG_TARGET:-mudge}
-
-while true; do
- inotifywait -e modify,create,delete,move -r drafts -r posts
- echo "changed at $(date)"
- sleep 5
- make "$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/lib/pressa.rb b/lib/pressa.rb
new file mode 100644
index 0000000..958d59c
--- /dev/null
+++ b/lib/pressa.rb
@@ -0,0 +1,14 @@
+require "pressa/site"
+require "pressa/site_generator"
+require "pressa/plugin"
+require "pressa/posts/plugin"
+require "pressa/projects/plugin"
+require "pressa/utils/markdown_renderer"
+require "pressa/config/loader"
+
+module Pressa
+ def self.create_site(source_path: ".", url_override: nil)
+ loader = Config::Loader.new(source_path:)
+ loader.build_site(url_override:)
+ end
+end
diff --git a/lib/pressa/config/loader.rb b/lib/pressa/config/loader.rb
new file mode 100644
index 0000000..bf11540
--- /dev/null
+++ b/lib/pressa/config/loader.rb
@@ -0,0 +1,217 @@
+require "pressa/site"
+require "pressa/posts/plugin"
+require "pressa/projects/plugin"
+require "pressa/utils/markdown_renderer"
+require "pressa/config/simple_toml"
+
+module Pressa
+ module Config
+ class ValidationError < StandardError; end
+
+ class Loader
+ REQUIRED_SITE_KEYS = %w[author email title description url].freeze
+ REQUIRED_PROJECT_KEYS = %w[name title description url].freeze
+
+ def initialize(source_path:)
+ @source_path = source_path
+ end
+
+ def build_site(url_override: nil)
+ site_config = load_toml("site.toml")
+
+ validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
+
+ site_url = url_override || site_config["url"]
+ plugins = build_plugins(site_config)
+
+ Site.new(
+ author: site_config["author"],
+ email: site_config["email"],
+ title: site_config["title"],
+ description: site_config["description"],
+ url: site_url,
+ image_url: normalize_image_url(site_config["image_url"], site_url),
+ scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
+ styles: build_styles(site_config["styles"], context: "site.toml styles"),
+ plugins:,
+ renderers: [
+ Utils::MarkdownRenderer.new
+ ]
+ )
+ end
+
+ private
+
+ def load_toml(filename)
+ path = File.join(@source_path, filename)
+ SimpleToml.load_file(path)
+ rescue ParseError => e
+ raise ValidationError, e.message
+ end
+
+ def build_projects(projects_config)
+ projects = projects_config["projects"]
+ raise ValidationError, "Missing required top-level array 'projects' in projects.toml" unless projects
+ raise ValidationError, "Expected 'projects' to be an array in projects.toml" unless projects.is_a?(Array)
+
+ projects.map.with_index do |project, index|
+ unless project.is_a?(Hash)
+ raise ValidationError, "Project entry #{index + 1} must be a table in projects.toml"
+ end
+
+ validate_required!(project, REQUIRED_PROJECT_KEYS, context: "projects.toml project ##{index + 1}")
+
+ Projects::Project.new(
+ name: project["name"],
+ title: project["title"],
+ description: project["description"],
+ url: project["url"]
+ )
+ end
+ end
+
+ def validate_required!(hash, keys, context:)
+ missing = keys.reject do |key|
+ hash[key].is_a?(String) && !hash[key].strip.empty?
+ end
+
+ return if missing.empty?
+
+ raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
+ end
+
+ def build_plugins(site_config)
+ plugin_names = parse_plugin_names(site_config["plugins"])
+
+ plugin_names.map.with_index do |plugin_name, index|
+ case plugin_name
+ when "posts"
+ Posts::Plugin.new
+ when "projects"
+ build_projects_plugin(site_config)
+ else
+ raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
+ end
+ end
+ end
+
+ def parse_plugin_names(value)
+ return [] if value.nil?
+ raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
+
+ seen = {}
+
+ value.map.with_index do |plugin_name, index|
+ unless plugin_name.is_a?(String) && !plugin_name.strip.empty?
+ raise ValidationError, "Expected site.toml plugins[#{index}] to be a non-empty String"
+ end
+
+ normalized_plugin_name = plugin_name.strip
+ if seen[normalized_plugin_name]
+ raise ValidationError, "Duplicate plugin '#{normalized_plugin_name}' in site.toml plugins"
+ end
+ seen[normalized_plugin_name] = true
+
+ normalized_plugin_name
+ end
+ end
+
+ def build_projects_plugin(site_config)
+ projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
+ projects_config = load_toml("projects.toml")
+ projects = build_projects(projects_config)
+
+ Projects::Plugin.new(
+ projects:,
+ scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
+ styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
+ )
+ end
+
+ def hash_or_empty(value, context)
+ return {} if value.nil?
+ return value if value.is_a?(Hash)
+
+ raise ValidationError, "Expected #{context} to be a table"
+ end
+
+ def build_scripts(value, context:)
+ entries = array_or_empty(value, context)
+
+ entries.map.with_index do |item, index|
+ case item
+ when String
+ validate_asset_path!(
+ item,
+ context: "#{context}[#{index}]"
+ )
+ Script.new(src: item, defer: true)
+ when Hash
+ src = item["src"]
+ raise ValidationError, "Expected #{context}[#{index}].src to be a String" unless src.is_a?(String) && !src.empty?
+ validate_asset_path!(
+ src,
+ context: "#{context}[#{index}].src"
+ )
+
+ defer = item.key?("defer") ? item["defer"] : true
+ unless [true, false].include?(defer)
+ raise ValidationError, "Expected #{context}[#{index}].defer to be a Boolean"
+ end
+
+ Script.new(src:, defer:)
+ else
+ raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
+ end
+ end
+ end
+
+ def build_styles(value, context:)
+ entries = array_or_empty(value, context)
+
+ entries.map.with_index do |item, index|
+ case item
+ when String
+ validate_asset_path!(
+ item,
+ context: "#{context}[#{index}]"
+ )
+ Stylesheet.new(href: item)
+ when Hash
+ href = item["href"]
+ raise ValidationError, "Expected #{context}[#{index}].href to be a String" unless href.is_a?(String) && !href.empty?
+ validate_asset_path!(
+ href,
+ context: "#{context}[#{index}].href"
+ )
+
+ Stylesheet.new(href:)
+ else
+ raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
+ end
+ end
+ end
+
+ def array_or_empty(value, context)
+ return [] if value.nil?
+ return value if value.is_a?(Array)
+
+ raise ValidationError, "Expected #{context} to be an array"
+ end
+
+ def normalize_image_url(value, site_url)
+ return nil if value.nil?
+ return value if value.start_with?("http://", "https://")
+
+ normalized = value.start_with?("/") ? value : "/#{value}"
+ "#{site_url}#{normalized}"
+ end
+
+ def validate_asset_path!(value, context:)
+ return if value.start_with?("/", "http://", "https://")
+
+ raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
+ end
+ end
+ end
+end
diff --git a/lib/pressa/config/simple_toml.rb b/lib/pressa/config/simple_toml.rb
new file mode 100644
index 0000000..b182405
--- /dev/null
+++ b/lib/pressa/config/simple_toml.rb
@@ -0,0 +1,224 @@
+require "json"
+
+module Pressa
+ module Config
+ class ParseError < StandardError; end
+
+ class SimpleToml
+ def self.load_file(path)
+ new.parse(File.read(path))
+ rescue Errno::ENOENT
+ raise ParseError, "Config file not found: #{path}"
+ end
+
+ def parse(content)
+ root = {}
+ current_table = root
+ lines = content.each_line.to_a
+
+ line_index = 0
+ while line_index < lines.length
+ line = lines[line_index]
+ line_number = line_index + 1
+ source = strip_comments(line).strip
+ if source.empty?
+ line_index += 1
+ next
+ end
+
+ if source =~ /\A\[\[(.+)\]\]\z/
+ current_table = start_array_table(root, Regexp.last_match(1), line_number)
+ line_index += 1
+ next
+ end
+
+ if source =~ /\A\[(.+)\]\z/
+ current_table = start_table(root, Regexp.last_match(1), line_number)
+ line_index += 1
+ next
+ end
+
+ key, raw_value = parse_assignment(source, line_number)
+ while needs_continuation?(raw_value)
+ line_index += 1
+ raise ParseError, "Unterminated value for key '#{key}' at line #{line_number}" if line_index >= lines.length
+
+ continuation = strip_comments(lines[line_index]).strip
+ next if continuation.empty?
+
+ raw_value = "#{raw_value} #{continuation}"
+ end
+
+ if current_table.key?(key)
+ raise ParseError, "Duplicate key '#{key}' at line #{line_number}"
+ end
+
+ current_table[key] = parse_value(raw_value, line_number)
+ line_index += 1
+ end
+
+ root
+ end
+
+ private
+
+ def start_array_table(root, raw_path, line_number)
+ keys = parse_path(raw_path, line_number)
+ parent = ensure_path(root, keys[0..-2], line_number)
+ table_name = keys.last
+
+ parent[table_name] ||= []
+ array = parent[table_name]
+ unless array.is_a?(Array)
+ raise ParseError, "Expected array for '[[#{raw_path}]]' at line #{line_number}"
+ end
+
+ table = {}
+ array << table
+ table
+ end
+
+ def start_table(root, raw_path, line_number)
+ keys = parse_path(raw_path, line_number)
+ ensure_path(root, keys, line_number)
+ end
+
+ def ensure_path(root, keys, line_number)
+ cursor = root
+
+ keys.each do |key|
+ cursor[key] ||= {}
+ unless cursor[key].is_a?(Hash)
+ raise ParseError, "Expected table path '#{keys.join(".")}' at line #{line_number}"
+ end
+
+ cursor = cursor[key]
+ end
+
+ cursor
+ end
+
+ def parse_path(raw_path, line_number)
+ keys = raw_path.split(".").map(&:strip)
+ if keys.empty? || keys.any? { |part| part.empty? || part !~ /\A[A-Za-z0-9_]+\z/ }
+ raise ParseError, "Invalid table path '#{raw_path}' at line #{line_number}"
+ end
+ keys
+ end
+
+ def parse_assignment(source, line_number)
+ separator = index_of_unquoted(source, "=")
+ raise ParseError, "Invalid assignment at line #{line_number}" unless separator
+
+ key = source[0...separator].strip
+ value = source[(separator + 1)..].strip
+
+ if key.empty? || key !~ /\A[A-Za-z0-9_]+\z/
+ raise ParseError, "Invalid key '#{key}' at line #{line_number}"
+ end
+ raise ParseError, "Missing value for key '#{key}' at line #{line_number}" if value.empty?
+
+ [key, value]
+ end
+
+ def parse_value(raw_value, line_number)
+ JSON.parse(raw_value)
+ rescue JSON::ParserError
+ raise ParseError, "Unsupported TOML value '#{raw_value}' at line #{line_number}"
+ end
+
+ def needs_continuation?(source)
+ in_string = false
+ escaped = false
+ depth = 0
+
+ source.each_char do |char|
+ if in_string
+ if escaped
+ escaped = false
+ elsif char == "\\"
+ escaped = true
+ elsif char == '"'
+ in_string = false
+ end
+
+ next
+ end
+
+ case char
+ when '"'
+ in_string = true
+ when "[", "{"
+ depth += 1
+ when "]", "}"
+ depth -= 1
+ end
+ end
+
+ in_string || depth.positive?
+ end
+
+ def strip_comments(line)
+ output = +""
+ in_string = false
+ escaped = false
+
+ line.each_char do |char|
+ if in_string
+ output << char
+
+ if escaped
+ escaped = false
+ elsif char == "\\"
+ escaped = true
+ elsif char == '"'
+ in_string = false
+ end
+
+ next
+ end
+
+ case char
+ when '"'
+ in_string = true
+ output << char
+ when "#"
+ break
+ else
+ output << char
+ end
+ end
+
+ output
+ end
+
+ def index_of_unquoted(source, target)
+ in_string = false
+ escaped = false
+
+ source.each_char.with_index do |char, index|
+ if in_string
+ if escaped
+ escaped = false
+ elsif char == "\\"
+ escaped = true
+ elsif char == '"'
+ in_string = false
+ end
+
+ next
+ end
+
+ if char == '"'
+ in_string = true
+ next
+ end
+
+ return index if char == target
+ end
+
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/pressa/plugin.rb b/lib/pressa/plugin.rb
new file mode 100644
index 0000000..4bd5d83
--- /dev/null
+++ b/lib/pressa/plugin.rb
@@ -0,0 +1,11 @@
+module Pressa
+ class Plugin
+ def setup(site:, source_path:)
+ raise NotImplementedError, "#{self.class}#setup must be implemented"
+ end
+
+ def render(site:, target_path:)
+ raise NotImplementedError, "#{self.class}#render must be implemented"
+ end
+ end
+end
diff --git a/lib/pressa/posts/json_feed.rb b/lib/pressa/posts/json_feed.rb
new file mode 100644
index 0000000..5b4b185
--- /dev/null
+++ b/lib/pressa/posts/json_feed.rb
@@ -0,0 +1,76 @@
+require "json"
+require "pressa/utils/file_writer"
+require "pressa/views/feed_post_view"
+
+module Pressa
+ module Posts
+ class JSONFeedWriter
+ FEED_VERSION = "https://jsonfeed.org/version/1.1"
+
+ def initialize(site:, posts_by_year:)
+ @site = site
+ @posts_by_year = posts_by_year
+ end
+
+ def write_feed(target_path:, limit: 30)
+ recent = @posts_by_year.recent_posts(limit)
+
+ feed = build_feed(recent)
+
+ json = JSON.pretty_generate(feed)
+ file_path = File.join(target_path, "feed.json")
+ Utils::FileWriter.write(path: file_path, content: json)
+ end
+
+ private
+
+ def build_feed(posts)
+ author = {
+ name: @site.author,
+ url: @site.url,
+ avatar: @site.image_url
+ }
+
+ items = posts.map { |post| feed_item(post) }
+
+ {
+ icon: icon_url,
+ favicon: favicon_url,
+ items: items,
+ home_page_url: @site.url,
+ author:,
+ version: FEED_VERSION,
+ authors: [author],
+ feed_url: @site.url_for("/feed.json"),
+ language: "en-CA",
+ title: @site.title
+ }
+ end
+
+ def icon_url
+ @site.url_for("/images/apple-touch-icon-300.png")
+ end
+
+ def favicon_url
+ @site.url_for("/images/apple-touch-icon-80.png")
+ end
+
+ def feed_item(post)
+ content_html = Views::FeedPostView.new(post:, site: @site).call
+ permalink = @site.url_for(post.path)
+
+ item = {}
+ item[:url] = permalink
+ item[:external_url] = post.link if post.link_post?
+ item[:tags] = post.tags unless post.tags.empty?
+ item[:content_html] = content_html
+ item[:title] = post.link_post? ? "→ #{post.title}" : post.title
+ item[:author] = {name: post.author}
+ item[:date_published] = post.date.iso8601
+ item[:id] = permalink
+
+ item
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/metadata.rb b/lib/pressa/posts/metadata.rb
new file mode 100644
index 0000000..1b58756
--- /dev/null
+++ b/lib/pressa/posts/metadata.rb
@@ -0,0 +1,50 @@
+require "yaml"
+require "date"
+
+module Pressa
+ module Posts
+ class PostMetadata
+ REQUIRED_FIELDS = %w[Title Author Date Timestamp].freeze
+
+ attr_reader :title, :author, :date, :formatted_date, :link, :tags
+
+ def initialize(yaml_hash)
+ @raw = yaml_hash
+ validate_required_fields!
+ parse_fields
+ end
+
+ def self.parse(content)
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n/m
+ yaml_content = $1
+ yaml_hash = YAML.safe_load(yaml_content, permitted_classes: [Date, Time])
+ new(yaml_hash)
+ else
+ raise "No YAML front-matter found in post"
+ end
+ end
+
+ private
+
+ def validate_required_fields!
+ missing = REQUIRED_FIELDS.reject { |field| @raw.key?(field) }
+ raise "Missing required fields: #{missing.join(", ")}" unless missing.empty?
+ end
+
+ def parse_fields
+ @title = @raw["Title"]
+ @author = @raw["Author"]
+ timestamp = @raw["Timestamp"]
+ @date = timestamp.is_a?(String) ? DateTime.parse(timestamp) : timestamp.to_datetime
+ @formatted_date = @raw["Date"]
+ @link = @raw["Link"]
+ @tags = parse_tags(@raw["Tags"])
+ end
+
+ def parse_tags(value)
+ return [] if value.nil?
+ value.is_a?(Array) ? value : value.split(",").map(&:strip)
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/models.rb b/lib/pressa/posts/models.rb
new file mode 100644
index 0000000..d95d63e
--- /dev/null
+++ b/lib/pressa/posts/models.rb
@@ -0,0 +1,95 @@
+require "dry-struct"
+require "pressa/site"
+
+module Pressa
+ module Posts
+ class Post < Dry::Struct
+ attribute :slug, Types::String
+ attribute :title, Types::String
+ attribute :author, Types::String
+ attribute :date, Types::Params::DateTime
+ attribute :formatted_date, Types::String
+ attribute :link, Types::String.optional.default(nil)
+ attribute :tags, Types::Array.of(Types::String).default([].freeze)
+ attribute :body, Types::String
+ attribute :excerpt, Types::String
+ attribute :path, Types::String
+
+ def link_post?
+ !link.nil?
+ end
+
+ def year
+ date.year
+ end
+
+ def month
+ date.month
+ end
+
+ def formatted_month
+ date.strftime("%B")
+ end
+
+ def padded_month
+ format("%02d", month)
+ end
+ end
+
+ class Month < Dry::Struct
+ attribute :name, Types::String
+ attribute :number, Types::Integer
+ attribute :padded, Types::String
+
+ def self.from_date(date)
+ new(
+ name: date.strftime("%B"),
+ number: date.month,
+ padded: format("%02d", date.month)
+ )
+ end
+ end
+
+ class MonthPosts < Dry::Struct
+ attribute :month, Month
+ attribute :posts, Types::Array.of(Post)
+
+ def sorted_posts
+ posts.sort_by(&:date).reverse
+ end
+ end
+
+ class YearPosts < Dry::Struct
+ attribute :year, Types::Integer
+ attribute :by_month, Types::Hash.map(Types::Integer, MonthPosts)
+
+ def sorted_months
+ by_month.keys.sort.reverse.map { |month_num| by_month[month_num] }
+ end
+
+ def all_posts
+ by_month.values.flat_map(&:posts).sort_by(&:date).reverse
+ end
+ end
+
+ class PostsByYear < Dry::Struct
+ attribute :by_year, Types::Hash.map(Types::Integer, YearPosts)
+
+ def sorted_years
+ by_year.keys.sort.reverse
+ end
+
+ def all_posts
+ by_year.values.flat_map(&:all_posts).sort_by(&:date).reverse
+ end
+
+ def recent_posts(limit = 10)
+ all_posts.take(limit)
+ end
+
+ def earliest_year
+ by_year.keys.min
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/plugin.rb b/lib/pressa/posts/plugin.rb
new file mode 100644
index 0000000..f12db01
--- /dev/null
+++ b/lib/pressa/posts/plugin.rb
@@ -0,0 +1,38 @@
+require "pressa/plugin"
+require "pressa/posts/repo"
+require "pressa/posts/writer"
+require "pressa/posts/json_feed"
+require "pressa/posts/rss_feed"
+
+module Pressa
+ module Posts
+ class Plugin < Pressa::Plugin
+ attr_reader :posts_by_year
+
+ def setup(site:, source_path:)
+ posts_dir = File.join(source_path, "posts")
+ return unless Dir.exist?(posts_dir)
+
+ repo = PostRepo.new
+ @posts_by_year = repo.read_posts(posts_dir)
+ end
+
+ def render(site:, target_path:)
+ return unless @posts_by_year
+
+ writer = PostWriter.new(site:, posts_by_year: @posts_by_year)
+ writer.write_posts(target_path:)
+ writer.write_recent_posts(target_path:, limit: 10)
+ writer.write_archive(target_path:)
+ writer.write_year_indexes(target_path:)
+ writer.write_month_rollups(target_path:)
+
+ json_feed = JSONFeedWriter.new(site:, posts_by_year: @posts_by_year)
+ json_feed.write_feed(target_path:, limit: 30)
+
+ rss_feed = RSSFeedWriter.new(site:, posts_by_year: @posts_by_year)
+ rss_feed.write_feed(target_path:, limit: 30)
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/repo.rb b/lib/pressa/posts/repo.rb
new file mode 100644
index 0000000..a036556
--- /dev/null
+++ b/lib/pressa/posts/repo.rb
@@ -0,0 +1,124 @@
+require "kramdown"
+require "pressa/posts/models"
+require "pressa/posts/metadata"
+
+module Pressa
+ module Posts
+ class PostRepo
+ EXCERPT_LENGTH = 300
+
+ def initialize(output_path: "posts")
+ @output_path = output_path
+ @posts_by_year = {}
+ end
+
+ def read_posts(posts_dir)
+ enumerate_markdown_files(posts_dir) do |file_path|
+ post = read_post(file_path)
+ add_post_to_hierarchy(post)
+ end
+
+ PostsByYear.new(by_year: @posts_by_year)
+ end
+
+ private
+
+ def enumerate_markdown_files(dir, &block)
+ Dir.glob(File.join(dir, "**", "*.md")).each(&block)
+ end
+
+ def read_post(file_path)
+ content = File.read(file_path)
+ metadata = PostMetadata.parse(content)
+
+ body_markdown = content.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
+
+ html_body = render_markdown(body_markdown)
+
+ slug = File.basename(file_path, ".md")
+ path = generate_path(slug, metadata.date)
+ excerpt = generate_excerpt(body_markdown)
+
+ Post.new(
+ slug:,
+ title: metadata.title,
+ author: metadata.author,
+ date: metadata.date,
+ formatted_date: metadata.formatted_date,
+ link: metadata.link,
+ tags: metadata.tags,
+ body: html_body,
+ excerpt:,
+ path:
+ )
+ end
+
+ def render_markdown(markdown)
+ Kramdown::Document.new(
+ markdown,
+ input: "GFM",
+ hard_wrap: false,
+ syntax_highlighter: "rouge",
+ syntax_highlighter_opts: {
+ line_numbers: false,
+ wrap: true
+ }
+ ).to_html
+ end
+
+ def generate_path(slug, date)
+ year = date.year
+ month = format("%02d", date.month)
+ "/#{@output_path}/#{year}/#{month}/#{slug}"
+ end
+
+ def generate_excerpt(markdown)
+ text = markdown.dup
+
+ text.gsub!(/!\[[^\]]*\]\([^)]+\)/, "")
+ text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, "")
+
+ text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
+ text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
+
+ text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
+
+ text.gsub!(/<[^>]+>/, "")
+
+ text.gsub!(/\s+/, " ")
+ text.strip!
+
+ return "..." if text.empty?
+
+ "#{text[0...EXCERPT_LENGTH]}..."
+ end
+
+ def add_post_to_hierarchy(post)
+ year = post.year
+ month_num = post.month
+
+ @posts_by_year[year] ||= create_year_posts(year)
+ year_posts = @posts_by_year[year]
+
+ month_posts = year_posts.by_month[month_num]
+ if month_posts
+ updated_posts = month_posts.posts + [post]
+ year_posts.by_month[month_num] = MonthPosts.new(
+ month: month_posts.month,
+ posts: updated_posts
+ )
+ else
+ month = Month.from_date(post.date)
+ year_posts.by_month[month_num] = MonthPosts.new(
+ month:,
+ posts: [post]
+ )
+ end
+ end
+
+ def create_year_posts(year)
+ YearPosts.new(year:, by_month: {})
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/rss_feed.rb b/lib/pressa/posts/rss_feed.rb
new file mode 100644
index 0000000..c58b353
--- /dev/null
+++ b/lib/pressa/posts/rss_feed.rb
@@ -0,0 +1,53 @@
+require "builder"
+require "pressa/utils/file_writer"
+require "pressa/views/feed_post_view"
+
+module Pressa
+ module Posts
+ class RSSFeedWriter
+ def initialize(site:, posts_by_year:)
+ @site = site
+ @posts_by_year = posts_by_year
+ end
+
+ def write_feed(target_path:, limit: 30)
+ recent = @posts_by_year.recent_posts(limit)
+
+ xml = Builder::XmlMarkup.new(indent: 2)
+ xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
+
+ xml.rss :version => "2.0",
+ "xmlns:atom" => "http://www.w3.org/2005/Atom",
+ "xmlns:content" => "http://purl.org/rss/1.0/modules/content/" do
+ xml.channel do
+ xml.title @site.title
+ xml.link @site.url
+ xml.description @site.description
+ xml.pubDate recent.first.date.rfc822 if recent.any?
+ xml.tag! "atom:link", href: @site.url_for("/feed.xml"), rel: "self", type: "application/rss+xml"
+
+ recent.each do |post|
+ xml.item do
+ title = post.link_post? ? "→ #{post.title}" : post.title
+ permalink = @site.url_for(post.path)
+ xml.title title
+ xml.link permalink
+ xml.guid permalink, isPermaLink: "true"
+ xml.pubDate post.date.rfc822
+ xml.author post.author
+ xml.tag!("content:encoded") { xml.cdata!(render_feed_post(post)) }
+ end
+ end
+ end
+ end
+
+ file_path = File.join(target_path, "feed.xml")
+ Utils::FileWriter.write(path: file_path, content: xml.target!)
+ end
+
+ def render_feed_post(post)
+ Views::FeedPostView.new(post:, site: @site).call
+ end
+ end
+ end
+end
diff --git a/lib/pressa/posts/writer.rb b/lib/pressa/posts/writer.rb
new file mode 100644
index 0000000..0d54aa1
--- /dev/null
+++ b/lib/pressa/posts/writer.rb
@@ -0,0 +1,137 @@
+require "pressa/utils/file_writer"
+require "pressa/views/layout"
+require "pressa/views/post_view"
+require "pressa/views/recent_posts_view"
+require "pressa/views/archive_view"
+require "pressa/views/year_posts_view"
+require "pressa/views/month_posts_view"
+
+module Pressa
+ module Posts
+ class PostWriter
+ def initialize(site:, posts_by_year:)
+ @site = site
+ @posts_by_year = posts_by_year
+ end
+
+ def write_posts(target_path:)
+ @posts_by_year.all_posts.each do |post|
+ write_post(post:, target_path:)
+ end
+ end
+
+ def write_recent_posts(target_path:, limit: 10)
+ recent = @posts_by_year.recent_posts(limit)
+ content_view = Views::RecentPostsView.new(posts: recent, site: @site)
+
+ html = render_layout(
+ page_subtitle: nil,
+ canonical_url: @site.url,
+ content: content_view,
+ page_description: "Recent posts",
+ page_type: "article"
+ )
+
+ file_path = File.join(target_path, "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def write_archive(target_path:)
+ content_view = Views::ArchiveView.new(posts_by_year: @posts_by_year, site: @site)
+
+ html = render_layout(
+ page_subtitle: "Archive",
+ canonical_url: @site.url_for("/posts/"),
+ content: content_view,
+ page_description: "Archive of all posts"
+ )
+
+ file_path = File.join(target_path, "posts", "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def write_year_indexes(target_path:)
+ @posts_by_year.sorted_years.each do |year|
+ year_posts = @posts_by_year.by_year[year]
+ write_year_index(year:, year_posts:, target_path:)
+ end
+ end
+
+ def write_month_rollups(target_path:)
+ @posts_by_year.by_year.each do |year, year_posts|
+ year_posts.by_month.each do |_month_num, month_posts|
+ write_month_rollup(year:, month_posts:, target_path:)
+ end
+ end
+ end
+
+ private
+
+ def write_post(post:, target_path:)
+ content_view = Views::PostView.new(post:, site: @site, article_class: "container")
+
+ html = render_layout(
+ page_subtitle: post.title,
+ canonical_url: @site.url_for(post.path),
+ content: content_view,
+ page_description: post.excerpt,
+ page_type: "article"
+ )
+
+ file_path = File.join(target_path, post.path.sub(/^\//, ""), "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def write_year_index(year:, year_posts:, target_path:)
+ content_view = Views::YearPostsView.new(year:, year_posts:, site: @site)
+
+ html = render_layout(
+ page_subtitle: year.to_s,
+ canonical_url: @site.url_for("/posts/#{year}/"),
+ content: content_view,
+ page_description: "Archive of all posts from #{year}",
+ page_type: "article"
+ )
+
+ file_path = File.join(target_path, "posts", year.to_s, "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def write_month_rollup(year:, month_posts:, target_path:)
+ month = month_posts.month
+ content_view = Views::MonthPostsView.new(year:, month_posts:, site: @site)
+
+ title = "#{month.name} #{year}"
+ html = render_layout(
+ page_subtitle: title,
+ canonical_url: @site.url_for("/posts/#{year}/#{month.padded}/"),
+ content: content_view,
+ page_description: "Archive of all posts from #{title}",
+ page_type: "article"
+ )
+
+ file_path = File.join(target_path, "posts", year.to_s, month.padded, "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def render_layout(
+ page_subtitle:,
+ canonical_url:,
+ content:,
+ page_description: nil,
+ page_type: "website"
+ )
+ layout = Views::Layout.new(
+ site: @site,
+ page_subtitle:,
+ canonical_url:,
+ page_description:,
+ page_type:,
+ content:
+ )
+
+ layout.call
+ end
+ end
+ end
+end
diff --git a/lib/pressa/projects/models.rb b/lib/pressa/projects/models.rb
new file mode 100644
index 0000000..a70c769
--- /dev/null
+++ b/lib/pressa/projects/models.rb
@@ -0,0 +1,22 @@
+require "dry-struct"
+require "pressa/site"
+
+module Pressa
+ module Projects
+ class Project < Dry::Struct
+ attribute :name, Types::String
+ attribute :title, Types::String
+ attribute :description, Types::String
+ attribute :url, Types::String
+
+ def github_path
+ uri = URI.parse(url)
+ uri.path.sub(/^\//, "")
+ end
+
+ def path
+ "/projects/#{name}"
+ end
+ end
+ end
+end
diff --git a/lib/pressa/projects/plugin.rb b/lib/pressa/projects/plugin.rb
new file mode 100644
index 0000000..cb1a611
--- /dev/null
+++ b/lib/pressa/projects/plugin.rb
@@ -0,0 +1,86 @@
+require "pressa/plugin"
+require "pressa/utils/file_writer"
+require "pressa/views/layout"
+require "pressa/views/projects_view"
+require "pressa/views/project_view"
+require "pressa/projects/models"
+
+module Pressa
+ module Projects
+ class Plugin < Pressa::Plugin
+ attr_reader :scripts, :styles
+
+ def initialize(projects: [], scripts: [], styles: [])
+ @projects = projects
+ @scripts = scripts
+ @styles = styles
+ end
+
+ def setup(site:, source_path:)
+ end
+
+ def render(site:, target_path:)
+ write_projects_index(site:, target_path:)
+
+ @projects.each do |project|
+ write_project_page(project:, site:, target_path:)
+ end
+ end
+
+ private
+
+ def write_projects_index(site:, target_path:)
+ content_view = Views::ProjectsView.new(projects: @projects, site:)
+
+ html = render_layout(
+ site:,
+ page_subtitle: "Projects",
+ canonical_url: site.url_for("/projects/"),
+ content: content_view
+ )
+
+ file_path = File.join(target_path, "projects", "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def write_project_page(project:, site:, target_path:)
+ content_view = Views::ProjectView.new(project:, site:)
+
+ html = render_layout(
+ site:,
+ page_subtitle: project.title,
+ canonical_url: site.url_for(project.path),
+ content: content_view,
+ page_scripts: @scripts,
+ page_styles: @styles,
+ page_description: project.description
+ )
+
+ file_path = File.join(target_path, "projects", project.name, "index.html")
+ Utils::FileWriter.write(path: file_path, content: html)
+ end
+
+ def render_layout(
+ site:,
+ page_subtitle:,
+ canonical_url:,
+ content:,
+ page_scripts: [],
+ page_styles: [],
+ page_description: nil
+ )
+ layout = Views::Layout.new(
+ site:,
+ page_subtitle:,
+ canonical_url:,
+ page_scripts:,
+ page_styles:,
+ page_description:,
+ content:
+ )
+
+ layout.call
+ end
+ end
+ end
+end
diff --git a/lib/pressa/site.rb b/lib/pressa/site.rb
new file mode 100644
index 0000000..99899d4
--- /dev/null
+++ b/lib/pressa/site.rb
@@ -0,0 +1,39 @@
+require "dry-struct"
+
+module Pressa
+ module Types
+ include Dry.Types()
+ end
+
+ class Script < Dry::Struct
+ attribute :src, Types::String
+ attribute :defer, Types::Bool.default(true)
+ end
+
+ class Stylesheet < Dry::Struct
+ attribute :href, Types::String
+ end
+
+ class Site < Dry::Struct
+ attribute :author, Types::String
+ attribute :email, Types::String
+ attribute :title, Types::String
+ attribute :description, Types::String
+ attribute :url, Types::String
+ attribute :image_url, Types::String.optional.default(nil)
+ attribute :copyright_start_year, Types::Integer.optional.default(nil)
+ attribute :scripts, Types::Array.of(Script).default([].freeze)
+ attribute :styles, Types::Array.of(Stylesheet).default([].freeze)
+ attribute :plugins, Types::Array.default([].freeze)
+ attribute :renderers, Types::Array.default([].freeze)
+
+ def url_for(path)
+ "#{url}#{path}"
+ end
+
+ def image_url_for(path)
+ return nil unless image_url
+ "#{image_url}#{path}"
+ end
+ end
+end
diff --git a/lib/pressa/site_generator.rb b/lib/pressa/site_generator.rb
new file mode 100644
index 0000000..bc7aa6b
--- /dev/null
+++ b/lib/pressa/site_generator.rb
@@ -0,0 +1,123 @@
+require "fileutils"
+require "pressa/utils/file_writer"
+
+module Pressa
+ class SiteGenerator
+ attr_reader :site
+
+ def initialize(site:)
+ @site = site
+ end
+
+ def generate(source_path:, target_path:)
+ validate_paths!(source_path:, target_path:)
+
+ FileUtils.rm_rf(target_path)
+ FileUtils.mkdir_p(target_path)
+
+ setup_site = site
+ setup_site.plugins.each { |plugin| plugin.setup(site: setup_site, source_path:) }
+
+ @site = site_with_copyright_start_year(setup_site)
+ site.plugins.each { |plugin| plugin.render(site:, target_path:) }
+
+ copy_static_files(source_path, target_path)
+ process_public_directory(source_path, target_path)
+ end
+
+ private
+
+ def validate_paths!(source_path:, target_path:)
+ source_abs = absolute_path(source_path)
+ target_abs = absolute_path(target_path)
+ return unless contains_path?(container: target_abs, path: source_abs)
+
+ raise ArgumentError, "target_path must not be the same as or contain source_path"
+ end
+
+ def absolute_path(path)
+ File.exist?(path) ? File.realpath(path) : File.expand_path(path)
+ end
+
+ def contains_path?(container:, path:)
+ path == container || path.start_with?("#{container}#{File::SEPARATOR}")
+ end
+
+ def copy_static_files(source_path, target_path)
+ public_dir = File.join(source_path, "public")
+ return unless Dir.exist?(public_dir)
+
+ Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
+ next if File.directory?(source_file)
+ next if skip_file?(source_file)
+
+ filename = File.basename(source_file)
+ ext = File.extname(source_file)[1..]
+
+ if can_render?(filename, ext)
+ next
+ end
+
+ relative_path = source_file.sub("#{public_dir}/", "")
+ target_file = File.join(target_path, relative_path)
+
+ FileUtils.mkdir_p(File.dirname(target_file))
+ FileUtils.cp(source_file, target_file)
+ end
+ end
+
+ def can_render?(filename, ext)
+ site.renderers.any? { |renderer| renderer.can_render_file?(filename:, extension: ext) }
+ end
+
+ def process_public_directory(source_path, target_path)
+ public_dir = File.join(source_path, "public")
+ return unless Dir.exist?(public_dir)
+
+ site.renderers.each do |renderer|
+ Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
+ next if File.directory?(source_file)
+ next if skip_file?(source_file)
+
+ filename = File.basename(source_file)
+ ext = File.extname(source_file)[1..]
+
+ if renderer.can_render_file?(filename:, extension: ext)
+ dir_name = File.dirname(source_file)
+ relative_path = if dir_name == public_dir
+ ""
+ else
+ dir_name.sub("#{public_dir}/", "")
+ end
+ target_dir = File.join(target_path, relative_path)
+
+ renderer.render(site:, file_path: source_file, target_dir:)
+ end
+ end
+ end
+ end
+
+ def skip_file?(source_file)
+ basename = File.basename(source_file)
+ basename.start_with?(".")
+ end
+
+ def site_with_copyright_start_year(base_site)
+ start_year = find_copyright_start_year(base_site)
+ Site.new(**base_site.to_h.merge(copyright_start_year: start_year))
+ end
+
+ def find_copyright_start_year(base_site)
+ years = base_site.plugins.filter_map do |plugin|
+ next unless plugin.respond_to?(:posts_by_year)
+
+ posts_by_year = plugin.posts_by_year
+ next unless posts_by_year.respond_to?(:earliest_year)
+
+ posts_by_year.earliest_year
+ end
+
+ years.min || Time.now.year
+ end
+ end
+end
diff --git a/lib/pressa/utils/file_writer.rb b/lib/pressa/utils/file_writer.rb
new file mode 100644
index 0000000..96331ce
--- /dev/null
+++ b/lib/pressa/utils/file_writer.rb
@@ -0,0 +1,20 @@
+require "fileutils"
+
+module Pressa
+ module Utils
+ class FileWriter
+ def self.write(path:, content:, permissions: 0o644)
+ FileUtils.mkdir_p(File.dirname(path))
+ File.write(path, content, mode: "w")
+ File.chmod(permissions, path)
+ end
+
+ def self.write_data(path:, data:, permissions: 0o644)
+ FileUtils.mkdir_p(File.dirname(path))
+
+ File.binwrite(path, data)
+ File.chmod(permissions, path)
+ end
+ end
+ end
+end
diff --git a/lib/pressa/utils/markdown_renderer.rb b/lib/pressa/utils/markdown_renderer.rb
new file mode 100644
index 0000000..8dc1bfc
--- /dev/null
+++ b/lib/pressa/utils/markdown_renderer.rb
@@ -0,0 +1,148 @@
+require "kramdown"
+require "yaml"
+require "pressa/utils/file_writer"
+require "pressa/site"
+require "pressa/views/layout"
+require "pressa/views/icons"
+
+module Pressa
+ module Utils
+ class MarkdownRenderer
+ EXCERPT_LENGTH = 300
+
+ def can_render_file?(filename:, extension:)
+ extension == "md"
+ end
+
+ def render(site:, file_path:, target_dir:)
+ content = File.read(file_path)
+ metadata, body_markdown = parse_content(content)
+
+ html_body = render_markdown(body_markdown)
+
+ page_title = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
+ page_type = presence(metadata["Page type"]) || "website"
+ page_description = presence(metadata["Description"]) || generate_excerpt(body_markdown)
+ show_extension = ["true", "yes", true].include?(metadata["Show extension"])
+
+ slug = File.basename(file_path, ".md")
+
+ relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
+ relative_dir = "" if relative_dir == "."
+
+ canonical_path = if show_extension
+ "/#{relative_dir}/#{slug}.html".squeeze("/")
+ else
+ "/#{relative_dir}/#{slug}/".squeeze("/")
+ end
+
+ html = render_layout(
+ site:,
+ page_subtitle: page_title,
+ canonical_url: site.url_for(canonical_path),
+ body: html_body,
+ page_description:,
+ page_type:
+ )
+
+ output_filename = if show_extension
+ "#{slug}.html"
+ else
+ File.join(slug, "index.html")
+ end
+
+ output_path = File.join(target_dir, output_filename)
+ FileWriter.write(path: output_path, content: html)
+ end
+
+ private
+
+ def parse_content(content)
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m
+ yaml_content = $1
+ markdown = $2
+ metadata = YAML.safe_load(yaml_content) || {}
+ [metadata, markdown]
+ else
+ [{}, content]
+ end
+ end
+
+ def render_markdown(markdown)
+ Kramdown::Document.new(
+ markdown,
+ input: "GFM",
+ hard_wrap: false,
+ syntax_highlighter: "rouge",
+ syntax_highlighter_opts: {
+ line_numbers: false,
+ wrap: true
+ }
+ ).to_html
+ end
+
+ def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:)
+ layout = Views::Layout.new(
+ site:,
+ page_subtitle:,
+ canonical_url:,
+ page_description:,
+ page_type:,
+ content: PageView.new(page_title: page_subtitle, body:)
+ )
+
+ layout.call
+ end
+
+ class PageView < Phlex::HTML
+ def initialize(page_title:, body:)
+ @page_title = page_title
+ @body = body
+ end
+
+ def view_template
+ article(class: "container") do
+ h1 { @page_title }
+ raw(safe(@body))
+ end
+
+ div(class: "row clearfix") do
+ p(class: "fin") do
+ raw(safe(Views::Icons.code))
+ end
+ end
+ end
+ end
+
+ def generate_excerpt(markdown)
+ text = markdown.dup
+
+ # Drop inline and reference-style images before links are simplified.
+ text.gsub!(/!\[[^\]]*\]\([^)]+\)/, "")
+ text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, "")
+
+ # Replace inline and reference links with just their text.
+ text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
+ text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
+
+ # Remove link reference definitions such as: [foo]: http://example.com
+ text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
+
+ text.gsub!(/<[^>]+>/, "")
+ text.gsub!(/\s+/, " ")
+ text.strip!
+
+ return nil if text.empty?
+
+ "#{text[0...EXCERPT_LENGTH]}..."
+ end
+
+ def presence(value)
+ return value unless value.respond_to?(:strip)
+
+ stripped = value.strip
+ stripped.empty? ? nil : stripped
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/archive_view.rb b/lib/pressa/views/archive_view.rb
new file mode 100644
index 0000000..1bde3b5
--- /dev/null
+++ b/lib/pressa/views/archive_view.rb
@@ -0,0 +1,24 @@
+require "phlex"
+require "pressa/views/year_posts_view"
+
+module Pressa
+ module Views
+ class ArchiveView < Phlex::HTML
+ def initialize(posts_by_year:, site:)
+ @posts_by_year = posts_by_year
+ @site = site
+ end
+
+ def view_template
+ div(class: "container") do
+ h1 { "Archive" }
+ end
+
+ @posts_by_year.sorted_years.each do |year|
+ year_posts = @posts_by_year.by_year[year]
+ render Views::YearPostsView.new(year:, year_posts:, site: @site)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/feed_post_view.rb b/lib/pressa/views/feed_post_view.rb
new file mode 100644
index 0000000..fabbc4b
--- /dev/null
+++ b/lib/pressa/views/feed_post_view.rb
@@ -0,0 +1,33 @@
+require "phlex"
+
+module Pressa
+ module Views
+ class FeedPostView < Phlex::HTML
+ def initialize(post:, site:)
+ @post = post
+ @site = site
+ end
+
+ def view_template
+ div do
+ p(class: "time") { @post.formatted_date }
+ raw(safe(normalized_body))
+ p do
+ a(class: "permalink", href: @site.url_for(@post.path)) { "∞" }
+ end
+ end
+ end
+
+ private
+
+ def normalized_body
+ @post.body.gsub(/(href|src)=(['"])(\/(?!\/)[^'"]*)\2/) do
+ attr = Regexp.last_match(1)
+ quote = Regexp.last_match(2)
+ path = Regexp.last_match(3)
+ %(#{attr}=#{quote}#{@site.url_for(path)}#{quote})
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/icons.rb b/lib/pressa/views/icons.rb
new file mode 100644
index 0000000..6a3c7dd
--- /dev/null
+++ b/lib/pressa/views/icons.rb
@@ -0,0 +1,34 @@
+module Pressa
+ module Views
+ module Icons
+ module_function
+
+ def mastodon
+ svg(class_name: "icon icon-mastodon", view_box: "0 0 448 512", path: IconPath::MASTODON)
+ end
+
+ def github
+ svg(class_name: "icon icon-github", view_box: "0 0 496 512", path: IconPath::GITHUB)
+ end
+
+ def rss
+ svg(class_name: "icon icon-rss", view_box: "0 0 448 512", path: IconPath::RSS)
+ end
+
+ def code
+ svg(class_name: "icon icon-code", view_box: "0 0 640 512", path: IconPath::CODE)
+ end
+
+ private_class_method def svg(class_name:, view_box:, path:)
+ " "
+ end
+
+ module IconPath
+ 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"
+ 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"
+ 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"
+ 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"
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/layout.rb b/lib/pressa/views/layout.rb
new file mode 100644
index 0000000..28dddcd
--- /dev/null
+++ b/lib/pressa/views/layout.rb
@@ -0,0 +1,208 @@
+require "phlex"
+require "pressa/views/icons"
+
+module Pressa
+ module Views
+ class Layout < Phlex::HTML
+ attr_reader :site,
+ :page_subtitle,
+ :page_description,
+ :page_type,
+ :canonical_url,
+ :page_scripts,
+ :page_styles,
+ :content
+
+ def initialize(
+ site:,
+ canonical_url:, page_subtitle: nil,
+ page_description: nil,
+ page_type: "website",
+ page_scripts: [],
+ page_styles: [],
+ content: nil
+ )
+ @site = site
+ @page_subtitle = page_subtitle
+ @page_description = page_description
+ @page_type = page_type
+ @canonical_url = canonical_url
+ @page_scripts = page_scripts
+ @page_styles = page_styles
+ @content = content
+ end
+
+ def view_template
+ doctype
+
+ html(lang: "en") do
+ comment { "meow" }
+
+ head do
+ meta(charset: "UTF-8")
+ title { full_title }
+ meta(name: "twitter:title", content: full_title)
+ meta(property: "og:title", content: full_title)
+ meta(name: "description", content: description)
+ meta(name: "twitter:description", content: description)
+ meta(property: "og:description", content: description)
+ meta(property: "og:site_name", content: site.title)
+
+ link(rel: "canonical", href: canonical_url)
+ meta(name: "twitter:url", content: canonical_url)
+ meta(property: "og:url", content: canonical_url)
+ meta(property: "og:image", content: og_image_url) if og_image_url
+ meta(property: "og:type", content: page_type)
+ meta(property: "article:author", content: site.author)
+ meta(name: "twitter:card", content: "summary")
+
+ link(
+ rel: "alternate",
+ href: site.url_for("/feed.xml"),
+ type: "application/rss+xml",
+ title: site.title
+ )
+ link(
+ rel: "alternate",
+ href: site.url_for("/feed.json"),
+ type: "application/json",
+ title: site.title
+ )
+
+ meta(name: "fediverse:creator", content: "@sjs@techhub.social")
+ link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
+ link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
+ link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
+ link(rel: "apple-touch-icon", href: site.url_for("/images/apple-touch-icon.png"))
+ link(rel: "mask-icon", color: "#aa0000", href: site.url_for("/images/safari-pinned-tab.svg"))
+ link(rel: "manifest", href: site.url_for("/images/manifest.json"))
+ meta(name: "msapplication-config", content: site.url_for("/images/browserconfig.xml"))
+ meta(name: "theme-color", content: "#121212")
+ meta(name: "viewport", content: "width=device-width, initial-scale=1.0, viewport-fit=cover")
+ link(rel: "dns-prefetch", href: "https://gist.github.com")
+
+ all_styles.each do |style|
+ link(rel: "stylesheet", type: "text/css", href: style_href(style.href))
+ end
+ end
+
+ body do
+ render_header
+ render(content) if content
+ render_footer
+ render_scripts
+ end
+ end
+ end
+
+ private
+
+ def description
+ page_description || site.description
+ end
+
+ def full_title
+ return site.title unless page_subtitle
+
+ "#{site.title}: #{page_subtitle}"
+ end
+
+ def og_image_url
+ site.image_url
+ end
+
+ def all_styles
+ site.styles + page_styles
+ end
+
+ def all_scripts
+ site.scripts + page_scripts
+ end
+
+ def render_header
+ header(class: "primary") do
+ div(class: "title") do
+ h1 do
+ a(href: site.url) { site.title }
+ end
+ br
+ h4 do
+ plain "By "
+ a(href: site.url_for("/about")) { site.author }
+ end
+ end
+
+ nav(class: "remote") do
+ ul do
+ li(class: "mastodon") do
+ a(rel: "me", "aria-label": "Mastodon", href: "https://techhub.social/@sjs") do
+ raw(safe(Icons.mastodon))
+ end
+ end
+ li(class: "github") do
+ a("aria-label": "GitHub", href: "https://github.com/samsonjs") do
+ raw(safe(Icons.github))
+ end
+ end
+ li(class: "rss") do
+ a("aria-label": "RSS", href: site.url_for("/feed.xml")) do
+ raw(safe(Icons.rss))
+ end
+ end
+ end
+ end
+
+ nav(class: "local") do
+ ul do
+ li { a(href: site.url_for("/about")) { "About" } }
+ li { a(href: site.url_for("/posts")) { "Archive" } }
+ li { a(href: site.url_for("/projects")) { "Projects" } }
+ end
+ end
+
+ div(class: "clearfix")
+ end
+ end
+
+ def render_footer
+ footer do
+ plain "© #{footer_years} "
+ a(href: site.url_for("/about")) { site.author }
+ end
+ end
+
+ def render_scripts
+ all_scripts.each do |scr|
+ attrs = {src: script_src(scr.src)}
+ attrs[:defer] = true if scr.defer
+ script(**attrs)
+ end
+ end
+
+ def script_src(src)
+ return src if src.start_with?("http://", "https://")
+
+ absolute_asset(src)
+ end
+
+ def style_href(href)
+ return href if href.start_with?("http://", "https://")
+
+ absolute_asset(href)
+ end
+
+ def absolute_asset(path)
+ normalized = path.start_with?("/") ? path : "/#{path}"
+ site.url_for(normalized)
+ end
+
+ def footer_years
+ current_year = Time.now.year
+ start_year = site.copyright_start_year || current_year
+ return current_year.to_s if start_year >= current_year
+
+ "#{start_year} - #{current_year}"
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/month_posts_view.rb b/lib/pressa/views/month_posts_view.rb
new file mode 100644
index 0000000..9b50016
--- /dev/null
+++ b/lib/pressa/views/month_posts_view.rb
@@ -0,0 +1,26 @@
+require "phlex"
+require "pressa/views/post_view"
+
+module Pressa
+ module Views
+ class MonthPostsView < Phlex::HTML
+ def initialize(year:, month_posts:, site:)
+ @year = year
+ @month_posts = month_posts
+ @site = site
+ end
+
+ def view_template
+ div(class: "container") do
+ h1 { "#{@month_posts.month.name} #{@year}" }
+ end
+
+ @month_posts.sorted_posts.each do |post|
+ div(class: "container") do
+ render PostView.new(post:, site: @site)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/post_view.rb b/lib/pressa/views/post_view.rb
new file mode 100644
index 0000000..30b0381
--- /dev/null
+++ b/lib/pressa/views/post_view.rb
@@ -0,0 +1,46 @@
+require "phlex"
+require "pressa/views/icons"
+
+module Pressa
+ module Views
+ class PostView < Phlex::HTML
+ def initialize(post:, site:, article_class: nil)
+ @post = post
+ @site = site
+ @article_class = article_class
+ end
+
+ def view_template
+ article(**article_attributes) do
+ header do
+ h2 do
+ if @post.link_post?
+ a(href: @post.link) { "→ #{@post.title}" }
+ else
+ a(href: @post.path) { @post.title }
+ end
+ end
+ time { @post.formatted_date }
+ a(href: @post.path, class: "permalink") { "∞" }
+ end
+
+ raw(safe(@post.body))
+ end
+
+ div(class: "row clearfix") do
+ p(class: "fin") do
+ raw(safe(Icons.code))
+ end
+ end
+ end
+
+ private
+
+ def article_attributes
+ return {} unless @article_class
+
+ {class: @article_class}
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/project_view.rb b/lib/pressa/views/project_view.rb
new file mode 100644
index 0000000..53f6906
--- /dev/null
+++ b/lib/pressa/views/project_view.rb
@@ -0,0 +1,63 @@
+require "phlex"
+require "pressa/views/icons"
+
+module Pressa
+ module Views
+ class ProjectView < Phlex::HTML
+ def initialize(project:, site:)
+ @project = project
+ @site = site
+ end
+
+ def view_template
+ article(class: "container project") do
+ h1(id: "project", data: {title: @project.title}) { @project.title }
+ h4 { @project.description }
+
+ div(class: "project-stats") do
+ p do
+ a(href: @project.url) { "GitHub" }
+ plain " • "
+ a(id: "nstar", href: stargazers_url)
+ plain " • "
+ a(id: "nfork", href: network_url)
+ end
+
+ p do
+ plain "Last updated on "
+ span(id: "updated")
+ end
+ end
+
+ div(class: "project-info row clearfix") do
+ div(class: "column half") do
+ h3 { "Contributors" }
+ div(id: "contributors")
+ end
+
+ div(class: "column half") do
+ h3 { "Languages" }
+ div(id: "langs")
+ end
+ end
+ end
+
+ div(class: "row clearfix") do
+ p(class: "fin") do
+ raw(safe(Icons.code))
+ end
+ end
+ end
+
+ private
+
+ def stargazers_url
+ "#{@project.url}/stargazers"
+ end
+
+ def network_url
+ "#{@project.url}/network/members"
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/projects_view.rb b/lib/pressa/views/projects_view.rb
new file mode 100644
index 0000000..a1f20d1
--- /dev/null
+++ b/lib/pressa/views/projects_view.rb
@@ -0,0 +1,34 @@
+require "phlex"
+require "pressa/views/icons"
+
+module Pressa
+ module Views
+ class ProjectsView < Phlex::HTML
+ def initialize(projects:, site:)
+ @projects = projects
+ @site = site
+ end
+
+ def view_template
+ article(class: "container") do
+ h1 { "Projects" }
+
+ @projects.each do |project|
+ div(class: "project-listing") do
+ h4 do
+ a(href: @site.url_for(project.path)) { project.title }
+ end
+ p(class: "description") { project.description }
+ end
+ end
+ end
+
+ div(class: "row clearfix") do
+ p(class: "fin") do
+ raw(safe(Icons.code))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/recent_posts_view.rb b/lib/pressa/views/recent_posts_view.rb
new file mode 100644
index 0000000..79550c2
--- /dev/null
+++ b/lib/pressa/views/recent_posts_view.rb
@@ -0,0 +1,21 @@
+require "phlex"
+require "pressa/views/post_view"
+
+module Pressa
+ module Views
+ class RecentPostsView < Phlex::HTML
+ def initialize(posts:, site:)
+ @posts = posts
+ @site = site
+ end
+
+ def view_template
+ div(class: "container") do
+ @posts.each do |post|
+ render PostView.new(post:, site: @site)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pressa/views/year_posts_view.rb b/lib/pressa/views/year_posts_view.rb
new file mode 100644
index 0000000..82b5743
--- /dev/null
+++ b/lib/pressa/views/year_posts_view.rb
@@ -0,0 +1,66 @@
+require "phlex"
+
+module Pressa
+ module Views
+ class YearPostsView < Phlex::HTML
+ def initialize(year:, year_posts:, site:)
+ @year = year
+ @year_posts = year_posts
+ @site = site
+ end
+
+ def view_template
+ div(class: "container") do
+ h2(class: "year") do
+ a(href: year_path) { @year.to_s }
+ end
+
+ @year_posts.sorted_months.each do |month_posts|
+ render_month(month_posts)
+ end
+ end
+ end
+
+ private
+
+ def year_path
+ @site.url_for("/posts/#{@year}/")
+ end
+
+ def render_month(month_posts)
+ month = month_posts.month
+
+ h3(class: "month") do
+ a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do
+ month.name
+ end
+ end
+
+ ul(class: "archive") do
+ month_posts.sorted_posts.each do |post|
+ render_post_item(post)
+ end
+ end
+ end
+
+ def render_post_item(post)
+ if post.link_post?
+ li do
+ a(href: post.link) { "→ #{post.title}" }
+ time { short_date(post.date) }
+ a(class: "permalink", href: post.path) { "∞" }
+ end
+ else
+ li do
+ a(href: post.path) { post.title }
+ time { short_date(post.date) }
+ end
+ end
+ end
+
+ def short_date(date)
+ date.strftime("%-d %b")
+ end
+ end
+ end
+end
diff --git a/posts/2006/02/first-post.md b/posts/2006/02/first-post.md
index fab2b2d..63ab7f4 100644
--- a/posts/2006/02/first-post.md
+++ b/posts/2006/02/first-post.md
@@ -1,7 +1,7 @@
---
-Title: First Post!
+Title: "First Post!"
Author: Sami Samhuri
-Date: 8th February, 2006
+Date: "8th February, 2006"
Timestamp: 2006-02-07T19:21:00-08:00
Tags: life
---
diff --git a/posts/2006/02/girlfriend-x.md b/posts/2006/02/girlfriend-x.md
index 03fbe89..14757ad 100644
--- a/posts/2006/02/girlfriend-x.md
+++ b/posts/2006/02/girlfriend-x.md
@@ -1,7 +1,7 @@
---
-Title: Girlfriend X
+Title: "Girlfriend X"
Author: Sami Samhuri
-Date: 18th February, 2006
+Date: "18th February, 2006"
Timestamp: 2006-02-18T11:50:00-08:00
Tags: crazy, funny
---
diff --git a/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate.md b/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate.md
index 6a42fa5..b9aebf2 100644
--- a/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate.md
+++ b/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate.md
@@ -1,7 +1,7 @@
---
-Title: Intelligent Migration Snippets 0.1 for TextMate
+Title: "Intelligent Migration Snippets 0.1 for TextMate"
Author: Sami Samhuri
-Date: 22nd February, 2006
+Date: "22nd February, 2006"
Timestamp: 2006-02-22T03:28:00-08:00
Tags: mac os x, textmate, rails, hacking, migrations, snippets
---
diff --git a/posts/2006/02/jump-to-viewcontroller-in-textmate.md b/posts/2006/02/jump-to-viewcontroller-in-textmate.md
index cddaba1..a6cbec6 100644
--- a/posts/2006/02/jump-to-viewcontroller-in-textmate.md
+++ b/posts/2006/02/jump-to-viewcontroller-in-textmate.md
@@ -1,7 +1,7 @@
---
-Title: Jump to view/controller in TextMate
+Title: "Jump to view/controller in TextMate"
Author: Sami Samhuri
-Date: 18th February, 2006
+Date: "18th February, 2006"
Timestamp: 2006-02-18T14:51:00-08:00
Tags: hacking, rails, textmate, rails, textmate
---
diff --git a/posts/2006/02/obligatory-post-about-ruby-on-rails.md b/posts/2006/02/obligatory-post-about-ruby-on-rails.md
index 49eb02c..28ba837 100644
--- a/posts/2006/02/obligatory-post-about-ruby-on-rails.md
+++ b/posts/2006/02/obligatory-post-about-ruby-on-rails.md
@@ -1,10 +1,9 @@
---
-Title: Obligatory Post about Ruby on Rails
+Title: "Obligatory Post about Ruby on Rails"
Author: Sami Samhuri
-Date: 20th February, 2006
+Date: "20th February, 2006"
Timestamp: 2006-02-20T00:31:00-08:00
Tags: rails, coding, hacking, migration, rails, testing
-Styles: typocode.css
---
I'm a Rails newbie and eager to learn. I welcome any suggestions or criticism you have. You can direct them to my inbox or leave me a comment below.
@@ -17,52 +16,36 @@ Styles: typocode.css
It's unlikely that he was surprised at my lengthy response, but I was. I have been known to write him long messages on topics that interest me. However, I've only been learning Rails for two weeks or so. Could I possibly have so much to say about it already? Apparently I do.
Ruby on Rails background
-
I assume a pretty basic knowledge of what Rails is, so if you're not familiar with it now's a good time to read something on the official Rails website and watch the infamous 15-minute screencast , where Rails creator, David Heinemeier Hansson , creates a simple blog application.
-
The screencasts are what sparked my curiosity, but they hardly scratch the surface of Rails. After that I spent hours reading whatever I could find about Rails before deciding to take the time to learn it well. As a result, a lot of what you read here will sound familiar if you've read other blogs and articles about Rails. This post wasn't planned so there's no list of references yet. I hope to add some links though so please contact me if any ideas or paraphrasing here is from your site, or if you know who I should give credit to.
-
Rails through my eyes
-
Rails is like my Black & Decker toolkit. I have a hammer, power screwdriver, tape measure, needle-nose pliers, wire cutters, a level, etc. This is exactly what I need—no more, no less. It helps me get things done quickly and easily that would otherwise be painful and somewhat difficult. I can pick up the tools and use them without much training. Therefore I am instantly productive with them.
-
The kit is suitable for many people who need these things at home, such as myself. Companies build skyscrapers and huge malls and apartments, and they clearly need more powerful tools than I. There are others that just need to drive in a nail to hang a picture, in which case the kit I have is overkill. They're better off just buying and using a single hammer. I happen to fall in the big grey middle chunk , not the other two.
-
I'm a university student. I code because it's satisfying and fun to create software. I do plan on coding for a living when I graduate. I don't work with ancient databases, or create monster sites like Amazon, Google, or Ebay. The last time I started coding a website from scratch I was using PHP , that was around the turn of the millennium. [It was a fan site for a favourite band of mine.]
-
After a year or so I realized I didn't have the time to do it properly (ie. securely and cleanly) if I wanted it to be done relatively soon. A slightly customized MediaWiki promptly took it's place. It did all that I needed quite well, just in a less specific way.
-
The wiki is serving my site extremely well, but there's still that itch to create my own site. I feel if Rails was around back then I may have been able to complete the project in a timely manner. I was also frustrated with PHP. Part of that is likely due to a lack of experience and of formal programming education at that time, but it was still not fun for me. It wasn't until I started learning Rails that I thought "hey, I could create that site pretty quickly using this! "
-
Rails fits my needs like a glove, and this is where it shines. Many professionals are making money creating sites in Rails, so I'm not trying to say it's for amateurs only or something equally silly.
-
Web Frameworks and iPods?
-
Some might say I have merely been swept up in hype and am following the herd. You may be right, and that's okay. I'm going to tell you a story. There was a guy who didn't get one of the oh-so-shiny iPods for a long time, though they looked neat. His discman plays mp3 CDs, and that was good enough for him. The latest iPod, which plays video, was sufficiently cool enough for him to forget that everyone at his school has an iPod and he would be trendy just like them now.
-
Shocker ending: he is I, and I am him. Now I know why everyone has one of those shiny devices. iPods and web frameworks have little in common except that many believe both the iPod and Rails are all hype and flash. I've realized that something creating this kind of buzz may actually just be a good product. I feel that this is the only other thing the iPod and Rails have in common: they are both damn good . Enough about the iPod, everyone hates hearing about it. My goal is to write about the other thing everyone is tired of hearing about.
-
Why is Rails special?
-
Rails is not magic. There are no exclusive JavaScript libraries or HTML tags. We all have to produce pages that render in the same web browsers. My dad was correct, there is nothing special about my website either. It's more or less a stock Typo website.
-
So what makes developing with Rails different? For me there are four big things that set Rails apart from the alternatives:
-
Separating data, function, and design
Readability (which is underrated)
@@ -70,148 +53,120 @@ Styles: typocode.css
Testing is so easy it hurts
-
MVC 101 (or, Separating data, function, and design)
-
Now I'm sure you've heard about separating content from design. Rails takes that one step further from just using CSS to style your website. It uses what's known as the MVC paradigm: Model-View-Controller . This is a tried and tested development method. I'd used MVC before in Cocoa programming on Mac OS X, so I was already sold on this point.
-
The model deals with your data. If you're creating an online store you have a product model, a shopping cart model, a customer model, etc. The model takes care of storing this data in the database (persistence), and presenting it to you as an object you can manipulate at runtime.
-
The view deals only with presentation. That's it, honestly. An interface to your app.
-
The controller binds the model to the view, so that when the user clicks on the Add to cart link the controller is wired to call the add_product method of the cart model and tell it which product to add. Then the controller takes the appropriate action such as redirecting the user to the shopping cart view.
-
Of course this is not exclusive to Rails, but it's an integral part of it's design.
-
Readability
-
Rails, and Ruby , both read amazingly like spoken English. This code is more or less straight out of Typo. You define relationships between objects like this:
-
-class Article < Content
- has_many :comments , :dependent => true , :order => " created_at ASC "
- has_many :trackbacks , :dependent => true , :order => " created_at ASC "
- has_and_belongs_to_many :categories , :foreign_key => ' article_id '
- has_and_belongs_to_many :tags , :foreign_key => ' article_id '
- belongs_to :user
- ...
+```ruby
+class Article < Content
+ has_many :comments, :dependent => true, :order => "created_at ASC"
+ has_many :trackbacks, :dependent => true, :order => "created_at ASC"
+ has_and_belongs_to_many :categories, :foreign_key => 'article_id'
+ has_and_belongs_to_many :tags, :foreign_key => 'article_id'
+ belongs_to :user
+ ...
+```
dependent => true means if an article is deleted, it's comments go with it . Don't worry if you don't understand it all, this is just for you to see some actual Rails code.
-
In the Comment model you have:
+```ruby
+class Comment < Content
+ belongs_to :article
+ belongs_to :user
-class Comment < Content
- belongs_to :article
- belongs_to :user
-
- validates_presence_of :author , :body
- validates_against_spamdb :body , :url , :ip
- validates_age_of :article_id
- ...
+ validates_presence_of :author, :body
+ validates_against_spamdb :body, :url, :ip
+ validates_age_of :article_id
+ ...
+```
(I snuck in some validations as well)
-
But look how it reads! Read it out loud. I'd bet that my mom would more or less follow this, and she's anything but a programmer. That's not to say programming should be easy for grandma, but code should be easily understood by humans . Let the computer understand things that are natural for me to type, since we're making it understand a common language anyways.
-
Ruby and Ruby on Rails allow and encourage you to write beautiful code. That is so much more important than you may realize, because it leads to many other virtues. Readability is obvious, and hence maintainability. You must read code to understand and modify it. Oh, and happy programmers will be more productive than frustrated programmers.
-
Database Migrations
-
Here's one more life-saver: migrations. Migrations are a way to version your database schema from within Rails. So you have a table, call it albums, and you want to add the date the album was released. You could modify the database directly, but that's not fun. Even if you only have one server, all your configuration will be in one central place, the app. And Rails doesn't care if you have PostgreSQL, MySQL, or SQLite behind it. You can develop and test on SQLite and deploy on MySQL and the migrations will just work in both environments.
+```ruby
+class AddDateReleased < ActiveRecord::Migration
+ def self.up
+ add_column "albums", "date_released", :datetime
+ Albums.update_all "date_released = now()"
+ end
-class AddDateReleased < ActiveRecord :: Migration
- def self.up
- add_column " albums ", " date_released ", :datetime
- Albums . update_all " date_released = now() "
- end
-
- def self.down
- remove_column " albums ", " date_released "
- end
-end
+ def self.down
+ remove_column "albums", "date_released"
+ end
+end
+```
Then you run the migration (rake migrate does that) and boom, your up to date. If you're wondering, the self.down method indeed implies that you can take this the other direction as well. Think rake migrate VERSION=X.
-
Along with the other screencasts is one on migrations featuring none other than David Hansson. You should take a look, it's the third video.
-
Testing so easy it hurts
-
To start a rails project you type rails project_name and it creates a directory structure with a fresh project in it. This includes a directory appropriately called test which houses unit tests for the project. When you generate models and controllers it creates test stubs for you in that directory. Basically, it makes it so easy to test that you're a fool not to do it. As someone wrote on their site: It means never having to say "I introduced a new bug while fixing another. "
-
Rails builds on the unit testing that comes with Ruby. On a larger scale, that means that Rails is unlikely to flop on you because it is regularly tested using the same method. Ruby is unlikely to flop for the same reason. That makes me look good as a programmer. If you code for a living then it's of even more value to you.
-
I don't know why it hurts. Maybe it hurts developers working with other frameworks or languages to see us have it so nice and easy.
-
Wrapping up
-
Rails means I have fun doing web development instead of being frustrated (CSS hacks aside). David Hansson may be right when he said you have to have been soured by Java or PHP to fully appreciate Rails, but that doesn't mean you won't enjoy it if you do like Java or PHP.
-
Justin Gehtland rewrote a Java app using Rails and the number of lines of code of the Rails version was very close to that of the XML configuration for the Java version. Java has strengths, libraries available now seems to be a big one, but it's too big for my needs. If you're like me then maybe you'll enjoy Rails as much as I do.
-
You're not done, you lied to me!
-
Sort of... there are a few things that it seems standard to include when someone writes about how Rails saved their life and gave them hope again. For completeness sake, I feel compelled to mention some principles common amongst those who develop Rails, and those who develop on Rails. It's entirely likely that there's nothing new for you here unless you're new to Rails or to programming, in which case I encourage you to read on.
-
DRY
-
Rails follows the DRY principle religiously. That is, Don't Repeat Yourself . Like MVC, I was already sold on this. I had previously encountered it in The Pragmatic Programmer . Apart from telling some_model it belongs_to :other_model and other_model that it has_many :some_models nothing has jumped out at me which violates this principle. However, I feel that reading a model's code and seeing it's relationships to other models right there is a Good Thingâ„¢.
-
Convention over configuration (or, Perceived intelligence)
-
Rails' developers also have the mantra "convention over configuration ", which you can see from the video there. (you did watch it, didn't you? ;) Basically that just means Rails has sane defaults, but is still flexible if you don't like the defaults. You don't have to write even one line of SQL with Rails, but if you need greater control then you can write your own SQL. A standard cliché: it makes the simple things easy and the hard possible .
-
Rails seems to have a level of intelligence which contributes to the wow-factor. After these relationships are defined I can now filter certain negative comments like so:
-
-article = Article . find :first
-for comment in article . comments do
- print comment unless comment . downcase == ' you suck! '
-end
+```ruby
+article = Article.find :first
+for comment in article.comments do
+ print comment unless comment.downcase == 'you suck!'
+end
+```
Rails knows to look for the field article_id in the comments table of the database. This is just a convention. You can call it something else but then you have to tell Rails what you like to call it.
-
Rails understands pluralization, which is a detail but it makes everything feel more natural. If you have a Person model then it will know to look for the table named people .
-
Code as you learn
-
I love how I've only been coding in Rails for a week or two and I can do so much already. It's natural, concise and takes care of the inane details. I love how I know that I don't even have to explain that migration example. It's plainly clear what it does to the database. It doesn't take long to get the basics down and once you do it goes fast .
-
diff --git a/posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md b/posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md
index 63d8695..8c6dc43 100644
--- a/posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md
+++ b/posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md
@@ -1,10 +1,9 @@
---
-Title: SJ's Rails Bundle 0.2 for TextMate
+Title: "SJ's Rails Bundle 0.2 for TextMate"
Author: Sami Samhuri
-Date: 23rd February, 2006
+Date: "23rd February, 2006"
Timestamp: 2006-02-23T17:18:00-08:00
Tags: textmate, rails, coding, bundle, macros, rails, snippets, textmate
-Styles: typocode.css
---
Everything that you've seen posted on my blog is now available in one bundle. Snippets for Rails database migrations and assertions are all included in this bundle.
@@ -13,15 +12,17 @@ There are 2 macros for class-end and def-end blocks, bound to ⌃Cmethod ( arg1 , arg2_ )
+```ruby
+method(arg1, arg2_)
+```
Typing ⌃D at this point results in this code:
-
-def method ( arg1 , arg2 )
- _
-end
+```ruby
+def method(arg1, arg2)
+ _
+end
+```
There is a list of the snippets in Features.rtf, which is included in the disk image. Of course you can also browse them in the Snippets Editor built into TextMate.
diff --git a/posts/2006/02/some-textmate-snippets-for-rails-migrations.md b/posts/2006/02/some-textmate-snippets-for-rails-migrations.md
index 22055e7..0aeebb0 100644
--- a/posts/2006/02/some-textmate-snippets-for-rails-migrations.md
+++ b/posts/2006/02/some-textmate-snippets-for-rails-migrations.md
@@ -1,7 +1,7 @@
---
-Title: Some TextMate snippets for Rails Migrations
+Title: "Some TextMate snippets for Rails Migrations"
Author: Sami Samhuri
-Date: 18th February, 2006
+Date: "18th February, 2006"
Timestamp: 2006-02-18T22:48:00-08:00
Tags: textmate, rails, hacking, rails, snippets, textmate
---
@@ -16,39 +16,53 @@ Scope should be *source.ruby.rails* and the triggers I use are above the snippet
mcdt: **M**igration **C**reate and **D**rop **T**able
- create_table "${1:table}" do |t|
- $0
- end
- ${2:drop_table "$1"}
+```ruby
+create_table "${1:table}" do |t|
+ $0
+end
+${2:drop_table "$1"}
+```
mcc: **M**igration **C**reate **C**olumn
- t.column "${1:title}", :${2:string}
+```ruby
+t.column "${1:title}", :${2:string}
+```
marc: **M**igration **A**dd and **R**emove **C**olumn
- add_column "${1:table}", "${2:column}", :${3:string}
- ${4:remove_column "$1", "$2"}
+```ruby
+add_column "${1:table}", "${2:column}", :${3:string}
+${4:remove_column "$1", "$2"}
+```
I realize this might not be for everyone, so here are my original 4 snippets that do the work of *marc* and *mcdt*.
mct: **M**igration **C**reate **T**able
- create_table "${1:table}" do |t|
- $0
- end
+```ruby
+create_table "${1:table}" do |t|
+ $0
+end
+```
mdt: **M**igration **D**rop **T**able
- drop_table "${1:table}"
+```ruby
+drop_table "${1:table}"
+```
mac: **M**igration **A**dd **C**olumn
- add_column "${1:table}", "${2:column}", :${3:string}
+```ruby
+add_column "${1:table}", "${2:column}", :${3:string}
+```
mrc: **M**igration **R**remove **C**olumn
- remove_column "${1:table}", "${2:column}"
+```ruby
+remove_column "${1:table}", "${2:column}"
+```
I'll be adding more snippets and macros. There should be a central place where the rails bundle can be improved and extended. Maybe there is...
@@ -91,4 +105,3 @@ I'll be adding more snippets and macros. There should be a central place where t
P.S. I tried several ways to get the combo-snippets to put the pieces inside the right functions but failed. We'll see tomorrow if Allan (creator of TextMate) has any ideas.
-
diff --git a/posts/2006/02/textmate-insert-text-into-self-down.md b/posts/2006/02/textmate-insert-text-into-self-down.md
index df9b935..34fb1b6 100644
--- a/posts/2006/02/textmate-insert-text-into-self-down.md
+++ b/posts/2006/02/textmate-insert-text-into-self-down.md
@@ -1,46 +1,44 @@
---
-Title: TextMate: Insert text into self.down
+Title: "TextMate: Insert text into self.down"
Author: Sami Samhuri
-Date: 21st February, 2006
+Date: "21st February, 2006"
Timestamp: 2006-02-21T14:55:00-08:00
Tags: textmate, rails, hacking, commands, macro, rails, snippets, textmate
-Styles: typocode.css
---
UPDATE: I got everything working and it's all packaged up here . There's an installation script this time as well.
Thanks to a helpful thread on the TextMate mailing list I have the beginning of a solution to insert text at 2 (or more) locations in a file.
-
I implemented this for a new snippet I was working on for migrations, rename_column. Since the command is the same in self.up and self.down simply doing a reverse search for rename_column in my hackish macro didn't return the cursor the desired location.
That's enough introduction, here's the program to do the insertion:
+```ruby
+#!/usr/bin/env ruby
+def indent(s)
+ s =~ /^(\s*)/
+ ' ' * $1.length
+end
-
-def indent ( s )
- s =~ / ^(\s *) /
- ' ' * $1 . length
-end
+up_line = 'rename_column "${1:table}", "${2:column}", "${3:new_name}"$0'
+down_line = "rename_column \"$$1\", \"$$3\", \"$$2\"\n"
-up_line = ' rename_column "${1:table}", "${2:column}", "${3:new_name}"$0 '
-down_line = " rename_column \" $$1\" , \" $$3\" , \" $$2\"\n "
+# find the end of self.down and insert 2nd line
+lines = STDIN.read.to_a.reverse
+ends_seen = 0
+lines.each_with_index do |line, i|
+ ends_seen += 1 if line =~ /^\s*end\b/
+ if ends_seen == 2
+ lines[i..i] = [lines[i], indent(lines[i]) * 2 + down_line]
+ break
+ end
+end
-
-lines = STDIN . read . to_a . reverse
-ends_seen = 0
-lines . each_with_index do | line , i |
- ends_seen += 1 if line =~ / ^\s *end\b /
- if ends_seen == 2
- lines [ i .. i ] = [ lines [ i ], indent ( lines [ i ]) * 2 + down_line ]
- break
- end
-end
-
-
-print up_line + lines . reverse . to_s . gsub (' [$`\\ ] ', ' \\\\ \1'). gsub (' \\ $\\ $', ' $ ')
+# return the new text, escaping special chars
+print up_line + lines.reverse.to_s.gsub(/([$`\\])/, '\\\\\1').gsub(/\$\$/, '$')
+```
Save this as a command in your Rails, or syncPeople on Rails , bundle. The command options should be as follows:
-
Save: Nothing
Input: Selected Text or Nothing
@@ -49,10 +47,8 @@ Styles: typocode.css
Scope Selector: source.ruby.rails
-
The first modification it needs is to get the lines to insert as command line arguments so we can use it for other snippets. Secondly, regardless of the Re-indent pasted text setting the text returned is indented incorrectly.
-
The macro I'm thinking of to invoke this is tab-triggered and will simply:
Select word (⌃W )
@@ -60,5 +56,3 @@ The macro I'm thinking of to invoke this is tab-triggered and will simply:
Select to end of file (⇧⌘↓ )
Run command "Put in self.down"
-
-
diff --git a/posts/2006/02/textmate-move-selection-to-self-down.md b/posts/2006/02/textmate-move-selection-to-self-down.md
index f277402..7df3ab2 100644
--- a/posts/2006/02/textmate-move-selection-to-self-down.md
+++ b/posts/2006/02/textmate-move-selection-to-self-down.md
@@ -1,32 +1,29 @@
---
-Title: TextMate: Move selection to self.down
+Title: "TextMate: Move selection to self.down"
Author: Sami Samhuri
-Date: 21st February, 2006
+Date: "21st February, 2006"
Timestamp: 2006-02-21T00:26:00-08:00
Tags: textmate, rails, hacking, hack, macro, rails, textmate
-Styles: typocode.css
---
UPDATE: This is obsolete, see this post for a better solution.
Duane's comment prompted me to think about how to get the drop_table and remove_column lines inserted in the right place. I don't think TextMate's snippets are built to do this sort of text manipulation. It would be nicer, but a quick hack will suffice for now.
Use MCDT to insert:
-create_table " table " do | t |
+```ruby
+create_table "table" do |t|
-end
-drop_table " table "
+end
+drop_table "table"
+```
Then press tab once more after typing the table name to select the code drop_table "table". I created a macro that cuts the selected text, finds def self.down and pastes the line there. Then it searches for the previous occurence of create_table and moves the cursor to the next line, ready for you to add some columns.
-
I have this bound to ⌃⌥⌘M because it wasn't in use. If your Control key is to the left the A key it's quite comfortable to hit this combo. Copy the following file into ~/Library/Application Support/TextMate/Bundles/Rails.tmbundle/Macros .
-
Move selection to self.down
-
This works for the MARC snippet as well. I didn't tell you the whole truth, the macro actually finds the previous occurence of (create_table|add_column).
-
The caveat here is that if there is a create_table or add_column between self.down and the table you just added, it will jump back to the wrong spot. It's still faster than doing it all manually, but should be improved. If you use these exclusively, the order they occur in self.down will be opposite of that in self.up. That means either leaving things backwards or doing the re-ordering manually. =/
diff --git a/posts/2006/02/textmate-snippets-for-rails-assertions.md b/posts/2006/02/textmate-snippets-for-rails-assertions.md
index 0e7cc1a..6255751 100644
--- a/posts/2006/02/textmate-snippets-for-rails-assertions.md
+++ b/posts/2006/02/textmate-snippets-for-rails-assertions.md
@@ -1,7 +1,7 @@
---
-Title: TextMate Snippets for Rails Assertions
+Title: "TextMate Snippets for Rails Assertions"
Author: Sami Samhuri
-Date: 20th February, 2006
+Date: "20th February, 2006"
Timestamp: 2006-02-20T23:52:00-08:00
Tags: textmate, rails, coding, rails, snippets, testing, textmate
---
diff --git a/posts/2006/02/touch-screen-on-steroids.md b/posts/2006/02/touch-screen-on-steroids.md
index 14cea60..7b7e93b 100644
--- a/posts/2006/02/touch-screen-on-steroids.md
+++ b/posts/2006/02/touch-screen-on-steroids.md
@@ -1,7 +1,7 @@
---
-Title: Touch Screen on Steroids
+Title: "Touch Screen on Steroids"
Author: Sami Samhuri
-Date: 8th February, 2006
+Date: "8th February, 2006"
Timestamp: 2006-02-08T06:06:00-08:00
Tags: technology, touch
---
diff --git a/posts/2006/02/urban-extreme-gymnastics.md b/posts/2006/02/urban-extreme-gymnastics.md
index fd09b10..a3838a6 100644
--- a/posts/2006/02/urban-extreme-gymnastics.md
+++ b/posts/2006/02/urban-extreme-gymnastics.md
@@ -1,7 +1,7 @@
---
-Title: Urban Extreme Gymnastics?
+Title: "Urban Extreme Gymnastics?"
Author: Sami Samhuri
-Date: 15th February, 2006
+Date: "15th February, 2006"
Timestamp: 2006-02-15T10:41:00-08:00
Tags: amusement
---
diff --git a/posts/2006/03/generate-selfdown-in-your-rails-migrations.md b/posts/2006/03/generate-selfdown-in-your-rails-migrations.md
index 0fd9e19..3a11eae 100644
--- a/posts/2006/03/generate-selfdown-in-your-rails-migrations.md
+++ b/posts/2006/03/generate-selfdown-in-your-rails-migrations.md
@@ -1,7 +1,7 @@
---
-Title: Generate self.down in your Rails migrations
+Title: "Generate self.down in your Rails migrations"
Author: Sami Samhuri
-Date: 3rd March, 2006
+Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:38:00-08:00
Tags: rails, textmate, migrations, rails, textmate
---
diff --git a/posts/2006/03/i-dont-mind-fairplay-either.md b/posts/2006/03/i-dont-mind-fairplay-either.md
index 1493050..da1cf2b 100644
--- a/posts/2006/03/i-dont-mind-fairplay-either.md
+++ b/posts/2006/03/i-dont-mind-fairplay-either.md
@@ -1,7 +1,7 @@
---
-Title: I don't mind FairPlay either
+Title: "I don't mind FairPlay either"
Author: Sami Samhuri
-Date: 3rd March, 2006
+Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:56:00-08:00
Tags: apple, mac os x, life, drm, fairplay, ipod, itunes
---
diff --git a/posts/2006/03/spore.md b/posts/2006/03/spore.md
index 770ffbc..a317e43 100644
--- a/posts/2006/03/spore.md
+++ b/posts/2006/03/spore.md
@@ -1,7 +1,7 @@
---
-Title: Spore
+Title: "Spore"
Author: Sami Samhuri
-Date: 3rd March, 2006
+Date: "3rd March, 2006"
Timestamp: 2006-03-03T21:43:00-08:00
Tags: amusement, technology, cool, fun, games
---
diff --git a/posts/2006/04/zsh-terminal-goodness-on-os-x.md b/posts/2006/04/zsh-terminal-goodness-on-os-x.md
index 93cda1d..279123e 100644
--- a/posts/2006/04/zsh-terminal-goodness-on-os-x.md
+++ b/posts/2006/04/zsh-terminal-goodness-on-os-x.md
@@ -1,7 +1,7 @@
---
-Title: zsh terminal goodness on OS X
+Title: "zsh terminal goodness on OS X"
Author: Sami Samhuri
-Date: 4th April, 2006
+Date: "4th April, 2006"
Timestamp: 2006-04-04T14:57:00-07:00
Tags: mac os x, apple, osx, terminal, zsh
---
diff --git a/posts/2006/05/os-x-and-fitts-law.md b/posts/2006/05/os-x-and-fitts-law.md
index ec1b553..48bfbfe 100644
--- a/posts/2006/05/os-x-and-fitts-law.md
+++ b/posts/2006/05/os-x-and-fitts-law.md
@@ -1,7 +1,7 @@
---
-Title: OS X and Fitt's law
+Title: "OS X and Fitt's law"
Author: Sami Samhuri
-Date: 7th May, 2006
+Date: "7th May, 2006"
Timestamp: 2006-05-07T20:43:00-07:00
Tags: mac os x, apple, mac, os, usability, x
---
diff --git a/posts/2006/05/wikipediafs-on-linux-in-python.md b/posts/2006/05/wikipediafs-on-linux-in-python.md
index 97c2ccf..6a81c63 100644
--- a/posts/2006/05/wikipediafs-on-linux-in-python.md
+++ b/posts/2006/05/wikipediafs-on-linux-in-python.md
@@ -1,7 +1,7 @@
---
-Title: WikipediaFS on Linux, in Python
+Title: "WikipediaFS on Linux, in Python"
Author: Sami Samhuri
-Date: 7th May, 2006
+Date: "7th May, 2006"
Timestamp: 2006-05-07T20:49:00-07:00
Tags: hacking, python, linux, fuse, linux, mediawiki, python, wikipediafs
---
diff --git a/posts/2006/06/apple-pays-attention-to-detail.md b/posts/2006/06/apple-pays-attention-to-detail.md
index 59aebbe..cc50544 100644
--- a/posts/2006/06/apple-pays-attention-to-detail.md
+++ b/posts/2006/06/apple-pays-attention-to-detail.md
@@ -1,7 +1,7 @@
---
-Title: Apple pays attention to detail
+Title: "Apple pays attention to detail"
Author: Sami Samhuri
-Date: 11th June, 2006
+Date: "11th June, 2006"
Timestamp: 2006-06-11T01:30:00-07:00
Tags: technology, mac os x, apple
---
diff --git a/posts/2006/06/ich-bin-auslnder-und-spreche-nicht-gut-deutsch.md b/posts/2006/06/ich-bin-auslnder-und-spreche-nicht-gut-deutsch.md
index 3736759..de8ce70 100644
--- a/posts/2006/06/ich-bin-auslnder-und-spreche-nicht-gut-deutsch.md
+++ b/posts/2006/06/ich-bin-auslnder-und-spreche-nicht-gut-deutsch.md
@@ -1,7 +1,7 @@
---
-Title: Ich bin Ausländer und spreche nicht gut Deutsch
+Title: "Ich bin Ausländer und spreche nicht gut Deutsch"
Author: Sami Samhuri
-Date: 5th June, 2006
+Date: "5th June, 2006"
Timestamp: 2006-06-05T10:11:00-07:00
Tags: life, munich, seekport, work
---
diff --git a/posts/2006/06/never-buy-a-german-keyboard.md b/posts/2006/06/never-buy-a-german-keyboard.md
index de487e8..43c012c 100644
--- a/posts/2006/06/never-buy-a-german-keyboard.md
+++ b/posts/2006/06/never-buy-a-german-keyboard.md
@@ -1,7 +1,7 @@
---
-Title: Never buy a German keyboard!
+Title: "Never buy a German keyboard!"
Author: Sami Samhuri
-Date: 9th June, 2006
+Date: "9th June, 2006"
Timestamp: 2006-06-09T01:17:00-07:00
Tags: apple, apple, german, keyboard
---
diff --git a/posts/2006/06/theres-nothing-regular-about-regular-expressions.md b/posts/2006/06/theres-nothing-regular-about-regular-expressions.md
index ca9cd48..385b9b8 100644
--- a/posts/2006/06/theres-nothing-regular-about-regular-expressions.md
+++ b/posts/2006/06/theres-nothing-regular-about-regular-expressions.md
@@ -1,7 +1,7 @@
---
-Title: There's nothing regular about regular expressions
+Title: "There's nothing regular about regular expressions"
Author: Sami Samhuri
-Date: 10th June, 2006
+Date: "10th June, 2006"
Timestamp: 2006-06-10T01:28:00-07:00
Tags: technology, book, regex
---
@@ -16,8 +16,9 @@ It requires more thinking than the last 2 computer books I read, *Programming Ru
QOTD, p. 329, about matching nested pairs of parens:
- \(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
- Wow, that's ugly.
+```conf
+\(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
+Wow, that's ugly.
+```
(Don't worry, there's a much better solution on the next 2 pages after that quote.)
-
diff --git a/posts/2006/07/class-method-instance-method-it-doesnt-matter-to-php.md b/posts/2006/07/class-method-instance-method-it-doesnt-matter-to-php.md
index 3bb1516..7e3e579 100644
--- a/posts/2006/07/class-method-instance-method-it-doesnt-matter-to-php.md
+++ b/posts/2006/07/class-method-instance-method-it-doesnt-matter-to-php.md
@@ -1,7 +1,7 @@
---
-Title: Class method? Instance method? It doesn't matter to PHP
+Title: "Class method? Instance method? It doesn't matter to PHP"
Author: Sami Samhuri
-Date: 21st July, 2006
+Date: "21st July, 2006"
Timestamp: 2006-07-21T07:56:00-07:00
Tags: php, coding
---
@@ -16,7 +16,7 @@ I would fully expect the PHP parser to give me an error like "No class method [f
This code:
-
+```php
class Foo {
public static function static_fun()
{
@@ -29,7 +29,7 @@ class Foo {
}
}
-echo '<pre>';
+echo '';
echo "From Foo:\n";
echo Foo::static_fun();
echo Foo::not_static();
@@ -37,14 +37,14 @@ echo "\n";
echo "From \$foo = new Foo():\n";
$foo = new Foo();
-echo $foo->static_fun();
-echo $foo->not_static();
-echo '</pre>';
-
+echo $foo->static_fun();
+echo $foo->not_static();
+echo '';
+```
Produces:
-
+```php
From Foo:
This is a class method!
This is an instance method!
@@ -52,7 +52,7 @@ This is an instance method!
From $foo = new Foo():
This is a class method!
This is an instance method!
-
+```
What the fuck?! http://www.php.net/manual/en/language.oop5.static.php is lying to everyone.
diff --git a/posts/2006/07/late-static-binding.md b/posts/2006/07/late-static-binding.md
index 6d673b5..965bf34 100644
--- a/posts/2006/07/late-static-binding.md
+++ b/posts/2006/07/late-static-binding.md
@@ -1,7 +1,7 @@
---
-Title: Late static binding
+Title: "Late static binding"
Author: Sami Samhuri
-Date: 19th July, 2006
+Date: "19th July, 2006"
Timestamp: 2006-07-19T10:23:00-07:00
Tags: php, coding, coding, php
---
@@ -10,8 +10,7 @@ Tags: php, coding, coding, php
As colder on ##php (freenode) told me today, class methods in PHP don't have what they call late static binding. What's that? It means that this code:
-
-
+```php
class Foo
{
public static function my_method()
@@ -24,15 +23,13 @@ class Bar extends Foo
{}
Bar::my_method();
-
-
+```
outputs "I'm a Foo!", instead of "I'm a Bar!". That's not fun.
Using __CLASS__ in place of get_class() makes zero difference. You end up with proxy methods in each subclass of Foo that pass in the real name of the calling class, which sucks.
-
-
+```php
class Bar extends Foo
{
public static function my_method()
@@ -40,8 +37,7 @@ class Bar extends Foo
return parent::my_method( get_class() );
}
}
-
-
+```
I was told that they had a discussion about this on the internal PHP list, so at least they're thinking about this stuff. Too bad PHP5 doesn't have it. I guess I should just be glad I won't be maintaining this code.
diff --git a/posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md b/posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md
index e27f1dc..768418c 100644
--- a/posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md
+++ b/posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md
@@ -1,7 +1,7 @@
---
-Title: Ruby and Rails have spoiled me rotten
+Title: "Ruby and Rails have spoiled me rotten"
Author: Sami Samhuri
-Date: 17th July, 2006
+Date: "17th July, 2006"
Timestamp: 2006-07-17T05:40:00-07:00
Tags: rails, ruby, php, coding, framework, php, rails, ruby, zend
---
diff --git a/posts/2006/07/ubuntu-linux-for-linux-users-please.md b/posts/2006/07/ubuntu-linux-for-linux-users-please.md
index 9c100d4..4bb18ea 100644
--- a/posts/2006/07/ubuntu-linux-for-linux-users-please.md
+++ b/posts/2006/07/ubuntu-linux-for-linux-users-please.md
@@ -1,7 +1,7 @@
---
-Title: Ubuntu: Linux for Linux users please
+Title: "Ubuntu: Linux for Linux users please"
Author: Sami Samhuri
-Date: 13th July, 2006
+Date: "13th July, 2006"
Timestamp: 2006-07-13T08:34:00-07:00
Tags: linux, linux, ubuntu
---
diff --git a/posts/2006/07/working-with-the-zend-framework.md b/posts/2006/07/working-with-the-zend-framework.md
index 188ae38..e22d73b 100644
--- a/posts/2006/07/working-with-the-zend-framework.md
+++ b/posts/2006/07/working-with-the-zend-framework.md
@@ -1,7 +1,7 @@
---
-Title: Working with the Zend Framework
+Title: "Working with the Zend Framework"
Author: Sami Samhuri
-Date: 6th July, 2006
+Date: "6th July, 2006"
Timestamp: 2006-07-06T07:36:00-07:00
Tags: coding, technology, php, framework, php, seekport, zend
---
diff --git a/posts/2006/08/where-are-my-headphones.md b/posts/2006/08/where-are-my-headphones.md
index a7edd27..7895735 100644
--- a/posts/2006/08/where-are-my-headphones.md
+++ b/posts/2006/08/where-are-my-headphones.md
@@ -1,7 +1,7 @@
---
-Title: Where are my headphones?
+Title: "Where are my headphones?"
Author: Sami Samhuri
-Date: 22nd August, 2006
+Date: "22nd August, 2006"
Timestamp: 2006-08-22T07:31:00-07:00
Tags: life, seekport
---
diff --git a/posts/2006/09/buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo.md b/posts/2006/09/buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo.md
index eb178ab..0609077 100644
--- a/posts/2006/09/buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo.md
+++ b/posts/2006/09/buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo-buffalo.md
@@ -1,7 +1,7 @@
---
-Title: Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo
+Title: "Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo"
Author: Sami Samhuri
-Date: 16th September, 2006
+Date: "16th September, 2006"
Timestamp: 2006-09-16T22:11:00-07:00
Tags: amusement, buffalo
Link: http://en.wikipedia.org/wiki/Buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo_buffalo
diff --git a/posts/2006/09/some-features-you-might-have-missed-in-itunes-7.md b/posts/2006/09/some-features-you-might-have-missed-in-itunes-7.md
index acf1ae4..9be9bf1 100644
--- a/posts/2006/09/some-features-you-might-have-missed-in-itunes-7.md
+++ b/posts/2006/09/some-features-you-might-have-missed-in-itunes-7.md
@@ -1,7 +1,7 @@
---
-Title: Some features you might have missed in iTunes 7
+Title: "Some features you might have missed in iTunes 7"
Author: Sami Samhuri
-Date: 22nd September, 2006
+Date: "22nd September, 2006"
Timestamp: 2006-09-22T16:59:00-07:00
Tags: apple, apple, itunes
---
diff --git a/posts/2006/12/coping-with-windows-xp-activiation-on-a-mac.md b/posts/2006/12/coping-with-windows-xp-activiation-on-a-mac.md
index 65dd185..622bb6e 100644
--- a/posts/2006/12/coping-with-windows-xp-activiation-on-a-mac.md
+++ b/posts/2006/12/coping-with-windows-xp-activiation-on-a-mac.md
@@ -1,7 +1,7 @@
---
-Title: Coping with Windows XP activiation on a Mac
+Title: "Coping with Windows XP activiation on a Mac"
Author: Sami Samhuri
-Date: 17th December, 2006
+Date: "17th December, 2006"
Timestamp: 2006-12-17T23:30:00-08:00
Tags: parallels, windows, apple, mac os x, bootcamp
---
@@ -28,7 +28,9 @@ If anyone actually knows how to write batch files I'd like to hear any suggestio
You will probably just want to test my method of testing for Parallels and Boot Camp first. The easiest way is to just open a command window and run this command:
- ipconfig /all | find "Parallels"
+```bat
+ipconfig /all | find "Parallels"
+```
If you see a line of output like **"Description . . . . : Parallels Network Adapter"** and you are in Parallels then the test works. If you see no output and you are in Boot Camp then the test works.
@@ -46,8 +48,10 @@ If you're lazy then you can download backup-parallels-wpa.bat
@@ -57,8 +61,10 @@ Download backup-bootcamp-wpa.bat
@@ -72,19 +78,21 @@ If you have XP Pro then you can get it to run using the Group Policy editor. Sav
If you have XP Home then the best you can do is run this script from your Startup folder (Start -> All Programs -> Startup), but that is not really going to work because eventually Windows will not even let you log in until you activate it. What a P.O.S.
- @echo off
+```bat
+@echo off
- ipconfig /all | find "Parallels" > network.tmp
- for /F "tokens=14" %%x in (network.tmp) do set parallels=%x
- del network.tmp
+ipconfig /all | find "Parallels" > network.tmp
+for /F "tokens=14" %%x in (network.tmp) do set parallels=%x
+del network.tmp
- if defined parallels (
- echo Parallels
- copy C:\Windows\System32\Parallels\wpa.* C:\Windows\System32
- ) else (
- echo Boot Camp
- copy C:\Windows\System32\BootCamp\wpa.* C:\Windows\System32
- )
+if defined parallels (
+ echo Parallels
+ copy C:\Windows\System32\Parallels\wpa.* C:\Windows\System32
+) else (
+ echo Boot Camp
+ copy C:\Windows\System32\BootCamp\wpa.* C:\Windows\System32
+)
+```
Download activate.bat
@@ -105,4 +113,3 @@ This method worked for me and hopefully it will work for you as well. I'm intere
I finally bought Windows XP this week and I'm starting to regret it because of all the hoops they make you jump through to use it. I only use it to fix sites in IE because it can't render a web page properly and I didn't want to buy it just for that. I thought that it would be good to finally get a legit copy since I was using a pirated version and was sick of working around validation bullshit for updates. Now I have to work around MS's activation bullshit and it's just as bad! Screw Microsoft for putting their customers through this sort of thing. Things like this and the annoying balloons near the system tray just fuel my contempt for Windows and reinforce my love of Linux and Mac OS X.
I don't make money off any of my sites, which is why I didn't want to have to buy stupid Windows. I hate MS so much for making shitty IE the standard browser.
-
diff --git a/posts/2007/03/digg-v4-reply-to-replies-greasemonkey-script.md b/posts/2007/03/digg-v4-reply-to-replies-greasemonkey-script.md
index 283862a..d5647b6 100644
--- a/posts/2007/03/digg-v4-reply-to-replies-greasemonkey-script.md
+++ b/posts/2007/03/digg-v4-reply-to-replies-greasemonkey-script.md
@@ -1,7 +1,7 @@
---
-Title: Digg v4: Reply to replies (Greasemonkey script)
+Title: "Digg v4: Reply to replies (Greasemonkey script)"
Author: Sami Samhuri
-Date: 8th March, 2007
+Date: "8th March, 2007"
Timestamp: 2007-03-08T23:19:00-08:00
Tags: coding, digg, firefox, userscript
---
diff --git a/posts/2007/03/diggscuss-0_9.md b/posts/2007/03/diggscuss-0_9.md
index 884b769..cffabb7 100644
--- a/posts/2007/03/diggscuss-0_9.md
+++ b/posts/2007/03/diggscuss-0_9.md
@@ -1,7 +1,7 @@
---
-Title: Diggscuss 0.9
+Title: "Diggscuss 0.9"
Author: Sami Samhuri
-Date: 25th March, 2007
+Date: "25th March, 2007"
Timestamp: 2007-03-25T08:03:00-07:00
Tags: coding, digg, firefox, userscript
---
diff --git a/posts/2007/03/full-screen-cover-flow.md b/posts/2007/03/full-screen-cover-flow.md
index 41fa62e..8c9c81d 100644
--- a/posts/2007/03/full-screen-cover-flow.md
+++ b/posts/2007/03/full-screen-cover-flow.md
@@ -1,7 +1,7 @@
---
-Title: Full-screen Cover Flow
+Title: "Full-screen Cover Flow"
Author: Sami Samhuri
-Date: 6th March, 2007
+Date: "6th March, 2007"
Timestamp: 2007-03-06T13:51:00-08:00
Tags: apple, coverflow, itunes
---
diff --git a/posts/2007/04/a-triple-booting-schizophrenic-macbook.md b/posts/2007/04/a-triple-booting-schizophrenic-macbook.md
index c07629a..bfea9c1 100644
--- a/posts/2007/04/a-triple-booting-schizophrenic-macbook.md
+++ b/posts/2007/04/a-triple-booting-schizophrenic-macbook.md
@@ -1,7 +1,7 @@
---
-Title: A triple-booting, schizophrenic MacBook
+Title: "A triple-booting, schizophrenic MacBook"
Author: Sami Samhuri
-Date: 4th April, 2007
+Date: "4th April, 2007"
Timestamp: 2007-04-04T23:30:00-07:00
Tags: linux, mac os x, windows
---
diff --git a/posts/2007/04/activerecord-base_find_or_create-and-find_or_initialize.md b/posts/2007/04/activerecord-base_find_or_create-and-find_or_initialize.md
index 17f86c2..3482add 100644
--- a/posts/2007/04/activerecord-base_find_or_create-and-find_or_initialize.md
+++ b/posts/2007/04/activerecord-base_find_or_create-and-find_or_initialize.md
@@ -1,7 +1,7 @@
---
-Title: ActiveRecord::Base.find_or_create and find_or_initialize
+Title: "ActiveRecord::Base.find_or_create and find_or_initialize"
Author: Sami Samhuri
-Date: 11th April, 2007
+Date: "11th April, 2007"
Timestamp: 2007-04-11T03:24:00-07:00
Tags: activerecord, coding, rails, ruby
---
@@ -12,98 +12,54 @@ They work exactly as you'd expect them to work with possibly one gotcha. If you
Enough chat, here's the self-explanatory code:
-1
-2
-3
-4
-
-# extend ActiveRecord::Base with find_or_create and find_or_initialize.
-ActiveRecord ::Base .class_eval do
- include ActiveRecordExtensions
-end
+```ruby
+# extend ActiveRecord::Base with find_or_create and find_or_initialize.
+ActiveRecord::Base.class_eval do
+ include ActiveRecordExtensions
+end
+```
+```ruby
+module ActiveRecordExtensions
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-16
-17
-18
-19
-20
-21
-22
-23
-24
-25
-26
-27
-28
-29
-30
-31
-32
-33
-34
-35
-36
-37
-38
-39
-40
-41
-
-module ActiveRecordExtensions
- def self .included(base)
- base.extend(ClassMethods )
- end
+ module ClassMethods
+ def find_or_initialize(params)
+ find_or_do('initialize', params)
+ end
- module ClassMethods
- def find_or_initialize (params)
- find_or_do(' initialize ' , params)
- end
-
- def find_or_create (params)
- find_or_do(' create ' , params)
- end
+ def find_or_create(params)
+ find_or_do('create', params)
+ end
private
- # Find a record that matches the attributes given in the +params+ hash, or do +action+
- # to retrieve a new object with the given parameters and return that.
- def find_or_do (action, params)
- # if an id is given just find the record directly
- self .find(params[:id ])
+ # Find a record that matches the attributes given in the +params+ hash, or do +action+
+ # to retrieve a new object with the given parameters and return that.
+ def find_or_do(action, params)
+ # if an id is given just find the record directly
+ self.find(params[:id])
- rescue ActiveRecord ::RecordNotFound => e
- attrs = {} # hash of attributes passed in params
+ rescue ActiveRecord::RecordNotFound => e
+ attrs = {} # hash of attributes passed in params
- # search for valid attributes in params
- self .column_names.map(&:to_sym ).each do |attrib|
- # skip unknown columns, and the id field
- next if params[attrib].nil? || attrib == :id
+ # search for valid attributes in params
+ self.column_names.map(&:to_sym).each do |attrib|
+ # skip unknown columns, and the id field
+ next if params[attrib].nil? || attrib == :id
attrs[attrib] = params[attrib]
- end
+ end
- # no valid params given, return nil
- return nil if attrs.empty?
+ # no valid params given, return nil
+ return nil if attrs.empty?
- # call the appropriate ActiveRecord finder method
- self .send(" find_or_ #{ action} _by_ #{ attrs.keys.join(' _and_ ' )} " , *attrs.values)
- end
- end
-end
+ # call the appropriate ActiveRecord finder method
+ self.send("find_or_#{action}_by_#{attrs.keys.join('_and_')}", *attrs.values)
+ end
+ end
+end
+```
diff --git a/posts/2007/04/funny-how-code-can-be-beautiful.md b/posts/2007/04/funny-how-code-can-be-beautiful.md
index 731846d..685aac4 100644
--- a/posts/2007/04/funny-how-code-can-be-beautiful.md
+++ b/posts/2007/04/funny-how-code-can-be-beautiful.md
@@ -1,14 +1,16 @@
---
-Title: Funny how code can be beautiful
+Title: "Funny how code can be beautiful"
Author: Sami Samhuri
-Date: 30th April, 2007
+Date: "30th April, 2007"
Timestamp: 2007-04-30T07:07:00-07:00
Tags: haskell
---
While reading a Haskell tutorial I came across the following code for defining the Fibonacci numbers :
- fib = 1 : 1 : [ a + b | (a, b) <- zip fib (tail fib) ]
+```haskell
+fib = 1 : 1 : [ a + b | (a, b) <- zip fib (tail fib) ]
+```
After reading it a few times and understanding how it works I couldn’t help but think how beautiful it is. I don’t mean that it’s aesthetically pleasing to me; the beautiful part is the meaning and simplicity. Lazy evaluation is sweet.
@@ -24,4 +26,3 @@ Going deeper down the functional rabbit-hole you’ll find things like What the hell are Monads?
* Monads on WikiBooks
* Monads for the Working Haskell Programmer
-
diff --git a/posts/2007/04/getting-to-know-vista.md b/posts/2007/04/getting-to-know-vista.md
index c41f491..afbd44f 100644
--- a/posts/2007/04/getting-to-know-vista.md
+++ b/posts/2007/04/getting-to-know-vista.md
@@ -1,7 +1,7 @@
---
-Title: Getting to know Vista
+Title: "Getting to know Vista"
Author: Sami Samhuri
-Date: 16th April, 2007
+Date: "16th April, 2007"
Timestamp: 2007-04-16T11:09:00-07:00
Tags: windows
---
diff --git a/posts/2007/04/quickly-inserting-millions-of-rows-with-mysql-innodb.md b/posts/2007/04/quickly-inserting-millions-of-rows-with-mysql-innodb.md
index 2c0f4fe..69eccb1 100644
--- a/posts/2007/04/quickly-inserting-millions-of-rows-with-mysql-innodb.md
+++ b/posts/2007/04/quickly-inserting-millions-of-rows-with-mysql-innodb.md
@@ -1,7 +1,7 @@
---
-Title: Quickly inserting millions of rows with MySQL/InnoDB
+Title: "Quickly inserting millions of rows with MySQL/InnoDB"
Author: Sami Samhuri
-Date: 26th April, 2007
+Date: "26th April, 2007"
Timestamp: 2007-04-26T07:06:00-07:00
Tags: linux, mysql
---
diff --git a/posts/2007/05/a-new-way-to-look-at-networking.md b/posts/2007/05/a-new-way-to-look-at-networking.md
index a927692..7d1bc32 100644
--- a/posts/2007/05/a-new-way-to-look-at-networking.md
+++ b/posts/2007/05/a-new-way-to-look-at-networking.md
@@ -1,7 +1,7 @@
---
-Title: A New Way to Look at Networking
+Title: "A New Way to Look at Networking"
Author: Sami Samhuri
-Date: 5th May, 2007
+Date: "5th May, 2007"
Timestamp: 2007-05-05T16:10:00-07:00
Tags: technology, networking
---
diff --git a/posts/2007/05/a-scheme-parser-in-haskell-part-1.md b/posts/2007/05/a-scheme-parser-in-haskell-part-1.md
index 576eebe..896b2ff 100644
--- a/posts/2007/05/a-scheme-parser-in-haskell-part-1.md
+++ b/posts/2007/05/a-scheme-parser-in-haskell-part-1.md
@@ -1,7 +1,7 @@
---
-Title: A Scheme parser in Haskell: Part 1
+Title: "A Scheme parser in Haskell: Part 1"
Author: Sami Samhuri
-Date: 3rd May, 2007
+Date: "3rd May, 2007"
Timestamp: 2007-05-03T00:47:50-07:00
Tags: coding, haskell
---
@@ -18,9 +18,10 @@ I'm going to explain one of the exercises because converting between the various
Last night I rewrote parseNumber using do and >>= (bind) notations (ex. 3.3.1). Here's parseNumber using the liftM method given in the tutorial:
-parseNumber :: Parser LispVal
+```haskell
+parseNumber :: Parser LispVal
parseNumber :: liftM (Number . read) $ many1 digit
-
+```
Okay that's pretty simple right? Let's break it down, first looking at the right-hand side of the $ operator, then the left.
* many1 digit reads as many decimal digits as it can.
@@ -41,24 +42,25 @@ The $ acts similar to a pipe in $FAVOURITE_SHELL, and
So how does a Haskell newbie go about re-writing that using other notations which haven't even been explained in the tutorial? Clearly one must search the web and read as much as they can until they understand enough to figure it out (which is one thing I like about the tutorial). If you're lazy like me, here are 3 equivalent pieces of code for you to chew on. parseNumber's type is Parser LispVal (Parser is a monad).
-
Familiar liftM method:
-parseNumber -> liftM (Number . read) $ many1 digit
-
+```haskell
+parseNumber -> liftM (Number . read) $ many1 digit
+```
Using do notation:
-parseNumber -> do digits <- many1 digit
+```haskell
+parseNumber -> do digits <- many1 digit
return $ (Number . read) digits
-
+```
If you're thinking "Hey a return, I know that one!" then the devious masterminds behind Haskell are certainly laughing evilly right now. return simply wraps up it's argument in a monad of some sort. In this case it's the Parser monad. The return part may seem strange at first. Since many1 digit yields a monad why do we need to wrap anything? The answer is that using <- causes digits to contain a String, stripped out of the monad which resulted from many1 digit. Hence we no longer use liftM to make (Number . read) monads, and instead need to use return to properly wrap it back up in a monad.
In other words liftM eliminates the need to explicitly re-monadize the contents as is necessary using do.
-
Finally, using >>= (bind) notation:
-parseNumber -> many1 digit >>= \digits ->
+```haskell
+parseNumber -> many1 digit >>= \digits ->
return $ (Number . read) digits
-
+```
At this point I don't think this warrants much of an explanation. The syntactic sugar provided by do should be pretty obvious. Just in case it's not, >>= passes the contents of its left argument (a monad) to the function on its right. Once again return is needed to wrap up the result and send it on its way.
When I first read about Haskell I was overwhelmed by not knowing anything, and not being able to apply my previous knowledge of programming to anything in Haskell. One piece of syntax at a time I am slowly able to understand more of the Haskell found in the wild .
diff --git a/posts/2007/05/cheating-at-life-in-general.md b/posts/2007/05/cheating-at-life-in-general.md
index 28a3a9e..7eecd58 100644
--- a/posts/2007/05/cheating-at-life-in-general.md
+++ b/posts/2007/05/cheating-at-life-in-general.md
@@ -1,7 +1,7 @@
---
-Title: Cheating at Life in General
+Title: "Cheating at Life in General"
Author: Sami Samhuri
-Date: 16th May, 2007
+Date: "16th May, 2007"
Timestamp: 2007-05-16T02:46:00-07:00
Tags: cheat, vim, emacs, textmate
---
diff --git a/posts/2007/05/dtrace-ruby-goodness-for-sun.md b/posts/2007/05/dtrace-ruby-goodness-for-sun.md
index b6c4204..4c94b61 100644
--- a/posts/2007/05/dtrace-ruby-goodness-for-sun.md
+++ b/posts/2007/05/dtrace-ruby-goodness-for-sun.md
@@ -1,7 +1,7 @@
---
-Title: dtrace + Ruby = Goodness for Sun
+Title: "dtrace + Ruby = Goodness for Sun"
Author: Sami Samhuri
-Date: 9th May, 2007
+Date: "9th May, 2007"
Timestamp: 2007-05-09T08:45:00-07:00
Tags: ruby, dtrace, sun
---
diff --git a/posts/2007/05/dumping-objects-to-the-browser-in-rails.md b/posts/2007/05/dumping-objects-to-the-browser-in-rails.md
index 13f7b4f..09f6fb0 100644
--- a/posts/2007/05/dumping-objects-to-the-browser-in-rails.md
+++ b/posts/2007/05/dumping-objects-to-the-browser-in-rails.md
@@ -1,35 +1,38 @@
---
-Title: Dumping Objects to the Browser in Rails
+Title: "Dumping Objects to the Browser in Rails"
Author: Sami Samhuri
-Date: 15th May, 2007
+Date: "15th May, 2007"
Timestamp: 2007-05-15T13:38:00-07:00
Tags: rails
-Styles: typocode.css
---
Here's an easy way to solve a problem that may have nagged you as it did me. Simply using foo.inspect to dump out some object to the browser dumps one long string which is barely useful except for short strings and the like. The ideal output is already available using the PrettyPrint module so we just need to use it.
-
-Unfortunately typing <%= PP.pp(@something, '') %> to quickly debug some possibly large object (or collection) can get old fast so we need a shortcut.
-
+Unfortunately typing <pre><%= PP.pp(@something, '') %></pre> to quickly debug some possibly large object (or collection) can get old fast so we need a shortcut.
Taking the definition of Object#pp_s from the extensions project it's trivial to create a helper method to just dump out an object in a reasonable manner.
+**/app/helpers/application_helper.rb**
-/app/helpers/application_helper.rb
def dump ( thing )
- s = StringIO . new
- PP . pp ( thing , s )
- s . string
-end
+```ruby
+def dump(thing)
+ s = StringIO.new
+ PP.pp(thing, s)
+ s.string
+end
+```
Alternatively you could do as the extensions folks do and actually define Object#pp_s so you can use it in your logs or anywhere else you may want to inspect an object. If you do this you probably want to change the dump helper method accordingly in case you decide to change pp_s in the future.
+**lib/local_support/core_ext/object.rb**
-lib/local_support/core_ext/object.rb
class Object
- def pp_s
- pps = StringIO . new
- PP . pp ( self , pps )
- pps . string
- end
-end
+```ruby
+class Object
+ def pp_s
+ pps = StringIO.new
+ PP.pp(self, pps)
+ pps.string
+ end
+end
+```
diff --git a/posts/2007/05/enumerable-pluck-and-string-to_proc-for-ruby.md b/posts/2007/05/enumerable-pluck-and-string-to_proc-for-ruby.md
index 37598bc..f159456 100644
--- a/posts/2007/05/enumerable-pluck-and-string-to_proc-for-ruby.md
+++ b/posts/2007/05/enumerable-pluck-and-string-to_proc-for-ruby.md
@@ -1,10 +1,9 @@
---
-Title: Enumurable#pluck and String#to_proc for Ruby
+Title: "Enumurable#pluck and String#to_proc for Ruby"
Author: Sami Samhuri
-Date: 10th May, 2007
+Date: "10th May, 2007"
Timestamp: 2007-05-10T16:14:00-07:00
Tags: ruby, extensions
-Styles: typocode.css
---
I wanted a method analogous to Prototype's pluck and invoke in Rails for building lists for options_for_select . Yes, I know about options_from_collection_for_select .
@@ -13,114 +12,130 @@ I wanted something more general that I can use anywhere - not just in Rails - so
First you need Symbol#to_proc , which shouldn't need an introduction. If you're using Rails you have this already.
-Symbol#to_proc
class Symbol
-
-
-
-
-
-
- def to_proc
- Proc . new {| thing , * args | thing . send ( self , * args )}
- end
-end
-
+**Symbol#to_proc**
+
+```ruby
+class Symbol
+ # Turns a symbol into a proc.
+ #
+ # Example:
+ # # The same as people.map { |p| p.birthdate }
+ # people.map(&:birthdate)
+ #
+ def to_proc
+ Proc.new {|thing, *args| thing.send(self, *args)}
+ end
+end
+```
Next we define String#to_proc, which is nearly identical to the Array#to_proc method I previously wrote about.
-String#to_proc
class String
-
-
-
-
-
-
- def to_proc
- Proc . new do |* args |
- split (' . '). inject ( args . shift ) do | thing , msg |
- thing = thing . send ( msg . to_sym , * args )
- end
- end
- end
-end
-
+**String#to_proc**
+
+```ruby
+class String
+ # Turns a string into a proc.
+ #
+ # Example:
+ # # The same as people.map { |p| p.birthdate.year }
+ # people.map(&'birthdate.year')
+ #
+ def to_proc
+ Proc.new do |*args|
+ split('.').inject(args.shift) do |thing, msg|
+ thing = thing.send(msg.to_sym, *args)
+ end
+ end
+ end
+end
+```
Finally there's Enumerable#to_proc which returns a proc that passes its parameter through each of its members and collects their results. It's easier to explain by example.
-Enumerable#to_proc
module Enumerable
-
-
-
-
-
-
-
-
- def to_proc
- @procs ||= map (& :to_proc )
- Proc . new do | thing , * args |
- @procs . map do | proc |
- proc . call ( thing , * args )
- end
- end
- end
-end
+**Enumerable#to_proc**
+
+```ruby
+module Enumerable
+ # Effectively treats itself as a list of transformations, and returns a proc
+ # which maps values to a list of the results of applying each transformation
+ # in that list to the value.
+ #
+ # Example:
+ # # The same as people.map { |p| [p.birthdate, p.email] }
+ # people.map(&[:birthdate, :email])
+ #
+ def to_proc
+ @procs ||= map(&:to_proc)
+ Proc.new do |thing, *args|
+ @procs.map do |proc|
+ proc.call(thing, *args)
+ end
+ end
+ end
+end
+```
Here's the cool part, Enumerable#pluck for Ruby in all its glory.
-Enumerable#pluck
module Enumerable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- def pluck (* args )
-
- map (& args )
- end
-end
+**Enumerable#pluck**
+
+```ruby
+module Enumerable
+ # Use this to pluck values from objects, especially useful for ActiveRecord models.
+ # This is analogous to Prototype's Enumerable.pluck method but more powerful.
+ #
+ # You can pluck values simply, like so:
+ # >> people.pluck(:last_name) #=> ['Samhuri', 'Jones', ...]
+ #
+ # But with Symbol#to_proc defined this is effectively the same as:
+ # >> people.map(&:last_name) #=> ['Samhuri', 'Jones', ...]
+ #
+ # Where pluck's power becomes evident is when you want to do something like:
+ # >> people.pluck(:name, :address, :phone)
+ # #=> [['Johnny Canuck', '123 Maple Lane', '416-555-124'], ...]
+ #
+ # Instead of:
+ # >> people.map { |p| [p.name, p.address, p.phone] }
+ #
+ # # map each person to: [person.country.code, person.id]
+ # >> people.pluck('country.code', :id)
+ # #=> [['US', 1], ['CA', 2], ...]
+ #
+ def pluck(*args)
+ # Thanks to Symbol#to_proc, Enumerable#to_proc and String#to_proc this Just Works(tm)
+ map(&args)
+ end
+end
+```
I wrote another version without using the various #to_proc methods so as to work with a standard Ruby while only patching 1 module.
-module Enumerable
-
- def pluck (* args )
- procs = args . map do | msgs |
-
- if String === msgs
- msgs = msgs . split (' . '). map {| a | a . to_sym }
- elsif !( Enumerable === msgs )
- msgs = [ msgs ]
- end
- Proc . new do | orig |
- msgs . inject ( orig ) { | thing , msg | thing = thing . send ( msg ) }
- end
- end
+```ruby
+module Enumerable
+ # A version of pluck which doesn't require any to_proc methods.
+ def pluck(*args)
+ procs = args.map do |msgs|
+ # always operate on lists of messages
+ if String === msgs
+ msgs = msgs.split('.').map {|a| a.to_sym} # allow 'country.code'
+ elsif !(Enumerable === msgs)
+ msgs = [msgs]
+ end
+ Proc.new do |orig|
+ msgs.inject(orig) { |thing, msg| thing = thing.send(msg) }
+ end
+ end
- if procs . size == 1
- map (& procs . first )
- else
- map do | thing |
- procs . map { | proc | proc . call ( thing ) }
- end
- end
- end
-end
+ if procs.size == 1
+ map(&procs.first)
+ else
+ map do |thing|
+ procs.map { |proc| proc.call(thing) }
+ end
+ end
+ end
+end
+```
It's just icing on the cake considering Ruby's convenient block syntax, but there it is. Do with it what you will. You can change or extend any of these to support drilling down into hashes quite easily too.
diff --git a/posts/2007/05/finnish-court-rules-css-ineffective-at-protecting-dvds.md b/posts/2007/05/finnish-court-rules-css-ineffective-at-protecting-dvds.md
index 4147df7..a6e6268 100644
--- a/posts/2007/05/finnish-court-rules-css-ineffective-at-protecting-dvds.md
+++ b/posts/2007/05/finnish-court-rules-css-ineffective-at-protecting-dvds.md
@@ -1,7 +1,7 @@
---
-Title: Finnish court rules CSS ineffective at protecting DVDs
+Title: "Finnish court rules CSS ineffective at protecting DVDs"
Author: Sami Samhuri
-Date: 26th May, 2007
+Date: "26th May, 2007"
Timestamp: 2007-05-26T03:24:00-07:00
Tags: drm
---
diff --git a/posts/2007/05/gotta-love-the-ferry-ride.md b/posts/2007/05/gotta-love-the-ferry-ride.md
index a934ddd..f6ceba7 100644
--- a/posts/2007/05/gotta-love-the-ferry-ride.md
+++ b/posts/2007/05/gotta-love-the-ferry-ride.md
@@ -1,7 +1,7 @@
---
-Title: Gotta Love the Ferry Ride
+Title: "Gotta Love the Ferry Ride"
Author: Sami Samhuri
-Date: 5th May, 2007
+Date: "5th May, 2007"
Timestamp: 2007-05-05T04:25:00-07:00
Tags: life, photo, bc, victoria
---
diff --git a/posts/2007/05/i-cant-wait-to-see-what-matt-stone-trey-parker-do-with-this.md b/posts/2007/05/i-cant-wait-to-see-what-matt-stone-trey-parker-do-with-this.md
index beea637..67335ca 100644
--- a/posts/2007/05/i-cant-wait-to-see-what-matt-stone-trey-parker-do-with-this.md
+++ b/posts/2007/05/i-cant-wait-to-see-what-matt-stone-trey-parker-do-with-this.md
@@ -1,7 +1,7 @@
---
-Title: I Can't Wait to See What Trey Parker & Matt Stone Do With This
+Title: "I Can't Wait to See What Trey Parker & Matt Stone Do With This"
Author: Sami Samhuri
-Date: 9th May, 2007
+Date: "9th May, 2007"
Timestamp: 2007-05-09T14:34:00-07:00
Tags: crazy
---
diff --git a/posts/2007/05/inspirado.md b/posts/2007/05/inspirado.md
index dc78cde..ce6095d 100644
--- a/posts/2007/05/inspirado.md
+++ b/posts/2007/05/inspirado.md
@@ -1,7 +1,7 @@
---
-Title: Inspirado
+Title: "Inspirado"
Author: Sami Samhuri
-Date: 22nd May, 2007
+Date: "22nd May, 2007"
Timestamp: 2007-05-22T13:23:00-07:00
Tags: rails, inspirado
---
diff --git a/posts/2007/05/iphone-humour.md b/posts/2007/05/iphone-humour.md
index 3aa8c4c..831ceed 100644
--- a/posts/2007/05/iphone-humour.md
+++ b/posts/2007/05/iphone-humour.md
@@ -1,7 +1,7 @@
---
-Title: iPhone Humour
+Title: "iPhone Humour"
Author: Sami Samhuri
-Date: 18th May, 2007
+Date: "18th May, 2007"
Timestamp: 2007-05-18T11:34:00-07:00
Tags: apple, funny, iphone
---
diff --git a/posts/2007/05/rails-plugins-link-dump.md b/posts/2007/05/rails-plugins-link-dump.md
index 93d249d..b235b96 100644
--- a/posts/2007/05/rails-plugins-link-dump.md
+++ b/posts/2007/05/rails-plugins-link-dump.md
@@ -1,7 +1,7 @@
---
-Title: Rails Plugins (link dump)
+Title: "Rails Plugins (link dump)"
Author: Sami Samhuri
-Date: 10th May, 2007
+Date: "10th May, 2007"
Timestamp: 2007-05-09T17:22:00-07:00
Tags: rails
---
diff --git a/posts/2007/05/typo-and-i-are-friends-again.md b/posts/2007/05/typo-and-i-are-friends-again.md
index aa18b76..e62c654 100644
--- a/posts/2007/05/typo-and-i-are-friends-again.md
+++ b/posts/2007/05/typo-and-i-are-friends-again.md
@@ -1,7 +1,7 @@
---
-Title: Typo and I are friends again
+Title: "Typo and I are friends again"
Author: Sami Samhuri
-Date: 1st May, 2007
+Date: "1st May, 2007"
Timestamp: 2007-05-01T21:51:37-07:00
Tags: typo
---
diff --git a/posts/2007/06/301-moved-permanently.md b/posts/2007/06/301-moved-permanently.md
index 25516da..e2174a6 100644
--- a/posts/2007/06/301-moved-permanently.md
+++ b/posts/2007/06/301-moved-permanently.md
@@ -1,7 +1,7 @@
---
-Title: 301 moved permanently
+Title: "301 moved permanently"
Author: Sami Samhuri
-Date: 8th June, 2007
+Date: "8th June, 2007"
Timestamp: 2007-06-08T18:00:00-07:00
Tags: life
---
diff --git a/posts/2007/06/back-on-gentoo-trying-new-things.md b/posts/2007/06/back-on-gentoo-trying-new-things.md
index 150e076..632f0ac 100644
--- a/posts/2007/06/back-on-gentoo-trying-new-things.md
+++ b/posts/2007/06/back-on-gentoo-trying-new-things.md
@@ -1,7 +1,7 @@
---
-Title: Back on Gentoo, trying new things
+Title: "Back on Gentoo, trying new things"
Author: Sami Samhuri
-Date: 18th June, 2007
+Date: "18th June, 2007"
Timestamp: 2007-06-18T18:05:00-07:00
Tags: emacs, gentoo, linux, vim
---
diff --git a/posts/2007/06/begging-the-question.md b/posts/2007/06/begging-the-question.md
index 3f48a82..833463a 100644
--- a/posts/2007/06/begging-the-question.md
+++ b/posts/2007/06/begging-the-question.md
@@ -1,7 +1,7 @@
---
-Title: Begging the question
+Title: "Begging the question"
Author: Sami Samhuri
-Date: 15th June, 2007
+Date: "15th June, 2007"
Timestamp: 2007-06-15T11:49:00-07:00
Tags: english, life, pedantry
---
@@ -14,9 +14,11 @@ Anyway I was very pleased to see the only correct usage of the phrase "begs the
This describes a perfectly legitimate mathematical function. We could use it to recognize whether one number is the square root of another, or to derive facts about square roots in general. On the other hand, the definition does not describe a procedure. Indeed, it tells us almost nothing about how to actually find the square root of a given number. It will not help matters to rephrase this definition in pseudo-Lisp:
-(define (sqrt x)
+```scheme
+(define (sqrt x)
(the y (and (= y 0)
- (= (square y) x))))
+ (= (square y) x))))
+```
This only begs the question.
diff --git a/posts/2007/06/controlling-volume-via-the-keyboard-on-linux.md b/posts/2007/06/controlling-volume-via-the-keyboard-on-linux.md
index f154e5c..7ec9dc2 100644
--- a/posts/2007/06/controlling-volume-via-the-keyboard-on-linux.md
+++ b/posts/2007/06/controlling-volume-via-the-keyboard-on-linux.md
@@ -1,7 +1,7 @@
---
-Title: Controlling volume via the keyboard on Linux
+Title: "Controlling volume via the keyboard on Linux"
Author: Sami Samhuri
-Date: 30th June, 2007
+Date: "30th June, 2007"
Timestamp: 2007-06-30T16:13:00-07:00
Tags: alsa, linux, ruby, volume
---
diff --git a/posts/2007/06/emacs-for-textmate-junkies.md b/posts/2007/06/emacs-for-textmate-junkies.md
index 5f02137..def8f7d 100644
--- a/posts/2007/06/emacs-for-textmate-junkies.md
+++ b/posts/2007/06/emacs-for-textmate-junkies.md
@@ -1,7 +1,7 @@
---
-Title: Emacs for TextMate junkies
+Title: "Emacs for TextMate junkies"
Author: Sami Samhuri
-Date: 23rd June, 2007
+Date: "23rd June, 2007"
Timestamp: 2007-06-22T19:17:00-07:00
Tags: emacs, textmate
---
@@ -14,76 +14,20 @@ Tags: emacs, textmate
Despite my current infatuation with Emacs there are many reasons I started using TextMate, especially little time-savers that are very addictive. I'll talk about one of those features tonight. When you have text selected in TextMate and you hit say the ' (single quote) then TextMate will surround the selected text with single quotes. The same goes for double quotes, parentheses, brackets, and braces. This little trick is one of my favourites so I had to come up with something similar in Emacs. It was easy since a mailing list post has a solution for surrounding the current region with tags, which served as a great starting point.
-
-1
-2
-3
-4
-5
-6
-7
-
-(defun surround-region-with-tag (tag-name beg end)
+```lisp
+(defun surround-region-with-tag (tag-name beg end)
(interactive "sTag name: \nr")
(save-excursion
(goto-char beg)
- (insert "<" tag-name ">")
+ (insert "<" tag-name ">")
(goto-char (+ end 2 (length tag-name)))
- (insert "</" tag-name ">")))
-
+ (insert "" tag-name ">")))
+```
With a little modification I now have the following in my ~/.emacs file:
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-16
-17
-18
-19
-20
-21
-22
-23
-24
-25
-26
-27
-28
-29
-30
-31
-32
-33
-34
-35
-36
-37
-38
-39
-40
-41
-42
-43
-44
-45
-46
-47
-
-;; help out a TextMate junkie
+```lisp
+;; help out a TextMate junkie
(defun wrap-region (left right beg end)
"Wrap the region in arbitrary text, LEFT goes to the left and RIGHT goes to the right."
@@ -103,7 +47,7 @@ With a little modification I now have the following in my ~/.emacs file:
(interactive)
(if (and mark-active transient-mark-mode)
(call-interactively 'wrap-region-with-tag)
- (insert "<")))
+ (insert "<")))
(defun wrap-region-with-tag (tag beg end)
"Wrap the region in the given HTML/XML tag using `wrap-region'. If any
@@ -111,10 +55,10 @@ attributes are specified then they are only included in the opening tag."
(interactive "*sTag (including attributes): \nr")
(let* ((elems (split-string tag " "))
(tag-name (car elems))
- (right (concat "</" tag-name ">")))
+ (right (concat "" tag-name ">")))
(if (= 1 (length elems))
- (wrap-region (concat "<" tag-name ">") right beg end)
- (wrap-region (concat "<" tag ">") right beg end))))
+ (wrap-region (concat "<" tag-name ">") right beg end)
+ (wrap-region (concat "<" tag ">") right beg end))))
(defun wrap-region-or-insert (left right)
"Wrap the region with `wrap-region' if an active region is marked, otherwise insert LEFT at point."
@@ -129,7 +73,8 @@ attributes are specified then they are only included in the opening tag."
(global-set-key "(" (wrap-region-with-function "(" ")"))
(global-set-key "[" (wrap-region-with-function "[" "]"))
(global-set-key "{" (wrap-region-with-function "{" "}"))
-(global-set-key "<" 'wrap-region-with-tag-or-insert) ;; I opted not to have a wrap-with-angle-brackets
+(global-set-key "<" 'wrap-region-with-tag-or-insert) ;; I opted not to have a wrap-with-angle-brackets
+```
↓ Download wrap-region.el
diff --git a/posts/2007/06/emacs-tagify-region-or-insert-tag.md b/posts/2007/06/emacs-tagify-region-or-insert-tag.md
index 4957412..e9e55fb 100644
--- a/posts/2007/06/emacs-tagify-region-or-insert-tag.md
+++ b/posts/2007/06/emacs-tagify-region-or-insert-tag.md
@@ -1,7 +1,7 @@
---
-Title: Emacs: tagify-region-or-insert-tag
+Title: "Emacs: tagify-region-or-insert-tag"
Author: Sami Samhuri
-Date: 25th June, 2007
+Date: "25th June, 2007"
Timestamp: 2007-06-25T15:13:00-07:00
Tags: emacs, tagify
---
diff --git a/posts/2007/06/embrace-the-database.md b/posts/2007/06/embrace-the-database.md
index 892113d..525110f 100644
--- a/posts/2007/06/embrace-the-database.md
+++ b/posts/2007/06/embrace-the-database.md
@@ -1,7 +1,7 @@
---
-Title: Embrace the database
+Title: "Embrace the database"
Author: Sami Samhuri
-Date: 22nd June, 2007
+Date: "22nd June, 2007"
Timestamp: 2007-06-22T03:14:00-07:00
Tags: activerecord, rails, ruby
---
diff --git a/posts/2007/06/floating-point-in-elschemo.md b/posts/2007/06/floating-point-in-elschemo.md
index e599a5b..fe43d91 100644
--- a/posts/2007/06/floating-point-in-elschemo.md
+++ b/posts/2007/06/floating-point-in-elschemo.md
@@ -1,7 +1,7 @@
---
-Title: Floating point in ElSchemo
+Title: "Floating point in ElSchemo"
Author: Sami Samhuri
-Date: 24th June, 2007
+Date: "24th June, 2007"
Timestamp: 2007-06-24T11:53:00-07:00
Tags: elschemo, haskell, scheme
---
@@ -10,24 +10,8 @@ Tags: elschemo, haskell, scheme
The first task is extending the LispVal type to grok floats.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-
-type LispInt = Integer
+```haskell
+type LispInt = Integer
type LispFloat = Float
-- numeric data types
@@ -41,30 +25,22 @@ data LispVal = Atom String
| Number LispNum
| Char Char
| String String
- | ...
-
+ | ...
+```
The reason for using the new LispNum type and not just throwing a new Float Float constructor in there is so that functions can accept and operate on parameters of any supported numeric type. First the floating point numbers need to be parsed. For now I only parse floating point numbers in decimal because the effort to parse other bases is too great for the benefits gained (none, for me).
ElSchemo now parses negative numbers so I'll start with 2 helper functions that are used when parsing both integers and floats:
-
-1
-2
-3
-4
-5
-6
-7
-
-parseSign :: Parser Char
+```haskell
+parseSign :: Parser Char
parseSign = do try (char '-')
- <|> do optional (char '+')
+ <|> do optional (char '+')
return '+'
-applySign :: Char -> LispNum -> LispNum
-applySign sign n = if sign == '-' then negate n else n
-
+applySign :: Char -> LispNum -> LispNum
+applySign sign n = if sign == '-' then negate n else n
+```
parseSign is straightforward as it follows the convention that a literal number is positive unless explicitly marked as negative with a leading minus sign. A leading plus sign is allowed but not required.
@@ -72,94 +48,64 @@ applySign sign n = if sign == '-' then negate n else n
Armed with these 2 functions we can now parse floating point numbers in decimal. Conforming to R5RS an optional #d prefix is allowed.
-
-1
-2
-3
-4
-5
-6
-7
-8
-
-parseFloat :: Parser LispVal
+```haskell
+parseFloat :: Parser LispVal
parseFloat = do optional (string "#d")
- sign <- parseSign
- whole <- many1 digit
+ sign <- parseSign
+ whole <- many1 digit
char '.'
- fract <- many1 digit
+ fract <- many1 digit
return . Number $ applySign sign (makeFloat whole fract)
- where makeFloat whole fract = Float . fst . head . readFloat $ whole ++ "." ++ fract
-
+ where makeFloat whole fract = Float . fst . head . readFloat $ whole ++ "." ++ fract
+```
The first 6 lines should be clear. Line 7 simply applies the parsed sign to the parsed number and returns it, delegating most of the work to makeFloat. makeFloat in turn delegates the work to the readFloat library function, extracts the result and constructs a LispNum for it.
The last step for parsing is to modify parseExpr to try and parse floats.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-
--- Integers, floats, characters and atoms can all start with a # so wrap those with try.
+```haskell
+-- Integers, floats, characters and atoms can all start with a # so wrap those with try.
-- (Left factor the grammar in the future)
parseExpr :: Parser LispVal
parseExpr = (try parseFloat)
- <|> (try parseInteger)
- <|> (try parseChar)
- <|> parseAtom
- <|> parseString
- <|> parseQuoted
- <|> do char '('
- x <- (try parseList) <|> parseDottedList
+ <|> (try parseInteger)
+ <|> (try parseChar)
+ <|> parseAtom
+ <|> parseString
+ <|> parseQuoted
+ <|> do char '('
+ x <- (try parseList) <|> parseDottedList
char ')'
return x
- <|> parseComment
-
+ <|> parseComment
+```
### Displaying the floats ###
-
That's it for parsing, now let's provide a way to display these suckers. LispVal is an instance of show, where show = showVal so showVal is our first stop. Remembering that LispVal now has a single Number constructor we modify it accordingly:
+```haskell
+showVal (Number n) = showNum n
-1
-2
-3
-4
-5
-6
-7
-
-showVal (Number n) = showNum n
-
-showNum :: LispNum -> String
+showNum :: LispNum -> String
showNum (Integer contents) = show contents
showNum (Float contents) = show contents
-instance Show LispNum where show = showNum
-
+instance Show LispNum where show = showNum
+```
One last, and certainly not least, step is to modify eval so that numbers evaluate to themselves.
-
- eval env val@(Number _) = return val
+```haskell
+eval env val@(Number _) = return val
+```
There's a little more housekeeping to be done such as fixing integer?, number?, implementing float? but I will leave those as an exercise to the reader, or just wait until I share the full code. As it stands now floating point numbers can be parsed and displayed. If you fire up the interpreter and type 2.5 or -10.88 they will be understood. Now try adding them:
- (+ 2.5 1.1)
- Invalid type: expected integer, found 2.5
+```scheme
+(+ 2.5 1.1)
+Invalid type: expected integer, found 2.5
+```
Oops, we don't know how to operate on floats yet!
@@ -167,79 +113,8 @@ Oops, we don't know how to operate on floats yet!
Parsing was the easy part. Operating on the new floats is not necessarily difficult, but it was more work than I realized it would be. I don't claim that this is the best or the only way to operate on any LispNum, it's just the way I did it and it seems to work. There's a bunch of boilerplate necessary to make LispNum an instance of the required classes, Eq, Num, Real, and Ord. I don't think I have done this properly but for now it works. What is clearly necessary is the code that operates on different types of numbers. I think I've specified sane semantics for coercion. This will be very handy shortly.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-16
-17
-18
-19
-20
-21
-22
-23
-24
-25
-26
-27
-28
-29
-30
-31
-32
-33
-34
-35
-36
-37
-38
-39
-40
-41
-42
-43
-44
-45
-46
-47
-48
-49
-50
-51
-52
-53
-54
-55
-56
-57
-58
-59
-60
-61
-62
-63
-64
-65
-66
-67
-68
-69
-70
-
-lispNumEq :: LispNum -> LispNum -> Bool
+```haskell
+lispNumEq :: LispNum -> LispNum -> Bool
lispNumEq (Integer arg1) (Integer arg2) = arg1 == arg2
lispNumEq (Integer arg1) (Float arg2) = (fromInteger arg1) == arg2
lispNumEq (Float arg1) (Float arg2) = arg1 == arg2
@@ -247,35 +122,35 @@ lispNumEq (Float arg1) (Integer arg2) = arg1 == (fromInteger arg2)
instance Eq LispNum where (==) = lispNumEq
-lispNumPlus :: LispNum -> LispNum -> LispNum
+lispNumPlus :: LispNum -> LispNum -> LispNum
lispNumPlus (Integer x) (Integer y) = Integer $ x + y
lispNumPlus (Integer x) (Float y) = Float $ (fromInteger x) + y
lispNumPlus (Float x) (Float y) = Float $ x + y
lispNumPlus (Float x) (Integer y) = Float $ x + (fromInteger y)
-lispNumMinus :: LispNum -> LispNum -> LispNum
+lispNumMinus :: LispNum -> LispNum -> LispNum
lispNumMinus (Integer x) (Integer y) = Integer $ x - y
lispNumMinus (Integer x) (Float y) = Float $ (fromInteger x) - y
lispNumMinus (Float x) (Float y) = Float $ x - y
lispNumMinus (Float x) (Integer y) = Float $ x - (fromInteger y)
-lispNumMult :: LispNum -> LispNum -> LispNum
+lispNumMult :: LispNum -> LispNum -> LispNum
lispNumMult (Integer x) (Integer y) = Integer $ x * y
lispNumMult (Integer x) (Float y) = Float $ (fromInteger x) * y
lispNumMult (Float x) (Float y) = Float $ x * y
lispNumMult (Float x) (Integer y) = Float $ x * (fromInteger y)
-lispNumDiv :: LispNum -> LispNum -> LispNum
+lispNumDiv :: LispNum -> LispNum -> LispNum
lispNumDiv (Integer x) (Integer y) = Integer $ x `div` y
lispNumDiv (Integer x) (Float y) = Float $ (fromInteger x) / y
lispNumDiv (Float x) (Float y) = Float $ x / y
lispNumDiv (Float x) (Integer y) = Float $ x / (fromInteger y)
-lispNumAbs :: LispNum -> LispNum
+lispNumAbs :: LispNum -> LispNum
lispNumAbs (Integer x) = Integer (abs x)
lispNumAbs (Float x) = Float (abs x)
-lispNumSignum :: LispNum -> LispNum
+lispNumSignum :: LispNum -> LispNum
lispNumSignum (Integer x) = Integer (signum x)
lispNumSignum (Float x) = Float (signum x)
@@ -287,50 +162,32 @@ instance Num LispNum where
signum = lispNumSignum
fromInteger x = Integer x
-
-lispNumToRational :: LispNum -> Rational
+lispNumToRational :: LispNum -> Rational
lispNumToRational (Integer x) = toRational x
lispNumToRational (Float x) = toRational x
instance Real LispNum where
toRational = lispNumToRational
-
-lispIntQuotRem :: LispInt -> LispInt -> (LispInt, LispInt)
+lispIntQuotRem :: LispInt -> LispInt -> (LispInt, LispInt)
lispIntQuotRem n d = quotRem n d
-lispIntToInteger :: LispInt -> Integer
+lispIntToInteger :: LispInt -> Integer
lispIntToInteger x = x
-lispNumLessThanEq :: LispNum -> LispNum -> Bool
-lispNumLessThanEq (Integer x) (Integer y) = x <= y
-lispNumLessThanEq (Integer x) (Float y) = (fromInteger x) <= y
-lispNumLessThanEq (Float x) (Integer y) = x <= (fromInteger y)
-lispNumLessThanEq (Float x) (Float y) = x <= y
-
-instance Ord LispNum where (<=) = lispNumLessThanEq
+lispNumLessThanEq :: LispNum -> LispNum -> Bool
+lispNumLessThanEq (Integer x) (Integer y) = x <= y
+lispNumLessThanEq (Integer x) (Float y) = (fromInteger x) <= y
+lispNumLessThanEq (Float x) (Integer y) = x <= (fromInteger y)
+lispNumLessThanEq (Float x) (Float y) = x <= y
+instance Ord LispNum where (<=) = lispNumLessThanEq
+```
Phew, ok with that out of the way now we can actually extend our operators to work with any type of LispNum. Our Scheme operators are defined using the functions numericBinop and numBoolBinop. First we'll slightly modify our definition of primitives:
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-
-primitives :: [(String, [LispVal] -> ThrowsError LispVal)]
+```haskell
+primitives :: [(String, [LispVal] -> ThrowsError LispVal)]
primitives = [("+", numericBinop (+)),
("-", subtractOp),
("*", numericBinop (*)),
@@ -339,94 +196,57 @@ primitives = [("+", numericBinop (+)),
("quotient", integralBinop quot),
("remainder", integralBinop rem),
("=", numBoolBinop (==)),
- ("<", numBoolBinop (<)),
- (">", numBoolBinop (>)),
+ ("<", numBoolBinop (<)),
+ (">", numBoolBinop (>)),
("/=", numBoolBinop (/=)),
- (">=", numBoolBinop (>=)),
- ("<=", numBoolBinop (<=)),
- ...]
-
+ (">=", numBoolBinop (>=)),
+ ("<=", numBoolBinop (<=)),
+ ...]
+```
Note that mod, quotient, and remainder are only defined for integers and as such use integralBinop, while division (/) is only defined for floating point numbers using floatBinop. subtractOp is different to support unary usage, e.g. (- 4) => -4, but it uses numericBinop internally when more than 1 argument is given. On to the implementation! First extend unpackNum to work with any LispNum, and provide separate unpackInt and unpackFloat functions to handle both kinds of LispNum.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-
-unpackNum :: LispVal -> ThrowsError LispNum
+```haskell
+unpackNum :: LispVal -> ThrowsError LispNum
unpackNum (Number (Integer n)) = return $ Integer n
unpackNum (Number (Float n)) = return $ Float n
unpackNum notNum = throwError $ TypeMismatch "number" notNum
-unpackInt :: LispVal -> ThrowsError Integer
+unpackInt :: LispVal -> ThrowsError Integer
unpackInt (Number (Integer n)) = return n
unpackInt (List [n]) = unpackInt n
unpackInt notInt = throwError $ TypeMismatch "integer" notInt
-unpackFloat :: LispVal -> ThrowsError Float
+unpackFloat :: LispVal -> ThrowsError Float
unpackFloat (Number (Float f)) = return f
unpackFloat (Number (Integer f)) = return $ fromInteger f
unpackFloat (List [f]) = unpackFloat f
-unpackFloat notFloat = throwError $ TypeMismatch "float" notFloat
-
+unpackFloat notFloat = throwError $ TypeMismatch "float" notFloat
+```
The initial work of separating integers and floats into the LispNum abstraction, and the code I said would be handy shortly, are going to be really handy here. There's relatively no change in numericBinop except for the type signature. integralBinop and floatBinop are just specific versions of the same function. I'm sure there's a nice Haskelly way of doing this with less repetition, and I welcome such corrections.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-16
-17
-18
-
-numericBinop :: (LispNum -> LispNum -> LispNum) -> [LispVal] -> ThrowsError LispVal
+```haskell
+numericBinop :: (LispNum -> LispNum -> LispNum) -> [LispVal] -> ThrowsError LispVal
numericBinop op singleVal@[_] = throwError $ NumArgs 2 singleVal
-numericBinop op params = mapM unpackNum params >>= return . Number . foldl1 op
+numericBinop op params = mapM unpackNum params >>= return . Number . foldl1 op
-integralBinop :: (LispInt -> LispInt -> LispInt) -> [LispVal] -> ThrowsError LispVal
+integralBinop :: (LispInt -> LispInt -> LispInt) -> [LispVal] -> ThrowsError LispVal
integralBinop op singleVal@[_] = throwError $ NumArgs 2 singleVal
-integralBinop op params = mapM unpackInt params >>= return . Number . Integer . foldl1 op
+integralBinop op params = mapM unpackInt params >>= return . Number . Integer . foldl1 op
-floatBinop :: (LispFloat -> LispFloat -> LispFloat) -> [LispVal] -> ThrowsError LispVal
+floatBinop :: (LispFloat -> LispFloat -> LispFloat) -> [LispVal] -> ThrowsError LispVal
floatBinop op singleVal@[_] = throwError $ NumArgs 2 singleVal
-floatBinop op params = mapM unpackFloat params >>= return . Number . Float . foldl1 op
+floatBinop op params = mapM unpackFloat params >>= return . Number . Float . foldl1 op
-subtractOp :: [LispVal] -> ThrowsError LispVal
-subtractOp num@[_] = unpackNum (head num) >>= return . Number . negate
+subtractOp :: [LispVal] -> ThrowsError LispVal
+subtractOp num@[_] = unpackNum (head num) >>= return . Number . negate
subtractOp params = numericBinop (-) params
-numBoolBinop :: (LispNum -> LispNum -> Bool) -> [LispVal] -> ThrowsError LispVal
-numBoolBinop op params = boolBinop unpackNum op params
-
+numBoolBinop :: (LispNum -> LispNum -> Bool) -> [LispVal] -> ThrowsError LispVal
+numBoolBinop op params = boolBinop unpackNum op params
+```
That was a bit of work but now ElSchemo supports floating point numbers, and if you're following along then your Scheme might too if I haven't missed any important details!
-
Next time I'll go over some of the special forms I have added, including short-circuiting and and or forms and the full repetoire of let, let*, and letrec. Stay tuned!
-
diff --git a/posts/2007/06/more-scheming-with-haskell.md b/posts/2007/06/more-scheming-with-haskell.md
index aa946d0..a8fdc70 100644
--- a/posts/2007/06/more-scheming-with-haskell.md
+++ b/posts/2007/06/more-scheming-with-haskell.md
@@ -1,7 +1,7 @@
---
-Title: More Scheming with Haskell
+Title: "More Scheming with Haskell"
Author: Sami Samhuri
-Date: 14th June, 2007
+Date: "14th June, 2007"
Timestamp: 2007-06-13T18:09:00-07:00
Tags: coding, haskell, scheme
---
@@ -14,11 +14,14 @@ It's been a little while since I wrote about Haskell and the R5RS compliant numbers , which is exercise 3.3.4 if you're following along the tutorial. Only integers in binary, octal, decimal, and hexadecimal are parsed right now. The syntaxes for those are #b101010, #o52, 42 (or #d42), and #x2a, respectively. To parse these we use the readOct, readDec, readHex, and readInt functions provided by the Numeric module, and import them thusly:
- import Numeric (readOct, readDec, readHex, readInt)
+```haskell
+import Numeric (readOct, readDec, readHex, readInt)
+```
In order to parse binary digits we need to write a few short functions to help us out. For some reason I couldn't find binDigit, isBinDigit and readBin in their respective modules but luckily they're trivial to implement. The first two are self-explanatory, as is the third if you look at the implementation of its relatives for larger bases. In a nutshell readBin says to: "read an integer in base 2, validating digits with isBinDigit."
--- parse a binary digit, analagous to decDigit, octDigit, hexDigit
+```haskell
+-- parse a binary digit, analagous to decDigit, octDigit, hexDigit
binDigit :: Parser Char
binDigit = oneOf "01"
@@ -28,24 +31,30 @@ isBinDigit c = (c == '0' || c == '1')
-- analogous to readDec, readOct, readHex
readBin :: (Integral a) = ReadS a
-readBin = readInt 2 isBinDigit digitToInt
+readBin = readInt 2 isBinDigit digitToInt
+```
The next step is to augment parseNumber so that it can handle R5RS numbers in addition to regular decimal numbers. To refresh, the tutorial's parseNumber function looks like this:
- parseNumber :: Parser LispVal
- parseNumber = liftM (Number . read) $ many1 digit
+```haskell
+parseNumber :: Parser LispVal
+parseNumber = liftM (Number . read) $ many1 digit
+```
Three more lines in this function will give us a decent starting point:
- parseNumber = do char '#'
- base <- oneOf "bdox"
- parseDigits base
+```haskell
+parseNumber = do char '#'
+ base <- oneOf "bdox"
+ parseDigits base
+```
Translation: First look for an R5RS style base, and if found call parseDigits with the given base to do the dirty work. If that fails then fall back to parsing a boring old string of decimal digits.
That brings us to actually parsing the numbers. parseDigits is simple, but there might be a more Haskell-y way of doing this.
--- Parse a string of digits in the given base.
+```haskell
+-- Parse a string of digits in the given base.
parseDigits :: Char - Parser LispVal
parseDigits base = many1 d >>= return
where d = case base of
@@ -53,7 +62,7 @@ parseDigits base = many1 d >>= return
'd' -> digit
'o' -> octDigit
'x' -> hexDigit
-
+```
The trickiest part of all this was figuring out how to use the various readFoo functions properly. They return a list of pairs so head grabs the first pair and fst grabs the first element of the pair. Once I had that straight it was smooth sailing. Having done this, parsing R5RS characters (#\a, #\Z, #\?, ...) is a breeze so I won't bore you with that.
@@ -61,27 +70,17 @@ The trickiest part of all this was figuring out how to use the various rea
It still takes me some time to knit together meaningful Haskell statements. Tonight I spent said time cobbling together an implementation of cond as a new special form. Have a look at the code. The explanation follows.
-
-1
-2
-3
-4
-5
-6
-7
-8
-9
-
-eval env (List (Atom "cond" : List (Atom "else" : exprs) : [])) =
+```haskell
+eval env (List (Atom "cond" : List (Atom "else" : exprs) : [])) =
liftM last $ mapM (eval env) exprs
eval env (List (Atom "cond" : List (pred : conseq) : rest)) =
- do result <- eval env $ pred
+ do result <- eval env $ pred
case result of
- Bool False -> case rest of
- [] -> return $ List []
- _ -> eval env $ List (Atom "cond" : rest)
- _ -> liftM last $ mapM (eval env) conseq
-
+ Bool False -> case rest of
+ [] -> return $ List []
+ _ -> eval env $ List (Atom "cond" : rest)
+ _ -> liftM last $ mapM (eval env) conseq
+```
* __Lines 1-2:__ Handle else clauses by evaluating the given expression(s), returning the last result. It must come first or it's overlapped by the next pattern.
* __Line 3:__ Evaluate a cond by splitting the first condition into predicate and consequence , tuck the remaining conditions into rest for later.
@@ -93,4 +92,3 @@ eval env (List (Atom "cond" : List (pred : conseq) : rest)) =
* __Line 9:__ Anything other than #f is considered true and causes conseq to be evaluated and returned. Like else, conseq can be a sequence of expressions.
So far my Scheme weighs in at 621 lines, 200 more than the tutorial's final code listing. Hopefully I'll keep adding things on my TODO list and it will grow a little bit more. Now that I have cond it will be more fun to expand my stdlib.scm as well.
-
diff --git a/posts/2007/06/propaganda-makes-me-sick.md b/posts/2007/06/propaganda-makes-me-sick.md
index 7ae47d4..01602e9 100644
--- a/posts/2007/06/propaganda-makes-me-sick.md
+++ b/posts/2007/06/propaganda-makes-me-sick.md
@@ -1,7 +1,7 @@
---
-Title: Propaganda makes me sick
+Title: "Propaganda makes me sick"
Author: Sami Samhuri
-Date: 25th June, 2007
+Date: "25th June, 2007"
Timestamp: 2007-06-25T03:55:00-07:00
Tags: propaganda
---
diff --git a/posts/2007/06/recent-ruby-and-rails-regales.md b/posts/2007/06/recent-ruby-and-rails-regales.md
index bb05c40..8b81d8f 100644
--- a/posts/2007/06/recent-ruby-and-rails-regales.md
+++ b/posts/2007/06/recent-ruby-and-rails-regales.md
@@ -1,7 +1,7 @@
---
-Title: Recent Ruby and Rails Regales
+Title: "Recent Ruby and Rails Regales"
Author: Sami Samhuri
-Date: 28th June, 2007
+Date: "28th June, 2007"
Timestamp: 2007-06-28T12:23:00-07:00
Tags: rails, rails on rules, regular expressions, ruby, sake, secure associations, regex
---
diff --git a/posts/2007/06/reinventing-the-wheel.md b/posts/2007/06/reinventing-the-wheel.md
index 3d42df3..737ecbd 100644
--- a/posts/2007/06/reinventing-the-wheel.md
+++ b/posts/2007/06/reinventing-the-wheel.md
@@ -1,7 +1,7 @@
---
-Title: Reinventing the wheel
+Title: "Reinventing the wheel"
Author: Sami Samhuri
-Date: 20th June, 2007
+Date: "20th June, 2007"
Timestamp: 2007-06-20T09:27:00-07:00
Tags: emacs, snippets
---
diff --git a/posts/2007/06/rtfm.md b/posts/2007/06/rtfm.md
index 34846d9..9bb5fc1 100644
--- a/posts/2007/06/rtfm.md
+++ b/posts/2007/06/rtfm.md
@@ -1,7 +1,7 @@
---
-Title: RTFM!
+Title: "RTFM!"
Author: Sami Samhuri
-Date: 26th June, 2007
+Date: "26th June, 2007"
Timestamp: 2007-06-25T14:19:00-07:00
Tags: emacs, rtfm
---
diff --git a/posts/2007/06/so-long-typo-and-thanks-for-all-the-timeouts.md b/posts/2007/06/so-long-typo-and-thanks-for-all-the-timeouts.md
index 31af818..94af7b8 100644
--- a/posts/2007/06/so-long-typo-and-thanks-for-all-the-timeouts.md
+++ b/posts/2007/06/so-long-typo-and-thanks-for-all-the-timeouts.md
@@ -1,7 +1,7 @@
---
-Title: so long typo (and thanks for all the timeouts)
+Title: "so long typo (and thanks for all the timeouts)"
Author: Sami Samhuri
-Date: 8th June, 2007
+Date: "8th June, 2007"
Timestamp: 2007-06-08T18:01:00-07:00
Tags: mephisto, typo
---
@@ -12,22 +12,15 @@ Recently I had looked at converting Typo to Mephisto and it seemed pretty painle
After running that code snippet to fix my tags, I decided to completely ditch categories in favour of tags. I tagged each new Mephisto article with a tag for each Typo category it had previously belonged to. I fired up RAILS_ENV=production script/console and typed something similar to the following:
-1
-2
-3
-4
-5
-6
-7
-
-require ' converters/base '
-require ' converters/typo '
-articles = Typo ::Article .find(:all ).map {|a| [a, Article .find_by_permalink(a.permalink)] }
-articles.each do |ta, ma|
- next if ma.nil?
- ma.tags << Tag .find_or_create(ta.categories.map(&:name ))
-end
-
+```ruby
+require 'converters/base'
+require 'converters/typo'
+articles = Typo::Article.find(:all).map {|a| [a, Article.find_by_permalink(a.permalink)] }
+articles.each do |ta, ma|
+ next if ma.nil?
+ ma.tags << Tag.find_or_create(ta.categories.map(&:name))
+end
+```
When I say something similar I mean exactly that. I just typed that from memory so it may not work, or even be syntactically correct. If any permalinks changed then you'll have to manually add new tags corresponding to old Typo categories. The only case where this bit me was when I had edited the title of an article, in which case the new Mephisto permalink matched the new title while the Typo permalink matched the initial title, whatever it was.
diff --git a/posts/2007/06/testspec-on-rails-declared-awesome-just-one-catch.md b/posts/2007/06/testspec-on-rails-declared-awesome-just-one-catch.md
index e4bae64..65add7d 100644
--- a/posts/2007/06/testspec-on-rails-declared-awesome-just-one-catch.md
+++ b/posts/2007/06/testspec-on-rails-declared-awesome-just-one-catch.md
@@ -1,7 +1,7 @@
---
-Title: test/spec on rails declared awesome, just one catch
+Title: "test/spec on rails declared awesome, just one catch"
Author: Sami Samhuri
-Date: 14th June, 2007
+Date: "14th June, 2007"
Timestamp: 2007-06-14T07:21:00-07:00
Tags: bdd, rails, test/spec
---
@@ -10,79 +10,47 @@ This last week I've been getting to know
-
-use_controller :foo
-
+```ruby
+use_controller :foo
+```
and can be placed in the setup method, like so:
+```ruby
+# in test/functional/sessions_controller_test.rb
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-14
-15
-
-# in test/functional/sessions_controller_test.rb
+context "A guest" do
+ fixtures :users
-context " A guest " do
- fixtures :users
+ setup do
+ use_controller :sessions
+ end
- setup do
- use_controller :sessions
- end
-
- specify " can login " do
- post :create , :username => ' sjs ' , :password => ' blah '
- response.should.redirect_to user_url(users(:sjs ))
+ specify "can login" do
+ post :create, :username => 'sjs', :password => 'blah'
+ response.should.redirect_to user_url(users(:sjs))
...
- end
-end
-
+ end
+end
+```
This is great and the test will work. But let's say that I have another controller that guests can access:
+```ruby
+# in test/functional/foo_controller_test.rb
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-13
-
-# in test/functional/foo_controller_test.rb
+context "A guest" do
+ setup do
+ use_controller :foo
+ end
-context " A guest " do
- setup do
- use_controller :foo
- end
-
- specify " can do foo stuff " do
- get :fooriffic
- status.should.be :success
+ specify "can do foo stuff" do
+ get :fooriffic
+ status.should.be :success
...
- end
-end
-
+ end
+end
+```
This test will pass on its own as well, which is what really tripped me up. When I ran my tests individually as I wrote them, they passed. When I ran rake test:functionals this morning and saw over a dozen failures and errors I was pretty alarmed. Then I looked at the errors and was thoroughly confused. Of course the action fooriffic can't be found in SessionsController , it lives in FooController and that's the controller I said to use! What gives?!
diff --git a/posts/2007/07/a-textmate-tip-for-emacs-users.md b/posts/2007/07/a-textmate-tip-for-emacs-users.md
index 2be4215..4a858fe 100644
--- a/posts/2007/07/a-textmate-tip-for-emacs-users.md
+++ b/posts/2007/07/a-textmate-tip-for-emacs-users.md
@@ -1,7 +1,7 @@
---
-Title: A TextMate tip for Emacs users
+Title: "A TextMate tip for Emacs users"
Author: Sami Samhuri
-Date: 3rd July, 2007
+Date: "3rd July, 2007"
Timestamp: 2007-07-03T09:45:00-07:00
Tags: emacs, keyboard shortcuts, textmate
---
diff --git a/posts/2007/07/people.md b/posts/2007/07/people.md
index 25a9638..5221132 100644
--- a/posts/2007/07/people.md
+++ b/posts/2007/07/people.md
@@ -1,7 +1,7 @@
---
-Title: people
+Title: "people"
Author: Sami Samhuri
-Date: 12th July, 2007
+Date: "12th July, 2007"
Timestamp: 2007-07-12T05:28:00-07:00
Tags: life, people
---
diff --git a/posts/2007/07/rushcheck-quickcheck-for-ruby.md b/posts/2007/07/rushcheck-quickcheck-for-ruby.md
index 7d477dc..9c6624b 100644
--- a/posts/2007/07/rushcheck-quickcheck-for-ruby.md
+++ b/posts/2007/07/rushcheck-quickcheck-for-ruby.md
@@ -1,7 +1,7 @@
---
-Title: RushCheck: QuickCheck for Ruby
+Title: "RushCheck: QuickCheck for Ruby"
Author: Sami Samhuri
-Date: 5th July, 2007
+Date: "5th July, 2007"
Timestamp: 2007-07-05T12:50:00-07:00
Tags: quickcheck, ruby, rushcheck
---
diff --git a/posts/2007/07/see-your-regular-expressions-in-emacs.md b/posts/2007/07/see-your-regular-expressions-in-emacs.md
index d52b7f2..e70bd95 100644
--- a/posts/2007/07/see-your-regular-expressions-in-emacs.md
+++ b/posts/2007/07/see-your-regular-expressions-in-emacs.md
@@ -1,7 +1,7 @@
---
-Title: See your regular expressions in Emacs
+Title: "See your regular expressions in Emacs"
Author: Sami Samhuri
-Date: 6th July, 2007
+Date: "6th July, 2007"
Timestamp: 2007-07-06T09:45:00-07:00
Tags: emacs, regex
---
diff --git a/posts/2007/08/5-ways-to-avoid-looking-like-a-jerk-on-the-internet.md b/posts/2007/08/5-ways-to-avoid-looking-like-a-jerk-on-the-internet.md
index 808f2b6..f96694b 100644
--- a/posts/2007/08/5-ways-to-avoid-looking-like-a-jerk-on-the-internet.md
+++ b/posts/2007/08/5-ways-to-avoid-looking-like-a-jerk-on-the-internet.md
@@ -1,7 +1,7 @@
---
-Title: 5 ways to avoid looking like a jerk on the Internet
+Title: "5 ways to avoid looking like a jerk on the Internet"
Author: Sami Samhuri
-Date: 30th August, 2007
+Date: "30th August, 2007"
Timestamp: 2007-08-30T08:25:00-07:00
Tags: life, netiquette
---
diff --git a/posts/2007/08/captivating-little-creatures.md b/posts/2007/08/captivating-little-creatures.md
index 9abb07c..05f883c 100644
--- a/posts/2007/08/captivating-little-creatures.md
+++ b/posts/2007/08/captivating-little-creatures.md
@@ -1,7 +1,7 @@
---
-Title: Captivating little creatures
+Title: "Captivating little creatures"
Author: Sami Samhuri
-Date: 26th August, 2007
+Date: "26th August, 2007"
Timestamp: 2007-08-26T05:35:00-07:00
Tags: games, lemmings
---
diff --git a/posts/2007/08/catch-compiler-errors-at-runtime.md b/posts/2007/08/catch-compiler-errors-at-runtime.md
index 602adfe..dc50e83 100644
--- a/posts/2007/08/catch-compiler-errors-at-runtime.md
+++ b/posts/2007/08/catch-compiler-errors-at-runtime.md
@@ -1,7 +1,7 @@
---
-Title: Catch compiler errors at runtime
+Title: "Catch compiler errors at runtime"
Author: Sami Samhuri
-Date: 19th August, 2007
+Date: "19th August, 2007"
Timestamp: 2007-08-19T15:17:00-07:00
Tags: ruby
---
diff --git a/posts/2007/08/cheat-from-emacs.md b/posts/2007/08/cheat-from-emacs.md
index c59a1f7..d75a89e 100644
--- a/posts/2007/08/cheat-from-emacs.md
+++ b/posts/2007/08/cheat-from-emacs.md
@@ -1,7 +1,7 @@
---
-Title: Cheat from Emacs
+Title: "Cheat from Emacs"
Author: Sami Samhuri
-Date: 9th August, 2007
+Date: "9th August, 2007"
Timestamp: 2007-08-09T18:56:00-07:00
Tags: Emacs
---
diff --git a/posts/2007/08/cheat-productively-in-emacs.md b/posts/2007/08/cheat-productively-in-emacs.md
index 1d1648e..57c6fd3 100644
--- a/posts/2007/08/cheat-productively-in-emacs.md
+++ b/posts/2007/08/cheat-productively-in-emacs.md
@@ -1,7 +1,7 @@
---
-Title: Cheat productively in Emacs
+Title: "Cheat productively in Emacs"
Author: Sami Samhuri
-Date: 21st August, 2007
+Date: "21st August, 2007"
Timestamp: 2007-08-21T11:20:00-07:00
Tags: Emacs
---
diff --git a/posts/2007/08/elschemo-boolean-logic-and-branching.md b/posts/2007/08/elschemo-boolean-logic-and-branching.md
index f3420e7..9ff64d0 100644
--- a/posts/2007/08/elschemo-boolean-logic-and-branching.md
+++ b/posts/2007/08/elschemo-boolean-logic-and-branching.md
@@ -1,7 +1,7 @@
---
-Title: ElSchemo: Boolean logic and branching
+Title: "ElSchemo: Boolean logic and branching"
Author: Sami Samhuri
-Date: 2nd August, 2007
+Date: "2nd August, 2007"
Timestamp: 2007-08-02T09:59:00-07:00
Tags: elschemo, haskell, scheme
---
@@ -16,7 +16,6 @@ that means the code here is for me to get some feedback as much
as to show others how to do this kind of stuff. This may not be too
interesting if you haven't at least browsed the tutorial.
-
I'm going to cover 3 new special forms: and, or, and cond. I
promised to cover the let family of special forms this time around
but methinks this is long enough as it is. My sincere apologies if
@@ -45,25 +44,16 @@ concise language. My explanations may be redundant because of this.
### lispAnd ###
-
-1
-2
-3
-4
-5
-6
-7
-8
-
-lispAnd :: Env -> [LispVal] -> IOThrowsError LispVal
+```haskell
+lispAnd :: Env -> [LispVal] -> IOThrowsError LispVal
lispAnd env [] = return $ Bool True
lispAnd env [pred] = eval env pred
lispAnd env (pred:rest) = do
- result <- eval env pred
+ result <- eval env pred
case result of
- Bool False -> return result
- _ -> lispAnd env rest
-
+ Bool False -> return result
+ _ -> lispAnd env rest
+```
Starting with the trivial case, and returns #t with zero
arguments.
@@ -84,25 +74,16 @@ just complicates things but it's a viable solution.
Predictably this is quite similar to lispAnd.
-
-1
-2
-3
-4
-5
-6
-7
-8
-
-lispOr :: Env -> [LispVal] -> IOThrowsError LispVal
+```haskell
+lispOr :: Env -> [LispVal] -> IOThrowsError LispVal
lispOr env [] = return $ Bool False
lispOr env [pred] = eval env pred
lispOr env (pred:rest) = do
- result <- eval env pred
+ result <- eval env pred
case result of
- Bool False -> lispOr env rest
- _ -> return result
-
+ Bool False -> lispOr env rest
+ _ -> return result
+```
With no arguments lispOr returns #f, and with one argument it
evaluates and returns the result.
@@ -117,33 +98,23 @@ First let me define a convenience function that I have added to
ElSchemo. It maps a list of expressions to their values by evaluating
each one in the given environment.
-
-1
-2
-
-evalExprs :: Env -> [LispVal] -> IOThrowsError [LispVal]
-evalExprs env exprs = mapM (eval env) exprs
-
+```haskell
+evalExprs :: Env -> [LispVal] -> IOThrowsError [LispVal]
+evalExprs env exprs = mapM (eval env) exprs
+```
### lispCond ###
Again, lispCond has the same type as eval.
-
-1
-2
-3
-4
-5
-6
-
-lispCond :: Env -> [LispVal] -> IOThrowsError LispVal
+```haskell
+lispCond :: Env -> [LispVal] -> IOThrowsError LispVal
lispCond env (List (pred:conseq) : rest) = do
- result <- eval env pred
+ result <- eval env pred
case result of
- Bool False -> if null rest then return result else lispCond env rest
- _ -> liftM last $ evalExprs env conseq
-
+ Bool False -> if null rest then return result else lispCond env rest
+ _ -> liftM last $ evalExprs env conseq
+```
Unlike Lisp – which uses a predicate of T (true) – Scheme uses a
predicate of else to trigger the default branch. When the pattern
@@ -164,15 +135,11 @@ expressions and return the value of the last one.
Now all that's left is to hook up the new functions in eval.
-
-1
-2
-3
-
-eval env (List (Atom "and" : params)) = lispAnd env params
+```haskell
+eval env (List (Atom "and" : params)) = lispAnd env params
eval env (List (Atom "or" : params)) = lispOr env params
-eval env (List (Atom "cond" : params)) = lispCond env params
-
+eval env (List (Atom "cond" : params)) = lispCond env params
+```
You could, of course, throw the entire definitions in eval itself but eval is big
enough for me as it is. YMMV.
diff --git a/posts/2007/08/opera-is-pretty-slick.md b/posts/2007/08/opera-is-pretty-slick.md
index f9a56d7..b5fa5cb 100644
--- a/posts/2007/08/opera-is-pretty-slick.md
+++ b/posts/2007/08/opera-is-pretty-slick.md
@@ -1,7 +1,7 @@
---
-Title: Opera is pretty slick
+Title: "Opera is pretty slick"
Author: Sami Samhuri
-Date: 11th August, 2007
+Date: "11th August, 2007"
Timestamp: 2007-08-11T05:11:00-07:00
Tags: browsers, firefox, opera
---
diff --git a/posts/2007/08/snap-crunchle-pop.md b/posts/2007/08/snap-crunchle-pop.md
index bc6c8dc..3ff3d54 100644
--- a/posts/2007/08/snap-crunchle-pop.md
+++ b/posts/2007/08/snap-crunchle-pop.md
@@ -1,7 +1,7 @@
---
-Title: Snap, crunchle, pop
+Title: "Snap, crunchle, pop"
Author: Sami Samhuri
-Date: 9th August, 2007
+Date: "9th August, 2007"
Timestamp: 2007-08-09T03:17:00-07:00
Tags: humans, injury, life
---
diff --git a/posts/2007/09/learning-lisp-read-pcl.md b/posts/2007/09/learning-lisp-read-pcl.md
index 5d925ff..c6c2cfb 100644
--- a/posts/2007/09/learning-lisp-read-pcl.md
+++ b/posts/2007/09/learning-lisp-read-pcl.md
@@ -1,7 +1,7 @@
---
-Title: Learning Lisp? Read PCL
+Title: "Learning Lisp? Read PCL"
Author: Sami Samhuri
-Date: 25th September, 2007
+Date: "25th September, 2007"
Timestamp: 2007-09-25T02:59:00-07:00
Tags: lisp
---
diff --git a/posts/2007/09/python-and-ruby-brain-dump.md b/posts/2007/09/python-and-ruby-brain-dump.md
index 07d1f80..55043a9 100644
--- a/posts/2007/09/python-and-ruby-brain-dump.md
+++ b/posts/2007/09/python-and-ruby-brain-dump.md
@@ -1,7 +1,7 @@
---
-Title: Python and Ruby brain dump
+Title: "Python and Ruby brain dump"
Author: Sami Samhuri
-Date: 26th September, 2007
+Date: "26th September, 2007"
Timestamp: 2007-09-26T03:34:00-07:00
Tags: python, ruby
---
diff --git a/posts/2007/10/gtkpod-in-gutsy-got-you-groaning.md b/posts/2007/10/gtkpod-in-gutsy-got-you-groaning.md
index e032d28..4595869 100644
--- a/posts/2007/10/gtkpod-in-gutsy-got-you-groaning.md
+++ b/posts/2007/10/gtkpod-in-gutsy-got-you-groaning.md
@@ -1,7 +1,7 @@
---
-Title: Gtkpod in Gutsy Got You Groaning?
+Title: "Gtkpod in Gutsy Got You Groaning?"
Author: Sami Samhuri
-Date: 29th October, 2007
+Date: "29th October, 2007"
Timestamp: 2007-10-29T14:14:00-07:00
Tags: broken, gtkpod, linux, ubuntu
---
@@ -21,21 +21,8 @@ Now that you know what to do I'll give you what you probably wanted at the begin
↓ gtkpod-aac-fix.sh
-1
-2
-3
-4
-5
-6
-7
-8
-9
-10
-11
-12
-
-
-mkdir /tmp/gtkpod-fix
+```shell
+mkdir /tmp/gtkpod-fix
cd /tmp/gtkpod-fix
wget http://ftp.uni-kl.de/debian-multimedia/pool/main/libm/libmpeg4ip/libmp4v2-0_1.5.0.1-0.3_amd64.deb
wget http://ftp.uni-kl.de/debian-multimedia/pool/main/libm/libmpeg4ip/libmp4v2-dev_1.5.0.1-0.3_amd64.deb
@@ -44,6 +31,7 @@ wget http://ftp.uni-kl.de/debian-multimedia/pool/main/libm/libmpeg4ip/libmpeg4ip
for f in *.deb; do sudo gdebi -n "$f"; done
svn co https://gtkpod.svn.sourceforge.net/svnroot/gtkpod/gtkpod/trunk gtkpod
cd gtkpod
-./autogen.sh --with-mp4v2 && make && sudo make install
+./autogen.sh --with-mp4v2 && make && sudo make install
cd
-rm -rf /tmp/gtkpod-fix
+rm -rf /tmp/gtkpod-fix
+```
diff --git a/posts/2008/01/random-pet-peeve-of-the-day.md b/posts/2008/01/random-pet-peeve-of-the-day.md
index eb43229..6084c2f 100644
--- a/posts/2008/01/random-pet-peeve-of-the-day.md
+++ b/posts/2008/01/random-pet-peeve-of-the-day.md
@@ -1,7 +1,7 @@
---
-Title: Random pet peeve of the day
+Title: "Random pet peeve of the day"
Author: Sami Samhuri
-Date: 7th January, 2008
+Date: "7th January, 2008"
Timestamp: 2008-01-07T09:42:00-08:00
Tags: usability, web
---
diff --git a/posts/2008/02/thoughts-on-arc.md b/posts/2008/02/thoughts-on-arc.md
index e1556e4..8d332ac 100644
--- a/posts/2008/02/thoughts-on-arc.md
+++ b/posts/2008/02/thoughts-on-arc.md
@@ -1,7 +1,7 @@
---
-Title: Thoughts on Arc
+Title: "Thoughts on Arc"
Author: Sami Samhuri
-Date: 19th February, 2008
+Date: "19th February, 2008"
Timestamp: 2008-02-19T03:26:00-08:00
Tags: lisp arc
---
diff --git a/posts/2008/03/project-euler-code-repo-in-arc.md b/posts/2008/03/project-euler-code-repo-in-arc.md
index a7af391..1b2ee00 100644
--- a/posts/2008/03/project-euler-code-repo-in-arc.md
+++ b/posts/2008/03/project-euler-code-repo-in-arc.md
@@ -1,17 +1,17 @@
---
-Title: Project Euler code repo in Arc
+Title: "Project Euler code repo in Arc"
Author: Sami Samhuri
-Date: 3rd March, 2008
+Date: "3rd March, 2008"
Timestamp: 2008-03-03T08:24:00-08:00
Tags: arc, project euler
---
Release early and often. This is a code repo web app for solutions to Project Euler problems. You can only see your own solutions so it's not that exciting yet (but it scratches my itch... once it highlights syntax). You can try it out or download the source . You'll need an up-to-date copy of Anarki to untar the source in. Just run arc.sh then enter this at the REPL:
-
-arc> (load "euler.arc")
-arc> (esv)
-
+```lisp
+arc> (load "euler.arc")
+arc> (esv)
+```
That will setup the web server on port 3141. If you want a different port then run (esv 25) (just to mess with 'em).
diff --git a/posts/2009/11/using-emacs-to-develop-mojo-apps-for-webos.md b/posts/2009/11/using-emacs-to-develop-mojo-apps-for-webos.md
index e97ea95..13d37dc 100644
--- a/posts/2009/11/using-emacs-to-develop-mojo-apps-for-webos.md
+++ b/posts/2009/11/using-emacs-to-develop-mojo-apps-for-webos.md
@@ -1,7 +1,7 @@
---
-Title: Using Emacs to Develop Mojo Apps for WebOS
+Title: "Using Emacs to Develop Mojo Apps for WebOS"
Author: Sami Samhuri
-Date: 21st November, 2009
+Date: "21st November, 2009"
Timestamp: 2009-11-21T00:00:00-08:00
Tags: emacs, mojo, webos, lisp, javascript
---
diff --git a/posts/2010/01/a-preview-of-mach-o-file-generation.md b/posts/2010/01/a-preview-of-mach-o-file-generation.md
index 0736e70..955f429 100644
--- a/posts/2010/01/a-preview-of-mach-o-file-generation.md
+++ b/posts/2010/01/a-preview-of-mach-o-file-generation.md
@@ -1,7 +1,7 @@
---
-Title: A preview of Mach-O file generation
+Title: "A preview of Mach-O file generation"
Author: Sami Samhuri
-Date: 20th January, 2010
+Date: "20th January, 2010"
Timestamp: 2010-01-20T00:00:00-08:00
Tags: ruby, mach-o, os x, compiler
---
diff --git a/posts/2010/01/basics-of-the-mach-o-file-format.md b/posts/2010/01/basics-of-the-mach-o-file-format.md
index 90c754d..791f109 100644
--- a/posts/2010/01/basics-of-the-mach-o-file-format.md
+++ b/posts/2010/01/basics-of-the-mach-o-file-format.md
@@ -1,7 +1,7 @@
---
-Title: Basics of the Mach-O file format
+Title: "Basics of the Mach-O file format"
Author: Sami Samhuri
-Date: 18th January, 2010
+Date: "18th January, 2010"
Timestamp: 2010-01-18T00:00:00-08:00
Tags: mach-o, os x, compiler
---
@@ -45,17 +45,15 @@ blob of machine code. That blob could be described by a single
section named \_\_text, inside a single nameless segment. Here's a
diagram showing the layout of such a file:
-
-
+```markdown
,---------------------------,
Header | Mach header |
| Segment 1 |
| Section 1 (__text) | --,
|---------------------------| |
- Data | blob | <-'
+ Data | blob | <-'
'---------------------------'
-
-
+```
The Mach Header
@@ -71,7 +69,6 @@ CStruct we define the Mach header like so:
-
Segments
Segments, or segment commands , specify where in memory the
@@ -92,7 +89,6 @@ be easy enough to follow.
-
Sections
All sections within a segment are described one after the other
@@ -115,7 +111,6 @@ two underscores, e.g. \_\_bss or \_\_text
-
macho.rb
As much of the Mach-O format as we need is defined in
@@ -126,7 +121,6 @@ constants as well.
I'll cover symbol tables and relocation tables in my next post.
-
Looking at real Mach-O files
To see the segments and sections of an object file, run
@@ -145,7 +139,6 @@ also disassemble the \_\_text section with
You'll get to know otool quite well if you work with Mach-O.
-
Take a break
That was probably a lot to digest, and to make real sense of it you
diff --git a/posts/2010/01/working-with-c-style-structs-in-ruby.md b/posts/2010/01/working-with-c-style-structs-in-ruby.md
index f2f40aa..cfd474c 100644
--- a/posts/2010/01/working-with-c-style-structs-in-ruby.md
+++ b/posts/2010/01/working-with-c-style-structs-in-ruby.md
@@ -1,40 +1,18 @@
---
-Title: Working with C-style structs in Ruby
+Title: "Working with C-style structs in Ruby"
Author: Sami Samhuri
-Date: 17th January, 2010
+Date: "17th January, 2010"
Timestamp: 2010-01-17T00:00:00-08:00
Tags: ruby, cstruct, compiler
---
-This is the beginning of a series on generating Mach-O object files in
-Ruby. We start small by introducing some Ruby tools that are useful when
-working with binary data. Subsequent articles will cover a subset of the
-Mach-O file format, then generating Mach object files suitable for linking
-with ld or gcc to produce working executables. A basic knowledge of Ruby and C
-are assumed. You can likely wing it on the Ruby side of things if you know any
-similar languages.
+This is the beginning of a series on generating Mach-O object files in Ruby. We start small by introducing some Ruby tools that are useful when working with binary data. Subsequent articles will cover a subset of the Mach-O file format, then generating Mach object files suitable for linking with ld or gcc to produce working executables. A basic knowledge of Ruby and C are assumed. You can likely wing it on the Ruby side of things if you know any similar languages.
-First we need to read and write structured binary files with Ruby.
-[Array#pack](http://ruby-doc.org/core/classes/Array.html#M002222) and
-[String#unpack](http://ruby-doc.org/core/classes/String.html#M000760)
-get the job done at a low level, but every time I use them I have to look up
-the documentation. It would also be nice to encapsulate serializing and
-deserializing into classes describing the various binary data structures. The
-built-in [Struct class](http://ruby-doc.org/core/classes/Struct.html) sounds
-promising but did not meet my needs, nor was it easily extended to meet them.
+First we need to read and write structured binary files with Ruby. [Array#pack](http://ruby-doc.org/core/classes/Array.html#M002222) and [String#unpack](http://ruby-doc.org/core/classes/String.html#M000760) get the job done at a low level, but every time I use them I have to look up the documentation. It would also be nice to encapsulate serializing and deserializing into classes describing the various binary data structures. The built-in [Struct class](http://ruby-doc.org/core/classes/Struct.html) sounds promising but did not meet my needs, nor was it easily extended to meet them.
-Meet [CStruct](https://github.com/samsonjs/compiler/blob/20c758ae85daa5cfa0ad9276c6633b78e982f8b4/asm/cstruct.rb#files),
-a class that you can use to describe a binary structure, somewhat similar to
-how you would do it in C. Subclassing CStruct results in a class whose
-instances can be serialized, and unserialized, with little effort. You can
-subclass descendants of CStruct to extend them with additional members.
-CStruct does not implement much more than is necessary for the compiler. For
-example there is no support for floating point. If you want to use this for
-more general purpose tasks be warned that it may require some work. Anything
-supported by Array#pack is fairly easy to add though.
+Meet [CStruct](https://github.com/samsonjs/compiler/blob/20c758ae85daa5cfa0ad9276c6633b78e982f8b4/asm/cstruct.rb#files), a class that you can use to describe a binary structure, somewhat similar to how you would do it in C. Subclassing CStruct results in a class whose instances can be serialized, and unserialized, with little effort. You can subclass descendants of CStruct to extend them with additional members. CStruct does not implement much more than is necessary for the compiler. For example there is no support for floating point. If you want to use this for more general purpose tasks be warned that it may require some work. Anything supported by Array#pack is fairly easy to add though.
-First a quick example and then we'll get into the CStruct class itself. In
-C you may write the following to have one struct "inherit" from another:
+First a quick example and then we'll get into the CStruct class itself. In C you may write the following to have one struct "inherit" from another:
@@ -42,30 +20,16 @@ With CStruct in Ruby that translates to:
-CStructs act like Ruby's built-in Struct to a certain extent. They are
-instantiated the same way, by passing values to #new in the same order they
-are defined in the class. You can find out the size (in bytes) of a CStruct
-instance using the #bytesize method, or of any member using #sizeof(name).
+CStructs act like Ruby's built-in Struct to a certain extent. They are instantiated the same way, by passing values to #new in the same order they are defined in the class. You can find out the size (in bytes) of a CStruct instance using the #bytesize method, or of any member using #sizeof(name).
-The most important method (for us) is #serialize, which returns a binary
-string representing the contents of the CStruct.
+The most important method (for us) is #serialize, which returns a binary string representing the contents of the CStruct.
-(I know that CStruct.new_from_bin should be called CStruct.unserialize, you
-can see where my focus was when I wrote it.)
+(I know that CStruct.new_from_bin should be called CStruct.unserialize, you can see where my focus was when I wrote it.)
-CStruct#serialize automatically creates a "pack pattern", which is an array
-of strings used to pack each member in turn. The pack pattern is mapped to the
-result of calling Array#pack on each corresponding member, and then the
-resulting strings are joined together. Serializing strings complicates matters
-so we cannot build up a pack pattern string and then serialize it in one go,
-but conceptually it's quite similar.
+CStruct#serialize automatically creates a "pack pattern", which is an array of strings used to pack each member in turn. The pack pattern is mapped to the result of calling Array#pack on each corresponding member, and then the resulting strings are joined together. Serializing strings complicates matters so we cannot build up a pack pattern string and then serialize it in one go, but conceptually it's quite similar.
-Unserializing is the same process in reverse, and was mainly added for
-completeness and testing purposes.
+Unserializing is the same process in reverse, and was mainly added for completeness and testing purposes.
-That's about all you need to know to use CStruct. The code needs some work
-but I decided to just go with what I have already so I can get on with the
-more interesting and fun tasks.
+That's about all you need to know to use CStruct. The code needs some work but I decided to just go with what I have already so I can get on with the more interesting and fun tasks.
*Next in this series: [Basics of the Mach-O file format](/posts/2010/01/basics-of-the-mach-o-file-format)*
-
diff --git a/posts/2010/11/37signals-chalk-dissected.md b/posts/2010/11/37signals-chalk-dissected.md
index c03ed40..a495b3c 100644
--- a/posts/2010/11/37signals-chalk-dissected.md
+++ b/posts/2010/11/37signals-chalk-dissected.md
@@ -1,7 +1,7 @@
---
-Title: 37signals' Chalk Dissected
+Title: "37signals' Chalk Dissected"
Author: Sami Samhuri
-Date: 4th November, 2010
+Date: "4th November, 2010"
Timestamp: 2010-11-04T00:00:00-07:00
Tags: 37signals, chalk, ipad, javascript, web, html, css, zepto.js
---
@@ -14,7 +14,8 @@ Tags: 37signals, chalk, ipad, javascript, web, html, css, zepto.js
The manifest is a nice summary of the contents, and allows browsers to cache the app for offline use. Combine this with mobile Safari's "Add to Home Screen" button and you have yourself a free chalkboard app that works offline.
-CACHE MANIFEST
+```conf
+CACHE MANIFEST
/
/zepto.min.js
@@ -26,7 +27,7 @@ Tags: 37signals, chalk, ipad, javascript, web, html, css, zepto.js
/images/chalk-tile-red.png
/images/chalk-tile-white.png
/stylesheets/chalk.css
-
+```
Not much there, just 10 requests to fetch the whole thing. 11 including the manifest. In we go.
@@ -170,7 +171,6 @@ chalk-sprites.png
-
When the light switch is touched (or clicked) the shade class on the body element is toggled. Nothing to it.
diff --git a/posts/2011/11/lights.md b/posts/2011/11/lights.md
index 864c578..cfeb60b 100644
--- a/posts/2011/11/lights.md
+++ b/posts/2011/11/lights.md
@@ -1,9 +1,8 @@
---
-Title: Lights
+Title: "Lights"
Author: Sami Samhuri
-Date: 27th November, 2011
+Date: "27th November, 2011"
Timestamp: 2011-11-27T18:11:00-08:00
-Tags:
Link: http://lights.elliegoulding.com/
---
diff --git a/posts/2011/11/recovering-old-posts.md b/posts/2011/11/recovering-old-posts.md
index ab2bf59..3bb2d31 100644
--- a/posts/2011/11/recovering-old-posts.md
+++ b/posts/2011/11/recovering-old-posts.md
@@ -1,7 +1,7 @@
---
-Title: Recovering Old Blog Posts
+Title: "Recovering Old Blog Posts"
Author: Sami Samhuri
-Date: 27th November, 2011
+Date: "27th November, 2011"
Timestamp: 2011-11-27T01:15:00-08:00
Tags: recover, old, blog, posts
---
diff --git a/posts/2011/12/i-see-http.md b/posts/2011/12/i-see-http.md
index 4b3cf4c..f1b3ec5 100644
--- a/posts/2011/12/i-see-http.md
+++ b/posts/2011/12/i-see-http.md
@@ -1,9 +1,8 @@
---
-Title: I see HTTP
+Title: "I see HTTP"
Author: Sami Samhuri
-Date: 15th December, 2011
+Date: "15th December, 2011"
Timestamp: 2011-12-15T07:47:15-08:00
-Tags:
Link: http://calendar.perfplanet.com/2011/i-see-http/
---
diff --git a/posts/2011/12/my-kind-of-feature-checklist.md b/posts/2011/12/my-kind-of-feature-checklist.md
index 28c0634..8a6eca7 100644
--- a/posts/2011/12/my-kind-of-feature-checklist.md
+++ b/posts/2011/12/my-kind-of-feature-checklist.md
@@ -1,9 +1,8 @@
---
-Title: My kind of feature checklist
+Title: "My kind of feature checklist"
Author: Sami Samhuri
-Date: 19th December, 2011
+Date: "19th December, 2011"
Timestamp: 2011-12-19T20:20:05-08:00
-Tags:
Link: http://www.marco.org/2011/12/19/amazon-kindle-vs-ipad
---
diff --git a/posts/2011/12/new-release-of-firefox-for-android-optimized-for-tablets.md b/posts/2011/12/new-release-of-firefox-for-android-optimized-for-tablets.md
index e298fcb..b048634 100644
--- a/posts/2011/12/new-release-of-firefox-for-android-optimized-for-tablets.md
+++ b/posts/2011/12/new-release-of-firefox-for-android-optimized-for-tablets.md
@@ -1,9 +1,8 @@
---
-Title: New Release of Firefox for Android, Optimized for Tablets
+Title: "New Release of Firefox for Android, Optimized for Tablets"
Author: Sami Samhuri
-Date: 22nd December, 2011
+Date: "22nd December, 2011"
Timestamp: 2011-12-25T18:54:11-08:00
-Tags:
Link: http://daringfireball.net/linked/2011/12/22/firefox-android
---
diff --git a/posts/2011/12/pure-css3-images-hmm-maybe-later.md b/posts/2011/12/pure-css3-images-hmm-maybe-later.md
index 2e6dade..df81f9e 100644
--- a/posts/2011/12/pure-css3-images-hmm-maybe-later.md
+++ b/posts/2011/12/pure-css3-images-hmm-maybe-later.md
@@ -1,9 +1,8 @@
---
-Title: Pure CSS3 images? Hmm, maybe later
+Title: "Pure CSS3 images? Hmm, maybe later"
Author: Sami Samhuri
-Date: 11th December, 2011
+Date: "11th December, 2011"
Timestamp: 2011-12-11T12:25:03-08:00
-Tags:
Link: http://calendar.perfplanet.com/2011/pure-css3-images-hmm-maybe-later/
---
diff --git a/posts/2011/12/static-url-shortener-using-htaccess.md b/posts/2011/12/static-url-shortener-using-htaccess.md
index 2337fc0..d0fd049 100644
--- a/posts/2011/12/static-url-shortener-using-htaccess.md
+++ b/posts/2011/12/static-url-shortener-using-htaccess.md
@@ -1,7 +1,7 @@
---
-Title: A Static URL Shortener Using .htaccess
+Title: "A Static URL Shortener Using .htaccess"
Author: Sami Samhuri
-Date: 10th December, 2011
+Date: "10th December, 2011"
Timestamp: 2011-12-10T22:29:09-08:00
Tags: s42.ca, url, shortener, samhuri.net, url shortener
---
diff --git a/posts/2011/12/the-broken-pixel-theory.md b/posts/2011/12/the-broken-pixel-theory.md
index 969cf1a..f575d16 100644
--- a/posts/2011/12/the-broken-pixel-theory.md
+++ b/posts/2011/12/the-broken-pixel-theory.md
@@ -1,9 +1,8 @@
---
-Title: The Broken Pixel Theory
+Title: "The Broken Pixel Theory"
Author: Sami Samhuri
-Date: 25th December, 2011
+Date: "25th December, 2011"
Timestamp: 2011-12-25T18:54:20-08:00
-Tags:
Link: http://jtaby.com/2011/12/25/the-broken-pixel-theory.html
---
diff --git a/posts/2012/01/fujitsu-has-lost-their-mind.md b/posts/2012/01/fujitsu-has-lost-their-mind.md
index 9815063..2597d86 100644
--- a/posts/2012/01/fujitsu-has-lost-their-mind.md
+++ b/posts/2012/01/fujitsu-has-lost-their-mind.md
@@ -1,9 +1,8 @@
---
-Title: Fujitsu has lost their mind
+Title: "Fujitsu has lost their mind"
Author: Sami Samhuri
-Date: 19th January, 2012
+Date: "19th January, 2012"
Timestamp: 2012-01-19T20:05:33-08:00
-Tags:
Link: http://tablet-news.com/2012/01/17/fujitsu-lifebook-2013-concept-incorporates-a-tablet-for-a-keyboard-phone-and-digital-camera/
---
diff --git a/posts/2012/01/recovering-from-a-computer-science-education.md b/posts/2012/01/recovering-from-a-computer-science-education.md
index f61dadb..8d3a70f 100644
--- a/posts/2012/01/recovering-from-a-computer-science-education.md
+++ b/posts/2012/01/recovering-from-a-computer-science-education.md
@@ -1,9 +1,8 @@
---
-Title: Recovering From a Computer Science Education
+Title: "Recovering From a Computer Science Education"
Author: Sami Samhuri
-Date: 17th January, 2012
+Date: "17th January, 2012"
Timestamp: 2012-01-17T00:00:00-08:00
-Tags:
Link: http://prog21.dadgum.com/123.html
---
diff --git a/posts/2012/01/sopa-lives-and-mpaa-calls-protests-an-abuse-of-power.md b/posts/2012/01/sopa-lives-and-mpaa-calls-protests-an-abuse-of-power.md
index 5f4bada..c2a48e4 100644
--- a/posts/2012/01/sopa-lives-and-mpaa-calls-protests-an-abuse-of-power.md
+++ b/posts/2012/01/sopa-lives-and-mpaa-calls-protests-an-abuse-of-power.md
@@ -1,9 +1,8 @@
---
-Title: SOPA lives - and MPAA calls protests an "abuse of power"
+Title: "SOPA lives - and MPAA calls protests an \"abuse of power\""
Author: Sami Samhuri
-Date: 17th January, 2012
+Date: "17th January, 2012"
Timestamp: 2012-01-17T02:46:40-08:00
-Tags:
Link: http://arstechnica.com/tech-policy/news/2012/01/sopa-livesand-mpaa-calls-protests-an-abuse-of-power.ars
---
diff --git a/posts/2012/01/the-40-standup-desk.md b/posts/2012/01/the-40-standup-desk.md
index b1a9f32..12ab2ed 100644
--- a/posts/2012/01/the-40-standup-desk.md
+++ b/posts/2012/01/the-40-standup-desk.md
@@ -1,9 +1,8 @@
---
-Title: The $40 Standup Desk
+Title: "The $40 Standup Desk"
Author: Sami Samhuri
-Date: 9th January, 2012
+Date: "9th January, 2012"
Timestamp: 2012-01-09T00:16:40-08:00
-Tags:
Link: http://opensoul.org/blog/archives/2012/01/09/the-40-standup-desk/
---
diff --git a/posts/2012/01/yak-shaving.md b/posts/2012/01/yak-shaving.md
index f1cd90d..60f6ec5 100644
--- a/posts/2012/01/yak-shaving.md
+++ b/posts/2012/01/yak-shaving.md
@@ -1,9 +1,8 @@
---
-Title: Yak shaving
+Title: "Yak shaving"
Author: Sami Samhuri
-Date: 4th January, 2012
+Date: "4th January, 2012"
Timestamp: 2012-01-04T13:24:00-08:00
-Tags:
Link: http://blog.hasmanythrough.com/2012/1/4/yak-shaving
---
diff --git a/posts/2013/03/zelda-tones-for-ios.md b/posts/2013/03/zelda-tones-for-ios.md
index 9d185ec..b568ebf 100644
--- a/posts/2013/03/zelda-tones-for-ios.md
+++ b/posts/2013/03/zelda-tones-for-ios.md
@@ -1,7 +1,7 @@
---
-Title: Zelda Tones for iOS
+Title: "Zelda Tones for iOS"
Author: Sami Samhuri
-Date: 6th March, 2013
+Date: "6th March, 2013"
Timestamp: 2013-03-06T18:51:13-08:00
Tags: zelda, nintendo, pacman, ringtones, tones, ios
---
diff --git a/posts/2013/09/linky.md b/posts/2013/09/linky.md
index 848e36e..9b6a99a 100644
--- a/posts/2013/09/linky.md
+++ b/posts/2013/09/linky.md
@@ -1,7 +1,7 @@
---
-Title: Linky
+Title: "Linky"
Author: Sami Samhuri
-Date: 27th September, 2013
+Date: "27th September, 2013"
Timestamp: 2013-09-27T21:49:02-07:00
Tags: linky, north watcher, ruby, gmail, links, notifications
---
@@ -35,17 +35,23 @@ Yup, that is a lot of moving parts. It is rather elegant in a [Unixy way](http:/
For example, the following lines would be created in a file at `~/Dropbox/Linky/Ruxton/.txt` for my machine named [Ruxton](http://en.wikipedia.org/wiki/Ruxton_Island).
- Callbacks as our Generations' Go To Statement
- http://tirania.org/blog/archive/2013/Aug-15.html
+```markdown
+Callbacks as our Generations' Go To Statement
+http://tirania.org/blog/archive/2013/Aug-15.html
+```
The filename field is defined as:
- {FromAddress}-{ReceivedAt}
+```conf
+{FromAddress}-{ReceivedAt}
+```
And the content is:
- {Subject}
- {BodyPlain}
+```conf
+{Subject}
+{BodyPlain}
+```
That means that when you email links, the subject should contain the title and the body should contain the link on the first line. It's ok if there's stuff after the body (like your signature), they will be ignored later.
@@ -63,7 +69,9 @@ This is a quick and dirty thing I whipped up a couple of years ago, and now it's
It has a text configuration file kind of like [cron](http://en.wikipedia.org/wiki/Cron). Here's mine from Ruxton:
- + Dropbox/Linky/Ruxton ruby /Users/sjs/bin/linky-notify
+```shell
++ Dropbox/Linky/Ruxton ruby /Users/sjs/bin/linky-notify
+```
That tells NorthWatcher to run `ruby /Users/sjs/bin/linky-notify` when files are added to the directory `~/Dropbox/Linky/Ruxton`.
@@ -80,4 +88,3 @@ You can get `terminal-notifier` with [homebrew](http://brew.sh) in a few seconds
## Cool story, bro
It may not be exciting, but as someone who typically suffers from [NIH syndrome](http://en.wikipedia.org/wiki/Not_invented_here) and writes too much from scratch, I found it pretty rewarding to cobble something seemingly complicated together with a bunch of existing components. It didn't take very long and only involved about 10 lines of code. It's not exactly what I wanted but it's surprisingly close. Success!
-
diff --git a/posts/2014/02/ember-structure.md b/posts/2014/02/ember-structure.md
index 7f771a2..cb6a56f 100644
--- a/posts/2014/02/ember-structure.md
+++ b/posts/2014/02/ember-structure.md
@@ -1,7 +1,7 @@
---
-Title: Structure of an Ember app
+Title: "Structure of an Ember app"
Author: Sami Samhuri
-Date: 3rd February, 2014
+Date: "3rd February, 2014"
Timestamp: 2014-02-03T18:05:49-08:00
Tags: ember.js
---
diff --git a/posts/2015/05/a-bitcoin-miner-in-every-device-and-in-every-hand.md b/posts/2015/05/a-bitcoin-miner-in-every-device-and-in-every-hand.md
index 6cf4d89..192cc3c 100644
--- a/posts/2015/05/a-bitcoin-miner-in-every-device-and-in-every-hand.md
+++ b/posts/2015/05/a-bitcoin-miner-in-every-device-and-in-every-hand.md
@@ -1,9 +1,8 @@
---
-Title: A bitcoin miner in every device and in every hand
+Title: "A bitcoin miner in every device and in every hand"
Author: Sami Samhuri
-Date: 19th May, 2015
+Date: "19th May, 2015"
Timestamp: 2015-05-18T19:53:54-07:00
-Tags:
Link: https://medium.com/@21dotco/a-bitcoin-miner-in-every-device-and-in-every-hand-e315b40f2821
---
diff --git a/posts/2015/05/apple-watch-human-interface-guidelines.md b/posts/2015/05/apple-watch-human-interface-guidelines.md
index 6c168b1..3de1b91 100644
--- a/posts/2015/05/apple-watch-human-interface-guidelines.md
+++ b/posts/2015/05/apple-watch-human-interface-guidelines.md
@@ -1,9 +1,8 @@
---
-Title: Apple Watch Human Interface Guidelines
+Title: "Apple Watch Human Interface Guidelines"
Author: Sami Samhuri
-Date: 10th May, 2015
+Date: "10th May, 2015"
Timestamp: 2015-05-09T18:57:19-07:00
-Tags:
Link: https://developer.apple.com/watch/human-interface-guidelines/
---
diff --git a/posts/2015/05/constraints-and-transforms-in-ios-8.md b/posts/2015/05/constraints-and-transforms-in-ios-8.md
index c92aaf2..43ac907 100644
--- a/posts/2015/05/constraints-and-transforms-in-ios-8.md
+++ b/posts/2015/05/constraints-and-transforms-in-ios-8.md
@@ -1,9 +1,8 @@
---
-Title: Constraints and Transforms in iOS 8
+Title: "Constraints and Transforms in iOS 8"
Author: Sami Samhuri
-Date: 15th May, 2015
+Date: "15th May, 2015"
Timestamp: 2015-05-15T07:26:35-07:00
-Tags:
Link: http://revealapp.com/blog/constraints-and-transforms.html
---
diff --git a/posts/2015/05/github-flow-like-a-pro.md b/posts/2015/05/github-flow-like-a-pro.md
index e275370..9a77f1d 100644
--- a/posts/2015/05/github-flow-like-a-pro.md
+++ b/posts/2015/05/github-flow-like-a-pro.md
@@ -1,9 +1,8 @@
---
-Title: GitHub Flow Like a Pro
+Title: "GitHub Flow Like a Pro"
Author: Sami Samhuri
-Date: 28th May, 2015
+Date: "28th May, 2015"
Timestamp: 2015-05-28T07:42:27-07:00
-Tags:
Link: http://haacked.com/archive/2014/07/28/github-flow-aliases/
---
diff --git a/posts/2015/05/importing-modules-in-lldb.md b/posts/2015/05/importing-modules-in-lldb.md
index beb74dd..52921cc 100644
--- a/posts/2015/05/importing-modules-in-lldb.md
+++ b/posts/2015/05/importing-modules-in-lldb.md
@@ -1,9 +1,8 @@
---
-Title: Importing Modules in LLDB
+Title: "Importing Modules in LLDB"
Author: Sami Samhuri
-Date: 12th May, 2015
+Date: "12th May, 2015"
Timestamp: 2015-05-11T19:03:35-07:00
-Tags:
Link: http://furbo.org/2015/05/11/an-import-ant-change-in-xcode/
---
diff --git a/posts/2015/05/lenovo-thinkpad-x1-carbon.md b/posts/2015/05/lenovo-thinkpad-x1-carbon.md
index 95e4b29..f1c5a51 100644
--- a/posts/2015/05/lenovo-thinkpad-x1-carbon.md
+++ b/posts/2015/05/lenovo-thinkpad-x1-carbon.md
@@ -1,9 +1,8 @@
---
-Title: Lenovo ThinkPad X1 Carbon
+Title: "Lenovo ThinkPad X1 Carbon"
Author: Sami Samhuri
-Date: 22nd May, 2015
+Date: "22nd May, 2015"
Timestamp: 2015-05-21T17:36:29-07:00
-Tags:
Link: http://www.anandtech.com/show/9264/the-lenovo-thinkpad-x1-carbon-review-2015
---
diff --git a/posts/2015/05/magical-wristband.md b/posts/2015/05/magical-wristband.md
index 40342c9..c16a82b 100644
--- a/posts/2015/05/magical-wristband.md
+++ b/posts/2015/05/magical-wristband.md
@@ -1,9 +1,8 @@
---
-Title: Magical Wristband
+Title: "Magical Wristband"
Author: Sami Samhuri
-Date: 27th May, 2015
+Date: "27th May, 2015"
Timestamp: 2015-05-26T22:17:29-07:00
-Tags:
Link: http://www.wired.com/2015/03/disney-magicband/
---
diff --git a/posts/2015/05/undocumented-corestorage-commands.md b/posts/2015/05/undocumented-corestorage-commands.md
index 2f1a325..97dcc80 100644
--- a/posts/2015/05/undocumented-corestorage-commands.md
+++ b/posts/2015/05/undocumented-corestorage-commands.md
@@ -1,9 +1,8 @@
---
-Title: Undocumented CoreStorage Commands
+Title: "Undocumented CoreStorage Commands"
Author: Sami Samhuri
-Date: 24th May, 2015
+Date: "24th May, 2015"
Timestamp: 2015-05-23T19:58:36-07:00
-Tags:
Link: http://blog.fosketts.net/2011/08/05/undocumented-corestorage-commands/
---
diff --git a/posts/2015/06/debugging-layouts-with-recursive-view-descriptions-in-xcode.md b/posts/2015/06/debugging-layouts-with-recursive-view-descriptions-in-xcode.md
index c4a79d2..f5f1fc8 100644
--- a/posts/2015/06/debugging-layouts-with-recursive-view-descriptions-in-xcode.md
+++ b/posts/2015/06/debugging-layouts-with-recursive-view-descriptions-in-xcode.md
@@ -1,9 +1,8 @@
---
-Title: Debugging Layouts with Recursive View Descriptions in Xcode
+Title: "Debugging Layouts with Recursive View Descriptions in Xcode"
Author: Sami Samhuri
-Date: 2nd June, 2015
+Date: "2nd June, 2015"
Timestamp: 2015-06-02T16:35:35-07:00
-Tags:
Link: http://jeffreysambells.com/2013/01/24/debugging-layouts-with-recursive-view-descriptions-in-xcode
---
diff --git a/posts/2015/06/the-unofficial-guide-to-xcconfig-files.md b/posts/2015/06/the-unofficial-guide-to-xcconfig-files.md
index e4da317..093614c 100644
--- a/posts/2015/06/the-unofficial-guide-to-xcconfig-files.md
+++ b/posts/2015/06/the-unofficial-guide-to-xcconfig-files.md
@@ -1,9 +1,8 @@
---
-Title: The Unofficial Guide to xcconfig files
+Title: "The Unofficial Guide to xcconfig files"
Author: Sami Samhuri
-Date: 1st June, 2015
+Date: "1st June, 2015"
Timestamp: 2015-06-01T08:16:51-07:00
-Tags:
Link: http://pewpewthespells.com/blog/xcconfig_guide.html?utm_campaign=iOS%2BDev%2BWeekly&utm_source=iOS_Dev_Weekly_Issue_200
---
diff --git a/posts/2015/07/scripts-to-rule-them-all.md b/posts/2015/07/scripts-to-rule-them-all.md
index 6b0119c..c2150a1 100644
--- a/posts/2015/07/scripts-to-rule-them-all.md
+++ b/posts/2015/07/scripts-to-rule-them-all.md
@@ -1,9 +1,8 @@
---
-Title: Scripts to Rule Them All
+Title: "Scripts to Rule Them All"
Author: Sami Samhuri
-Date: 1st July, 2015
+Date: "1st July, 2015"
Timestamp: 2015-07-01T07:37:04-07:00
-Tags:
Link: http://githubengineering.com/scripts-to-rule-them-all/
---
diff --git a/posts/2015/07/swift-new-stuff-in-xcode-7-beta-3.md b/posts/2015/07/swift-new-stuff-in-xcode-7-beta-3.md
index 92e0715..371dbbd 100644
--- a/posts/2015/07/swift-new-stuff-in-xcode-7-beta-3.md
+++ b/posts/2015/07/swift-new-stuff-in-xcode-7-beta-3.md
@@ -1,9 +1,8 @@
---
-Title: Swift: New stuff in Xcode 7 Beta 3
+Title: "Swift: New stuff in Xcode 7 Beta 3"
Author: Sami Samhuri
-Date: 9th July, 2015
+Date: "9th July, 2015"
Timestamp: 2015-07-09T09:17:13-07:00
-Tags:
Link: http://ericasadun.com/2015/07/08/swift-new-stuff-in-xcode-7-beta-3/
---
diff --git a/posts/2015/08/acorn-5s-live-help-search.md b/posts/2015/08/acorn-5s-live-help-search.md
index 5159e57..84687fc 100644
--- a/posts/2015/08/acorn-5s-live-help-search.md
+++ b/posts/2015/08/acorn-5s-live-help-search.md
@@ -1,9 +1,8 @@
---
-Title: Acorn 5's Live Help Search
+Title: "Acorn 5's Live Help Search"
Author: Sami Samhuri
-Date: 25th August, 2015
+Date: "25th August, 2015"
Timestamp: 2015-08-24T22:00:27-07:00
-Tags:
Link: http://shapeof.com/archives/2015/8/acorn_5_search_index.html
---
diff --git a/posts/2015/08/cloaks-updated-privacy-policy.md b/posts/2015/08/cloaks-updated-privacy-policy.md
index b9a3077..099e7f8 100644
--- a/posts/2015/08/cloaks-updated-privacy-policy.md
+++ b/posts/2015/08/cloaks-updated-privacy-policy.md
@@ -1,9 +1,8 @@
---
-Title: Cloak's Updated Privacy Policy
+Title: "Cloak's Updated Privacy Policy"
Author: Sami Samhuri
-Date: 27th August, 2015
+Date: "27th August, 2015"
Timestamp: 2015-08-26T19:56:54-07:00
-Tags:
Link: https://blog.getcloak.com/2015/08/25/updated-privacy-policy/
---
diff --git a/posts/2016/03/moving-beyond-the-oop-obsession.md b/posts/2016/03/moving-beyond-the-oop-obsession.md
index fd3cb3a..89148bb 100644
--- a/posts/2016/03/moving-beyond-the-oop-obsession.md
+++ b/posts/2016/03/moving-beyond-the-oop-obsession.md
@@ -1,9 +1,8 @@
---
-Title: Moving Beyond the OOP Obsession
+Title: "Moving Beyond the OOP Obsession"
Author: Sami Samhuri
-Date: 28th March, 2016
+Date: "28th March, 2016"
Timestamp: 2016-03-28T09:08:47-07:00
-Tags:
Link: http://prog21.dadgum.com/218.html
---
diff --git a/posts/2016/03/reduce-the-cognitive-load-of-your-code.md b/posts/2016/03/reduce-the-cognitive-load-of-your-code.md
index 5311ff4..397cc94 100644
--- a/posts/2016/03/reduce-the-cognitive-load-of-your-code.md
+++ b/posts/2016/03/reduce-the-cognitive-load-of-your-code.md
@@ -1,9 +1,8 @@
---
-Title: Reduce the cognitive load of your code
+Title: "Reduce the cognitive load of your code"
Author: Sami Samhuri
-Date: 30th March, 2016
+Date: "30th March, 2016"
Timestamp: 2016-03-30T07:10:29-07:00
-Tags:
Link: http://chrismm.com/blog/how-to-reduce-the-cognitive-load-of-your-code/
---
diff --git a/posts/2016/04/tales-of-prk-laser-eye-surgery.md b/posts/2016/04/tales-of-prk-laser-eye-surgery.md
index c7c5f5c..6acbade 100644
--- a/posts/2016/04/tales-of-prk-laser-eye-surgery.md
+++ b/posts/2016/04/tales-of-prk-laser-eye-surgery.md
@@ -1,9 +1,8 @@
---
-Title: Tales of PRK Laser Eye Surgery
+Title: "Tales of PRK Laser Eye Surgery"
Author: Sami Samhuri
-Date: 12th April, 2016
+Date: "12th April, 2016"
Timestamp: 2016-04-11T20:52:53-07:00
-Tags:
---
Today I scheduled PRK laser eye surgery on April 19th. Exciting but also kind of terrifying because the procedure sounds a bit horrific. Most accounts from people don't sound very bad though so the operation itself should be a breeze! I scoured the web for PRK recovery stories to get an idea of what I was in for and found some good quotes.
diff --git a/posts/2016/08/easy-optimization-wins.md b/posts/2016/08/easy-optimization-wins.md
index edfcfd6..22289fa 100644
--- a/posts/2016/08/easy-optimization-wins.md
+++ b/posts/2016/08/easy-optimization-wins.md
@@ -1,7 +1,7 @@
---
-Title: Easy Optimization Wins
+Title: "Easy Optimization Wins"
Author: Sami Samhuri
-Date: 10th August, 2016
+Date: "10th August, 2016"
Timestamp: 2016-08-10T10:30:49-07:00
Tags: ios, git
---
@@ -10,7 +10,7 @@ It's not hard to hide a whole lot of complexity behind a function call, so you h
Here's some example code illustrating a big performance problem I found in a codebase I've inherited. We have a dictionary keyed by a string representing a date, e.g. "2016-08-10", and where the values are arrays of videos for that given date. Due to some unimportant product details videos can actually appear in more than one of the array values. The goal is to get an array of all videos, sorted by date, and with no duplicates. So we need to discard duplicates when building the sorted array.
-```Swift
+```swift
func allVideosSortedByDate(allVideos: [String:[Video]]) -> [Video] {
var sortedVideos: [Video] = []
// sort keys newest first
@@ -32,7 +32,7 @@ Because this is being called from within a loop that's already looping over all
In this particular case my first instinct is to reach for a set. We want a collection of all the videos and want to ensure that they're unique, and that's what sets are for. So what about sorting? Well we can build up the set of all videos, then sort that set, converting it to an array in the process. Sounds like a lot of work right? Is it really faster? Let's see what it looks like.
-```Swift
+```swift
func allVideosSortedByDate(allVideos: [String:[Video]]) -> [Video] {
var uniqueVideos: Set = []
for key in allVideos.allKeys {
diff --git a/posts/2016/08/ios-git-pre-commit-hook.md b/posts/2016/08/ios-git-pre-commit-hook.md
index 40398d3..f7dc45a 100644
--- a/posts/2016/08/ios-git-pre-commit-hook.md
+++ b/posts/2016/08/ios-git-pre-commit-hook.md
@@ -1,7 +1,7 @@
---
-Title: A Git Pre-commit Hook for iOS
+Title: "A Git Pre-commit Hook for iOS"
Author: Sami Samhuri
-Date: 4th August, 2016
+Date: "4th August, 2016"
Timestamp: 2016-08-04T09:38:03-07:00
Tags: ios, git
---
diff --git a/posts/2017/10/swift-optional-or.md b/posts/2017/10/swift-optional-or.md
index eeaf03c..6422f3c 100644
--- a/posts/2017/10/swift-optional-or.md
+++ b/posts/2017/10/swift-optional-or.md
@@ -1,14 +1,14 @@
---
Author: Sami Samhuri
-Title: A nil-coalescing alternative for Swift
-Date: 6th October, 2017
+Title: "A nil-coalescing alternative for Swift"
+Date: "6th October, 2017"
Timestamp: 2017-10-06T14:20:13-07:00
Tags: iOS, Swift
---
Swift compile times leave something to be desired and a common culprit is the affectionately-named [nil-coalescing operator][nilop]. A small extension to `Optional` can improve this without sacrificing a lot of readability.
-```Swift
+```swift
extension Optional {
func or(_ defaultValue: Wrapped) -> Wrapped {
switch self {
@@ -21,7 +21,7 @@ extension Optional {
And you use it like so:
-```Swift
+```swift
let dict: [String : String] = [:]
let maybeString = dict["not here"]
print("the string is: \(maybeString.or("default"))")
diff --git a/posts/2024/04/photos-navigation-url-scheme.md b/posts/2024/04/photos-navigation-url-scheme.md
index b6db6a5..135bb3f 100644
--- a/posts/2024/04/photos-navigation-url-scheme.md
+++ b/posts/2024/04/photos-navigation-url-scheme.md
@@ -1,7 +1,7 @@
---
Author: Sami Samhuri
-Title: Reverse-engineering the photos-navigation URL scheme on iOS
-Date: 18th April, 2024
+Title: "Reverse-engineering the photos-navigation URL scheme on iOS"
+Date: "18th April, 2024"
Timestamp: 2024-04-18T20:08:02-07:00
Tags: iOS, Swift, hacking
---
diff --git a/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md b/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md
index 5b1e388..e59d902 100644
--- a/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md
+++ b/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md
@@ -1,7 +1,7 @@
---
Author: Sami Samhuri
-Title: Type-safe notifications and async stream monitoring with Swift 6
-Date: 6th June, 2025
+Title: "Type-safe notifications and async stream monitoring with Swift 6"
+Date: "6th June, 2025"
Timestamp: 2025-06-06T14:27:11-07:00
Tags: Swift, iOS, notifications, async, concurrency, AsyncMonitor, NotificationSmuggler
---
diff --git a/projects.toml b/projects.toml
new file mode 100644
index 0000000..fb81377
--- /dev/null
+++ b/projects.toml
@@ -0,0 +1,65 @@
+[[projects]]
+name = "samhuri.net"
+title = "samhuri.net"
+description = "this site"
+url = "https://github.com/samsonjs/samhuri.net"
+
+[[projects]]
+name = "bin"
+title = "bin"
+description = "my collection of scripts in ~/bin"
+url = "https://github.com/samsonjs/bin"
+
+[[projects]]
+name = "config"
+title = "config"
+description = "important dot files (zsh, emacs, vim, screen)"
+url = "https://github.com/samsonjs/config"
+
+[[projects]]
+name = "compiler"
+title = "compiler"
+description = "a compiler targeting x86 in Ruby"
+url = "https://github.com/samsonjs/compiler"
+
+[[projects]]
+name = "lake"
+title = "lake"
+description = "a simple implementation of Scheme in C"
+url = "https://github.com/samsonjs/lake"
+
+[[projects]]
+name = "AsyncMonitor"
+title = "AsyncMonitor"
+description = "easily monitor async sequences using Swift concurrency"
+url = "https://github.com/samsonjs/AsyncMonitor"
+
+[[projects]]
+name = "NotificationSmuggler"
+title = "NotificationSmuggler"
+description = "embed strongly-typed values in notifications on Apple platforms"
+url = "https://github.com/samsonjs/NotificationSmuggler"
+
+[[projects]]
+name = "strftime"
+title = "strftime"
+description = "strftime for JavaScript"
+url = "https://github.com/samsonjs/strftime"
+
+[[projects]]
+name = "format"
+title = "format"
+description = "printf for JavaScript"
+url = "https://github.com/samsonjs/format"
+
+[[projects]]
+name = "gitter"
+title = "gitter"
+description = "a GitHub client for Node (v3 API)"
+url = "https://github.com/samsonjs/gitter"
+
+[[projects]]
+name = "cheat.el"
+title = "cheat.el"
+description = "cheat from emacs"
+url = "https://github.com/samsonjs/cheat.el"
diff --git a/public/css/brands.min.css b/public/css/brands.min.css
deleted file mode 100644
index d13c1e6..0000000
--- a/public/css/brands.min.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/*!
- * Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license (Commercial License)
- */
-@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands";font-weight:400}
\ No newline at end of file
diff --git a/public/css/fontawesome.min.css b/public/css/fontawesome.min.css
deleted file mode 100644
index e8c166c..0000000
--- a/public/css/fontawesome.min.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/*!
- * Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license (Commercial License)
- */
-.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-abacus:before{content:"\f640"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acorn:before{content:"\f6ae"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-conditioner:before{content:"\f8f4"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-alarm-clock:before{content:"\f34e"}.fa-alarm-exclamation:before{content:"\f843"}.fa-alarm-plus:before{content:"\f844"}.fa-alarm-snooze:before{content:"\f845"}.fa-album:before{content:"\f89f"}.fa-album-collection:before{content:"\f8a0"}.fa-algolia:before{content:"\f36c"}.fa-alicorn:before{content:"\f6b0"}.fa-alien:before{content:"\f8f5"}.fa-alien-monster:before{content:"\f8f6"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-align-slash:before{content:"\f846"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-amp-guitar:before{content:"\f8a1"}.fa-analytics:before{content:"\f643"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angel:before{content:"\f779"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-crate:before{content:"\f6b1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-alt-down:before{content:"\f354"}.fa-arrow-alt-from-bottom:before{content:"\f346"}.fa-arrow-alt-from-left:before{content:"\f347"}.fa-arrow-alt-from-right:before{content:"\f348"}.fa-arrow-alt-from-top:before{content:"\f349"}.fa-arrow-alt-left:before{content:"\f355"}.fa-arrow-alt-right:before{content:"\f356"}.fa-arrow-alt-square-down:before{content:"\f350"}.fa-arrow-alt-square-left:before{content:"\f351"}.fa-arrow-alt-square-right:before{content:"\f352"}.fa-arrow-alt-square-up:before{content:"\f353"}.fa-arrow-alt-to-bottom:before{content:"\f34a"}.fa-arrow-alt-to-left:before{content:"\f34b"}.fa-arrow-alt-to-right:before{content:"\f34c"}.fa-arrow-alt-to-top:before{content:"\f34d"}.fa-arrow-alt-up:before{content:"\f357"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-from-bottom:before{content:"\f342"}.fa-arrow-from-left:before{content:"\f343"}.fa-arrow-from-right:before{content:"\f344"}.fa-arrow-from-top:before{content:"\f345"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-square-down:before{content:"\f339"}.fa-arrow-square-left:before{content:"\f33a"}.fa-arrow-square-right:before{content:"\f33b"}.fa-arrow-square-up:before{content:"\f33c"}.fa-arrow-to-bottom:before{content:"\f33d"}.fa-arrow-to-left:before{content:"\f33e"}.fa-arrow-to-right:before{content:"\f340"}.fa-arrow-to-top:before{content:"\f341"}.fa-arrow-up:before{content:"\f062"}.fa-arrows:before{content:"\f047"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-arrows-h:before{content:"\f07e"}.fa-arrows-v:before{content:"\f07d"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-atom-alt:before{content:"\f5d3"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-axe:before{content:"\f6b2"}.fa-axe-battle:before{content:"\f6b3"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backpack:before{content:"\f5d4"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bacteria:before{content:"\e059"}.fa-bacterium:before{content:"\e05a"}.fa-badge:before{content:"\f335"}.fa-badge-check:before{content:"\f336"}.fa-badge-dollar:before{content:"\f645"}.fa-badge-percent:before{content:"\f646"}.fa-badge-sheriff:before{content:"\f8a2"}.fa-badger-honey:before{content:"\f6b4"}.fa-bags-shopping:before{content:"\f847"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ball-pile:before{content:"\f77e"}.fa-ballot:before{content:"\f732"}.fa-ballot-check:before{content:"\f733"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-banjo:before{content:"\f8a3"}.fa-barcode:before{content:"\f02a"}.fa-barcode-alt:before{content:"\f463"}.fa-barcode-read:before{content:"\f464"}.fa-barcode-scan:before{content:"\f465"}.fa-bars:before{content:"\f0c9"}.fa-baseball:before{content:"\f432"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-basketball-hoop:before{content:"\f435"}.fa-bat:before{content:"\f6b5"}.fa-bath:before{content:"\f2cd"}.fa-battery-bolt:before{content:"\f376"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-slash:before{content:"\f377"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-bed-alt:before{content:"\f8f7"}.fa-bed-bunk:before{content:"\f8f8"}.fa-bed-empty:before{content:"\f8f9"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-exclamation:before{content:"\f848"}.fa-bell-on:before{content:"\f8fa"}.fa-bell-plus:before{content:"\f849"}.fa-bell-school:before{content:"\f5d5"}.fa-bell-school-slash:before{content:"\f5d6"}.fa-bell-slash:before{content:"\f1f6"}.fa-bells:before{content:"\f77f"}.fa-betamax:before{content:"\f8a4"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-biking-mountain:before{content:"\f84b"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blanket:before{content:"\f498"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blinds:before{content:"\f8fb"}.fa-blinds-open:before{content:"\f8fc"}.fa-blinds-raised:before{content:"\f8fd"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bone-break:before{content:"\f5d8"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-alt:before{content:"\f5d9"}.fa-book-dead:before{content:"\f6b7"}.fa-book-heart:before{content:"\f499"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-book-spells:before{content:"\f6b8"}.fa-book-user:before{content:"\f7e7"}.fa-bookmark:before{content:"\f02e"}.fa-books:before{content:"\f5db"}.fa-books-medical:before{content:"\f7e8"}.fa-boombox:before{content:"\f8a5"}.fa-boot:before{content:"\f782"}.fa-booth-curtain:before{content:"\f734"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-bottom:before{content:"\f84d"}.fa-border-center-h:before{content:"\f89c"}.fa-border-center-v:before{content:"\f89d"}.fa-border-inner:before{content:"\f84e"}.fa-border-left:before{content:"\f84f"}.fa-border-none:before{content:"\f850"}.fa-border-outer:before{content:"\f851"}.fa-border-right:before{content:"\f852"}.fa-border-style:before{content:"\f853"}.fa-border-style-alt:before{content:"\f854"}.fa-border-top:before{content:"\f855"}.fa-bow-arrow:before{content:"\f6b9"}.fa-bowling-ball:before{content:"\f436"}.fa-bowling-pins:before{content:"\f437"}.fa-box:before{content:"\f466"}.fa-box-alt:before{content:"\f49a"}.fa-box-ballot:before{content:"\f735"}.fa-box-check:before{content:"\f467"}.fa-box-fragile:before{content:"\f49b"}.fa-box-full:before{content:"\f49c"}.fa-box-heart:before{content:"\f49d"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\e05b"}.fa-box-up:before{content:"\f49f"}.fa-box-usd:before{content:"\f4a0"}.fa-boxes:before{content:"\f468"}.fa-boxes-alt:before{content:"\f4a1"}.fa-boxing-glove:before{content:"\f438"}.fa-brackets:before{content:"\f7e9"}.fa-brackets-curly:before{content:"\f7ea"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-loaf:before{content:"\f7eb"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-bring-forward:before{content:"\f856"}.fa-bring-front:before{content:"\f857"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-browser:before{content:"\f37e"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-bullseye-arrow:before{content:"\f648"}.fa-bullseye-pointer:before{content:"\f649"}.fa-burger-soda:before{content:"\f858"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-burrito:before{content:"\f7ed"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-bus-school:before{content:"\f5dd"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-cabinet-filing:before{content:"\f64b"}.fa-cactus:before{content:"\f8a7"}.fa-calculator:before{content:"\f1ec"}.fa-calculator-alt:before{content:"\f64c"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-edit:before{content:"\f333"}.fa-calendar-exclamation:before{content:"\f334"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-star:before{content:"\f736"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camcorder:before{content:"\f8a8"}.fa-camera:before{content:"\f030"}.fa-camera-alt:before{content:"\f332"}.fa-camera-home:before{content:"\f8fe"}.fa-camera-movie:before{content:"\f8a9"}.fa-camera-polaroid:before{content:"\f8aa"}.fa-camera-retro:before{content:"\f083"}.fa-campfire:before{content:"\f6ba"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candle-holder:before{content:"\f6bc"}.fa-candy-cane:before{content:"\f786"}.fa-candy-corn:before{content:"\f6bd"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-building:before{content:"\f859"}.fa-car-bump:before{content:"\f5e0"}.fa-car-bus:before{content:"\f85a"}.fa-car-crash:before{content:"\f5e1"}.fa-car-garage:before{content:"\f5e2"}.fa-car-mechanic:before{content:"\f5e3"}.fa-car-side:before{content:"\f5e4"}.fa-car-tilt:before{content:"\f5e5"}.fa-car-wash:before{content:"\f5e6"}.fa-caravan:before{content:"\f8ff"}.fa-caravan-alt:before{content:"\e000"}.fa-caret-circle-down:before{content:"\f32d"}.fa-caret-circle-left:before{content:"\f32e"}.fa-caret-circle-right:before{content:"\f330"}.fa-caret-circle-up:before{content:"\f331"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cars:before{content:"\f85b"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cassette-tape:before{content:"\f8ab"}.fa-cat:before{content:"\f6be"}.fa-cat-space:before{content:"\e001"}.fa-cauldron:before{content:"\f6bf"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-cctv:before{content:"\f8ac"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chair-office:before{content:"\f6c1"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-line-down:before{content:"\f64d"}.fa-chart-network:before{content:"\f78a"}.fa-chart-pie:before{content:"\f200"}.fa-chart-pie-alt:before{content:"\f64e"}.fa-chart-scatter:before{content:"\f7ee"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-cheese-swiss:before{content:"\f7f0"}.fa-cheeseburger:before{content:"\f7f1"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-bishop-alt:before{content:"\f43b"}.fa-chess-board:before{content:"\f43c"}.fa-chess-clock:before{content:"\f43d"}.fa-chess-clock-alt:before{content:"\f43e"}.fa-chess-king:before{content:"\f43f"}.fa-chess-king-alt:before{content:"\f440"}.fa-chess-knight:before{content:"\f441"}.fa-chess-knight-alt:before{content:"\f442"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-pawn-alt:before{content:"\f444"}.fa-chess-queen:before{content:"\f445"}.fa-chess-queen-alt:before{content:"\f446"}.fa-chess-rook:before{content:"\f447"}.fa-chess-rook-alt:before{content:"\f448"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-double-down:before{content:"\f322"}.fa-chevron-double-left:before{content:"\f323"}.fa-chevron-double-right:before{content:"\f324"}.fa-chevron-double-up:before{content:"\f325"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-square-down:before{content:"\f329"}.fa-chevron-square-left:before{content:"\f32a"}.fa-chevron-square-right:before{content:"\f32b"}.fa-chevron-square-up:before{content:"\f32c"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chimney:before{content:"\f78b"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clarinet:before{content:"\f8ad"}.fa-claw-marks:before{content:"\f6c2"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clipboard-list-check:before{content:"\f737"}.fa-clipboard-prescription:before{content:"\f5e8"}.fa-clipboard-user:before{content:"\f7f3"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-drizzle:before{content:"\f738"}.fa-cloud-hail:before{content:"\f739"}.fa-cloud-hail-mixed:before{content:"\f73a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-music:before{content:"\f8ae"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-rainbow:before{content:"\f73e"}.fa-cloud-showers:before{content:"\f73f"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sleet:before{content:"\f741"}.fa-cloud-snow:before{content:"\f742"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload:before{content:"\f0ee"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudflare:before{content:"\e07d"}.fa-clouds:before{content:"\f744"}.fa-clouds-moon:before{content:"\f745"}.fa-clouds-sun:before{content:"\f746"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-club:before{content:"\f327"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-code-commit:before{content:"\f386"}.fa-code-merge:before{content:"\f387"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-coffee-pot:before{content:"\e002"}.fa-coffee-togo:before{content:"\f6c5"}.fa-coffin:before{content:"\f6c6"}.fa-coffin-cross:before{content:"\e051"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coin:before{content:"\f85c"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comet:before{content:"\e003"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-alt-check:before{content:"\f4a2"}.fa-comment-alt-dollar:before{content:"\f650"}.fa-comment-alt-dots:before{content:"\f4a3"}.fa-comment-alt-edit:before{content:"\f4a4"}.fa-comment-alt-exclamation:before{content:"\f4a5"}.fa-comment-alt-lines:before{content:"\f4a6"}.fa-comment-alt-medical:before{content:"\f7f4"}.fa-comment-alt-minus:before{content:"\f4a7"}.fa-comment-alt-music:before{content:"\f8af"}.fa-comment-alt-plus:before{content:"\f4a8"}.fa-comment-alt-slash:before{content:"\f4a9"}.fa-comment-alt-smile:before{content:"\f4aa"}.fa-comment-alt-times:before{content:"\f4ab"}.fa-comment-check:before{content:"\f4ac"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-edit:before{content:"\f4ae"}.fa-comment-exclamation:before{content:"\f4af"}.fa-comment-lines:before{content:"\f4b0"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-minus:before{content:"\f4b1"}.fa-comment-music:before{content:"\f8b0"}.fa-comment-plus:before{content:"\f4b2"}.fa-comment-slash:before{content:"\f4b3"}.fa-comment-smile:before{content:"\f4b4"}.fa-comment-times:before{content:"\f4b5"}.fa-comments:before{content:"\f086"}.fa-comments-alt:before{content:"\f4b6"}.fa-comments-alt-dollar:before{content:"\f652"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compass-slash:before{content:"\f5e9"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-compress-wide:before{content:"\f326"}.fa-computer-classic:before{content:"\f8b1"}.fa-computer-speaker:before{content:"\f8b2"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-construction:before{content:"\f85d"}.fa-container-storage:before{content:"\f4b7"}.fa-contao:before{content:"\f26d"}.fa-conveyor-belt:before{content:"\f46e"}.fa-conveyor-belt-alt:before{content:"\f46f"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-corn:before{content:"\f6c7"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cow:before{content:"\f6c8"}.fa-cowbell:before{content:"\f8b3"}.fa-cowbell-more:before{content:"\f8b4"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-credit-card-blank:before{content:"\f389"}.fa-credit-card-front:before{content:"\f38a"}.fa-cricket:before{content:"\f449"}.fa-critical-role:before{content:"\f6c9"}.fa-croissant:before{content:"\f7f6"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-crutches:before{content:"\f7f8"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-curling:before{content:"\f44a"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dagger:before{content:"\f6cb"}.fa-dailymotion:before{content:"\e052"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-debug:before{content:"\f7f9"}.fa-deer:before{content:"\f78e"}.fa-deer-rudolph:before{content:"\f78f"}.fa-deezer:before{content:"\e077"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-desktop-alt:before{content:"\f390"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dewpoint:before{content:"\f748"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diamond:before{content:"\f219"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d10:before{content:"\f6cd"}.fa-dice-d12:before{content:"\f6ce"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d4:before{content:"\f6d0"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-d8:before{content:"\f6d2"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digging:before{content:"\f85e"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-diploma:before{content:"\f5ea"}.fa-directions:before{content:"\f5eb"}.fa-disc-drive:before{content:"\f8b5"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-do-not-enter:before{content:"\f5ec"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dog-leashed:before{content:"\f6d4"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-empty:before{content:"\f473"}.fa-dolly-flatbed:before{content:"\f474"}.fa-dolly-flatbed-alt:before{content:"\f475"}.fa-dolly-flatbed-empty:before{content:"\f476"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-circle:before{content:"\f5ed"}.fa-draw-polygon:before{content:"\f5ee"}.fa-draw-square:before{content:"\f5ef"}.fa-dreidel:before{content:"\f792"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-drone:before{content:"\f85f"}.fa-drone-alt:before{content:"\f860"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick:before{content:"\f6d6"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dryer:before{content:"\f861"}.fa-dryer-alt:before{content:"\f862"}.fa-duck:before{content:"\f6d8"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-ear:before{content:"\f5f0"}.fa-ear-muffs:before{content:"\f795"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-eclipse:before{content:"\f749"}.fa-eclipse-alt:before{content:"\f74a"}.fa-edge:before{content:"\f282"}.fa-edge-legacy:before{content:"\e078"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-egg-fried:before{content:"\f7fc"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-elephant:before{content:"\f6da"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-h-alt:before{content:"\f39b"}.fa-ellipsis-v:before{content:"\f142"}.fa-ellipsis-v-alt:before{content:"\f39c"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-empty-set:before{content:"\f656"}.fa-engine-warning:before{content:"\f5f2"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-dollar:before{content:"\f657"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange:before{content:"\f0ec"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-square:before{content:"\f321"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows:before{content:"\f31d"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expand-wide:before{content:"\f320"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link:before{content:"\f08e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square:before{content:"\f14c"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-evil:before{content:"\f6db"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fan-table:before{content:"\e004"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-farm:before{content:"\f864"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-faucet:before{content:"\e005"}.fa-faucet-drip:before{content:"\e006"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-field-hockey:before{content:"\f44c"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-certificate:before{content:"\f5f3"}.fa-file-chart-line:before{content:"\f659"}.fa-file-chart-pie:before{content:"\f65a"}.fa-file-check:before{content:"\f316"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-edit:before{content:"\f31c"}.fa-file-excel:before{content:"\f1c3"}.fa-file-exclamation:before{content:"\f31a"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-minus:before{content:"\f318"}.fa-file-music:before{content:"\f8b6"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-plus:before{content:"\f319"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-search:before{content:"\f865"}.fa-file-signature:before{content:"\f573"}.fa-file-spreadsheet:before{content:"\f65b"}.fa-file-times:before{content:"\f317"}.fa-file-upload:before{content:"\f574"}.fa-file-user:before{content:"\f65c"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-files-medical:before{content:"\f7fd"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-film-alt:before{content:"\f3a0"}.fa-film-canister:before{content:"\f8b7"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-fire-smoke:before{content:"\f74b"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\e007"}.fa-fireplace:before{content:"\f79a"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fish-cooked:before{content:"\f7fe"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-alt:before{content:"\f74c"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flame:before{content:"\f6df"}.fa-flashlight:before{content:"\f8b8"}.fa-flask:before{content:"\f0c3"}.fa-flask-poison:before{content:"\f6e0"}.fa-flask-potion:before{content:"\f6e1"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flower:before{content:"\f7ff"}.fa-flower-daffodil:before{content:"\f800"}.fa-flower-tulip:before{content:"\f801"}.fa-flushed:before{content:"\f579"}.fa-flute:before{content:"\f8b9"}.fa-flux-capacitor:before{content:"\f8ba"}.fa-fly:before{content:"\f417"}.fa-fog:before{content:"\f74e"}.fa-folder:before{content:"\f07b"}.fa-folder-download:before{content:"\e053"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-folder-times:before{content:"\f65f"}.fa-folder-tree:before{content:"\f802"}.fa-folder-upload:before{content:"\e054"}.fa-folders:before{content:"\f660"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-font-case:before{content:"\f866"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-football-helmet:before{content:"\f44f"}.fa-forklift:before{content:"\f47a"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-fragile:before{content:"\f4bb"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-french-fries:before{content:"\f803"}.fa-frog:before{content:"\f52e"}.fa-frosty-head:before{content:"\f79b"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-function:before{content:"\f661"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-galaxy:before{content:"\e008"}.fa-game-board:before{content:"\f867"}.fa-game-board-alt:before{content:"\f868"}.fa-game-console-handheld:before{content:"\f8bb"}.fa-gamepad:before{content:"\f11b"}.fa-gamepad-alt:before{content:"\f8bc"}.fa-garage:before{content:"\e009"}.fa-garage-car:before{content:"\e00a"}.fa-garage-open:before{content:"\e00b"}.fa-gas-pump:before{content:"\f52f"}.fa-gas-pump-slash:before{content:"\f5f4"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gift-card:before{content:"\f663"}.fa-gifts:before{content:"\f79c"}.fa-gingerbread-man:before{content:"\f79d"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass:before{content:"\f804"}.fa-glass-champagne:before{content:"\f79e"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-citrus:before{content:"\f869"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glass-whiskey-rocks:before{content:"\f7a1"}.fa-glasses:before{content:"\f530"}.fa-glasses-alt:before{content:"\f5f5"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-globe-snow:before{content:"\f7a3"}.fa-globe-stand:before{content:"\f5f6"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-golf-club:before{content:"\f451"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-pay:before{content:"\e079"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gramophone:before{content:"\f8bd"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guilded:before{content:"\e07e"}.fa-guitar:before{content:"\f7a6"}.fa-guitar-electric:before{content:"\f8be"}.fa-guitars:before{content:"\f8bf"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-h1:before{content:"\f313"}.fa-h2:before{content:"\f314"}.fa-h3:before{content:"\f315"}.fa-h4:before{content:"\f86a"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hammer-war:before{content:"\f6e4"}.fa-hamsa:before{content:"\f665"}.fa-hand-heart:before{content:"\f4bc"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-box:before{content:"\f47b"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-magic:before{content:"\f6e5"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-hand-holding-seedling:before{content:"\f4bf"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-receiving:before{content:"\f47c"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\e05d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-heart:before{content:"\f4c3"}.fa-hands-helping:before{content:"\f4c4"}.fa-hands-usd:before{content:"\f4c5"}.fa-hands-wash:before{content:"\e05e"}.fa-handshake:before{content:"\f2b5"}.fa-handshake-alt:before{content:"\f4c6"}.fa-handshake-alt-slash:before{content:"\e05f"}.fa-handshake-slash:before{content:"\e060"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-chef:before{content:"\f86b"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-santa:before{content:"\f7a7"}.fa-hat-winter:before{content:"\f7a8"}.fa-hat-witch:before{content:"\f6e7"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-head-side:before{content:"\f6e9"}.fa-head-side-brain:before{content:"\f808"}.fa-head-side-cough:before{content:"\e061"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-head-side-headphones:before{content:"\f8c2"}.fa-head-side-mask:before{content:"\e063"}.fa-head-side-medical:before{content:"\f809"}.fa-head-side-virus:before{content:"\e064"}.fa-head-vr:before{content:"\f6ea"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heart-circle:before{content:"\f4c7"}.fa-heart-rate:before{content:"\f5f8"}.fa-heart-square:before{content:"\f4c8"}.fa-heartbeat:before{content:"\f21e"}.fa-heat:before{content:"\e00c"}.fa-helicopter:before{content:"\f533"}.fa-helmet-battle:before{content:"\f6eb"}.fa-hexagon:before{content:"\f312"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hive:before{content:"\e07f"}.fa-hockey-mask:before{content:"\f6ee"}.fa-hockey-puck:before{content:"\f453"}.fa-hockey-sticks:before{content:"\f454"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-home-alt:before{content:"\f80a"}.fa-home-heart:before{content:"\f4c9"}.fa-home-lg:before{content:"\f80b"}.fa-home-lg-alt:before{content:"\f80c"}.fa-hood-cloak:before{content:"\f6ef"}.fa-hooli:before{content:"\f427"}.fa-horizontal-rule:before{content:"\f86c"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-horse-saddle:before{content:"\f8c3"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hospital-user:before{content:"\f80d"}.fa-hospitals:before{content:"\f80e"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house:before{content:"\e00d"}.fa-house-damage:before{content:"\f6f1"}.fa-house-day:before{content:"\e00e"}.fa-house-flood:before{content:"\f74f"}.fa-house-leave:before{content:"\e00f"}.fa-house-night:before{content:"\e010"}.fa-house-return:before{content:"\e011"}.fa-house-signal:before{content:"\e012"}.fa-house-user:before{content:"\e065"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-humidity:before{content:"\f750"}.fa-hurricane:before{content:"\f751"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-ice-skate:before{content:"\f7ac"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-icons-alt:before{content:"\f86e"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\e013"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-image-polaroid:before{content:"\f8c4"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-inbox-in:before{content:"\f310"}.fa-inbox-out:before{content:"\f311"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-industry-alt:before{content:"\f3b3"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-info-square:before{content:"\f30f"}.fa-inhaler:before{content:"\f5f9"}.fa-innosoft:before{content:"\e080"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\e055"}.fa-instalod:before{content:"\e081"}.fa-integral:before{content:"\f667"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-intersection:before{content:"\f668"}.fa-inventory:before{content:"\f480"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-island-tropical:before{content:"\f811"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-jack-o-lantern:before{content:"\f30e"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-joystick:before{content:"\f8c5"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-jug:before{content:"\f8c6"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-kazoo:before{content:"\f8c7"}.fa-kerning:before{content:"\f86f"}.fa-key:before{content:"\f084"}.fa-key-skeleton:before{content:"\f6f3"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-keynote:before{content:"\f66c"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kidneys:before{content:"\f5fb"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kite:before{content:"\f6f4"}.fa-kiwi-bird:before{content:"\f535"}.fa-knife-kitchen:before{content:"\f6f5"}.fa-korvue:before{content:"\f42f"}.fa-lambda:before{content:"\f66e"}.fa-lamp:before{content:"\f4ca"}.fa-lamp-desk:before{content:"\e014"}.fa-lamp-floor:before{content:"\e015"}.fa-landmark:before{content:"\f66f"}.fa-landmark-alt:before{content:"\f752"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-house:before{content:"\e066"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lasso:before{content:"\f8c8"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-layer-minus:before{content:"\f5fe"}.fa-layer-plus:before{content:"\f5ff"}.fa-leaf:before{content:"\f06c"}.fa-leaf-heart:before{content:"\f4cb"}.fa-leaf-maple:before{content:"\f6f6"}.fa-leaf-oak:before{content:"\f6f7"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down:before{content:"\f149"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up:before{content:"\f148"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-light-ceiling:before{content:"\e016"}.fa-light-switch:before{content:"\e017"}.fa-light-switch-off:before{content:"\e018"}.fa-light-switch-on:before{content:"\e019"}.fa-lightbulb:before{content:"\f0eb"}.fa-lightbulb-dollar:before{content:"\f670"}.fa-lightbulb-exclamation:before{content:"\f671"}.fa-lightbulb-on:before{content:"\f672"}.fa-lightbulb-slash:before{content:"\f673"}.fa-lights-holiday:before{content:"\f7b2"}.fa-line:before{content:"\f3c0"}.fa-line-columns:before{content:"\f870"}.fa-line-height:before{content:"\f871"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lips:before{content:"\f600"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-music:before{content:"\f8c9"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location:before{content:"\f601"}.fa-location-arrow:before{content:"\f124"}.fa-location-circle:before{content:"\f602"}.fa-location-slash:before{content:"\f603"}.fa-lock:before{content:"\f023"}.fa-lock-alt:before{content:"\f30d"}.fa-lock-open:before{content:"\f3c1"}.fa-lock-open-alt:before{content:"\f3c2"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-long-arrow-up:before{content:"\f176"}.fa-loveseat:before{content:"\f4cc"}.fa-low-vision:before{content:"\f2a8"}.fa-luchador:before{content:"\f455"}.fa-luggage-cart:before{content:"\f59d"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\e067"}.fa-lyft:before{content:"\f3c3"}.fa-mace:before{content:"\f6f8"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailbox:before{content:"\f813"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-mandolin:before{content:"\f6f9"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-marker-alt-slash:before{content:"\f605"}.fa-map-marker-check:before{content:"\f606"}.fa-map-marker-edit:before{content:"\f607"}.fa-map-marker-exclamation:before{content:"\f608"}.fa-map-marker-minus:before{content:"\f609"}.fa-map-marker-plus:before{content:"\f60a"}.fa-map-marker-question:before{content:"\f60b"}.fa-map-marker-slash:before{content:"\f60c"}.fa-map-marker-smile:before{content:"\f60d"}.fa-map-marker-times:before{content:"\f60e"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-meat:before{content:"\f814"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaphone:before{content:"\f675"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\e01a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microphone-stand:before{content:"\f8cb"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-microwave:before{content:"\e01b"}.fa-mind-share:before{content:"\f677"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-hexagon:before{content:"\f307"}.fa-minus-octagon:before{content:"\f308"}.fa-minus-square:before{content:"\f146"}.fa-mistletoe:before{content:"\f7b4"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\e056"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-mobile-android:before{content:"\f3ce"}.fa-mobile-android-alt:before{content:"\f3cf"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-money-check-edit:before{content:"\f872"}.fa-money-check-edit-alt:before{content:"\f873"}.fa-monitor-heart-rate:before{content:"\f611"}.fa-monkey:before{content:"\f6fb"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-moon-cloud:before{content:"\f754"}.fa-moon-stars:before{content:"\f755"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mountains:before{content:"\f6fd"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-alt:before{content:"\f8cd"}.fa-mouse-pointer:before{content:"\f245"}.fa-mp3-player:before{content:"\f8ce"}.fa-mug:before{content:"\f874"}.fa-mug-hot:before{content:"\f7b6"}.fa-mug-marshmallows:before{content:"\f7b7"}.fa-mug-tea:before{content:"\f875"}.fa-music:before{content:"\f001"}.fa-music-alt:before{content:"\f8cf"}.fa-music-alt-slash:before{content:"\f8d0"}.fa-music-slash:before{content:"\f8d1"}.fa-napster:before{content:"\f3d2"}.fa-narwhal:before{content:"\f6fe"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-octagon:before{content:"\f306"}.fa-octopus-deploy:before{content:"\e082"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-oil-temp:before{content:"\f614"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-omega:before{content:"\f67a"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-ornament:before{content:"\f7b8"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-outlet:before{content:"\e01c"}.fa-oven:before{content:"\e01d"}.fa-overline:before{content:"\f876"}.fa-page-break:before{content:"\f877"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-brush-alt:before{content:"\f5a9"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-pallet-alt:before{content:"\f483"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-paragraph-rtl:before{content:"\f878"}.fa-parking:before{content:"\f540"}.fa-parking-circle:before{content:"\f615"}.fa-parking-circle-slash:before{content:"\f616"}.fa-parking-slash:before{content:"\f617"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paw-alt:before{content:"\f701"}.fa-paw-claws:before{content:"\f702"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pegasus:before{content:"\f703"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil:before{content:"\f040"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-paintbrush:before{content:"\f618"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-pennant:before{content:"\f456"}.fa-penny-arcade:before{content:"\f704"}.fa-people-arrows:before{content:"\e068"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-perbyte:before{content:"\e083"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-person-carry:before{content:"\f4cf"}.fa-person-dolly:before{content:"\f4d0"}.fa-person-dolly-empty:before{content:"\f4d1"}.fa-person-sign:before{content:"\f757"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-laptop:before{content:"\f87a"}.fa-phone-office:before{content:"\f67d"}.fa-phone-plus:before{content:"\f4d2"}.fa-phone-rotary:before{content:"\f8d3"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pi:before{content:"\f67e"}.fa-piano:before{content:"\f8d4"}.fa-piano-keyboard:before{content:"\f8d5"}.fa-pie:before{content:"\f705"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\e01e"}.fa-pig:before{content:"\f706"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza:before{content:"\f817"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-alt:before{content:"\f3de"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\e069"}.fa-planet-moon:before{content:"\e01f"}.fa-planet-ringed:before{content:"\e020"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-hexagon:before{content:"\f300"}.fa-plus-octagon:before{content:"\f301"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-podium:before{content:"\f680"}.fa-podium-star:before{content:"\f758"}.fa-police-box:before{content:"\e021"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poll-people:before{content:"\f759"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-popcorn:before{content:"\f819"}.fa-portal-enter:before{content:"\e022"}.fa-portal-exit:before{content:"\e023"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-presentation:before{content:"\f685"}.fa-print:before{content:"\f02f"}.fa-print-search:before{content:"\f81a"}.fa-print-slash:before{content:"\f686"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-projector:before{content:"\f8d6"}.fa-pump-medical:before{content:"\e06a"}.fa-pump-soap:before{content:"\e06b"}.fa-pumpkin:before{content:"\f707"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-question-square:before{content:"\f2fd"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-rabbit:before{content:"\f708"}.fa-rabbit-fast:before{content:"\f709"}.fa-racquet:before{content:"\f45a"}.fa-radar:before{content:"\e024"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-radio:before{content:"\f8d7"}.fa-radio-alt:before{content:"\f8d8"}.fa-rainbow:before{content:"\f75b"}.fa-raindrops:before{content:"\f75c"}.fa-ram:before{content:"\f70a"}.fa-ramp-loading:before{content:"\f4d4"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-raygun:before{content:"\e025"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-rectangle-landscape:before{content:"\f2fa"}.fa-rectangle-portrait:before{content:"\f2fb"}.fa-rectangle-wide:before{content:"\f2fc"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-refrigerator:before{content:"\e026"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-repeat:before{content:"\f363"}.fa-repeat-1:before{content:"\f365"}.fa-repeat-1-alt:before{content:"\f366"}.fa-repeat-alt:before{content:"\f364"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-retweet-alt:before{content:"\f361"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-rings-wedding:before{content:"\f81b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocket-launch:before{content:"\e027"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-route-highway:before{content:"\f61a"}.fa-route-interstate:before{content:"\f61b"}.fa-router:before{content:"\f8da"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-triangle:before{content:"\f61c"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-rust:before{content:"\e07a"}.fa-rv:before{content:"\f7be"}.fa-sack:before{content:"\f81c"}.fa-sack-dollar:before{content:"\f81d"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salad:before{content:"\f81e"}.fa-salesforce:before{content:"\f83b"}.fa-sandwich:before{content:"\f81f"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-sausage:before{content:"\f820"}.fa-save:before{content:"\f0c7"}.fa-sax-hot:before{content:"\f8db"}.fa-saxophone:before{content:"\f8dc"}.fa-scalpel:before{content:"\f61d"}.fa-scalpel-path:before{content:"\f61e"}.fa-scanner:before{content:"\f488"}.fa-scanner-image:before{content:"\f8f3"}.fa-scanner-keyboard:before{content:"\f489"}.fa-scanner-touchscreen:before{content:"\f48a"}.fa-scarecrow:before{content:"\f70d"}.fa-scarf:before{content:"\f7c1"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-scroll-old:before{content:"\f70f"}.fa-scrubber:before{content:"\f2f8"}.fa-scythe:before{content:"\f710"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-send-back:before{content:"\f87e"}.fa-send-backward:before{content:"\f87f"}.fa-sensor:before{content:"\e028"}.fa-sensor-alert:before{content:"\e029"}.fa-sensor-fire:before{content:"\e02a"}.fa-sensor-on:before{content:"\e02b"}.fa-sensor-smoke:before{content:"\e02c"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-all:before{content:"\f367"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-sheep:before{content:"\f711"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield:before{content:"\f132"}.fa-shield-alt:before{content:"\f3ed"}.fa-shield-check:before{content:"\f2f7"}.fa-shield-cross:before{content:"\f712"}.fa-shield-virus:before{content:"\e06c"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shipping-timed:before{content:"\f48c"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shish-kebab:before{content:"\f821"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopify:before{content:"\e057"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shovel:before{content:"\f713"}.fa-shovel-snow:before{content:"\f7c3"}.fa-shower:before{content:"\f2cc"}.fa-shredder:before{content:"\f68a"}.fa-shuttle-van:before{content:"\f5b6"}.fa-shuttlecock:before{content:"\f45b"}.fa-sickle:before{content:"\f822"}.fa-sigma:before{content:"\f68b"}.fa-sign:before{content:"\f4d9"}.fa-sign-in:before{content:"\f090"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out:before{content:"\f08b"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signal-1:before{content:"\f68c"}.fa-signal-2:before{content:"\f68d"}.fa-signal-3:before{content:"\f68e"}.fa-signal-4:before{content:"\f68f"}.fa-signal-alt:before{content:"\f690"}.fa-signal-alt-1:before{content:"\f691"}.fa-signal-alt-2:before{content:"\f692"}.fa-signal-alt-3:before{content:"\f693"}.fa-signal-alt-slash:before{content:"\f694"}.fa-signal-slash:before{content:"\f695"}.fa-signal-stream:before{content:"\f8dd"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sink:before{content:"\e06d"}.fa-siren:before{content:"\e02d"}.fa-siren-on:before{content:"\e02e"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-skeleton:before{content:"\f620"}.fa-sketch:before{content:"\f7c6"}.fa-ski-jump:before{content:"\f7c7"}.fa-ski-lift:before{content:"\f7c8"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-cow:before{content:"\f8de"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sledding:before{content:"\f7cb"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-sliders-h-square:before{content:"\f3f0"}.fa-sliders-v:before{content:"\f3f1"}.fa-sliders-v-square:before{content:"\f3f2"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-plus:before{content:"\f5b9"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoke:before{content:"\f760"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snake:before{content:"\f716"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snooze:before{content:"\f880"}.fa-snow-blowing:before{content:"\f761"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowflakes:before{content:"\f7cf"}.fa-snowman:before{content:"\f7d0"}.fa-snowmobile:before{content:"\f7d1"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\e06e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-solar-system:before{content:"\e02f"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-alt:before{content:"\f883"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-circle:before{content:"\e030"}.fa-sort-circle-down:before{content:"\e031"}.fa-sort-circle-up:before{content:"\e032"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-shapes-down:before{content:"\f888"}.fa-sort-shapes-down-alt:before{content:"\f889"}.fa-sort-shapes-up:before{content:"\f88a"}.fa-sort-shapes-up-alt:before{content:"\f88b"}.fa-sort-size-down:before{content:"\f88c"}.fa-sort-size-down-alt:before{content:"\f88d"}.fa-sort-size-up:before{content:"\f88e"}.fa-sort-size-up-alt:before{content:"\f88f"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-soup:before{content:"\f823"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-space-station-moon:before{content:"\e033"}.fa-space-station-moon-alt:before{content:"\e034"}.fa-spade:before{content:"\f2f4"}.fa-sparkles:before{content:"\f890"}.fa-speakap:before{content:"\f3f3"}.fa-speaker:before{content:"\f8df"}.fa-speaker-deck:before{content:"\f83c"}.fa-speakers:before{content:"\f8e0"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spider-black-widow:before{content:"\f718"}.fa-spider-web:before{content:"\f719"}.fa-spinner:before{content:"\f110"}.fa-spinner-third:before{content:"\f3f4"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-sprinkler:before{content:"\e035"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root:before{content:"\f697"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-squirrel:before{content:"\f71a"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-staff:before{content:"\f71b"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-christmas:before{content:"\f7d4"}.fa-star-exclamation:before{content:"\f2f3"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-star-shooting:before{content:"\e036"}.fa-starfighter:before{content:"\e037"}.fa-starfighter-alt:before{content:"\e038"}.fa-stars:before{content:"\f762"}.fa-starship:before{content:"\e039"}.fa-starship-freighter:before{content:"\e03a"}.fa-staylinked:before{content:"\f3f5"}.fa-steak:before{content:"\f824"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-steering-wheel:before{content:"\f622"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stocking:before{content:"\f7d5"}.fa-stomach:before{content:"\f623"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\e06f"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-store-alt-slash:before{content:"\e070"}.fa-store-slash:before{content:"\e071"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-stretcher:before{content:"\f825"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-sun-cloud:before{content:"\f763"}.fa-sun-dust:before{content:"\f764"}.fa-sun-haze:before{content:"\f765"}.fa-sunglasses:before{content:"\f892"}.fa-sunrise:before{content:"\f766"}.fa-sunset:before{content:"\f767"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-sword:before{content:"\f71c"}.fa-sword-laser:before{content:"\e03b"}.fa-sword-laser-alt:before{content:"\e03c"}.fa-swords:before{content:"\f71d"}.fa-swords-laser:before{content:"\e03d"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablet-android:before{content:"\f3fb"}.fa-tablet-android-alt:before{content:"\f3fc"}.fa-tablet-rugged:before{content:"\f48f"}.fa-tablets:before{content:"\f490"}.fa-tachometer:before{content:"\f0e4"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tachometer-alt-average:before{content:"\f624"}.fa-tachometer-alt-fast:before{content:"\f625"}.fa-tachometer-alt-fastest:before{content:"\f626"}.fa-tachometer-alt-slow:before{content:"\f627"}.fa-tachometer-alt-slowest:before{content:"\f628"}.fa-tachometer-average:before{content:"\f629"}.fa-tachometer-fast:before{content:"\f62a"}.fa-tachometer-fastest:before{content:"\f62b"}.fa-tachometer-slow:before{content:"\f62c"}.fa-tachometer-slowest:before{content:"\f62d"}.fa-taco:before{content:"\f826"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tally:before{content:"\f69c"}.fa-tanakh:before{content:"\f827"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-tasks-alt:before{content:"\f828"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-telescope:before{content:"\e03e"}.fa-temperature-down:before{content:"\e03f"}.fa-temperature-frigid:before{content:"\f768"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-hot:before{content:"\f76a"}.fa-temperature-low:before{content:"\f76b"}.fa-temperature-up:before{content:"\e040"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-tennis-ball:before{content:"\f45e"}.fa-terminal:before{content:"\f120"}.fa-text:before{content:"\f893"}.fa-text-height:before{content:"\f034"}.fa-text-size:before{content:"\f894"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-theta:before{content:"\f69e"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-thunderstorm:before{content:"\f76c"}.fa-thunderstorm-moon:before{content:"\f76d"}.fa-thunderstorm-sun:before{content:"\f76e"}.fa-ticket:before{content:"\f145"}.fa-ticket-alt:before{content:"\f3ff"}.fa-tiktok:before{content:"\e07b"}.fa-tilde:before{content:"\f69f"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-times-hexagon:before{content:"\f2ee"}.fa-times-octagon:before{content:"\f2f0"}.fa-times-square:before{content:"\f2d3"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tire:before{content:"\f631"}.fa-tire-flat:before{content:"\f632"}.fa-tire-pressure-warning:before{content:"\f633"}.fa-tire-rugged:before{content:"\f634"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-alt:before{content:"\f71f"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-tombstone:before{content:"\f720"}.fa-tombstone-alt:before{content:"\f721"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-toothbrush:before{content:"\f635"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tornado:before{content:"\f76f"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-cone:before{content:"\f636"}.fa-traffic-light:before{content:"\f637"}.fa-traffic-light-go:before{content:"\f638"}.fa-traffic-light-slow:before{content:"\f639"}.fa-traffic-light-stop:before{content:"\f63a"}.fa-trailer:before{content:"\e041"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-transporter:before{content:"\e042"}.fa-transporter-1:before{content:"\e043"}.fa-transporter-2:before{content:"\e044"}.fa-transporter-3:before{content:"\e045"}.fa-transporter-empty:before{content:"\e046"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-trash-undo:before{content:"\f895"}.fa-trash-undo-alt:before{content:"\f896"}.fa-treasure-chest:before{content:"\f723"}.fa-tree:before{content:"\f1bb"}.fa-tree-alt:before{content:"\f400"}.fa-tree-christmas:before{content:"\f7db"}.fa-tree-decorated:before{content:"\f7dc"}.fa-tree-large:before{content:"\f7dd"}.fa-tree-palm:before{content:"\f82b"}.fa-trees:before{content:"\f724"}.fa-trello:before{content:"\f181"}.fa-triangle:before{content:"\f2ec"}.fa-triangle-music:before{content:"\f8e2"}.fa-trophy:before{content:"\f091"}.fa-trophy-alt:before{content:"\f2eb"}.fa-truck:before{content:"\f0d1"}.fa-truck-container:before{content:"\f4dc"}.fa-truck-couch:before{content:"\f4dd"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-truck-plow:before{content:"\f7de"}.fa-truck-ramp:before{content:"\f4e0"}.fa-trumpet:before{content:"\f8e3"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-turkey:before{content:"\f725"}.fa-turntable:before{content:"\f8e4"}.fa-turtle:before{content:"\f726"}.fa-tv:before{content:"\f26c"}.fa-tv-alt:before{content:"\f8e5"}.fa-tv-music:before{content:"\f8e6"}.fa-tv-retro:before{content:"\f401"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typewriter:before{content:"\f8e7"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-ufo:before{content:"\e047"}.fa-ufo-beam:before{content:"\e048"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-uncharted:before{content:"\e084"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-unicorn:before{content:"\f727"}.fa-union:before{content:"\f6a2"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\e049"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-unsplash:before{content:"\e07c"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-usb-drive:before{content:"\f8e9"}.fa-usd-circle:before{content:"\f2e8"}.fa-usd-square:before{content:"\f2e9"}.fa-user:before{content:"\f007"}.fa-user-alien:before{content:"\e04a"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-chart:before{content:"\f6a3"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-cowboy:before{content:"\f8ea"}.fa-user-crown:before{content:"\f6a4"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-hard-hat:before{content:"\f82c"}.fa-user-headset:before{content:"\f82d"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-md-chat:before{content:"\f82e"}.fa-user-minus:before{content:"\f503"}.fa-user-music:before{content:"\f8eb"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-robot:before{content:"\e04b"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-user-unlock:before{content:"\e058"}.fa-user-visor:before{content:"\e04c"}.fa-users:before{content:"\f0c0"}.fa-users-class:before{content:"\f63d"}.fa-users-cog:before{content:"\f509"}.fa-users-crown:before{content:"\f6a5"}.fa-users-medical:before{content:"\f830"}.fa-users-slash:before{content:"\e073"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-fork:before{content:"\f2e3"}.fa-utensil-knife:before{content:"\f2e4"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-utensils-alt:before{content:"\f2e6"}.fa-vaadin:before{content:"\f408"}.fa-vacuum:before{content:"\e04d"}.fa-vacuum-robot:before{content:"\e04e"}.fa-value-absolute:before{content:"\f6a6"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-vest:before{content:"\e085"}.fa-vest-patches:before{content:"\e086"}.fa-vhs:before{content:"\f8ec"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-plus:before{content:"\f4e1"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-violin:before{content:"\f8ed"}.fa-virus:before{content:"\e074"}.fa-virus-slash:before{content:"\e075"}.fa-viruses:before{content:"\e076"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volcano:before{content:"\f770"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume:before{content:"\f6a8"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-slash:before{content:"\f2e2"}.fa-volume-up:before{content:"\f028"}.fa-vote-nay:before{content:"\f771"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-wagon-covered:before{content:"\f8ee"}.fa-walker:before{content:"\f831"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-wand:before{content:"\f72a"}.fa-wand-magic:before{content:"\f72b"}.fa-warehouse:before{content:"\f494"}.fa-warehouse-alt:before{content:"\f495"}.fa-washer:before{content:"\f898"}.fa-watch:before{content:"\f2e1"}.fa-watch-calculator:before{content:"\f8f0"}.fa-watch-fitness:before{content:"\f63e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-water:before{content:"\f773"}.fa-water-lower:before{content:"\f774"}.fa-water-rise:before{content:"\f775"}.fa-wave-sine:before{content:"\f899"}.fa-wave-square:before{content:"\f83e"}.fa-wave-triangle:before{content:"\f89a"}.fa-waveform:before{content:"\f8f1"}.fa-waveform-path:before{content:"\f8f2"}.fa-waze:before{content:"\f83f"}.fa-webcam:before{content:"\f832"}.fa-webcam-slash:before{content:"\f833"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whale:before{content:"\f72c"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheat:before{content:"\f72d"}.fa-wheelchair:before{content:"\f193"}.fa-whistle:before{content:"\f460"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wifi-1:before{content:"\f6aa"}.fa-wifi-2:before{content:"\f6ab"}.fa-wifi-slash:before{content:"\f6ac"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-wind-turbine:before{content:"\f89b"}.fa-wind-warning:before{content:"\f776"}.fa-window:before{content:"\f40e"}.fa-window-alt:before{content:"\f40f"}.fa-window-close:before{content:"\f410"}.fa-window-frame:before{content:"\e04f"}.fa-window-frame-open:before{content:"\e050"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-windsock:before{content:"\f777"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wodu:before{content:"\e088"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wreath:before{content:"\f7e2"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}
\ No newline at end of file
diff --git a/public/css/solid.min.css b/public/css/solid.min.css
deleted file mode 100644
index 229ccc1..0000000
--- a/public/css/solid.min.css
+++ /dev/null
@@ -1,5 +0,0 @@
-/*!
- * Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license (Commercial License)
- */
-@font-face{font-family:"Font Awesome 5 Pro";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Pro";font-weight:900}
\ No newline at end of file
diff --git a/public/css/style.css b/public/css/style.css
index bc394a1..ef89b65 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -445,6 +445,10 @@ article header time {
font-family: "Helvetica Neue", "Verdana", sans-serif;
}
+.permalink {
+ padding-left: 8px;
+}
+
p.fin {
text-align: center;
color: #c4c4c4;
diff --git a/public/css/syntax.css b/public/css/syntax.css
new file mode 100644
index 0000000..0d35462
--- /dev/null
+++ b/public/css/syntax.css
@@ -0,0 +1,97 @@
+.highlight table td { padding: 5px; }
+.highlight table pre { margin: 0; }
+.highlight, .highlight .w {
+ color: #fbf1c7;
+ background-color: #282828;
+}
+.highlight .err {
+ color: #fb4934;
+ background-color: #282828;
+ font-weight: bold;
+}
+.highlight .c, .highlight .ch, .highlight .cd, .highlight .cm, .highlight .cpf, .highlight .c1, .highlight .cs {
+ color: #928374;
+ font-style: italic;
+}
+.highlight .cp {
+ color: #8ec07c;
+}
+.highlight .nt {
+ color: #fb4934;
+}
+.highlight .o, .highlight .ow {
+ color: #fbf1c7;
+}
+.highlight .p, .highlight .pi {
+ color: #fbf1c7;
+}
+.highlight .gi {
+ color: #b8bb26;
+ background-color: #282828;
+}
+.highlight .gd {
+ color: #fb4934;
+ background-color: #282828;
+}
+.highlight .gh {
+ color: #b8bb26;
+ font-weight: bold;
+}
+.highlight .ge {
+ font-style: italic;
+}
+.highlight .ges {
+ font-weight: bold;
+ font-style: italic;
+}
+.highlight .gs {
+ font-weight: bold;
+}
+.highlight .k, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kv {
+ color: #fb4934;
+}
+.highlight .kc {
+ color: #d3869b;
+}
+.highlight .kt {
+ color: #fabd2f;
+}
+.highlight .kd {
+ color: #fe8019;
+}
+.highlight .s, .highlight .sb, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .sh, .highlight .sx, .highlight .s1 {
+ color: #b8bb26;
+ font-style: italic;
+}
+.highlight .si {
+ color: #b8bb26;
+ font-style: italic;
+}
+.highlight .sr {
+ color: #b8bb26;
+ font-style: italic;
+}
+.highlight .sa {
+ color: #fb4934;
+}
+.highlight .se {
+ color: #fe8019;
+}
+.highlight .nn {
+ color: #8ec07c;
+}
+.highlight .nc {
+ color: #8ec07c;
+}
+.highlight .no {
+ color: #d3869b;
+}
+.highlight .na {
+ color: #b8bb26;
+}
+.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .il, .highlight .mo, .highlight .mx {
+ color: #d3869b;
+}
+.highlight .ss {
+ color: #83a598;
+}
diff --git a/public/css/typocode.css b/public/css/typocode.css
deleted file mode 100644
index 1dce8dd..0000000
--- a/public/css/typocode.css
+++ /dev/null
@@ -1,181 +0,0 @@
-/* Syntax highlighting */
-.typocode_ruby .normal {}
-
-.typocode_ruby .comment {
- color: #005;
- font-style: italic;
-}
-
-.typocode_ruby .keyword {
- color: #A00;
- font-weight: bold;
-}
-
-.typocode_ruby .method {
- color: #077;
-}
-
-.typocode_ruby .class {
- color: #074;
-}
-
-.typocode_ruby .module {
- color: #050;
-}
-
-.typocode_ruby .punct {
- color: #447;
- font-weight: bold;
-}
-
-.typocode_ruby .symbol {
- color: #099;
-}
-
-.typocode_ruby .string {
- color: #944;
- background: #FFE;
-}
-
-.typocode_ruby .char {
- color: #F07;
-}
-
-.typocode_ruby .ident {
- color: #004;
-}
-
-.typocode_ruby .constant {
- color: #07F;
-}
-
-.typocode_ruby .regex {
- color: #B66;
- background: #FEF;
-}
-
-.typocode_ruby .number {
- color: #F99;
-}
-
-.typocode_ruby .attribute {
- color: #7BB;
-}
-
-.typocode_ruby .global {
- color: #7FB;
-}
-
-.typocode_ruby .expr {
- color: #227;
-}
-
-.typocode_ruby .escape {
- color: #277;
-}
-
-.typocode_xml .normal {}
-
-.typocode_xml .namespace {
- color: #B66;
- font-weight: bold;
-}
-
-.typocode_xml .tag {
- color: #F88;
-}
-
-.typocode_xml .comment {
- color: #005;
- font-style: italic;
-}
-
-.typocode_xml .punct {
- color: #447;
- font-weight: bold;
-}
-
-.typocode_xml .string {
- color: #944;
-}
-
-.typocode_xml .number {
- color: #F99;
-}
-
-.typocode_xml .attribute {
- color: #BB7;
-}
-
-.typocode_yaml .normal {}
-
-.typocode_yaml .document {
- font-weight: bold;
- color: #07F;
-}
-
-.typocode_yaml .type {
- font-weight: bold;
- color: #05C;
-}
-
-.typocode_yaml .key {
- color: #F88;
-}
-
-.typocode_yaml .comment {
- color: #005;
- font-style: italic;
-}
-
-.typocode_yaml .punct {
- color: #447;
- font-weight: bold;
-}
-
-.typocode_yaml .string {
- color: #944;
-}
-
-.typocode_yaml .number {
- color: #F99;
-}
-
-.typocode_yaml .time {
- color: #F99;
-}
-
-.typocode_yaml .date {
- color: #F99;
-}
-
-.typocode_yaml .ref {
- color: #944;
-}
-
-.typocode_yaml .anchor {
- color: #944;
-}
-
-.typocode {
- background-color: #eee;
- padding: 2px;
- margin: 5px;
-}
-
-.typocode pre {
- padding: 0.2rem 0.5rem;
- margin: 0;
- border: none;
- background: transparent;
- font-family: 'Fira Code', 'JetBrains Mono', 'Meslo LG M', 'Inconsolata', 'Menlo', 'Courier New', monospace;
- overflow: auto;
-}
-
-
-.typocode .lineno {
- text-align: right;
- color: #B00;
- font-family: 'Fira Code', 'JetBrains Mono', 'Meslo LG M', 'Inconsolata', 'Menlo', 'Courier New', monospace;
- padding-right: 1rem;
-}
\ No newline at end of file
diff --git a/public/webfonts/fa-brands-400.eot b/public/webfonts/fa-brands-400.eot
deleted file mode 100644
index 1ee7a56..0000000
Binary files a/public/webfonts/fa-brands-400.eot and /dev/null differ
diff --git a/public/webfonts/fa-brands-400.svg b/public/webfonts/fa-brands-400.svg
deleted file mode 100644
index 1345151..0000000
--- a/public/webfonts/fa-brands-400.svg
+++ /dev/null
@@ -1,3717 +0,0 @@
-
-
-
-
-Created by FontForge 20201107 at Wed Aug 4 12:24:13 2021
- By Robert Madole
-Copyright (c) Font Awesome
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/webfonts/fa-brands-400.ttf b/public/webfonts/fa-brands-400.ttf
deleted file mode 100644
index 032f907..0000000
Binary files a/public/webfonts/fa-brands-400.ttf and /dev/null differ
diff --git a/public/webfonts/fa-brands-400.woff b/public/webfonts/fa-brands-400.woff
deleted file mode 100644
index e6a44f8..0000000
Binary files a/public/webfonts/fa-brands-400.woff and /dev/null differ
diff --git a/public/webfonts/fa-brands-400.woff2 b/public/webfonts/fa-brands-400.woff2
deleted file mode 100644
index c851402..0000000
Binary files a/public/webfonts/fa-brands-400.woff2 and /dev/null differ
diff --git a/public/webfonts/fa-duotone-900.eot b/public/webfonts/fa-duotone-900.eot
deleted file mode 100644
index b4a9aa8..0000000
Binary files a/public/webfonts/fa-duotone-900.eot and /dev/null differ
diff --git a/public/webfonts/fa-duotone-900.svg b/public/webfonts/fa-duotone-900.svg
deleted file mode 100644
index 231438e..0000000
--- a/public/webfonts/fa-duotone-900.svg
+++ /dev/null
@@ -1,15328 +0,0 @@
-
-
-
-
-Created by FontForge 20201107 at Wed Aug 4 12:24:16 2021
- By Robert Madole
-Copyright (c) Font Awesome
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/webfonts/fa-duotone-900.ttf b/public/webfonts/fa-duotone-900.ttf
deleted file mode 100644
index bc8c92b..0000000
Binary files a/public/webfonts/fa-duotone-900.ttf and /dev/null differ
diff --git a/public/webfonts/fa-duotone-900.woff b/public/webfonts/fa-duotone-900.woff
deleted file mode 100644
index 09cc0ba..0000000
Binary files a/public/webfonts/fa-duotone-900.woff and /dev/null differ
diff --git a/public/webfonts/fa-duotone-900.woff2 b/public/webfonts/fa-duotone-900.woff2
deleted file mode 100644
index ac0d45b..0000000
Binary files a/public/webfonts/fa-duotone-900.woff2 and /dev/null differ
diff --git a/public/webfonts/fa-light-300.eot b/public/webfonts/fa-light-300.eot
deleted file mode 100644
index fd2cfc7..0000000
Binary files a/public/webfonts/fa-light-300.eot and /dev/null differ
diff --git a/public/webfonts/fa-light-300.svg b/public/webfonts/fa-light-300.svg
deleted file mode 100644
index 666198f..0000000
--- a/public/webfonts/fa-light-300.svg
+++ /dev/null
@@ -1,12423 +0,0 @@
-
-
-
-
-Created by FontForge 20201107 at Wed Aug 4 12:24:17 2021
- By Robert Madole
-Copyright (c) Font Awesome
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/webfonts/fa-light-300.ttf b/public/webfonts/fa-light-300.ttf
deleted file mode 100644
index ea1f36c..0000000
Binary files a/public/webfonts/fa-light-300.ttf and /dev/null differ
diff --git a/public/webfonts/fa-light-300.woff b/public/webfonts/fa-light-300.woff
deleted file mode 100644
index da6981a..0000000
Binary files a/public/webfonts/fa-light-300.woff and /dev/null differ
diff --git a/public/webfonts/fa-light-300.woff2 b/public/webfonts/fa-light-300.woff2
deleted file mode 100644
index fbdeaaa..0000000
Binary files a/public/webfonts/fa-light-300.woff2 and /dev/null differ
diff --git a/public/webfonts/fa-regular-400.eot b/public/webfonts/fa-regular-400.eot
deleted file mode 100644
index c0bf92e..0000000
Binary files a/public/webfonts/fa-regular-400.eot and /dev/null differ
diff --git a/public/webfonts/fa-regular-400.svg b/public/webfonts/fa-regular-400.svg
deleted file mode 100644
index 71b2f7c..0000000
--- a/public/webfonts/fa-regular-400.svg
+++ /dev/null
@@ -1,11323 +0,0 @@
-
-
-
-
-Created by FontForge 20201107 at Wed Aug 4 12:24:14 2021
- By Robert Madole
-Copyright (c) Font Awesome
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/webfonts/fa-regular-400.ttf b/public/webfonts/fa-regular-400.ttf
deleted file mode 100644
index 2d03c19..0000000
Binary files a/public/webfonts/fa-regular-400.ttf and /dev/null differ
diff --git a/public/webfonts/fa-regular-400.woff b/public/webfonts/fa-regular-400.woff
deleted file mode 100644
index 012f42c..0000000
Binary files a/public/webfonts/fa-regular-400.woff and /dev/null differ
diff --git a/public/webfonts/fa-regular-400.woff2 b/public/webfonts/fa-regular-400.woff2
deleted file mode 100644
index 70fc754..0000000
Binary files a/public/webfonts/fa-regular-400.woff2 and /dev/null differ
diff --git a/public/webfonts/fa-solid-900.eot b/public/webfonts/fa-solid-900.eot
deleted file mode 100644
index ddbd2a5..0000000
Binary files a/public/webfonts/fa-solid-900.eot and /dev/null differ
diff --git a/public/webfonts/fa-solid-900.svg b/public/webfonts/fa-solid-900.svg
deleted file mode 100644
index b068060..0000000
--- a/public/webfonts/fa-solid-900.svg
+++ /dev/null
@@ -1,9653 +0,0 @@
-
-
-
-
-Created by FontForge 20201107 at Wed Aug 4 12:24:16 2021
- By Robert Madole
-Copyright (c) Font Awesome
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/webfonts/fa-solid-900.ttf b/public/webfonts/fa-solid-900.ttf
deleted file mode 100644
index e6330e6..0000000
Binary files a/public/webfonts/fa-solid-900.ttf and /dev/null differ
diff --git a/public/webfonts/fa-solid-900.woff b/public/webfonts/fa-solid-900.woff
deleted file mode 100644
index 45f5cd5..0000000
Binary files a/public/webfonts/fa-solid-900.woff and /dev/null differ
diff --git a/public/webfonts/fa-solid-900.woff2 b/public/webfonts/fa-solid-900.woff2
deleted file mode 100644
index dff46ed..0000000
Binary files a/public/webfonts/fa-solid-900.woff2 and /dev/null differ
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/site.toml b/site.toml
new file mode 100644
index 0000000..6c906e3
--- /dev/null
+++ b/site.toml
@@ -0,0 +1,18 @@
+author = "Sami Samhuri"
+email = "sami@samhuri.net"
+title = "samhuri.net"
+description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days."
+url = "https://samhuri.net"
+image_url = "/images/me.jpg"
+scripts = []
+styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"]
+plugins = ["posts", "projects"]
+
+[projects_plugin]
+scripts = [
+ "https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js",
+ "/js/gitter.js",
+ "/js/store.js",
+ "/js/projects.js"
+]
+styles = []
diff --git a/test/config/loader_test.rb b/test/config/loader_test.rb
new file mode 100644
index 0000000..4a4df45
--- /dev/null
+++ b/test/config/loader_test.rb
@@ -0,0 +1,391 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::Config::LoaderTest < Minitest::Test
+ def test_build_site_builds_a_site_from_site_toml_and_projects_toml
+ with_temp_config do |dir|
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ site = loader.build_site
+
+ assert_equal("Sami Samhuri", site.author)
+ assert_equal("https://samhuri.net", site.url)
+ assert_equal("https://samhuri.net/images/me.jpg", site.image_url)
+ assert_equal(["/css/style.css"], site.styles.map(&:href))
+
+ projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) }
+ refute_nil(projects_plugin)
+ assert_equal(["/js/projects.js"], projects_plugin.scripts.map(&:src))
+ end
+ end
+
+ def test_build_site_applies_url_override_and_rewrites_relative_image_url_with_override_host
+ with_temp_config do |dir|
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ site = loader.build_site(url_override: "https://beta.samhuri.net")
+
+ assert_equal("https://beta.samhuri.net", site.url)
+ assert_equal("https://beta.samhuri.net/images/me.jpg", site.image_url)
+ end
+ end
+
+ def test_build_site_raises_a_validation_error_for_missing_required_site_keys
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), "title = \"x\"\n")
+ File.write(File.join(dir, "projects.toml"), "")
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Missing required site\.toml keys/, error.message)
+ end
+ end
+
+ def test_build_site_defaults_to_no_plugins_when_plugins_key_is_missing
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ site = loader.build_site
+ assert_empty(site.plugins)
+ end
+ end
+
+ def test_build_site_raises_for_invalid_plugins_type
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = "posts"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml plugins to be an array/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_unknown_plugin_name
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["wat"]
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Unknown plugin 'wat'/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_empty_plugin_name
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = [""]
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml plugins\[0\] to be a non-empty String/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_duplicate_plugins
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["posts", "posts"]
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Duplicate plugin 'posts' in site\.toml plugins/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_missing_projects_array
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["projects"]
+ TOML
+ File.write(File.join(dir, "projects.toml"), "title = \"no projects\"\n")
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Missing required top-level array 'projects'/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_invalid_project_entries
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["projects"]
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ projects = [1]
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Project entry 1 must be a table/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_invalid_projects_plugin_type
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["projects"]
+ projects_plugin = []
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml projects_plugin to be a table/, error.message)
+ end
+ end
+
+ def test_build_site_raises_for_invalid_script_and_style_entries
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ scripts = [{}]
+ styles = [123]
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ script_error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml scripts\[0\]\.src to be a String/, script_error.message)
+
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ scripts = []
+ styles = [123]
+ TOML
+ style_error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml styles\[0\] to be a String or table/, style_error.message)
+ end
+ end
+
+ def test_build_site_accepts_script_hashes_and_absolute_image_url
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ image_url = "https://images.example.net/me.jpg"
+ scripts = [{"src": "/js/site.js", "defer": false}]
+ styles = [{"href": "/css/site.css"}]
+ plugins = ["posts", "projects"]
+
+ [projects_plugin]
+ scripts = [{"src": "/js/projects.js", "defer": true}]
+ styles = [{"href": "/css/projects.css"}]
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ site = loader.build_site
+
+ assert_equal("https://images.example.net/me.jpg", site.image_url)
+ assert_equal(["/js/site.js"], site.scripts.map(&:src))
+ assert_equal([false], site.scripts.map(&:defer))
+ assert_equal(["/css/site.css"], site.styles.map(&:href))
+ end
+ end
+
+ def test_build_site_rewraps_toml_parse_errors_as_validation_errors
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), "author = \"unterminated\n")
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Unterminated value for key 'author'/, error.message)
+ end
+ end
+
+ def test_build_site_rejects_non_boolean_defer_values
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ scripts = [{"src": "/js/site.js", "defer": "yes"}]
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml scripts\[0\]\.defer to be a Boolean/, error.message)
+ end
+ end
+
+ def test_build_site_rejects_non_string_or_table_scripts_and_non_array_script_lists
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ scripts = [123]
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ invalid_item = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml scripts\[0\] to be a String or table/, invalid_item.message)
+
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ plugins = ["projects"]
+
+ [projects_plugin]
+ scripts = "js/projects.js"
+ TOML
+ non_array = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(/Expected site\.toml projects_plugin\.scripts to be an array/, non_array.message)
+ end
+ end
+
+ def test_build_site_rejects_non_absolute_local_asset_paths
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ scripts = ["js/site.js"]
+ styles = ["css/site.css"]
+ TOML
+ File.write(File.join(dir, "projects.toml"), "")
+
+ loader = Pressa::Config::Loader.new(source_path: dir)
+ error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
+ assert_match(%r{start with / or use http\(s\) scheme}, error.message)
+ end
+ end
+
+ private
+
+ def with_temp_config
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ image_url = "/images/me.jpg"
+ scripts = []
+ styles = ["/css/style.css"]
+ plugins = ["posts", "projects"]
+
+ [projects_plugin]
+ scripts = ["/js/projects.js"]
+ styles = []
+ TOML
+
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ yield dir
+ end
+ end
+end
diff --git a/test/config/simple_toml_test.rb b/test/config/simple_toml_test.rb
new file mode 100644
index 0000000..582b82f
--- /dev/null
+++ b/test/config/simple_toml_test.rb
@@ -0,0 +1,147 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::Config::SimpleTomlTest < Minitest::Test
+ def parser
+ @parser ||= Pressa::Config::SimpleToml.new
+ end
+
+ def test_load_file_raises_parse_error_for_missing_file
+ Dir.mktmpdir do |dir|
+ missing = File.join(dir, "missing.toml")
+
+ error = assert_raises(Pressa::Config::ParseError) do
+ Pressa::Config::SimpleToml.load_file(missing)
+ end
+
+ assert_match(/Config file not found/, error.message)
+ end
+ end
+
+ def test_parse_supports_tables_array_tables_comments_and_multiline_arrays
+ content = <<~TOML
+ title = "samhuri # not a comment"
+ [projects_plugin]
+ scripts = ["js/a.js", "js/b.js"]
+ styles = [
+ "css/a.css",
+ "css/b.css"
+ ]
+
+ [[projects]]
+ name = "alpha"
+ title = "Alpha"
+ description = "Project Alpha"
+ url = "https://github.com/samsonjs/alpha"
+
+ [[projects]]
+ name = "beta"
+ title = "Beta"
+ description = "Project Beta"
+ url = "https://github.com/samsonjs/beta"
+ TOML
+
+ parsed = parser.parse(content)
+
+ assert_equal("samhuri # not a comment", parsed["title"])
+ assert_equal(["js/a.js", "js/b.js"], parsed.dig("projects_plugin", "scripts"))
+ assert_equal(["css/a.css", "css/b.css"], parsed.dig("projects_plugin", "styles"))
+ assert_equal(2, parsed["projects"].length)
+ assert_equal("alpha", parsed["projects"][0]["name"])
+ assert_equal("beta", parsed["projects"][1]["name"])
+ end
+
+ def test_parse_rejects_duplicate_keys
+ content = <<~TOML
+ author = "Sami"
+ author = "Sam"
+ TOML
+
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse(content) }
+ assert_match(/Duplicate key 'author'/, error.message)
+ end
+
+ def test_parse_rejects_invalid_assignment
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse("invalid") }
+ assert_match(/Invalid assignment/, error.message)
+ end
+
+ def test_parse_rejects_invalid_key_names
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse("bad-key = 1") }
+ assert_match(/Invalid key/, error.message)
+ end
+
+ def test_parse_rejects_missing_value
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse("author = ") }
+ assert_match(/Missing value for key 'author'/, error.message)
+ end
+
+ def test_parse_rejects_invalid_table_paths
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse("[projects..plugin]") }
+ assert_match(/Invalid table path/, error.message)
+ end
+
+ def test_parse_rejects_array_table_when_table_already_exists
+ content = <<~TOML
+ [projects]
+ title = "single"
+ [[projects]]
+ title = "array item"
+ TOML
+
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse(content) }
+ assert_match(/Expected array for '\[\[projects\]\]'/, error.message)
+ end
+
+ def test_parse_rejects_nested_table_on_non_table_path
+ content = <<~TOML
+ projects = 1
+ [projects.plugin]
+ enabled = true
+ TOML
+
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse(content) }
+ assert_match(/Expected table path 'projects.plugin'/, error.message)
+ end
+
+ def test_parse_rejects_unsupported_value_types
+ error = assert_raises(Pressa::Config::ParseError) do
+ parser.parse("published_at = 2025-01-01")
+ end
+
+ assert_match(/Unsupported TOML value/, error.message)
+ end
+
+ def test_parse_rejects_unterminated_multiline_value
+ content = <<~TOML
+ scripts = [
+ "a.js",
+ "b.js"
+ TOML
+
+ error = assert_raises(Pressa::Config::ParseError) { parser.parse(content) }
+ assert_match(/Unterminated value for key 'scripts'/, error.message)
+ end
+
+ def test_parse_ignores_comments_but_not_hashes_inside_strings
+ content = <<~TOML
+ url = "https://example.com/#anchor" # remove me
+ TOML
+
+ parsed = parser.parse(content)
+ assert_equal("https://example.com/#anchor", parsed["url"])
+ end
+
+ def test_private_parsing_helpers_handle_escaped_quotes_inside_strings
+ refute(parser.send(:needs_continuation?, "\"a\\\\\\\"b\""))
+
+ stripped = parser.send(:strip_comments, "title = \"a\\\\\\\"b # keep\" # drop\n")
+ assert_equal("title = \"a\\\\\\\"b # keep\" ", stripped)
+
+ source = "\"a\\\\\\\"=b\" = 1"
+ index = parser.send(:index_of_unquoted, source, "=")
+ refute_nil(index)
+ assert_equal("=", source[index])
+ assert(index > source.rindex('"'))
+ end
+end
diff --git a/test/plugin_test.rb b/test/plugin_test.rb
new file mode 100644
index 0000000..15c6fbf
--- /dev/null
+++ b/test/plugin_test.rb
@@ -0,0 +1,23 @@
+require "test_helper"
+
+class Pressa::PluginTest < Minitest::Test
+ def test_setup_requires_subclass_implementation
+ plugin = Pressa::Plugin.new
+
+ error = assert_raises(NotImplementedError) do
+ plugin.setup(site: Object.new, source_path: "/tmp/source")
+ end
+
+ assert_match(/Pressa::Plugin#setup must be implemented/, error.message)
+ end
+
+ def test_render_requires_subclass_implementation
+ plugin = Pressa::Plugin.new
+
+ error = assert_raises(NotImplementedError) do
+ plugin.render(site: Object.new, target_path: "/tmp/target")
+ end
+
+ assert_match(/Pressa::Plugin#render must be implemented/, error.message)
+ end
+end
diff --git a/test/posts/json_feed_test.rb b/test/posts/json_feed_test.rb
new file mode 100644
index 0000000..6325432
--- /dev/null
+++ b/test/posts/json_feed_test.rb
@@ -0,0 +1,115 @@
+require "test_helper"
+require "json"
+require "tmpdir"
+
+class Pressa::Posts::JSONFeedWriterTest < Minitest::Test
+ class PostsByYearStub
+ attr_accessor :posts
+
+ def initialize(posts)
+ @posts = posts
+ end
+
+ def recent_posts(_limit = 30)
+ @posts
+ end
+ end
+
+ def setup
+ @site = Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ image_url: "https://samhuri.net/images/me.jpg"
+ )
+
+ @posts_by_year = PostsByYearStub.new([link_post])
+ @writer = Pressa::Posts::JSONFeedWriter.new(site: @site, posts_by_year: @posts_by_year)
+ end
+
+ def test_write_feed_for_link_posts_uses_permalink_as_url_and_keeps_external_url
+ Dir.mktmpdir do |dir|
+ @writer.write_feed(target_path: dir, limit: 30)
+ feed = JSON.parse(File.read(File.join(dir, "feed.json")))
+ item = feed.fetch("items").first
+
+ assert_equal("https://samhuri.net/posts/2015/05/github-flow-like-a-pro", item.fetch("id"))
+ assert_equal("https://samhuri.net/posts/2015/05/github-flow-like-a-pro", item.fetch("url"))
+ assert_equal("http://haacked.com/archive/2014/07/28/github-flow-aliases/", item.fetch("external_url"))
+ end
+ end
+
+ def test_write_feed_for_regular_posts_omits_external_url
+ @posts_by_year.posts = [regular_post]
+
+ Dir.mktmpdir do |dir|
+ @writer.write_feed(target_path: dir, limit: 30)
+ feed = JSON.parse(File.read(File.join(dir, "feed.json")))
+ item = feed.fetch("items").first
+
+ assert_equal("https://samhuri.net/posts/2017/10/swift-optional-or", item.fetch("url"))
+ refute(item.key?("external_url"))
+ end
+ end
+
+ def test_write_feed_expands_root_relative_links_in_content_html
+ @posts_by_year.posts = [post_with_assets]
+
+ Dir.mktmpdir do |dir|
+ @writer.write_feed(target_path: dir, limit: 30)
+ feed = JSON.parse(File.read(File.join(dir, "feed.json")))
+ item = feed.fetch("items").first
+ content_html = item.fetch("content_html")
+
+ assert_includes(content_html, 'href="https://samhuri.net/posts/2010/01/basics-of-the-mach-o-file-format"')
+ assert_includes(content_html, 'src="https://samhuri.net/images/me.jpg"')
+ assert_includes(content_html, 'href="//cdn.example.net/app.js"')
+ end
+ end
+
+ private
+
+ def link_post
+ Pressa::Posts::Post.new(
+ slug: "github-flow-like-a-pro",
+ title: "GitHub Flow Like a Pro",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2015-05-28T07:42:27-07:00"),
+ formatted_date: "28th May, 2015",
+ link: "http://haacked.com/archive/2014/07/28/github-flow-aliases/",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2015/05/github-flow-like-a-pro"
+ )
+ end
+
+ def regular_post
+ Pressa::Posts::Post.new(
+ slug: "swift-optional-or",
+ title: "Swift Optional OR",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2017-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2017",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2017/10/swift-optional-or"
+ )
+ end
+
+ def post_with_assets
+ Pressa::Posts::Post.new(
+ slug: "swift-optional-or",
+ title: "Swift Optional OR",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2017-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2017",
+ body: 'read
' \
+ '
' \
+ 'cdn
',
+ excerpt: "hello...",
+ path: "/posts/2017/10/swift-optional-or"
+ )
+ end
+end
diff --git a/test/posts/metadata_test.rb b/test/posts/metadata_test.rb
new file mode 100644
index 0000000..2bd8a72
--- /dev/null
+++ b/test/posts/metadata_test.rb
@@ -0,0 +1,68 @@
+require "test_helper"
+
+class Pressa::Posts::PostMetadataTest < Minitest::Test
+ def test_parse_parses_valid_yaml_front_matter
+ content = <<~MARKDOWN
+ ---
+ Title: Test Post
+ Author: Trent Reznor
+ Date: 5th November, 2025
+ Timestamp: 2025-11-05T10:00:00-08:00
+ Tags: Ruby, Testing
+ Link: https://example.net/external
+ ---
+
+ This is the post body.
+ MARKDOWN
+
+ metadata = Pressa::Posts::PostMetadata.parse(content)
+
+ assert_equal("Test Post", metadata.title)
+ assert_equal("Trent Reznor", metadata.author)
+ assert_equal("5th November, 2025", metadata.formatted_date)
+ assert_equal(2025, metadata.date.year)
+ assert_equal(11, metadata.date.month)
+ assert_equal(5, metadata.date.day)
+ assert_equal("https://example.net/external", metadata.link)
+ assert_equal(["Ruby", "Testing"], metadata.tags)
+ end
+
+ def test_parse_raises_error_when_required_fields_are_missing
+ content = <<~MARKDOWN
+ ---
+ Title: Incomplete Post
+ ---
+
+ Body content
+ MARKDOWN
+
+ error = assert_raises(StandardError) { Pressa::Posts::PostMetadata.parse(content) }
+ assert_match(/Missing required fields/, error.message)
+ end
+
+ def test_parse_handles_posts_without_optional_fields
+ content = <<~MARKDOWN
+ ---
+ Title: Simple Post
+ Author: Fat Mike
+ Date: 1st January, 2025
+ Timestamp: 2025-01-01T12:00:00-08:00
+ ---
+
+ Simple content
+ MARKDOWN
+
+ metadata = Pressa::Posts::PostMetadata.parse(content)
+
+ assert_equal([], metadata.tags)
+ assert_nil(metadata.link)
+ end
+
+ def test_parse_raises_error_when_front_matter_is_missing
+ error = assert_raises(StandardError) do
+ Pressa::Posts::PostMetadata.parse("just plain markdown")
+ end
+
+ assert_match(/No YAML front-matter found in post/, error.message)
+ end
+end
diff --git a/test/posts/models_test.rb b/test/posts/models_test.rb
new file mode 100644
index 0000000..9acc302
--- /dev/null
+++ b/test/posts/models_test.rb
@@ -0,0 +1,82 @@
+require "test_helper"
+
+class Pressa::Posts::ModelsTest < Minitest::Test
+ def regular_post
+ @regular_post ||= Pressa::Posts::Post.new(
+ slug: "regular",
+ title: "Regular",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2025-11-05T10:00:00-08:00"),
+ formatted_date: "5th November, 2025",
+ body: "regular
",
+ excerpt: "regular...",
+ path: "/posts/2025/11/regular"
+ )
+ end
+
+ def link_post
+ @link_post ||= Pressa::Posts::Post.new(
+ slug: "linked",
+ title: "Linked",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2024-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2024",
+ link: "https://example.net/post",
+ body: "linked
",
+ excerpt: "linked...",
+ path: "/posts/2024/10/linked"
+ )
+ end
+
+ def test_post_helpers_report_date_parts_and_link_state
+ assert_equal(2025, regular_post.year)
+ assert_equal(11, regular_post.month)
+ assert_equal("November", regular_post.formatted_month)
+ assert_equal("11", regular_post.padded_month)
+ refute(regular_post.link_post?)
+ assert(link_post.link_post?)
+ end
+
+ def test_month_from_date_creates_expected_values
+ month = Pressa::Posts::Month.from_date(DateTime.parse("2025-02-14T08:00:00-08:00"))
+ assert_equal("February", month.name)
+ assert_equal(2, month.number)
+ assert_equal("02", month.padded)
+ end
+
+ def test_month_posts_sorted_posts_returns_descending_by_date
+ month_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "November", number: 11, padded: "11"),
+ posts: [link_post, regular_post]
+ )
+
+ assert_equal([regular_post, link_post], month_posts.sorted_posts)
+ end
+
+ def test_year_posts_and_posts_by_year_sorting_helpers
+ oct_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "October", number: 10, padded: "10"),
+ posts: [link_post]
+ )
+ nov_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "November", number: 11, padded: "11"),
+ posts: [regular_post]
+ )
+
+ year_2025 = Pressa::Posts::YearPosts.new(year: 2025, by_month: {11 => nov_posts, 10 => oct_posts})
+ year_2024 = Pressa::Posts::YearPosts.new(year: 2024, by_month: {10 => oct_posts})
+ posts_by_year = Pressa::Posts::PostsByYear.new(by_year: {2024 => year_2024, 2025 => year_2025})
+
+ assert_equal([11, 10], year_2025.sorted_months.map { |mp| mp.month.number })
+ assert_equal([regular_post, link_post], year_2025.all_posts)
+ assert_equal([2025, 2024], posts_by_year.sorted_years)
+ assert_equal(2024, posts_by_year.earliest_year)
+ assert_equal(3, posts_by_year.all_posts.length)
+ assert_equal([regular_post], posts_by_year.recent_posts(1))
+ end
+
+ def test_posts_by_year_earliest_year_is_nil_for_empty_collection
+ posts_by_year = Pressa::Posts::PostsByYear.new(by_year: {})
+ assert_nil(posts_by_year.earliest_year)
+ end
+end
diff --git a/test/posts/plugin_test.rb b/test/posts/plugin_test.rb
new file mode 100644
index 0000000..8caf419
--- /dev/null
+++ b/test/posts/plugin_test.rb
@@ -0,0 +1,67 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::Posts::PluginTest < Minitest::Test
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def test_setup_skips_when_posts_directory_does_not_exist
+ Dir.mktmpdir do |source_path|
+ plugin = Pressa::Posts::Plugin.new
+ plugin.setup(site:, source_path:)
+
+ assert_nil(plugin.posts_by_year)
+ end
+ end
+
+ def test_render_skips_when_setup_did_not_load_posts
+ Dir.mktmpdir do |target_path|
+ plugin = Pressa::Posts::Plugin.new
+ plugin.render(site:, target_path:)
+
+ refute(File.exist?(File.join(target_path, "index.html")))
+ refute(File.exist?(File.join(target_path, "feed.json")))
+ refute(File.exist?(File.join(target_path, "feed.xml")))
+ end
+ end
+
+ def test_setup_and_render_write_post_indexes_and_feeds
+ Dir.mktmpdir do |root|
+ source_path = File.join(root, "source")
+ target_path = File.join(root, "target")
+ posts_dir = File.join(source_path, "posts", "2025", "11")
+ FileUtils.mkdir_p(posts_dir)
+
+ File.write(File.join(posts_dir, "shredding.md"), <<~MARKDOWN)
+ ---
+ Title: Shredding in November
+ Author: Shaun White
+ Date: 5th November, 2025
+ Timestamp: 2025-11-05T10:00:00-08:00
+ ---
+
+ Had an epic day at Whistler. The powder was deep and the lines were short.
+ MARKDOWN
+
+ plugin = Pressa::Posts::Plugin.new
+ plugin.setup(site:, source_path:)
+ plugin.render(site:, target_path:)
+
+ assert(File.exist?(File.join(target_path, "index.html")))
+ assert(File.exist?(File.join(target_path, "posts/index.html")))
+ assert(File.exist?(File.join(target_path, "posts/2025/index.html")))
+ assert(File.exist?(File.join(target_path, "posts/2025/11/index.html")))
+ assert(File.exist?(File.join(target_path, "posts/2025/11/shredding/index.html")))
+ assert(File.exist?(File.join(target_path, "feed.json")))
+ assert(File.exist?(File.join(target_path, "feed.xml")))
+ end
+ end
+end
diff --git a/test/posts/repo_test.rb b/test/posts/repo_test.rb
new file mode 100644
index 0000000..6d60e25
--- /dev/null
+++ b/test/posts/repo_test.rb
@@ -0,0 +1,108 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::Posts::PostRepoTest < Minitest::Test
+ def repo
+ @repo ||= Pressa::Posts::PostRepo.new
+ end
+
+ def test_read_posts_reads_and_organizes_posts_by_year_and_month
+ Dir.mktmpdir do |tmpdir|
+ posts_dir = File.join(tmpdir, "posts", "2025", "11")
+ FileUtils.mkdir_p(posts_dir)
+
+ post_content = <<~MARKDOWN
+ ---
+ Title: Shredding in November
+ Author: Shaun White
+ Date: 5th November, 2025
+ Timestamp: 2025-11-05T10:00:00-08:00
+ ---
+
+ Had an epic day at Whistler. The powder was deep and the lines were short.
+ MARKDOWN
+
+ File.write(File.join(posts_dir, "shredding.md"), post_content)
+
+ posts_by_year = repo.read_posts(File.join(tmpdir, "posts"))
+
+ assert_equal(1, posts_by_year.all_posts.length)
+
+ post = posts_by_year.all_posts.first
+ assert_equal("Shredding in November", post.title)
+ assert_equal("Shaun White", post.author)
+ assert_equal("shredding", post.slug)
+ assert_equal(2025, post.year)
+ assert_equal(11, post.month)
+ assert_equal("/posts/2025/11/shredding", post.path)
+ end
+ end
+
+ def test_read_posts_generates_excerpts_from_post_content
+ Dir.mktmpdir do |tmpdir|
+ posts_dir = File.join(tmpdir, "posts", "2025", "11")
+ FileUtils.mkdir_p(posts_dir)
+
+ post_content = <<~MARKDOWN
+ ---
+ Title: Test Post
+ Author: Greg Graffin
+ Date: 5th November, 2025
+ Timestamp: 2025-11-05T10:00:00-08:00
+ ---
+
+ This is a test post with some content. It should generate an excerpt.
+
+ 
+
+ More content with a [link](https://example.net).
+ MARKDOWN
+
+ File.write(File.join(posts_dir, "test.md"), post_content)
+
+ posts_by_year = repo.read_posts(File.join(tmpdir, "posts"))
+ post = posts_by_year.all_posts.first
+
+ assert_includes(post.excerpt, "test post")
+ refute_includes(post.excerpt, "![")
+ assert_includes(post.excerpt, "link")
+ refute_includes(post.excerpt, "[link]")
+ end
+ end
+
+ def test_read_posts_merges_multiple_posts_in_same_month
+ Dir.mktmpdir do |tmpdir|
+ posts_dir = File.join(tmpdir, "posts", "2025", "11")
+ FileUtils.mkdir_p(posts_dir)
+
+ File.write(File.join(posts_dir, "first.md"), <<~MARKDOWN)
+ ---
+ Title: First Post
+ Author: Sami Samhuri
+ Date: 5th November, 2025
+ Timestamp: 2025-11-05T10:00:00-08:00
+ ---
+
+ First
+ MARKDOWN
+
+ File.write(File.join(posts_dir, "second.md"), <<~MARKDOWN)
+ ---
+ Title: Second Post
+ Author: Sami Samhuri
+ Date: 6th November, 2025
+ Timestamp: 2025-11-06T10:00:00-08:00
+ ---
+
+ Second
+ MARKDOWN
+
+ posts_by_year = repo.read_posts(File.join(tmpdir, "posts"))
+ month_posts = posts_by_year.by_year.fetch(2025).by_month.fetch(11)
+
+ assert_equal(2, month_posts.posts.length)
+ assert_equal(["Second Post", "First Post"], month_posts.sorted_posts.map(&:title))
+ end
+ end
+end
diff --git a/test/posts/rss_feed_test.rb b/test/posts/rss_feed_test.rb
new file mode 100644
index 0000000..a3b3155
--- /dev/null
+++ b/test/posts/rss_feed_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::Posts::RSSFeedWriterTest < Minitest::Test
+ class PostsByYearStub
+ attr_accessor :posts
+
+ def initialize(posts)
+ @posts = posts
+ end
+
+ def recent_posts(_limit = 30)
+ @posts
+ end
+ end
+
+ def setup
+ @site = Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ @posts_by_year = PostsByYearStub.new([link_post])
+ @writer = Pressa::Posts::RSSFeedWriter.new(site: @site, posts_by_year: @posts_by_year)
+ end
+
+ def test_write_feed_for_link_post_uses_arrow_title_permalink_and_content
+ Dir.mktmpdir do |dir|
+ @writer.write_feed(target_path: dir, limit: 30)
+ xml = File.read(File.join(dir, "feed.xml"))
+
+ assert_includes(xml, "→ GitHub Flow Like a Pro ")
+ assert_includes(xml, "https://samhuri.net/posts/2015/05/github-flow-like-a-pro ")
+ assert_includes(xml, "https://samhuri.net/feed.xml")
+ assert_includes(xml, "Sami Samhuri ")
+ assert_match(%r{\s*Swift Optional OR")
+ refute_includes(xml, "→ Swift Optional OR")
+ end
+ end
+
+ def test_write_feed_without_posts_skips_channel_pub_date
+ @posts_by_year.posts = []
+
+ Dir.mktmpdir do |dir|
+ @writer.write_feed(target_path: dir, limit: 30)
+ xml = File.read(File.join(dir, "feed.xml"))
+
+ assert_includes(xml, "")
+ refute_match(%r{.*?.*? }m, xml)
+ end
+ end
+
+ private
+
+ def link_post
+ Pressa::Posts::Post.new(
+ slug: "github-flow-like-a-pro",
+ title: "GitHub Flow Like a Pro",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2015-05-28T07:42:27-07:00"),
+ formatted_date: "28th May, 2015",
+ link: "http://haacked.com/archive/2014/07/28/github-flow-aliases/",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2015/05/github-flow-like-a-pro"
+ )
+ end
+
+ def regular_post
+ Pressa::Posts::Post.new(
+ slug: "swift-optional-or",
+ title: "Swift Optional OR",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2017-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2017",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2017/10/swift-optional-or"
+ )
+ end
+end
diff --git a/test/posts/writer_test.rb b/test/posts/writer_test.rb
new file mode 100644
index 0000000..15fd95f
--- /dev/null
+++ b/test/posts/writer_test.rb
@@ -0,0 +1,123 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::Posts::PostWriterTest < Minitest::Test
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def posts_by_year
+ @posts_by_year ||= begin
+ link_post = Pressa::Posts::Post.new(
+ slug: "link-post",
+ title: "Linked",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2025-11-05T10:00:00-08:00"),
+ formatted_date: "5th November, 2025",
+ link: "https://example.net/linked",
+ body: "linked body
",
+ excerpt: "linked body...",
+ path: "/posts/2025/11/link-post"
+ )
+ regular_post = Pressa::Posts::Post.new(
+ slug: "regular-post",
+ title: "Regular",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2024-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2024",
+ body: "regular body
",
+ excerpt: "regular body...",
+ path: "/posts/2024/10/regular-post"
+ )
+
+ nov_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "November", number: 11, padded: "11"),
+ posts: [link_post]
+ )
+ oct_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "October", number: 10, padded: "10"),
+ posts: [regular_post]
+ )
+
+ Pressa::Posts::PostsByYear.new(
+ by_year: {
+ 2025 => Pressa::Posts::YearPosts.new(year: 2025, by_month: {11 => nov_posts}),
+ 2024 => Pressa::Posts::YearPosts.new(year: 2024, by_month: {10 => oct_posts})
+ }
+ )
+ end
+ end
+
+ def writer
+ @writer ||= Pressa::Posts::PostWriter.new(site:, posts_by_year:)
+ end
+
+ def test_write_posts_writes_each_post_page
+ Dir.mktmpdir do |dir|
+ writer.write_posts(target_path: dir)
+
+ regular = File.join(dir, "posts/2024/10/regular-post/index.html")
+ linked = File.join(dir, "posts/2025/11/link-post/index.html")
+
+ assert(File.exist?(regular))
+ assert(File.exist?(linked))
+ assert_includes(File.read(regular), "Regular")
+ assert_includes(File.read(linked), "→ Linked")
+ end
+ end
+
+ def test_write_recent_posts_writes_index_page
+ Dir.mktmpdir do |dir|
+ writer.write_recent_posts(target_path: dir, limit: 1)
+
+ index_path = File.join(dir, "index.html")
+ assert(File.exist?(index_path))
+ html = File.read(index_path)
+ assert_includes(html, "Linked")
+ refute_includes(html, "Regular")
+ end
+ end
+
+ def test_write_archive_writes_archive_index
+ Dir.mktmpdir do |dir|
+ writer.write_archive(target_path: dir)
+
+ archive_path = File.join(dir, "posts/index.html")
+ assert(File.exist?(archive_path))
+ html = File.read(archive_path)
+ assert_includes(html, "Archive")
+ assert_includes(html, "https://samhuri.net/posts/2025/")
+ end
+ end
+
+ def test_write_year_indexes_writes_each_year_index
+ Dir.mktmpdir do |dir|
+ writer.write_year_indexes(target_path: dir)
+
+ path_2025 = File.join(dir, "posts/2025/index.html")
+ path_2024 = File.join(dir, "posts/2024/index.html")
+ assert(File.exist?(path_2025))
+ assert(File.exist?(path_2024))
+ assert_includes(File.read(path_2025), "November")
+ end
+ end
+
+ def test_write_month_rollups_writes_each_month_index
+ Dir.mktmpdir do |dir|
+ writer.write_month_rollups(target_path: dir)
+
+ nov = File.join(dir, "posts/2025/11/index.html")
+ oct = File.join(dir, "posts/2024/10/index.html")
+ assert(File.exist?(nov))
+ assert(File.exist?(oct))
+ assert_includes(File.read(nov), "November 2025")
+ assert_includes(File.read(oct), "October 2024")
+ end
+ end
+end
diff --git a/test/projects/models_test.rb b/test/projects/models_test.rb
new file mode 100644
index 0000000..0fc2cb7
--- /dev/null
+++ b/test/projects/models_test.rb
@@ -0,0 +1,15 @@
+require "test_helper"
+
+class Pressa::Projects::ModelsTest < Minitest::Test
+ def test_project_helpers_compute_paths
+ project = Pressa::Projects::Project.new(
+ name: "demo",
+ title: "Demo",
+ description: "Demo project",
+ url: "https://github.com/samsonjs/demo"
+ )
+
+ assert_equal("samsonjs/demo", project.github_path)
+ assert_equal("/projects/demo", project.path)
+ end
+end
diff --git a/test/projects/plugin_test.rb b/test/projects/plugin_test.rb
new file mode 100644
index 0000000..708f241
--- /dev/null
+++ b/test/projects/plugin_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::Projects::PluginTest < Minitest::Test
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def project
+ @project ||= Pressa::Projects::Project.new(
+ name: "demo",
+ title: "Demo",
+ description: "Demo project",
+ url: "https://github.com/samsonjs/demo"
+ )
+ end
+
+ def test_setup_is_a_no_op
+ plugin = Pressa::Projects::Plugin.new(projects: [project])
+ assert_nil(plugin.setup(site:, source_path: "/tmp/unused"))
+ end
+
+ def test_render_writes_projects_index_and_project_page
+ plugin = Pressa::Projects::Plugin.new(
+ projects: [project],
+ scripts: [Pressa::Script.new(src: "js/projects.js", defer: false)],
+ styles: [Pressa::Stylesheet.new(href: "css/projects.css")]
+ )
+
+ Dir.mktmpdir do |dir|
+ plugin.render(site:, target_path: dir)
+
+ index_path = File.join(dir, "projects/index.html")
+ project_path = File.join(dir, "projects/demo/index.html")
+
+ assert(File.exist?(index_path))
+ assert(File.exist?(project_path))
+
+ index_html = File.read(index_path)
+ details_html = File.read(project_path)
+
+ assert_includes(index_html, "Projects")
+ assert_includes(index_html, "Demo")
+ assert_includes(details_html, "Demo project")
+ assert_includes(details_html, "js/projects.js")
+ assert_includes(details_html, "css/projects.css")
+ end
+ end
+end
diff --git a/test/site_generator_rendering_test.rb b/test/site_generator_rendering_test.rb
new file mode 100644
index 0000000..14af007
--- /dev/null
+++ b/test/site_generator_rendering_test.rb
@@ -0,0 +1,164 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::SiteGeneratorRenderingTest < Minitest::Test
+ class PluginSpy
+ attr_reader :calls
+
+ def initialize
+ @calls = []
+ end
+
+ def setup(site:, source_path:)
+ @calls << [:setup, site.title, source_path]
+ end
+
+ def render(site:, target_path:)
+ @calls << [:render, site.title, target_path]
+ File.write(File.join(target_path, "plugin-output.txt"), "plugin rendered")
+ end
+ end
+
+ class PostsPluginSpy < PluginSpy
+ attr_reader :posts_by_year, :render_site_year
+
+ def initialize(posts_by_year:)
+ super()
+ @posts_by_year = posts_by_year
+ end
+
+ def render(site:, target_path:)
+ @render_site_year = site.copyright_start_year
+ super
+ end
+ end
+
+ class MarkdownRendererSpy
+ attr_reader :calls
+
+ def initialize
+ @calls = []
+ end
+
+ def can_render_file?(filename:, extension:)
+ extension == "md" && !filename.start_with?("_")
+ end
+
+ def render(site:, file_path:, target_dir:)
+ @calls << [site.title, file_path, target_dir]
+ FileUtils.mkdir_p(target_dir)
+ slug = File.basename(file_path, ".md")
+ File.write(File.join(target_dir, "#{slug}.html"), "rendered #{slug}")
+ end
+ end
+
+ def build_site(plugin:, renderer:)
+ Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ plugins: [plugin],
+ renderers: [renderer]
+ )
+ end
+
+ def build_posts_by_year(year:)
+ post = Pressa::Posts::Post.new(
+ slug: "first-post",
+ title: "First Post",
+ author: "Sami Samhuri",
+ date: DateTime.parse("#{year}-02-01T10:00:00-08:00"),
+ formatted_date: "1st February, #{year}",
+ body: "First post
",
+ excerpt: "First post...",
+ path: "/posts/#{year}/02/first-post"
+ )
+
+ month_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "February", number: 2, padded: "02"),
+ posts: [post]
+ )
+
+ year_posts = Pressa::Posts::YearPosts.new(year:, by_month: {2 => month_posts})
+ Pressa::Posts::PostsByYear.new(by_year: {year => year_posts})
+ end
+
+ def test_generate_runs_plugins_copies_static_files_and_renders_supported_files
+ Dir.mktmpdir do |root|
+ source_path = File.join(root, "source")
+ target_path = File.join(root, "target")
+ public_dir = File.join(source_path, "public", "nested")
+ FileUtils.mkdir_p(public_dir)
+
+ File.write(File.join(source_path, "public", "plain.txt"), "copy me")
+ File.write(File.join(source_path, "public", "home.md"), "# home")
+ File.write(File.join(source_path, "public", ".hidden"), "skip me")
+ File.write(File.join(public_dir, "page.md"), "# title")
+ File.write(File.join(public_dir, "_ignore.md"), "# ignored")
+
+ plugin = PluginSpy.new
+ renderer = MarkdownRendererSpy.new
+ site = build_site(plugin:, renderer:)
+
+ Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
+
+ assert_equal(2, plugin.calls.length)
+ assert_equal(:setup, plugin.calls[0][0])
+ assert_equal(:render, plugin.calls[1][0])
+ assert_equal("samhuri.net", renderer.calls.first[0])
+ assert(renderer.calls.any? do |call|
+ call[1].end_with?("/public/nested/page.md") &&
+ File.expand_path(call[2]) == File.expand_path(File.join(target_path, "nested"))
+ end)
+ assert(renderer.calls.any? do |call|
+ call[1].end_with?("/public/home.md") &&
+ File.expand_path(call[2]) == File.expand_path(target_path)
+ end)
+
+ assert(File.exist?(File.join(target_path, "plain.txt")))
+ assert_equal("copy me", File.read(File.join(target_path, "plain.txt")))
+ refute(File.exist?(File.join(target_path, ".hidden")))
+ refute(File.exist?(File.join(target_path, "nested", "page.md")))
+ assert(File.exist?(File.join(target_path, "home.html")))
+ assert(File.exist?(File.join(target_path, "nested", "page.html")))
+ refute(File.exist?(File.join(target_path, "nested", "_ignore.html")))
+ assert(File.exist?(File.join(target_path, "plugin-output.txt")))
+ end
+ end
+
+ def test_generate_handles_missing_public_directory
+ Dir.mktmpdir do |root|
+ source_path = File.join(root, "source")
+ target_path = File.join(root, "target")
+ FileUtils.mkdir_p(source_path)
+
+ plugin = PluginSpy.new
+ renderer = MarkdownRendererSpy.new
+ site = build_site(plugin:, renderer:)
+
+ Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
+
+ assert(File.exist?(File.join(target_path, "plugin-output.txt")))
+ assert_empty(renderer.calls)
+ end
+ end
+
+ def test_generate_sets_copyright_start_year_from_earliest_post_year
+ Dir.mktmpdir do |root|
+ source_path = File.join(root, "source")
+ target_path = File.join(root, "target")
+ FileUtils.mkdir_p(source_path)
+
+ plugin = PostsPluginSpy.new(posts_by_year: build_posts_by_year(year: 2006))
+ renderer = MarkdownRendererSpy.new
+ site = build_site(plugin:, renderer:)
+
+ Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
+
+ assert_equal(2006, plugin.render_site_year)
+ end
+ end
+end
diff --git a/test/site_generator_test.rb b/test/site_generator_test.rb
new file mode 100644
index 0000000..93b965b
--- /dev/null
+++ b/test/site_generator_test.rb
@@ -0,0 +1,52 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::SiteGeneratorTest < Minitest::Test
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ plugins: [],
+ renderers: []
+ )
+ end
+
+ def test_rejects_a_target_path_that_matches_the_source_path
+ Dir.mktmpdir do |dir|
+ FileUtils.mkdir_p(File.join(dir, "public"))
+ source_file = File.join(dir, "public", "keep.txt")
+ File.write(source_file, "safe")
+
+ generator = Pressa::SiteGenerator.new(site:)
+ error = assert_raises(ArgumentError) do
+ generator.generate(source_path: dir, target_path: dir)
+ end
+
+ assert_match(/must not be the same as or contain source_path/, error.message)
+ assert_equal("safe", File.read(source_file))
+ end
+ end
+
+ def test_does_not_copy_ignored_dotfiles_from_public
+ Dir.mktmpdir do |dir|
+ source_path = File.join(dir, "source")
+ target_path = File.join(dir, "target")
+ public_path = File.join(source_path, "public")
+ FileUtils.mkdir_p(public_path)
+
+ File.write(File.join(public_path, ".DS_Store"), "finder cache")
+ File.write(File.join(public_path, ".gitkeep"), "")
+ File.write(File.join(public_path, "visible.txt"), "ok")
+
+ Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
+
+ assert(File.exist?(File.join(target_path, "visible.txt")))
+ refute(File.exist?(File.join(target_path, ".DS_Store")))
+ refute(File.exist?(File.join(target_path, ".gitkeep")))
+ end
+ end
+end
diff --git a/test/site_test.rb b/test/site_test.rb
new file mode 100644
index 0000000..c042954
--- /dev/null
+++ b/test/site_test.rb
@@ -0,0 +1,52 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::SiteTest < Minitest::Test
+ def test_url_helpers
+ site = Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ image_url: "https://images.example.net"
+ )
+
+ assert_equal("https://samhuri.net/posts", site.url_for("/posts"))
+ assert_equal("https://images.example.net/avatar.png", site.image_url_for("/avatar.png"))
+ end
+
+ def test_image_url_for_returns_nil_when_image_url_not_configured
+ site = Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+
+ assert_nil(site.image_url_for("/avatar.png"))
+ end
+
+ def test_create_site_builds_site_using_loader
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "site.toml"), <<~TOML)
+ author = "Sami Samhuri"
+ email = "sami@samhuri.net"
+ title = "samhuri.net"
+ description = "blog"
+ url = "https://samhuri.net"
+ TOML
+ File.write(File.join(dir, "projects.toml"), <<~TOML)
+ [[projects]]
+ name = "demo"
+ title = "demo"
+ description = "demo project"
+ url = "https://github.com/samsonjs/demo"
+ TOML
+
+ site = Pressa.create_site(source_path: dir, url_override: "https://beta.samhuri.net")
+ assert_equal("https://beta.samhuri.net", site.url)
+ end
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..47bad16
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,4 @@
+lib_path = File.expand_path("../lib", __dir__)
+$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
+require "pressa"
+require "minitest/autorun"
diff --git a/test/utils/file_writer_test.rb b/test/utils/file_writer_test.rb
new file mode 100644
index 0000000..eb7bad8
--- /dev/null
+++ b/test/utils/file_writer_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+require "tmpdir"
+
+class Pressa::Utils::FileWriterTest < Minitest::Test
+ def test_write_creates_directories_writes_content_and_sets_permissions
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, "nested", "file.txt")
+ Pressa::Utils::FileWriter.write(path:, content: "hello", permissions: 0o600)
+
+ assert_equal("hello", File.read(path))
+ assert_equal("600", format("%o", File.stat(path).mode & 0o777))
+ end
+ end
+
+ def test_write_data_writes_binary_content_and_sets_permissions
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, "nested", "data.bin")
+ data = "\x00\xFFabc".b
+ Pressa::Utils::FileWriter.write_data(path:, data:, permissions: 0o640)
+
+ assert_equal(data, File.binread(path))
+ assert_equal("640", format("%o", File.stat(path).mode & 0o777))
+ end
+ end
+end
diff --git a/test/utils/markdown_renderer_test.rb b/test/utils/markdown_renderer_test.rb
new file mode 100644
index 0000000..4ec69f4
--- /dev/null
+++ b/test/utils/markdown_renderer_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+require "fileutils"
+require "tmpdir"
+
+class Pressa::Utils::MarkdownRendererTest < Minitest::Test
+ def renderer
+ @renderer ||= Pressa::Utils::MarkdownRenderer.new
+ end
+
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def test_can_render_file_checks_md_extension
+ assert(renderer.can_render_file?(filename: "about.md", extension: "md"))
+ refute(renderer.can_render_file?(filename: "about.txt", extension: "txt"))
+ end
+
+ def test_render_writes_pretty_url_output_by_default
+ Dir.mktmpdir do |dir|
+ source_file = File.join(dir, "public", "about.md")
+ target_dir = File.join(dir, "www")
+ FileUtils.mkdir_p(File.dirname(source_file))
+
+ File.write(source_file, <<~MARKDOWN)
+ ---
+ Title: About
+ Description: About page
+ ---
+
+ This is [my bio](https://example.net).
+ MARKDOWN
+
+ renderer.render(site:, file_path: source_file, target_dir:)
+
+ output_file = File.join(target_dir, "about", "index.html")
+ assert(File.exist?(output_file))
+
+ html = File.read(output_file)
+ assert_includes(html, "samhuri.net: About ")
+ assert_includes(html, " ")
+ assert_includes(html, " ")
+ end
+ end
+
+ def test_render_writes_html_extension_when_enabled_and_uses_fallbacks
+ Dir.mktmpdir do |dir|
+ source_file = File.join(dir, "public", "docs", "readme.md")
+ target_dir = File.join(dir, "www", "docs")
+ FileUtils.mkdir_p(File.dirname(source_file))
+
+ File.write(source_file, <<~MARKDOWN)
+ ---
+ Show extension: yes
+ Page type: article
+ ---
+
+ Hello world . This is an  excerpt with [a link](https://example.net).
+ MARKDOWN
+
+ renderer.render(site:, file_path: source_file, target_dir:)
+
+ output_file = File.join(target_dir, "readme.html")
+ assert(File.exist?(output_file))
+
+ html = File.read(output_file)
+ assert_includes(html, "samhuri.net: Readme ")
+ assert_includes(html, " ")
+ assert_includes(html, " ")
+ assert_includes(html, " ")
+ end
+ end
+
+ def test_render_without_front_matter_uses_filename_title
+ Dir.mktmpdir do |dir|
+ source_file = File.join(dir, "public", "notes.md")
+ target_dir = File.join(dir, "www")
+ FileUtils.mkdir_p(File.dirname(source_file))
+
+ File.write(source_file, "hello from markdown")
+ renderer.render(site:, file_path: source_file, target_dir:)
+
+ html = File.read(File.join(target_dir, "notes", "index.html"))
+ assert_includes(html, "samhuri.net: Notes ")
+ assert_includes(html, "Notes ")
+ end
+ end
+end
diff --git a/test/views/layout_test.rb b/test/views/layout_test.rb
new file mode 100644
index 0000000..26cb724
--- /dev/null
+++ b/test/views/layout_test.rb
@@ -0,0 +1,99 @@
+require "test_helper"
+
+class Pressa::Views::LayoutTest < Minitest::Test
+ def content_view
+ Class.new(Phlex::HTML) do
+ def view_template
+ article do
+ h1 { "Hello" }
+ end
+ end
+ end.new
+ end
+
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def site_with_copyright_start_year(year)
+ Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ copyright_start_year: year
+ )
+ end
+
+ def test_rendering_child_components_as_html_instead_of_escaped_text
+ html = Pressa::Views::Layout.new(
+ site:,
+ canonical_url: "https://samhuri.net/posts/",
+ content: content_view
+ ).call
+
+ assert_includes(html, "")
+ assert_includes(html, "Hello ")
+ refute_includes(html, "<article>")
+ end
+
+ def test_keeps_escaping_enabled_for_untrusted_string_fields
+ subtitle = " "
+ html = Pressa::Views::Layout.new(
+ site:,
+ canonical_url: "https://samhuri.net/posts/",
+ page_subtitle: subtitle,
+ content: content_view
+ ).call
+
+ assert_includes(html, "samhuri.net: <img src=x onerror=alert(1)> ")
+ end
+
+ def test_preserves_absolute_stylesheet_urls
+ cdn_site = Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net",
+ styles: [Pressa::Stylesheet.new(href: "https://cdn.example.com/site.css")]
+ )
+
+ html = Pressa::Views::Layout.new(
+ site: cdn_site,
+ canonical_url: "https://samhuri.net/posts/",
+ content: content_view
+ ).call
+
+ assert_includes(html, %( ))
+ end
+
+ def test_footer_renders_year_range_using_copyright_start_year
+ html = Pressa::Views::Layout.new(
+ site: site_with_copyright_start_year(2006),
+ canonical_url: "https://samhuri.net/posts/",
+ content: content_view
+ ).call
+
+ assert_includes(html, "")
+ end
+
+ def test_footer_renders_single_year_when_start_year_matches_current_year
+ current_year = Time.now.year
+ html = Pressa::Views::Layout.new(
+ site: site_with_copyright_start_year(current_year),
+ canonical_url: "https://samhuri.net/posts/",
+ content: content_view
+ ).call
+
+ assert_includes(html, "")
+ refute_includes(html, "© #{current_year} - #{current_year} ")
+ end
+end
diff --git a/test/views/rendering_test.rb b/test/views/rendering_test.rb
new file mode 100644
index 0000000..5e1cc37
--- /dev/null
+++ b/test/views/rendering_test.rb
@@ -0,0 +1,129 @@
+require "test_helper"
+
+class Pressa::Views::RenderingTest < Minitest::Test
+ def site
+ @site ||= Pressa::Site.new(
+ author: "Sami Samhuri",
+ email: "sami@samhuri.net",
+ title: "samhuri.net",
+ description: "blog",
+ url: "https://samhuri.net"
+ )
+ end
+
+ def regular_post
+ @regular_post ||= Pressa::Posts::Post.new(
+ slug: "swift-optional-or",
+ title: "Swift Optional OR",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2017-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2017",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2017/10/swift-optional-or"
+ )
+ end
+
+ def link_post
+ @link_post ||= Pressa::Posts::Post.new(
+ slug: "github-flow-like-a-pro",
+ title: "GitHub Flow Like a Pro",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2015-05-28T07:42:27-07:00"),
+ formatted_date: "28th May, 2015",
+ link: "http://haacked.com/archive/2014/07/28/github-flow-aliases/",
+ body: "hello
",
+ excerpt: "hello...",
+ path: "/posts/2015/05/github-flow-like-a-pro"
+ )
+ end
+
+ def test_post_view_renders_regular_post_and_article_class
+ html = Pressa::Views::PostView.new(
+ post: regular_post,
+ site:,
+ article_class: "container"
+ ).call
+
+ assert_includes(html, "")
+ assert_includes(html, "Swift Optional OR ")
+ assert_includes(html, "∞ ")
+ end
+
+ def test_post_view_renders_link_post_title_with_arrow
+ html = Pressa::Views::PostView.new(post: link_post, site:).call
+
+ assert_includes(html, "→ GitHub Flow Like a Pro")
+ assert_includes(html, "http://haacked.com/archive/2014/07/28/github-flow-aliases/")
+ end
+
+ def test_feed_post_view_expands_root_relative_urls_only
+ post = Pressa::Posts::Post.new(
+ slug: "with-assets",
+ title: "With Assets",
+ author: "Sami Samhuri",
+ date: DateTime.parse("2017-10-01T10:00:00-07:00"),
+ formatted_date: "1st October, 2017",
+ body: 'read
' \
+ '
' \
+ 'cdn
',
+ excerpt: "hello...",
+ path: "/posts/2017/10/with-assets"
+ )
+
+ html = Pressa::Views::FeedPostView.new(post:, site:).call
+
+ assert_includes(html, 'href="https://samhuri.net/posts/2010/01/basics-of-the-mach-o-file-format"')
+ assert_includes(html, 'src="https://samhuri.net/images/me.jpg"')
+ assert_includes(html, 'href="//cdn.example.net/app.js"')
+ end
+
+ def test_project_and_projects_views_render_project_links_and_stats
+ project = Pressa::Projects::Project.new(
+ name: "demo",
+ title: "Demo Project",
+ description: "Demo project description",
+ url: "https://github.com/samsonjs/demo"
+ )
+
+ listing = Pressa::Views::ProjectsView.new(projects: [project], site:).call
+ details = Pressa::Views::ProjectView.new(project:, site:).call
+
+ assert_includes(listing, "Demo Project")
+ assert_includes(listing, "https://samhuri.net/projects/demo")
+ assert_includes(details, "https://github.com/samsonjs/demo/stargazers")
+ assert_includes(details, "https://github.com/samsonjs/demo/network/members")
+ end
+
+ def test_archive_views_render_year_month_and_both_post_types
+ may_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "May", number: 5, padded: "05"),
+ posts: [link_post]
+ )
+ oct_posts = Pressa::Posts::MonthPosts.new(
+ month: Pressa::Posts::Month.new(name: "October", number: 10, padded: "10"),
+ posts: [regular_post]
+ )
+
+ by_year = {
+ 2017 => Pressa::Posts::YearPosts.new(year: 2017, by_month: {10 => oct_posts}),
+ 2015 => Pressa::Posts::YearPosts.new(year: 2015, by_month: {5 => may_posts})
+ }
+ posts_by_year = Pressa::Posts::PostsByYear.new(by_year:)
+
+ year_html = Pressa::Views::YearPostsView.new(year: 2015, year_posts: by_year[2015], site:).call
+ month_html = Pressa::Views::MonthPostsView.new(year: 2017, month_posts: oct_posts, site:).call
+ recent_html = Pressa::Views::RecentPostsView.new(posts: [regular_post], site:).call
+ archive_html = Pressa::Views::ArchiveView.new(posts_by_year:, site:).call
+
+ assert_includes(year_html, "https://samhuri.net/posts/2015/05/")
+ assert_includes(year_html, "→ GitHub Flow Like a Pro")
+ assert_match(%r{]*class="permalink")(?=[^>]*href="/posts/2015/05/github-flow-like-a-pro")[^>]*>∞ }, year_html)
+
+ assert_includes(month_html, "October 2017")
+ assert_includes(recent_html, "Swift Optional OR")
+ assert_includes(archive_html, "Archive")
+ assert_includes(archive_html, "https://samhuri.net/posts/2017/")
+ assert_includes(archive_html, "https://samhuri.net/posts/2015/")
+ end
+end