This commit is contained in:
Levi Neuwirth 2026-03-30 20:45:03 -04:00
parent b06b1e741c
commit aee326bfec
7 changed files with 257 additions and 123 deletions

117
CLAUDE.md
View File

@ -1,117 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
`levineuwirth.org` is a personal website built as a static site using **Hakyll** (Haskell) + **Pandoc**. The spec is in `spec.md`. The site is inspired by gwern.net in architecture: sidenotes, semantic zoom, monochrome typography, Pandoc filters, no client-side tracking.
## Build Commands
```bash
make build # cabal run site -- build && pagefind --site _site
make deploy # build + rsync -avz --delete _site/ vps:/var/www/levineuwirth.com/
make watch # cabal run site -- watch (live-reload dev server)
make clean # cabal run site -- clean
```
**Important:** Hakyll caches compiled items in `_cache/` keyed to source file mtimes.
Changing a `.hs` file (filter, compiler, context) does **not** invalidate the cache for
existing content files. Always run `make clean && make build` after any Haskell-side change,
or content will be served from the stale cache.
The Haskell build program lives in `build/`. The entry point is `build/Main.hs`.
## Architecture
### Build System (`build/`)
The Hakyll site compiler is split into focused modules:
- `Main.hs` — entry point
- `Site.hs` — all Hakyll rules (which patterns compile to which outputs)
- `Compilers.hs` — custom Pandoc compiler wrappers
- `Contexts.hs` — Hakyll template contexts (includes auto-computed `word-count`, `reading-time`)
- `Metadata.hs` — loads YAML frontmatter + merges external JSON from `data/`
- `Tags.hs` — hierarchical tag system using Hakyll `buildTags`
- `Pagination.hs` — 20/page for blog and tag indexes; essays all on one page
- `Citations.hs` — citeproc + BibLaTeX + Chicago Author-Date; bib file at `data/bibliography.bib`
- `Filters.hs` — re-exports all Pandoc AST filter modules
- `Filters/Typography.hs` — smart quotes, dashes, etc.
- `Filters/Sidenotes.hs` — converts footnotes to sidenotes
- `Filters/Dropcaps.hs` — decorative drop capitals
- `Filters/Smallcaps.hs` — smallcaps via `smcp` OT feature
- `Filters/Wikilinks.hs``[[wikilink]]` syntax
- `Filters/Links.hs` — external link classification and icon injection
- `Filters/Math.hs` — simple LaTeX → Unicode at build time; complex math → KaTeX SSR (static HTML+MathML)
- `Utils.hs` — shared helpers
### Math Pipeline
Two-tier, no client-side JS required:
1. Simple math → Unicode/HTML via Pandoc Lua filter (inherits body font)
2. Complex math → KaTeX server-side rendering → static HTML+MathML (KaTeX CSS loaded conditionally, only on pages that use math)
### CSS (`static/css/`)
| File | Purpose |
|------|---------|
| `base.css` | CSS variables, palette, dark mode (`[data-theme="dark"]` + `prefers-color-scheme`) |
| `typography.css` | Spectral OT features: smallcaps (`smcp`), ligatures, figure styles, dropcaps |
| `layout.css` | Three-column layout: sticky TOC (left) | body 650700px (center) | sidenotes (right). Collapses on narrow screens. |
| `sidenotes.css` | Sidenote positioning |
| `popups.css` | Link preview popups |
| `syntax.css` | Monochrome code highlighting (JetBrains Mono) |
| `components.css` | Two-row nav, metadata block, collapsibles |
### JavaScript (`static/js/`)
| File | Source | Purpose |
|------|--------|---------|
| `sidenotes.js` | Adopted — Said Achmiz (MIT) | Sidenote positioning |
| `popups.js` | Forked + simplified — Said Achmiz (MIT) | Internal previews, Wikipedia, citation previews |
| `theme.js` | Original | Dark/light toggle with `localStorage` |
| `toc.js` | Original | Sticky TOC + scroll tracking via `IntersectionObserver` |
| `search.js` | Original | Pagefind integration |
| `nav.js` | Original | Portal row expand/collapse (state in `localStorage`) |
| `collapse.js` | Original | Section collapsing |
### Typography
- **Body:** Spectral (SIL OFL) — self-hosted WOFF2, subsetted with full OT features (`liga`, `smcp`, `onum`, etc.)
- **UI/Headers:** Fira Sans (SIL OFL) — smallcaps for primary nav row
- **Code:** JetBrains Mono (SIL OFL)
All fonts self-hosted from source (not Google Fonts, which strips OT features). Subset with `pyftsubset`.
### Navigation Structure
```
Home | Me | New | Index | [🔍] ← primary row (always visible), Fira Sans smallcaps
────────────────────────────────
▼ Fiction | Miscellany | Music | Nonfiction | Poetry | Research ← portal row
```
Portal row collapsed by default; expansion state in `localStorage`.
### Content Portals
Six content portals map to `content/` subdirectories: Fiction, Miscellany, Music, Nonfiction, Poetry, Research. Essays live under Nonfiction; blog posts are a separate stream.
### Metadata
Frontmatter keys for Phase 1: `title`, `created`, `modified`, `status`, `tags`, `abstract`.
Auto-computed at build: `word-count`, `reading-time`.
External data loaded from `data/annotations.yaml` and `data/bibliography.bib`.
### Deployment
Local build → `_site/``rsync` to VPS. No server-side processing; nginx serves static files. No Docker.
## Key Design Constraints
- **No tracking, no analytics, no fingerprinting** — enforced at the nginx CSP header level too
- **No client-side math rendering** — KaTeX runs at build time
- **Sidenotes right-column only** (matching gwern's `useLeftColumn: () => false`)
- **Configuration is code** — the Makefile and Haskell build system are the deployment pipeline
- **Content license:** CC BY-SA-NC 4.0 | **Code license:** MIT

View File

@ -8,11 +8,13 @@ module Contexts
, poetryCtx , poetryCtx
, fictionCtx , fictionCtx
, compositionCtx , compositionCtx
, contentKindField
) where ) where
import Data.Aeson (Value (..)) import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V import qualified Data.Vector as V
import Data.List (isPrefixOf)
import Data.Maybe (catMaybes, fromMaybe) import Data.Maybe (catMaybes, fromMaybe)
import Data.Time.Calendar (toGregorian) import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (getCurrentTime, utctDay) import Data.Time.Clock (getCurrentTime, utctDay)
@ -81,6 +83,25 @@ buildTimeField = field "build-time" $ \_ ->
| n `mod` 10 == 3 = "rd" | n `mod` 10 == 3 = "rd"
| otherwise = "th" | otherwise = "th"
-- ---------------------------------------------------------------------------
-- Content kind field
-- ---------------------------------------------------------------------------
-- | @$item-kind$@: human-readable content type derived from the item's route.
-- Used on the New page to label each entry (Essay, Post, Poem, etc.).
contentKindField :: Context String
contentKindField = field "item-kind" $ \item -> do
r <- getRoute (itemIdentifier item)
return $ case r of
Nothing -> "Page"
Just route
| "essays/" `isPrefixOf` route -> "Essay"
| "blog/" `isPrefixOf` route -> "Post"
| "poetry/" `isPrefixOf` route -> "Poem"
| "fiction/" `isPrefixOf` route -> "Fiction"
| "music/" `isPrefixOf` route -> "Composition"
| otherwise -> "Page"
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Site-wide context -- Site-wide context
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------

