aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStonewall Jackson <stonewall@sacredheartsc.com>2023-01-22 09:56:17 -0500
committerStonewall Jackson <stonewall@sacredheartsc.com>2023-01-22 09:56:17 -0500
commit9f1b37fa346a7dcf77c1b6963a6d2e4b871fe5ed (patch)
tree14aa09de866a3283e1b07a94ea9cc6d70fc3e49d
downloadwww-9f1b37fa346a7dcf77c1b6963a6d2e4b871fe5ed.tar.gz
www-9f1b37fa346a7dcf77c1b6963a6d2e4b871fe5ed.zip
initial commit
-rw-r--r--.gitignore5
-rw-r--r--LICENSE21
-rw-r--r--Makefile108
-rw-r--r--README.md56
-rw-r--r--defaults.yaml9
-rw-r--r--requirements.txt2
-rwxr-xr-xscripts/bloglist.py26
-rw-r--r--scripts/common.py53
-rwxr-xr-xscripts/rss.py48
-rw-r--r--src/android-chrome-192x192.pngbin0 -> 22287 bytes
-rw-r--r--src/apple-touch-icon.pngbin0 -> 20142 bytes
-rw-r--r--src/blog/desktop-linux-with-nfs-homedirs/index.md226
-rw-r--r--src/blog/index.md9
-rw-r--r--src/blog/makefile-based-blogging/index.md249
-rw-r--r--src/browserconfig.xml9
-rw-r--r--src/css/style.css89
-rw-r--r--src/cv/index.md102
-rw-r--r--src/favicon-16x16.pngbin0 -> 1230 bytes
-rw-r--r--src/favicon-32x32.pngbin0 -> 1989 bytes
-rw-r--r--src/favicon.icobin0 -> 15086 bytes
-rw-r--r--src/gpg.asc41
-rw-r--r--src/index.md35
-rw-r--r--src/mstile-150x150.pngbin0 -> 11220 bytes
-rw-r--r--src/sacredheart.pngbin0 -> 29328 bytes
-rw-r--r--src/safari-pinned-tab.svg89
-rw-r--r--src/site.webmanifest14
-rw-r--r--templates/cv.html93
-rw-r--r--templates/default.html89
28 files changed, 1373 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..99dddf5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+public
+.bloglist.md
+*.pyc
+*.pyo
+*.swp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6fa4003
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 stonewall@sacredheartsc.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..8f81f2b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,108 @@
+### CHANGE ME ######################
+DOMAIN = www.sacredheartsc.com
+URL = https://$(DOMAIN)
+RSYNC_TARGET = $(DOMAIN):/var/www/$(DOMAIN)
+FEED_TITLE = sacredheartsc blog
+FEED_DESCRIPTION = Carolina-grown articles about self-hosting, privacy, unix, and more.
+STATIC_REGEX = .*\.(html|css|jpg|jpeg|png|ico|xml|txt|asc)
+BLOG_LIST_LIMIT = 5
+
+
+### VARIABLES ######################
+SHELL = /bin/bash -e -o pipefail
+
+SOURCE_DIR = src
+OUTPUT_DIR = public
+SCRIPT_DIR = scripts
+
+BLOG_DIR = blog
+
+TEMPLATE = templates/default.html
+DEFAULTS = defaults.yaml
+
+BLOG_LIST_SCRIPT = $(SCRIPT_DIR)/bloglist.py
+BLOG_LIST_REPLACE = __BLOG_LIST__
+BLOG_LIST_FILE = .bloglist.md
+
+BLOG_RSS_SCRIPT = $(SCRIPT_DIR)/rss.py
+BLOG_RSS_FILE = $(BLOG_DIR)/feed.xml
+
+SOURCE_DIRS := $(shell find $(SOURCE_DIR) -mindepth 1 -type d)
+SOURCE_MARKDOWN := $(shell find $(SOURCE_DIR) -type f -name '*.md' -and ! -name $(BLOG_LIST_FILE))
+SOURCE_STATIC := $(shell find $(SOURCE_DIR) -type f -regextype posix-extended -iregex '$(STATIC_REGEX)')
+BLOG_POSTS := $(shell find $(SOURCE_DIR)/$(BLOG_DIR) -type f -name '*.md' -and ! -name $(BLOG_LIST_FILE) -and ! -path $(SOURCE_DIR)/$(BLOG_DIR)/index.md)
+
+OUTPUT_DIRS := $(patsubst $(SOURCE_DIR)/%, $(OUTPUT_DIR)/%, $(SOURCE_DIRS))
+OUTPUT_MARKDOWN := $(patsubst $(SOURCE_DIR)/%, $(OUTPUT_DIR)/%, $(patsubst %.md, %.html, $(SOURCE_MARKDOWN)))
+OUTPUT_STATIC := $(patsubst $(SOURCE_DIR)/%, $(OUTPUT_DIR)/%, $(SOURCE_STATIC))
+
+COPY = cp --preserve=timestamps
+
+PANDOC = pandoc \
+ --highlight-style=kate \
+ --metadata=feed:/$(BLOG_RSS_FILE) \
+ --defaults=$(DEFAULTS)
+
+RSSGEN = $(BLOG_RSS_SCRIPT) \
+ $(SOURCE_DIR)/$(BLOG_DIR) \
+ --title="$(FEED_TITLE)" \
+ --description="$(FEED_DESCRIPTION)" \
+ --url="$(URL)" \
+ --blog-path="/$(BLOG_DIR)" \
+ --feed-path="/$(BLOG_RSS_FILE)"
+
+
+### TARGETS ######################
+public: \
+ $(OUTPUT_DIRS) \
+ $(OUTPUT_MARKDOWN) \
+ $(OUTPUT_STATIC) \
+ $(OUTPUT_DIR)/$(BLOG_RSS_FILE)
+
+$(OUTPUT_DIRS):
+ mkdir -p $@
+
+# Homepage
+$(OUTPUT_DIR)/index.html: $(SOURCE_DIR)/index.md $(SOURCE_DIR)/$(BLOG_LIST_FILE) $(TEMPLATE)
+ sed $$'/$(BLOG_LIST_REPLACE)/{r $(SOURCE_DIR)/$(BLOG_LIST_FILE)\nd}' $< | $(PANDOC) --template=$(TEMPLATE) --output=$@
+
+# CV
+$(OUTPUT_DIR)/cv/index.html: $(SOURCE_DIR)/cv/index.md templates/cv.html
+ $(PANDOC) --template=templates/cv.html --output=$@ $<
+
+$(SOURCE_DIR)/$(BLOG_LIST_FILE): $(BLOG_POSTS) $(BLOG_LIST_SCRIPT)
+ $(BLOG_LIST_SCRIPT) $(SOURCE_DIR)/$(BLOG_DIR) $(BLOG_LIST_LIMIT) > $@
+
+# Blog listing
+$(OUTPUT_DIR)/$(BLOG_DIR)/index.html: $(SOURCE_DIR)/$(BLOG_DIR)/index.md $(SOURCE_DIR)/$(BLOG_DIR)/$(BLOG_LIST_FILE) $(TEMPLATE)
+ sed $$'/$(BLOG_LIST_REPLACE)/{r $(SOURCE_DIR)/$(BLOG_DIR)/$(BLOG_LIST_FILE)\nd}' $< | $(PANDOC) --template=$(TEMPLATE) --output=$@
+
+$(SOURCE_DIR)/$(BLOG_DIR)/$(BLOG_LIST_FILE): $(BLOG_POSTS) $(BLOG_LIST_SCRIPT)
+ $(BLOG_LIST_SCRIPT) $(SOURCE_DIR)/$(BLOG_DIR) > $@
+
+# RSS feed
+$(OUTPUT_DIR)/$(BLOG_RSS_FILE): $(BLOG_POSTS) $(BLOG_RSS_SCRIPT)
+ $(RSSGEN) > $@
+
+# Blog posts
+$(OUTPUT_DIR)/%.html: $(SOURCE_DIR)/%.md $(TEMPLATE)
+ $(PANDOC) --template=$(TEMPLATE) --output=$@ $<
+
+# Catch-all: static assets
+$(OUTPUT_DIR)/%: $(SOURCE_DIR)/%
+ $(COPY) $< $@
+
+.PHONY: install clean serve rsync
+install:
+ pip install -r requirements.txt
+
+serve: public
+ cd $(OUTPUT_DIR) && python3 -m http.server
+
+clean:
+ rm -rf $(OUTPUT_DIR)
+ rm -f $(SOURCE_DIR)/$(BLOG_LIST_FILE)
+ rm -f $(SOURCE_DIR)/$(BLOG_DIR)/$(BLOG_LIST_FILE)
+
+rsync: public
+ rsync -rlphv --delete $(OUTPUT_DIR)/ $(RSYNC_TARGET)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3f34cf6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+www.sacredheadheartsc.com
+=========================
+
+This repository contains the source for [www.sacredheartsc.com](https://www.sacredheartsc.com),
+which consists of markdown documents and a Makefile-driven static site generator.
+
+# Requirements
+
+- coreutils
+- python3
+- pandoc
+- make
+
+# Instructions
+
+First, install the `pip` requirements:
+
+ make install
+
+You'll want to edit the [Makefile](/www/tree/Makefile) to set your site URL,
+RSS feed title, etc.
+
+Then, start writing markdown documents in the `src` directory. You can use
+whatever naming convention and directory structure you like. Files ending in
+`.md` will be converted to `.html` with the same path.
+
+The `src/blog` directory is special. Markdown files in this directory are
+used to populate the front-page blog listing in [index.md](/www/tree/src/index.md).
+Before pandoc converts this file to HTML, the special string `__BLOG_LIST__`
+is replaced with the output of [bloglist.py](/www/tree/scripts/bloglist.py).
+This Python script produces a date-sorted markdown list of all your blog posts.
+
+Each markdown file can have YAML frontmatter with the following metadata:
+
+ ---
+ title: A boring blog post
+ date: YYYY-MM-DD
+ subtitle: an optional subtitle
+ heading: optional, if you want the first <h1> to be different from <title>
+ description: optional, short description for <head> and the blog listing
+ draft: if set, hides the post from the blog listing
+ ---
+
+You can change the resulting HTML by modifying the [template](/www/tree/templates/default.html).
+Changing the format of the blog listing requires modifying the Python script.
+
+Build the website by using the default target:
+
+ make
+
+This will create a directory called `public` containing all your markdown files
+rendered to HTML.
+
+You also can run a local webserver, which listens on port 8000, using:
+
+ make serve
diff --git a/defaults.yaml b/defaults.yaml
new file mode 100644
index 0000000..c628134
--- /dev/null
+++ b/defaults.yaml
@@ -0,0 +1,9 @@
+from: markdown+fenced_divs+bracketed_spans+smart
+to: html5
+standalone: true
+html-q-tags: true
+
+css:
+ - /css/style.css
+
+metadata:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..ea85e26
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+python-frontmatter
+dateparser
diff --git a/scripts/bloglist.py b/scripts/bloglist.py
new file mode 100755
index 0000000..9e10354
--- /dev/null
+++ b/scripts/bloglist.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+import argparse
+from common import get_blog_posts
+
+DATE_FORMAT = '%Y-%m-%d'
+
+parser = argparse.ArgumentParser('bloglist')
+parser.add_argument('BLOG_DIR', type=str, help='Directory containing markdown blog posts')
+parser.add_argument('LIMIT', nargs='?', default=None, type=int, help='Maximum number of posts to show')
+args = parser.parse_args()
+
+posts = get_blog_posts(args.BLOG_DIR)
+
+if args.LIMIT is not None:
+ posts = posts[0:args.LIMIT]
+
+if len(posts) == 0:
+ print('Nothing has been posted yet!')
+else:
+ for post in posts:
+ post_date = post['date'].strftime(DATE_FORMAT)
+
+ print(f'- [{post["title"]}]({post["href"]}) ({post_date})\n')
+ if post['description']:
+ print(f' {post["description"]}\n')
diff --git a/scripts/common.py b/scripts/common.py
new file mode 100644
index 0000000..9b23816
--- /dev/null
+++ b/scripts/common.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+import os
+import frontmatter
+import dateparser
+import datetime
+from pathlib import Path
+
+BLOG_LIST_FILE = '.bloglist.md'
+
+def get_href(path):
+ path = Path(path)
+ path = path.relative_to(*path.parts[:1])
+ if path.name == 'index.md':
+ return '/{}/'.format(path.parent)
+ else:
+ return '/{}/{}.html'.format(path.parent, path.stem)
+
+def read_metadata(file):
+ post = frontmatter.load(file)
+
+ for field in ['title', 'date']:
+ if post.get(field) is None:
+ raise Exception("{} is missing metadata field '{}'".format(file, field))
+
+ if type(post['date']) not in [datetime.datetime, datetime.date]:
+ date = dateparser.parse(post['date'])
+ else:
+ date = post['date']
+
+ return {
+ 'title': post.get('title'),
+ 'date': date,
+ 'author': post.get('author'),
+ 'description': post.get('description'),
+ 'draft': post.get('draft'),
+ 'href': get_href(file)
+ }
+
+def get_blog_posts(blog_dir):
+ blog_index = os.path.join(blog_dir, 'index.md')
+ posts = []
+
+ for root, dirs, files in os.walk(blog_dir):
+ for file in files:
+ path = os.path.join(root, file)
+ if path.endswith('.md') and path != blog_index and file != BLOG_LIST_FILE:
+ metadata = read_metadata(path)
+ if not metadata['draft']:
+ posts.append(metadata)
+
+ posts.sort(key=lambda p: (p is None, p['date']), reverse=True)
+ return posts
diff --git a/scripts/rss.py b/scripts/rss.py
new file mode 100755
index 0000000..cf0cb9a
--- /dev/null
+++ b/scripts/rss.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+import argparse
+import email.utils
+from datetime import datetime
+from common import get_blog_posts
+
+parser = argparse.ArgumentParser('rss')
+parser.add_argument('BLOG_DIR', type=str, help='Directory containing markdown blog posts')
+parser.add_argument('--limit', default=15, type=int, help='Maximum number of posts to show')
+parser.add_argument('--title', help='Feed title', required=True)
+parser.add_argument('--description', help='Feed description', required=True)
+parser.add_argument('--url', help='Root URL', required=True)
+parser.add_argument('--blog-path', help='Blog path', required=True)
+parser.add_argument('--feed-path', help='RSS feed path', required=True)
+args = parser.parse_args()
+
+posts = get_blog_posts(args.BLOG_DIR)
+posts = posts[0:args.limit]
+
+build_date = email.utils.format_datetime(datetime.now().astimezone())
+
+print(f'''<?xml version="1.0"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+<channel>
+ <title>{args.title}</title>
+ <link>{args.url}{args.blog_path}</link>
+ <language>en-US</language>
+ <description>{args.description}</description>
+ <lastBuildDate>{build_date}</lastBuildDate>
+ <atom:link href="{args.url}{args.feed_path}" rel="self" type="application/rss+xml"/>''')
+
+for post in posts:
+ pub_date = email.utils.format_datetime(post['date'].astimezone())
+
+ print(f''' <item>
+ <title>{post["title"]}</title>
+ <link>{args.url}{post["href"]}</link>
+ <guid>{args.url}{post["href"]}</guid>
+ <pubDate>{pub_date}</pubDate>''')
+
+ if 'description' in post:
+ print(f' <description>{post["description"]}</description>')
+
+ print(' </item>')
+
+print('</channel>')
+print('</rss>')
diff --git a/src/android-chrome-192x192.png b/src/android-chrome-192x192.png
new file mode 100644
index 0000000..f731faa
--- /dev/null
+++ b/src/android-chrome-192x192.png
Binary files differ
diff --git a/src/apple-touch-icon.png b/src/apple-touch-icon.png
new file mode 100644
index 0000000..0491547
--- /dev/null
+++ b/src/apple-touch-icon.png
Binary files differ
diff --git a/src/blog/desktop-linux-with-nfs-homedirs/index.md b/src/blog/desktop-linux-with-nfs-homedirs/index.md
new file mode 100644
index 0000000..e01581a
--- /dev/null
+++ b/src/blog/desktop-linux-with-nfs-homedirs/index.md
@@ -0,0 +1,226 @@
+---
+title: Desktop Linux with NFS Home Directories
+date: January 19, 2023
+subtitle: Something no one does anymore, apparently.
+description: Issues you'll face with NFS-mounted homedirs, and some workarounds.
+---
+
+I manage multiple [Rocky Linux](https://rockylinux.org/) workstations that automount
+users' home directories via kerberized NFS. Unfortunately, I don't think this is a common
+setup anymore--I encountered a few bugs and performance issues that needed non-obvious
+workarounds.
+
+## Problems
+
+### 1. Things break when you log in from two places at once
+
+If you can somehow restrict your users to a single GNOME session at any given time,
+you'll probably be fine. However, as soon as someone leaves his desktop running and
+logs into another workstation, strange things begin to happen. Here are some oddities
+I've observed:
+
+ - GNOME settings on one machine are clobbered by the other (this may or may not be desirable).
+
+ - Firefox refuses to run, because the profile directory is already in use.
+
+ - `gnome-keyring` freaks out and creates many login keyrings under `~/.local/share/keyrings`,
+ losing previously stored secrets in the process!
+
+ - Sound quits working (I suspect this is due to `~/.config/pulse/cookie` being clobbered).
+
+ - Flatpak apps completely blow up (each app stores its state in `~/.var`, and
+ [this is nonconfigurable](https://github.com/flatpak/flatpak/issues/1651)). Running
+ multiple instances of `signal-dekstop` instantly corrupts the sqlite database.
+
+ - `goa-daemon` generates thousands of syslog messages per minute (I am unsure if this is
+ due to `~/.config/goa-1.0/accounts.conf` getting clobbered, or a symptom of
+ [this bug](https://gitlab.gnome.org/GNOME/gnome-online-accounts/-/issues/32)).
+ I have no idea what `goa-daemon` does, nor do I want to. I have been victimized by
+ [the bazaar](http://www.catb.org/~esr/writings/cathedral-bazaar/cathedral-bazaar/)
+ enough for one lifetime.
+
+### 2. It's slow
+
+I/O-heavy tasks, like compiling and grepping, will be much slower over NFS than the local
+disk. Browser profiles stored on NFS (`~/.mozilla`, `~/.cache/chromium`, etc.) provide
+a noticeably poor experience.
+
+File browsing is also painful if you have lots of images or videos. Thumbnails for
+files stored on NFS will be cached in `~/.cache/thumbnails`, which is **also** stored
+on NFS!
+
+## Solution: Move stuff to local storage
+
+The [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
+lets you change the default locations of `~/.cache`, `~/.config`, and the like by setting
+some environment variables in the user's session. We can solve most of these problems
+by moving the various XDG directories to the local disk.
+
+### Automatically provision local home directories
+
+First, let's write a script that automatically provisions a _local_ home directory
+whenever someone logs in:
+
+````bash
+#!/bin/bash
+
+# /usr/local/sbin/create-local-homedir.sh
+
+# Log all output to syslog.
+exec 1> >(logger -s -t $(basename "$0")) 2>&1
+
+PAM_UID=$(id -u "${PAM_USER}")
+
+if (( PAM_UID >= 1000 )); then
+ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/usr/local/home/${PAM_USER}"
+fi
+````
+
+Of course, it needs to be executable:
+
+````bash
+chmod 755 /usr/local/sbin/create-local-homedir.sh
+````
+
+Next, we modify the PAM configuration to execute our script whenever anyone logs in
+via GDM or SSH:
+
+````diff
+--- /etc/pam.d/gdm-password
++++ /etc/pam.d/gdm-password
+@@ -1,5 +1,6 @@
+ auth [success=done ignore=ignore default=bad] pam_selinux_permit.so
+ auth substack password-auth
++auth optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
+ auth optional pam_gnome_keyring.so
+ auth include postlogin
+
+--- /etc/pam.d/sshd
++++ /etc/pam.d/sshd
+@@ -15,3 +15,4 @@
+ session optional pam_motd.so
+ session include password-auth
+ session include postlogin
++session optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
+````
+
+<details>
+<summary>A note on SELinux</summary>
+
+If you're using SELinux, you'll need a separate copy of the `create-local-homedir` script
+for use with GDM, labeled with `xdm_unconfined_exec_t`:
+
+````bash
+ln /usr/local/sbin/create-local-homedir{,-gdm}.sh
+semanage fcontext -a -t xdm_unconfined_exec_t /usr/local/sbin/create-local-homedir-gdm.sh
+restorecon -v /usr/local/sbin/create-local-homedir-gdm.sh
+````
+
+Be sure to modify `/etc/pam.d/gdm-password` appropriately.
+
+</details>
+
+### Set XDG Environment Variables
+
+We need to tell the user's applications to use the new local home directory
+for storage. We have to do this early in the PAM stack for GDM, because `$XDG_DATA_HOME`
+must be set before `gnome-keyring` gets executed.
+
+Edit your PAM files again, adding one more line:
+
+````diff
+--- /etc/pam.d/gdm-password
++++ /etc/pam.d/gdm-password
+@@ -1,6 +1,7 @@
+ auth [success=done ignore=ignore default=bad] pam_selinux_permit.so
+ auth substack password-auth
+ auth optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
++auth optional pam_env.so conffile=/etc/security/pam_env_xdg.conf
+ auth optional pam_gnome_keyring.so
+ auth include postlogin
+
+--- /etc/pam.d/sshd
++++ /etc/pam.d/sshd
+@@ -16,3 +16,4 @@
+ session include password-auth
+ session include postlogin
+ session optional pam_exec.so /usr/local/sbin/create-local-homedir.sh
++session optional pam_env.so conffile=/etc/security/pam_env_xdg.conf
+````
+
+Then, create the corresponding `pam_env.conf(5)` file:
+
+````default
+# /etc/security/pam_env_xdg.conf
+
+XDG_DATA_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.local/share
+XDG_STATE_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.local/state
+XDG_CACHE_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.cache
+XDG_CONFIG_HOME DEFAULT=/usr/local/home/@{PAM_USER}/.config
+````
+
+### Hacks for Non-XDG-Compliant Apps
+
+Unfortunately, since a majority of open source developers follow the
+[CADT model](https://www.jwz.org/doc/cadt.html), there are many apps that ignore the
+XDG specification. Sometimes these apps have their own environment variables
+for specifying their storage locations. Otherwise, symlinks can provide us with an escape
+hatch.
+
+Create a script in `/etc/profile.d` for these workarounds. Scripts in this directory
+are executed within the context of the user's session, so we can freely write inside
+his NFS home directory using his UID (and kerberos ticket, if applicable).
+
+````bash
+# /etc/profile.d/local-homedirs.sh
+
+if (( UID >= 1000 )); then
+ # Building code is *much* faster on the local disk. Modify as needed:
+ export PYTHONUSERBASE="/usr/local/home/${USER}/.local" # python
+ export npm_config_cache="/usr/local/home/${USER}/.npm" # nodejs
+ export CARGO_HOME="/usr/local/home/${USER}/.cargo" # rust
+ export GOPATH="/usr/local/home/${USER}/go" # golang
+
+ # Firefox doesn't provide an environment variable for setting the default profile
+ # path, so we'll just symlink it to /usr/local/home.
+ mkdir -p "/usr/local/home/${USER}/.mozilla"
+ ln -sfn "/usr/local/home/${USER}/.mozilla" "${HOME}/.mozilla"
+
+ # Flatpak hardcodes ~/.var, so symlink it to /opt/flatpak.
+ ln -sfn "/opt/flatpak/${USER}" "${HOME}/.var"
+fi
+````
+
+If you use any Flatpak apps, each user will need his own local Flatpak directory.
+The Flatpak runtime appears to shadow the entire `/usr` using mount namespaces,
+so any `/usr/local/home` symlinks will disappear into the abyss. Luckily, `/opt`
+appears to be undefiled. Modify your original script like so:
+
+````diff
+--- /usr/local/sbin/create-local-homedir.sh
++++ /usr/local/sbin/create-local-homedir.sh
+@@ -6,4 +6,5 @@
+
+ if (( PAM_UID >= 1000 )); then
+ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/usr/local/home/${PAM_USER}"
++ install -o "${PAM_USER}" -g "${PAM_USER}" -m 0700 -d "/opt/flatpak/${PAM_USER}"
+ fi
+````
+
+## Closing Thoughts
+
+Most of my users are nontechnical, so I'm pleased that these workarounds do not require
+any manual intervention on their part.
+
+I am sad that `$XDG_CONFIG_HOME` can't be shared between multiple workstations reliably.
+When I change my desktop background or add a new password to `gnome-keyring`, it only
+affects the local machine.
+
+Initially, I tried symlinking various subdirectories of `~/.config` to the local disk
+individually as I encountered different bugs (e.g. `~/.config/pulse`). Unfortunately this
+proved brittle, as I was constantly playing whack-a-mole with apps that abused `$XDG_CONFIG_HOME`
+for storing local state. In the end, it was less of a headache to just dump the whole thing
+onto the local disk.
+
+I suppose if you verified an app behaved properly with multiple simultaneous NFS clients,
+you could always symlink `/usr/local/home/$USER/.config/$APP` **back** onto NFS!
diff --git a/src/blog/index.md b/src/blog/index.md
new file mode 100644
index 0000000..1c35f0a
--- /dev/null
+++ b/src/blog/index.md
@@ -0,0 +1,9 @@
+---
+title: sacredheartsc blog
+header: Blog
+description: Carolina-grown articles about self-hosting, privacy, unix, and more.
+---
+
+::: bloglist
+__BLOG_LIST__
+:::
diff --git a/src/blog/makefile-based-blogging/index.md b/src/blog/makefile-based-blogging/index.md
new file mode 100644
index 0000000..9244a0d
--- /dev/null
+++ b/src/blog/makefile-based-blogging/index.md
@@ -0,0 +1,249 @@
+---
+title: Makefile-Based Blogging
+date: December 12, 2022
+subtitle: Yet another static site generator using `pandoc(1)` and `make(1)`.
+description: Building a markdown-based static site generator using pandoc and make.
+---
+
+A few days ago, I got the gumption to start blogging again. The last time I wrote
+with any frequency, I lovingly hand-crafted each HTML file before `rsync`ing it to
+my web server. This time, I wanted a more efficient workflow.
+
+I surveyed the [vast number](https://github.com/myles/awesome-static-generators)
+of static site generators available on GitHub, but most of them seemed like
+overkill for my humble website. I figured that by the time I wrapped by head
+around one of them, I could have just written a Makefile.
+
+Finally, I came across [pandoc-blog](https://github.com/lukasschwab/pandoc-blog),
+which gave me inspiration and showed me the ideal pandoc incantations for
+generating HTML from markdown files. And thus, my
+[Makefile-based static site generator](https://git.sacredheartsc.com/www/about/)
+was born. You're reading the inaugural post!
+
+## Generating the HTML
+
+The workhorse of this thing is [pandoc](https://pandoc.org), which is a ubiquitous
+open-source document converter. Transforming markdown into HTML is as simple as:
+
+```bash
+pandoc document.md -o document.html
+```
+
+Simple! But to generate an entire website, we'll need some of pandoc's additional
+features: custom templates and document metadata.
+
+### Custom Templates
+
+The layout of pandoc's output document is determined by the
+[template](https://pandoc.org/MANUAL.html#templates) in use. Pandoc includes
+default templates for a variety of document formats, but you can also specify
+your own.
+
+A very simple HTML template might look something like this:
+
+```html
+<html lang="en">
+ <head>
+ <meta name="author" content="$author-meta$">
+ <meta name="description" content="$description$">
+ </head>
+ <body>
+ <h1 class="title">$title$</h1>
+$body$
+ </body>
+</html>
+```
+
+[My pandoc template](https://git.sacredheartsc.com/www/tree/templates/default.html)
+is what generates the navigation bar at the top of this page.
+
+The variable `$body$` is replaced by the content of your markdown document when
+pandoc renders the template. The other variables are replaced by their
+corresponding values from the document's metadata.
+
+### Document Metadata
+
+Each pandoc source document can have associated metadata values. There are three
+ways of specifying metadata: the `--medatata` [flag](https://pandoc.org/MANUAL.html#option--metadata),
+a dedicated [metadata file](https://pandoc.org/MANUAL.html#option--metadata-file), or
+a [YAML metadata block](https://pandoc.org/MANUAL.html#extension-yaml_metadata_block)
+embedded within the document itself. We'll be using the embedded metadata blocks.
+
+Each markdown document for my website starts with a YAML metadata block. The
+metadata for the post you're
+[currently reading](https://git.sacredheartsc.com/www/tree/src/blog/makefile-based-blogging/index.md)
+looks like this:
+
+
+```yaml
+---
+title: Makefile-Based Blogging
+date: December 12, 2022
+subtitle: Yet another static site generator using `pandoc(1)` and `make(1)`.
+description: Building a markdown-based static site generator using pandoc and make.
+---
+```
+
+You can put whatever YAML you like in your markdown files, as long as the metadata
+starts and ends with three hyphens.
+
+## Automating pandoc with make
+
+Using a Makefile, we can automatically invoke pandoc to convert each markdown
+file in our blog to HTML. In addition, `make` will keep track of which source
+files have changed since the last run and rebuild them accordingly.
+
+First, lets describe the project layout:
+
+- **src/**: the source files of our blog, including markdown files and static
+ assets (CSS, images, etc). The subdirectory structure is entirely up to you.
+
+- **public/**: the output directory. After running `make`, the contents of this
+ directory can be `rsync`'d straight to your web server.
+
+- **scripts/**: helper scripts for generating the blog artifacts. Currently there
+ are only two:
+
+ - [bloglist.py](https://git.sacredheartsc.com/www/tree/scripts/bloglist.py)
+ generates a markdown-formatted list of all your blog posts, sorted by the
+ `date` field in the YAML metadata block.
+
+ - [rss.py](https://git.sacredheartsc.com/www/tree/scripts/rss.py) generates
+ an RSS feed for your blog.
+
+- **templates/**: pandoc templates which generate HTML from markdown files
+ (currently, there is only one).
+
+The Makefile used to build this website is located [here](https://git.sacredheartsc.com/www/tree/Makefile).
+I've reproduced a simplified version below, to make it easier to step through.
+
+```makefile
+######################
+# Variable definitions
+######################
+
+# These variables are used to generate the RSS feed
+URL = https://www.sacredheartsc.com
+FEED_TITLE = sacredheartsc blog
+FEED_DESCRIPTION = Carolina-grown articles about self-hosting, privacy, unix, and more.
+
+# The number of blog posts to show on the homepage
+BLOG_LIST_LIMIT = 5
+
+# File extensions (other than .md) that should be included in public/ directory
+STATIC_REGEX = .*\.(html|css|jpg|jpeg|png|xml|txt)
+
+# Pandoc template used to generate HTML
+TEMPLATE = templates/default.html
+
+# List of subdirectories to create
+SOURCE_DIRS := $(shell find src -mindepth 1 -type d)
+
+# List of source markdown files
+SOURCE_MARKDOWN := $(shell find src -type f -name '*.md' -and ! -name .bloglist.md)
+
+# List of static assets
+SOURCE_STATIC := $(shell find src \
+ -type f \
+ -regextype posix-extended \
+ -iregex '$(STATIC_REGEX)')
+
+# List of all blog posts (excluding the main blog page)
+BLOG_POSTS := $(shell find src/blog \
+ -type f \
+ -name '*.md' \
+ -and ! -name .bloglist.md \
+ -and ! -path src/blog/index.md)
+
+# Subdirectories to create under public/
+OUTPUT_DIRS := $(patsubst src/%, public/%, $(SOURCE_DIRS))
+
+# .html files under public/, corresponding to each .md file under src/
+OUTPUT_MARKDOWN := $(patsubst src/%, public/%, $(patsubst %.md, %.html, $(SOURCE_MARKDOWN)))
+
+# Static file targets under public/
+OUTPUT_STATIC := $(patsubst src/%, public/%, $(SOURCE_STATIC))
+
+# Script to generate RSS feed
+RSSGEN = scripts/rss.py \
+ src/blog \
+ --title="$(FEED_TITLE)" \
+ --description="$(FEED_DESCRIPTION)" \
+ --url=$(URL) \
+ --blog-path=/blog \
+ --feed-path=/blog/rss/feed.xml
+
+
+######################
+# File Targets
+######################
+
+# Default target: convert .md to .html, copy static assets, and generate RSS
+public: \
+ $(OUTPUT_DIRS) \
+ $(OUTPUT_MARKDOWN) \
+ $(OUTPUT_STATIC) \
+ public/blog/feed.xml
+
+# Homepage (/)
+public/index.html: src/index.md src/.bloglist.md $(TEMPLATE)
+ sed $$'/__BLOG_LIST__/{r src/.bloglist.md\nd}' $< \
+ | pandoc --template=$(TEMPLATE) --output=$@
+
+# Markdown list of 5 most recent blog posts
+src/.bloglist.md: $(BLOG_POSTS) scripts/bloglist.py
+ scripts/bloglist.py src/blog $(BLOG_LIST_LIMIT) > $@
+
+# The main blog listing (/blog/)
+public/blog/index.html: src/blog/index.md src/blog/.bloglist.md $(TEMPLATE)
+ sed $$'/__BLOG_LIST__/{r src/blog/.bloglist.md\nd}' $< \
+ | pandoc --template=$(TEMPLATE) --output=$@
+
+# Markdown list of _all_ blog posts
+src/blog/.bloglist.md: $(BLOG_POSTS) scripts/bloglist.py
+ scripts/bloglist.py src/blog > $@
+
+# Convert all other .md files to .html
+public/%.html: src/%.md $(TEMPLATE)
+ pandoc --template=$(TEMPLATE) --output=$@ $<
+
+# Catch-all: copy static assets in src/ to public/
+public/%: src/%
+ cp --preserve=timestamps $< $@
+
+# RSS feed
+public/blog/feed.xml: $(BLOG_POSTS) scripts/rss.py
+ $(RSSGEN) > $@
+
+
+######################
+# Phony Targets
+######################
+
+.PHONY: serve rsync clean
+
+# Run a local HTTP server in the output directory
+serve: public
+ cd public && python3 -m http.server
+
+# Deploy the site to your webserver
+rsync: public
+ rsync -rlphv --delete public/ webserver.example.com:/var/www/html
+
+clean:
+ rm -rf public
+ rm -f src/.bloglist.md
+ rm -f src/blog/.bloglist.md
+```
+
+## Closing Thoughts
+
+I admit, there is a small amount of hackery involved. You obviously can't generate
+a time-sorted list of blog posts using pure markdown, so I'm generating the
+markdown list using a Python script in an intermediate step. I then (ab)use `sed`
+to shove that list into the markdown source on the fly. This means that changing
+the look of the [blog list](/blog/) requires hacking up the Python code.
+
+But overall, I've been quite happy with this little project. There's just something
+about writing paragraphs in `vi` and typing `:!make` that warms my soul with
+memories of simpler times.
diff --git a/src/browserconfig.xml b/src/browserconfig.xml
new file mode 100644
index 0000000..b3930d0
--- /dev/null
+++ b/src/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#da532c</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/src/css/style.css b/src/css/style.css
new file mode 100644
index 0000000..27d6a7f
--- /dev/null
+++ b/src/css/style.css
@@ -0,0 +1,89 @@
+body {
+ color: #333;
+ margin: 1em auto 2em auto;
+ max-width: 43em;
+ padding: 0 1em;
+ font-family: "PT Sans", "Myriad Pro", "Trebuchet MS", Helvetica, sans-serif;
+}
+
+a {
+ color: #0074D9
+}
+
+a:visited {
+ color: #941352
+}
+
+header .subtitle {
+ margin-top: -1em;
+ margin-bottom: 21.44px;
+}
+
+.bloglist p:nth-of-type(2) {
+ margin-top: -8px;
+}
+
+header .date {
+ font-style: italic;
+ margin-bottom: 21.44px;
+}
+
+footer .date {
+ float: right;
+}
+
+footer {
+ font-style: italic;
+ font-size: 14px;
+ margin-top: 32px;
+}
+
+.logo {
+ float: right;
+}
+
+@media only screen and (max-device-width : 667px) {
+ .logo {
+ max-width: 66px;
+ }
+
+ footer .date {
+ float: none;
+ }
+}
+
+@media print {
+ .navbar {
+ display: none !important;
+ }
+}
+
+pre {
+ background-color: #f5f5f5;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-family: monospace;
+ line-height: 1.2;
+ padding: 8px;
+ white-space: pre;
+ word-wrap: normal;
+}
+
+.sourceCode {
+ overflow: auto;
+}
+
+details {
+ font-size: 87.5%;
+ margin: 0 1em;
+}
+
+.redacted {
+ background: black;
+ color: black;
+ cursor: default;
+}
+
+.right {
+ float: right;
+}
diff --git a/src/cv/index.md b/src/cv/index.md
new file mode 100644
index 0000000..76f7f92
--- /dev/null
+++ b/src/cv/index.md
@@ -0,0 +1,102 @@
+---
+title: Curriculum Vitae
+description: The CV of stonewall, a Linux engineer in upstate South Carolina.
+date: January 20, 2023
+---
+
+[stonewall@sacredheartsc.com](mailto:stonewall@sacredheartsc.com)
+[sacredheartsc.com](https://www.sacredheartsc.com){.right}
+
+Site reliability engineer in upstate South Carolina, specializing in the programming,
+administration, and troubleshooting of Linux systems. 8+ years of development and
+operations experience. Strong skills in Unix internals, shell scripting, system
+administration, and debugging, from userspace to the kernel. Extensive experience
+in Linux, BSD, and Solaris-based operating systems, as well as networking and
+infrastructure management.
+
+
+## Work Experience
+
+**Linux Engineer** at [REDACTED, Inc.]{.redacted} (Remote)
+[2021-present]{.right}
+
+ - Details forthcoming.
+
+**Linux Engineer** at **[Jane Street](https://www.janestreet.com/)** (New York, NY)
+[2018-2021]{.right}
+
+ - Investigated production errors and performance regressions using packet captures,
+ userspace profiling, and kernel instrumentation.
+ - Maintained Linux infrastructure and developed management tools for network
+ storage systems.
+ - Diagnosed performance bottlenecks in NFS application workflows and client/server
+ implementations.
+ - Designed and implemented automated systems for the deployment, configuration
+ management, and monitoring of Linux hosts.
+ - Wrote patches for bug fixes and performance improvements for various open source
+ applications in the GNU/Linux ecosystem.
+
+**Site Reliability Engineer** at **Thesys CAT** (Charleston, SC)
+[2017-2018]{.right}
+
+ - Designed infrastructure, networking, and operations strategies for the
+ implementation of the SEC’s Consolidated Audit Trail.
+ - Led infrastructure rollout and configuration management effort using Ansible
+ for hundreds of bare metal and cloud servers.
+ - Implemented Kerberos-based authentication system for both applications and user
+ accounts.
+ - Built an object storage solution using FreeBSD and ZFS nearing a petabyte of
+ usable space.
+ - Created automated build and packaging pipeline for in-house software repositories.
+
+**DevOps Engineer** at **Thesys Technologies** (Charleston, SC)
+[2016-2017]{.right}
+
+ - Implemented market access software in C for Tilera’s TILE64 architecture, supporting
+ over $1 billion per day of order volume.
+ - Improved the accuracy of distributed C-based client risk checks while maintaining
+ ≤2.5μs order latency.
+ - Responsible for implementation and monitoring of a C++-based software trading
+ platform as part of trading technologies DevOps team.
+ - Coordinated network connectivity and colocation with clients and stock exchanges
+ in North America.
+ - Assessed and resolved real-time trading and connectivity issues in a high-stress
+ environment while managing relationships with customers.
+
+**Software Engineer** at **SPARC** (Charleston, SC)
+[2015-2016]{.right}
+
+ - Developed benefit management software for the Department of Veterans Affairs
+ using Java, Spring Framework, and AngularJS.
+ - Led a special performance team which identified and mitigated application
+ bottlenecks.
+ - Achieved a >10x speedup by refactoring application-layer JPA logic into more
+ efficient database queries.
+ - Created and maintained CentOS VM images for Oracle Database.
+
+**Graduate Research Assistant** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2013-2015]{.right}
+
+ - Designed and implemented a middleware system for watershed-scale sensor networks
+ using Java and NodeJS.
+ - Maintained a middleware backend which served over 50 live ecological sensor
+ deployment sites across South Carolina.
+ - Independently developed a metadata management system for sensor network hardware,
+ which was presented at IEEE and ACM conferences.
+
+
+## Education
+
+**M.S. in Computer Science** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2013-2015]{.right}
+
+ - Developed new data analytics and management tools for wireless sensor networks
+ in Clemson’s Dependable Systems Research Group.
+ - GPA 3.70
+
+**B.S. in Computer Science** at **[Clemson University](https://www.clemson.edu/)** (Clemson, SC)
+[2009-2013]{.right}
+
+ - Graduated magna cum laude as an undergraduate researcher in the Calhoun
+ Honors College.
+ - GPA 3.82
diff --git a/src/favicon-16x16.png b/src/favicon-16x16.png
new file mode 100644
index 0000000..e64368b
--- /dev/null
+++ b/src/favicon-16x16.png
Binary files differ
diff --git a/src/favicon-32x32.png b/src/favicon-32x32.png
new file mode 100644
index 0000000..caa141a
--- /dev/null
+++ b/src/favicon-32x32.png
Binary files differ
diff --git a/src/favicon.ico b/src/favicon.ico
new file mode 100644
index 0000000..4536c08
--- /dev/null
+++ b/src/favicon.ico
Binary files differ
diff --git a/src/gpg.asc b/src/gpg.asc
new file mode 100644
index 0000000..1a3ad50
--- /dev/null
+++ b/src/gpg.asc
@@ -0,0 +1,41 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+=MkZr
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/index.md b/src/index.md
new file mode 100644
index 0000000..3ec5b6b
--- /dev/null
+++ b/src/index.md
@@ -0,0 +1,35 @@
+---
+title: sacredheartsc.com
+heading: Cor Jesu Sacratissimum, miserere nobis
+subtitle: Most Sacred Heart of Jesus, have mercy on us!
+description: The digital home of a Southern sysadmin.
+---
+
+![](sacredheart.png "Sacred Heart"){.logo}
+
+Welcome to my personal website. I'm a husband, father of three, and Unix herder.
+The HTML you're currently viewing was artisanally crafted in South Carolina.
+
+Professionally, I'm something between a programmer and a system administrator.
+Nothing here represents the views of any employer. It's just a collection of my
+personal projects and interests, which include digital privacy, self-hosting,
+and old-school sysadminning.
+
+- Contact:
+ [Email](mailto:stonewall@sacredheartsc.com){title="stonewall@sacredheartsc.com"} |
+ [XMPP](xmpp:stonewall@sacredheartsc.com?message){title="stonewall@sacredheartsc.com"} |
+ [IRC](ircs://irc.libera.chat/stonewall,isnick){title="stonewall on irc.libera.chat"}
+- GPG: [0x6DF66CCB](/gpg.asc)
+- Code:
+ [Git](https://git.sacredheartsc.com/) |
+ [GitHub](https://github.com/sacredheartsc)
+- [Blog](/blog/)
+- [Curriculum Vitae](/cv/)
+
+This website is powered by a [Makefile](https://git.sacredheartsc.com/www/about/)!
+
+## Recent Posts
+
+::: bloglist
+__BLOG_LIST__
+:::
diff --git a/src/mstile-150x150.png b/src/mstile-150x150.png
new file mode 100644
index 0000000..8d29538
--- /dev/null
+++ b/src/mstile-150x150.png
Binary files differ
diff --git a/src/sacredheart.png b/src/sacredheart.png
new file mode 100644
index 0000000..e9eedf0
--- /dev/null
+++ b/src/sacredheart.png
Binary files differ
diff --git a/src/safari-pinned-tab.svg b/src/safari-pinned-tab.svg
new file mode 100644
index 0000000..0580306
--- /dev/null
+++ b/src/safari-pinned-tab.svg
@@ -0,0 +1,89 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="202.000000pt" height="202.000000pt" viewBox="0 0 202.000000 202.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+</metadata>
+<g transform="translate(0.000000,202.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M376 1935 c-4 -20 -10 -33 -14 -31 -5 3 -14 1 -22 -5 -13 -7 -12 -9
+3 -9 9 0 17 -6 17 -12 0 -9 4 -8 10 2 5 8 10 11 10 6 0 -5 11 1 25 14 29 28
+30 33 9 54 -23 24 -31 20 -38 -19z"/>
+<path d="M454 1895 c-7 -18 2 -45 15 -45 13 0 22 27 15 45 -4 8 -10 15 -15 15
+-5 0 -12 -7 -15 -15z"/>
+<path d="M526 1884 c-9 -8 -16 -19 -16 -24 0 -5 -3 -16 -6 -24 -5 -12 -2 -15
+10 -10 9 3 23 1 31 -6 12 -10 15 -10 15 0 0 7 6 10 14 7 8 -3 21 2 30 12 18
+20 20 31 6 31 -5 0 -10 -6 -10 -13 0 -7 -13 -13 -30 -13 -27 -2 -29 0 -23 27
+6 33 1 36 -21 13z"/>
+<path d="M1025 1860 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
+-8 -4 -11 -10z"/>
+<path d="M1536 1855 c4 -8 11 -15 16 -15 6 0 5 6 -2 15 -7 8 -14 15 -16 15 -2
+0 -1 -7 2 -15z"/>
+<path d="M1010 1830 c0 -5 5 -10 10 -10 6 0 10 5 10 10 0 6 -4 10 -10 10 -5 0
+-10 -4 -10 -10z"/>
+<path d="M480 1785 c-10 -12 -10 -15 4 -15 9 0 16 7 16 15 0 8 -2 15 -4 15 -2
+0 -9 -7 -16 -15z"/>
+<path d="M637 1783 c-4 -3 -7 -18 -6 -32 1 -26 1 -25 10 3 9 31 7 41 -4 29z"/>
+<path d="M300 1771 c0 -5 5 -13 10 -16 6 -3 10 -2 10 4 0 5 -4 13 -10 16 -5 3
+-10 2 -10 -4z"/>
+<path d="M1016 1770 c-42 -8 -58 -27 -63 -75 -4 -37 -9 -77 -12 -90 -1 -5 -6
+-3 -11 5 -5 8 -10 10 -10 4 0 -6 -10 -9 -23 -7 -13 3 -40 -2 -60 -11 -30 -12
+-37 -21 -38 -43 -1 -15 0 -32 1 -38 2 -5 4 -18 5 -28 3 -29 21 -39 82 -44 32
+-3 60 -7 62 -9 8 -8 13 -48 14 -129 2 -91 -8 -129 -32 -119 -61 26 -115 15
+-179 -37 l-49 -40 -13 -90 c-12 -82 -11 -93 5 -132 10 -23 27 -51 37 -63 10
+-11 18 -28 18 -37 0 -10 11 -27 25 -39 14 -12 25 -27 25 -33 0 -7 20 -31 45
+-54 25 -23 45 -45 45 -50 0 -4 44 -54 99 -111 l99 -104 -28 -26 c-16 -15 -34
+-45 -41 -69 -11 -38 -15 -42 -43 -41 -53 3 -59 1 -53 -20 7 -26 -1 -25 -24 2
+-19 21 -19 22 1 43 41 44 16 73 -31 36 -62 -48 -92 -30 -44 27 14 17 25 33 25
+36 0 10 -29 7 -49 -6 -15 -9 -25 -9 -43 -1 l-23 12 27 0 c42 1 58 11 58 36 0
+27 -20 37 -61 28 -28 -5 -30 -4 -23 21 5 21 3 26 -13 26 -27 0 -93 -58 -93
+-81 0 -11 4 -18 9 -14 10 5 51 -32 51 -46 0 -3 14 -12 31 -19 22 -9 29 -18 26
+-31 -6 -21 31 -49 62 -49 11 0 22 -9 26 -19 3 -10 20 -24 38 -31 18 -6 45 -18
+60 -27 15 -8 47 -16 70 -17 23 -2 47 -4 52 -6 25 -6 56 1 52 13 -2 6 -10 12
+-18 12 -33 1 -41 16 -35 68 10 69 22 90 45 77 23 -12 31 -4 31 28 0 17 4 23
+15 18 9 -3 37 11 68 34 28 22 55 40 60 40 4 0 7 6 7 13 0 8 9 22 20 32 11 10
+18 20 16 23 -3 2 12 32 33 65 39 65 81 145 98 187 29 72 49 195 33 205 -6 3
+-7 17 -4 30 9 38 -24 116 -68 157 -31 30 -50 38 -96 45 -65 9 -101 6 -119 -9
+-25 -20 -37 -2 -39 63 -7 177 -7 179 30 179 34 1 104 15 113 24 10 10 -8 96
+-24 112 -12 11 -27 14 -52 10 -20 -3 -46 -3 -59 1 -22 5 -23 10 -22 77 2 96 3
+91 -25 104 -28 13 -41 13 -99 2z"/>
+<path d="M520 1761 c0 -5 7 -11 15 -15 8 -3 15 -1 15 3 0 5 -7 11 -15 15 -8 3
+-15 1 -15 -3z"/>
+<path d="M460 1740 c0 -5 5 -10 11 -10 5 0 7 5 4 10 -3 6 -8 10 -11 10 -2 0
+-4 -4 -4 -10z"/>
+<path d="M440 1704 c0 -8 5 -12 10 -9 6 4 8 11 5 16 -9 14 -15 11 -15 -7z"/>
+<path d="M688 1693 c7 -3 16 -2 19 1 4 3 -2 6 -13 5 -11 0 -14 -3 -6 -6z"/>
+<path d="M635 1420 c-3 -5 -1 -10 4 -10 6 0 11 5 11 10 0 6 -2 10 -4 10 -3 0
+-8 -4 -11 -10z"/>
+<path d="M1592 743 c3 -26 1 -28 -29 -25 -27 3 -33 0 -33 -15 0 -25 61 -48 88
+-34 40 21 33 101 -9 101 -16 0 -19 -6 -17 -27z"/>
+<path d="M1460 750 c0 -5 9 -10 20 -10 11 0 20 5 20 10 0 6 -9 10 -20 10 -11
+0 -20 -4 -20 -10z"/>
+<path d="M592 688 c-17 -17 -15 -31 8 -51 11 -9 20 -22 20 -29 0 -10 -3 -9 -9
+1 -7 11 -13 8 -26 -13 l-18 -27 -26 31 c-24 29 -28 31 -53 20 -37 -17 -43 -48
+-12 -72 13 -11 21 -24 18 -30 -4 -6 1 -8 15 -3 18 5 21 2 21 -20 0 -19 4 -26
+14 -22 8 3 20 0 26 -8 6 -7 17 -12 25 -11 24 3 95 83 95 106 0 11 7 23 16 27
+8 3 13 12 10 19 -3 8 -1 14 4 14 6 0 10 7 10 15 0 9 -9 15 -25 15 -24 0 -51
+31 -38 44 3 3 -9 6 -28 6 -19 0 -40 -5 -47 -12z m29 -147 c-7 -5 -11 -13 -7
+-19 3 -5 -1 -12 -9 -16 -9 -3 -19 3 -25 14 -8 15 -7 18 3 14 8 -3 20 2 27 11
+7 8 15 13 19 10 3 -3 -1 -10 -8 -14z"/>
+<path d="M1493 641 c-13 -11 -23 -25 -23 -31 0 -19 40 -70 55 -70 16 0 25 -22
+25 -61 0 -27 -3 -29 -38 -29 -31 0 -45 7 -66 31 -25 27 -32 30 -64 24 -87 -16
+-120 -109 -51 -146 34 -17 47 4 26 40 -12 21 -14 32 -6 36 13 9 104 -83 111
+-112 9 -35 -14 -68 -47 -68 -32 0 -43 -16 -21 -34 18 -15 59 -5 81 19 23 25
+25 113 2 137 -24 27 -12 34 43 27 46 -6 47 -5 61 27 7 19 13 52 12 74 0 24 5
+44 14 50 32 24 -31 106 -80 105 -7 -1 -22 -9 -34 -19z"/>
+<path d="M777 543 c-4 -3 -7 -15 -7 -25 0 -13 7 -18 25 -18 26 0 29 7 19 34
+-6 16 -26 21 -37 9z"/>
+<path d="M1133 283 c-7 -2 -13 -11 -13 -19 0 -8 -5 -14 -10 -14 -6 0 -9 -12
+-6 -27 2 -16 5 -31 5 -35 1 -4 18 -19 38 -33 43 -29 63 -20 38 18 -16 24 -16
+28 1 56 15 25 15 32 5 45 -13 16 -34 19 -58 9z"/>
+<path d="M430 160 c-9 -6 -10 -10 -3 -10 6 0 15 5 18 10 8 12 4 12 -15 0z"/>
+<path d="M360 116 c0 -19 4 -23 20 -19 23 6 25 14 8 31 -18 18 -28 14 -28 -12z"/>
+<path d="M418 128 c-7 -19 2 -38 18 -38 15 0 17 9 8 34 -7 19 -20 21 -26 4z"/>
+<path d="M286 94 c-3 -9 -2 -23 3 -32 10 -18 11 -16 27 36 5 18 -23 15 -30 -4z"/>
+</g>
+</svg>
diff --git a/src/site.webmanifest b/src/site.webmanifest
new file mode 100644
index 0000000..c9761d6
--- /dev/null
+++ b/src/site.webmanifest
@@ -0,0 +1,14 @@
+{
+ "name": "SacredHeartSC",
+ "short_name": "SacredHeartSC",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ff0000",
+ "background_color": "#ff0000",
+ "display": "standalone"
+}
diff --git a/templates/cv.html b/templates/cv.html
new file mode 100644
index 0000000..d61dd33
--- /dev/null
+++ b/templates/cv.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+$for(author-meta)$
+ <meta name="author" content="$author-meta$">
+$endfor$
+$if(date-meta)$
+ <meta name="dcterms.date" content="$date-meta$">
+$endif$
+$if(description)$
+ <meta name="description" content="$description$">
+$endif$
+$if(keywords)$
+ <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$">
+$endif$
+ <title>$if(title-prefix)$$title-prefix$ – $endif$$title$</title>
+ <link rel="alternate" type="application/rss+xml" href="$feed$">
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+ <link rel="manifest" href="/site.webmanifest">
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#eea35f">
+ <meta name="msapplication-TileColor" content="#da532c">
+ <meta name="theme-color" content="#eea35f">
+ <style>
+ code{white-space: pre-wrap;}
+ span.smallcaps{font-variant: small-caps;}
+ span.underline{text-decoration: underline;}
+ div.column{display: inline-block; vertical-align: top; width: 50%;}
+$if(quotes)$
+ q { quotes: "“" "”" "‘" "’"; }
+$endif$
+ </style>
+$if(highlighting-css)$
+ <style>
+$highlighting-css$
+ </style>
+$endif$
+$for(css)$
+ <link rel="stylesheet" href="$css$">
+$endfor$
+$for(header-includes)$
+ $header-includes$
+$endfor$
+</head>
+<body>
+$for(include-before)$
+$include-before$
+$endfor$
+
+<nav class="navbar">
+ [<a href="/">home</a>]&nbsp;
+ [<a href="/blog/">blog</a>]&nbsp;
+ [<a href="https://git.sacredheartsc.com/">git</a>]&nbsp;
+ [<a href="mailto:stonewall@sacredheartsc.com">email</a>]&nbsp;
+ [<a href="$feed$">rss</a>]
+ <span style="float: right">JMJ</span>
+<hr>
+</nav>
+
+<header>
+$if(heading)$
+ <h1 class="title">$heading$</h1>
+$else$
+ <h1 class="title">$title$</h1>
+$endif$
+$if(subtitle)$
+ <p class="subtitle">$subtitle$</p>
+$endif$
+</header>
+
+$if(toc)$
+<nav id="$idprefix$toc" role="doc-toc">
+$table-of-contents$
+</nav>
+$endif$
+
+$body$
+
+$for(include-after)$
+$include-after$
+$endfor$
+<footer>
+ <p>
+ Please <a href="mailto:stonewall@sacredheartsc.com">contact me</a> if you
+ require a de-anonymized CV.
+ <span class="date">Last updated $date$</span>
+ </p>
+</footer>
+</body>
+</html>
diff --git a/templates/default.html b/templates/default.html
new file mode 100644
index 0000000..4145c9b
--- /dev/null
+++ b/templates/default.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+$for(author-meta)$
+ <meta name="author" content="$author-meta$">
+$endfor$
+$if(date-meta)$
+ <meta name="dcterms.date" content="$date-meta$">
+$endif$
+$if(description)$
+ <meta name="description" content="$description$">
+$endif$
+$if(keywords)$
+ <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$">
+$endif$
+ <title>$if(title-prefix)$$title-prefix$ – $endif$$title$</title>
+ <link rel="alternate" type="application/rss+xml" href="$feed$">
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+ <link rel="manifest" href="/site.webmanifest">
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#eea35f">
+ <meta name="msapplication-TileColor" content="#da532c">
+ <meta name="theme-color" content="#eea35f">
+ <style>
+ code{white-space: pre-wrap;}
+ span.smallcaps{font-variant: small-caps;}
+ span.underline{text-decoration: underline;}
+ div.column{display: inline-block; vertical-align: top; width: 50%;}
+$if(quotes)$
+ q { quotes: "“" "”" "‘" "’"; }
+$endif$
+ </style>
+$if(highlighting-css)$
+ <style>
+$highlighting-css$
+ </style>
+$endif$
+$for(css)$
+ <link rel="stylesheet" href="$css$">
+$endfor$
+$for(header-includes)$
+ $header-includes$
+$endfor$
+</head>
+<body>
+$for(include-before)$
+$include-before$
+$endfor$
+
+<nav class="navbar">
+ [<a href="/">home</a>]&nbsp;
+ [<a href="/blog/">blog</a>]&nbsp;
+ [<a href="https://git.sacredheartsc.com/">git</a>]&nbsp;
+ [<a href="mailto:stonewall@sacredheartsc.com">email</a>]&nbsp;
+ [<a href="$feed$">rss</a>]
+ <span style="float: right">JMJ</span>
+<hr>
+</nav>
+
+<header>
+$if(heading)$
+ <h1 class="title">$heading$</h1>
+$else$
+ <h1 class="title">$title$</h1>
+$endif$
+$if(subtitle)$
+ <p class="subtitle">$subtitle$</p>
+$endif$
+$if(date)$
+<p class="date">$date$</p>
+$endif$
+</header>
+
+$if(toc)$
+<nav id="$idprefix$toc" role="doc-toc">
+$table-of-contents$
+</nav>
+$endif$
+
+$body$
+
+$for(include-after)$
+$include-after$
+$endfor$
+</body>
+</html>