Ozymandias: A Static Site Framework

My name is Ozymandias, King of Kings;
Look on my Works, ye Mighty, and despair!
Nothing beside remains. Round the decay
Of that colossal Wreck, boundless and bare
The lone and level sands stretch far away.

Ozymandias — Percy Bysshe Shelley

The name is a joke. Every framework is a monument that its author believes will outlast the work produced in it. The name is also a warning: the writing you put in a framework might actually outlast the framework itself, which is why the framework should be small, coherent, and legible — not a cathedral built to impress.

The core of this website has been extracted and released as Ozymandias, a static site framework under the MIT license. It is the full pipeline: the Haskell build system, the Pandoc filter stack, all templates, all stylesheets, all client-side JavaScript — minus my personal content. If you want a website that works like this one and want to understand exactly how it works, Ozymandias is where to start.

This page describes what Ozymandias is, how it diverged from this site during extraction, and how to use it.


What It Is

Ozymandias is a static site generator for long-form writing. It is built on two mature Haskell tools: Hakyll for build orchestration and Pandoc for document processing. The framework handles routing, templating, and pagination through Hakyll, and applies a custom sequence of Pandoc AST transforms during compilation. The output is a directory of plain HTML files that can be served by any web server.

The short version of what comes with it:

The prerequisite list is short: GHC 9.6+, cabal-install, and Pagefind. Image conversion and the embedding pipeline are both optional and add their own dependencies (cwebp and Python with uv, respectively).


How It Diverged From This Site

When I extracted Ozymandias, the primary engineering work was disentangling site-specific configuration from the framework machinery. In levineuwirth.org, several values — the site URL, the author name, the navigation structure, the feed title — were compiled directly into the Haskell source. That is fine for a personal site and irritating for a reusable framework. The extraction introduced a Config.hs module and a site.yaml file that together hold all identity and navigation configuration. The rest of the build system reads from these at startup and never hardcodes a domain or author name.

The result is that you can fork the repository, edit one file, and have a working site with a completely different identity. The Haskell source does not need to be touched unless you want to extend or modify the framework itself.

Beyond configuration, the content in content/ was replaced with a small set of demo pages that exercise the filter pipeline without constituting a personal corpus. The data/bibliography.bib file was emptied and replaced with a placeholder. Everything in static/ — the fonts, stylesheets, scripts, and link icons — shipped intact. No features were removed during the extraction. Ozymandias has the full pipeline.

What Remains Shared

The two repositories share the same filter modules, the same templates (minus identity strings), and the same static assets. Changes to the filter pipeline in one are intended to be ported to the other. The practical result is that this site is an Ozymandias instance — it runs on the same engine, only with the configuration file pointing at levineuwirth.org rather than example.com. This page is compiled by the same code that compiles an Ozymandias site built from the framework.

What Diverges Intentionally

Several features of this site are too specific to my personal corpus to include in the framework defaults. The similarity embedding index — which requires running a neural model over all page content — is present in Ozymandias as an optional pipeline but ships with an empty index. The music catalog, the commonplace book, and the statistics page are included in the framework because they are useful to authors in general, but they contain no data by default. The semantic search ONNX model weights are downloaded by a separate make download-model target rather than committed to the repository.


The Filter Pipeline

The filters are the heart of the framework. Pandoc compiles Markdown to an abstract syntax tree, and the filters walk and transform that tree before Pandoc serializes it to HTML. They are applied in a fixed sequence; the order matters.

Source-level preprocessors run before Pandoc sees the file. They transform raw Markdown strings:

AST-level filters run after parsing. They are pure functions over the Pandoc AST:

The IO-performing filters (Score, Viz, Images) run before the pure ones. This ordering ensures that downstream filters see a stable AST without pending file reads.


Epistemic Profiles

The epistemic profile is a structured block that appears in the footer of any essay or post whose frontmatter includes a status field. It is the most distinctive feature of the framework philosophically, and the one most worth understanding before deploying it.

The fields:

The version history block, just above the epistemic footer, uses a three-tier fallback: authored history: notes in the frontmatter, then the raw git log, then the date: field as a creation record.

The point is not precision — a 72% confidence rating is not false exactness. It is an attempt to make explicit what most writing leaves implicit: where the author actually stands, and whether that position is stable or still shifting.


Backlinks require a two-pass architecture, because a page cannot know which pages will link to it until all pages have been compiled.

Pass one compiles every content item in a special “links” version that extracts all internal links together with the surrounding paragraph HTML. Pass two inverts this map — grouping sources by their targets — and produces data/backlinks.json. The final compilation pass loads this file as a dependency and injects the backlinks section into each page’s template context.

The practical consequence for authors is that internal links automatically generate backlink sections with source titles and context snippets, without any manual cross-referencing. The [Wikilinks](/wikilinks.html) syntax makes it natural to link between pages; the backlinks system makes those connections visible to readers moving in either direction.


Both features are optional and require Python with uv:

uv sync            # install dependencies from pyproject.toml
make download-model  # fetch ONNX weights for client-side search

Full-text search uses Pagefind, which indexes the compiled HTML and produces a static search index that runs entirely in the browser. No external service is involved.

Semantic search runs a sentence-transformers model (all-MiniLM-L6-v2, 384 dimensions) over extracted page text, builds a FAISS similarity index, and stores page-level neighbors in data/similar-links.json. At render time, this file is loaded as a Hakyll dependency and the top similar pages are injected into each essay’s template context as a “Related” section. The same model can be run client-side in the browser via ONNX Runtime Web for semantic query matching — the weights are served from the same origin, which means no external API calls.aa This is the design decision I care most about. Bolting semantic search onto a static site usually means sending queries to a third-party service. Serving the model weights from the same origin means the feature works without any network request beyond what is needed to load the page.