View File

@ -13,7 +13,8 @@ import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fi
compositionCompiler) compositionCompiler)
import Catalog (musicCatalogCtx) import Catalog (musicCatalogCtx)
import Commonplace (commonplaceCtx) import Commonplace (commonplaceCtx)
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx) import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
contentKindField)
import Tags (buildAllTags, applyTagRules) import Tags (buildAllTags, applyTagRules)
import Pagination (blogPaginateRules) import Pagination (blogPaginateRules)
import Stats (statsRules) import Stats (statsRules)
@ -309,6 +310,31 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" ctx >>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls >>= relativizeUrls
-- ---------------------------------------------------------------------------
-- New page — all content sorted by creation date, newest first
-- ---------------------------------------------------------------------------
create ["new.html"] $ do
route idRoute
compile $ do
let allContent = ( "content/essays/*.md"
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. allPoetry
.||. "content/music/*/index.md"
) .&&. hasNoVersion
items <- recentFirst =<< loadAll allContent
let itemCtx = contentKindField
<> dateField "date-iso" "%Y-%m-%d"
<> essayCtx
ctx = listField "recent-items" itemCtx (return items)
<> constField "title" "New"
<> constField "new-page" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/new.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Library — comprehensive portal-grouped index of all content -- Library — comprehensive portal-grouped index of all content
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------

View File

@ -132,7 +132,7 @@ constraints: any.Glob ==0.10.2,
any.optparse-applicative ==0.18.1.0, any.optparse-applicative ==0.18.1.0,
any.ordered-containers ==0.2.4, any.ordered-containers ==0.2.4,
any.os-string ==2.0.8, any.os-string ==2.0.8,
any.pandoc ==3.5, any.pandoc ==3.6,
any.pandoc-types ==1.23.1, any.pandoc-types ==1.23.1,
any.parsec ==3.1.16.1, any.parsec ==3.1.16.1,
any.pem ==0.2.4, any.pem ==0.2.4,
@ -177,14 +177,14 @@ constraints: any.Glob ==0.10.2,
any.tagsoup ==0.14.8, any.tagsoup ==0.14.8,
any.template-haskell ==2.20.0.0, any.template-haskell ==2.20.0.0,
any.temporary ==1.3, any.temporary ==1.3,
any.texmath ==0.12.8.11, any.texmath ==0.12.8.12,
any.text ==2.0.2, any.text ==2.0.2,
any.text-conversions ==0.3.1.1, any.text-conversions ==0.3.1.1,
any.text-icu ==0.8.0.5, any.text-icu ==0.8.0.5,
any.text-iso8601 ==0.1.1, any.text-iso8601 ==0.1.1,
any.text-short ==0.1.6, any.text-short ==0.1.6,
any.th-abstraction ==0.6.0.0, any.th-abstraction ==0.6.0.0,
any.th-compat ==0.1.6, any.th-compat ==0.1.7,
any.th-expand-syns ==0.4.12.0, any.th-expand-syns ==0.4.12.0,
any.th-lift ==0.8.6, any.th-lift ==0.8.6,
any.th-lift-instances ==0.1.20, any.th-lift-instances ==0.1.20,
@ -201,8 +201,8 @@ constraints: any.Glob ==0.10.2,
any.transformers-base ==0.4.6.1, any.transformers-base ==0.4.6.1,
any.transformers-compat ==0.7.2, any.transformers-compat ==0.7.2,
any.typed-process ==0.2.13.0, any.typed-process ==0.2.13.0,
any.typst ==0.6, any.typst ==0.6.1,
any.typst-symbols ==0.1.6, any.typst-symbols ==0.1.7,
any.unicode-collation ==0.1.3.6, any.unicode-collation ==0.1.3.6,
any.unicode-data ==0.6.0, any.unicode-data ==0.6.0,
any.unicode-transforms ==0.4.0.1, any.unicode-transforms ==0.4.0.1,