Content Types

Ozymandias supports six content types, each with its own template and routing convention:

Type Path Route Template
Essay content/essays/*.md /essays/{slug}.html essay.html
Blog post content/blog/*.md /blog/YYYY-MM-DD-{slug}.html blog-post.html
Poetry content/poetry/*.md /poetry/{slug}.html reading.html
Fiction content/fiction/*.md /fiction/{slug}.html reading.html
Composition content/music/{slug}/index.md /music/{slug}/index.html composition.html
Page content/*.md /{slug}.html page.html

Essays and blog posts support the full feature set: TOC, epistemic profiles, backlinks, similar links, citations, version history. Poetry and fiction use a reading CSS class that adjusts line spacing and disables indentation, making stanza structure visible. Music compositions get a separate score-reader view at /music/{slug}/score/ — a minimal interface with swipe navigation through SVG score pages.

Several pages are generated automatically without source files: /essays/index.html, /blog/index.html (paginated, 20 per page), /new.html (all content sorted by creation date), /library.html (portal taxonomy), tag index pages at /{tag}/index.html, author pages at /authors/{slug}/index.html, and /feed.xml.

Drafts live in content/drafts/essays/ and are only visible when the SITE_ENV=dev environment variable is set. Production builds exclude them entirely — they do not appear in feeds, tag pages, backlinks, or the library.


Configuration

All site identity and navigation lives in site.yaml. The full schema:

site-name:        "My Site"
site-url:         "https://example.com"
site-description: "A personal site built with Ozymandias"
site-language:    "en"

author-name:  "Your Name"
author-email: "you@example.com"

feed-title:       "My Site"
feed-description: "Essays, notes, and creative work"

license:    "CC BY-SA 4.0"
source-url: ""              # optional link to git repository

gpg-fingerprint: ""         # leave empty to omit sig links
gpg-pubkey-url:  "/gpg/pubkey.asc"

nav:
  - { href: "/",             label: "Home"    }
  - { href: "/library.html", label: "Library" }
  - { href: "/new.html",     label: "New"     }
  - { href: "/search.html",  label: "Search"  }

portals:
  - { slug: "writing", name: "Writing" }
  - { slug: "code",    name: "Code"    }
  - { slug: "notes",   name: "Notes"   }

Portals are the library taxonomy. Each portal collects all content whose tags include the portal’s slug or any tag with that slug as a prefix. Content tagged writing/essays and writing/fiction both appear under the writing portal.


Getting Started

git clone https://git.levineuwirth.org/neuwirth/ozymandias my-site
cd my-site
$EDITOR site.yaml    # set site-name, site-url, author-name, author-email
make dev             # build with drafts visible; serve on :8000

make dev builds with SITE_ENV=dev (so drafts are included) and starts a local server. make build produces the production output in _site/. make watch adds incremental rebuilds on file changes.

For deployment, the included make deploy target runs make clean && make build, optionally signs each page with GPG, rsyncs _site/ to a VPS configured via .env, and pushes to the git remote. Set VPS_USER, VPS_HOST, and VPS_PATH in .env to configure the destination.bb The make deploy target always begins with make clean to avoid stale build artifacts. Incremental Hakyll rebuilds are safe for development but can produce subtly incorrect output — particularly for pages whose template context depends on the full backlink graph — if the dependency graph is not fully consistent. The clean ensures the graph is always recomputed from scratch for production.


Writing Content

An essay with the full feature set looks like this:

---
title: "On the Virtues of Careful Writing"
date: 2026-04-12
abstract: >
  A brief description that appears on index pages and in the epistemic header.
tags: [writing, research/rhetoric]
authors: ["Your Name", "Collaborator | https://example.com"]
affiliation: "Institution | https://institution.edu"

status: "Working model"
confidence: 65
importance: 4
evidence: 3
scope: average
novelty: moderate
practicality: high
confidence-history: [50, 65]

history:
  - date: "2026-04-12"
    note: Initial draft

bibliography: data/bibliography.bib
further-reading: [key1, key2]
---

::: dropcap
Opening paragraph here. Sidenotes use the standard Pandoc footnote syntax.^[Like this.]
:::

## First Section

Wikilinks to other pages: [About This Site](/about-this-site.html). External links work normally.
Citations use Pandoc's citeproc syntax: [@author2024].

The authors field defaults to the author-name in site.yaml when absent. The affiliation field takes a Name | URL format. The history: block overrides git-derived version history when the git log alone would not convey what changed.


License

The framework code — everything in build/, templates/, static/, tools/, and the configuration files — is MIT licensed. The demo content under content/ is public domain. Your content is yours; add whatever license you choose.

The MIT license was chosen deliberately: it imposes no obligations, carries no viral clauses, and makes no claims on the writing produced with it. Frameworks should not take a stake in the work they compile.


The Relationship Between Ozymandias and This Site

This site is Ozymandias with my configuration and my content. Changes flow in both directions, with the understanding that the framework is the more conservative of the two repositories: features that turn out to be site-specific stay in levineuwirth.org; features that generalize get ported to Ozymandias. The filter pipeline and the template system are intended to stay in sync.

The divergence is, in a sense, the point. A personal website is a position, as I elaborate upon in the Colophon. Ozymandias is the mechanism; the position is what you put in it.