149
static/css/new.css Normal file
View File

@ -0,0 +1,149 @@
/* new.css — Recently published content page */
.new-intro {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin-bottom: 2rem;
}
/* ============================================================
COUNT CONTROL
============================================================ */
.new-controls {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.new-controls-label {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-faint);
}
.new-controls-options {
display: flex;
gap: 0.3rem;
}
.new-count-btn {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.55em;
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.new-count-btn:hover {
border-color: var(--border-muted);
color: var(--text);
}
.new-count-btn.is-active {
border-color: var(--text-muted);
color: var(--text);
font-weight: 600;
}
/* ============================================================
ENTRY LIST
============================================================ */
.new-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.new-entry {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.new-entry:first-child {
border-top: 1px solid var(--border);
}
/* ============================================================
KIND BADGE
============================================================ */
.new-entry-kind {
font-family: var(--font-sans);
font-size: 0.63rem;
font-variant: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
background: var(--bg-offset);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.5em;
flex-shrink: 0;
margin-top: 0.25em;
min-width: 5.5rem;
text-align: center;
line-height: 1.6;
}
/* ============================================================
ENTRY CONTENT
============================================================ */
.new-entry-main {
flex: 1;
min-width: 0;
}
.new-entry-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.new-entry-title {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text);
text-decoration: none;
line-height: 1.35;
}
.new-entry-title:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.new-entry-date {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.new-entry-abstract {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin: 0.3rem 0 0;
line-height: 1.55;
/* Clamp to two lines */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

54
templates/new.html Normal file
View File

@ -0,0 +1,54 @@
<main id="markdownBody">
<h1 class="page-title">New</h1>
<div class="new-controls">
<span class="new-controls-label">Show</span>
<div class="new-controls-options" role="group" aria-label="Number of entries to show">
<button class="new-count-btn" data-count="25">25</button>
<button class="new-count-btn" data-count="50">50</button>
<button class="new-count-btn" data-count="100">100</button>
<button class="new-count-btn" data-count="all">All</button>
</div>
</div>
<ul class="new-list">
$for(recent-items)$
<li class="new-entry">
<span class="new-entry-kind">$item-kind$</span>
<div class="new-entry-main">
<div class="new-entry-header">
<a class="new-entry-title" href="$url$">$title$</a>
<time class="new-entry-date" datetime="$date-iso$">$date-created$</time>
</div>
$if(abstract)$<p class="new-entry-abstract">$abstract$</p>$endif$
</div>
</li>
$endfor$
</ul>
</main>
<script>
(function () {
var STORAGE_KEY = 'new-page-count';
var DEFAULT = 25;
function applyCount(n) {
var entries = document.querySelectorAll('.new-entry');
var limit = (n === 'all') ? Infinity : parseInt(n, 10);
entries.forEach(function (el, i) {
el.hidden = i >= limit;
});
document.querySelectorAll('.new-count-btn').forEach(function (btn) {
btn.classList.toggle('is-active', btn.dataset.count === String(n));
});
try { localStorage.setItem(STORAGE_KEY, n); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
var saved;
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
applyCount(saved || DEFAULT);
document.querySelectorAll('.new-count-btn').forEach(function (btn) {
btn.addEventListener('click', function () { applyCount(btn.dataset.count); });
});
});
}());
</script>

View File

@ -14,6 +14,7 @@ $if(home)$<title>Levi Neuwirth</title>$else$<title>$title$ — Levi Neuwirth</ti
<link rel="stylesheet" href="/css/images.css"> <link rel="stylesheet" href="/css/images.css">
$if(home)$<link rel="stylesheet" href="/css/home.css">$endif$ $if(home)$<link rel="stylesheet" href="/css/home.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/library.css">$endif$ $if(library)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(new-page)$<link rel="stylesheet" href="/css/new.css">$endif$
$if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$ $if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$
$if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$ $if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$
$if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$ $if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$