diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ee99a96..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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 650–700px (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 diff --git a/build/Contexts.hs b/build/Contexts.hs index fa0c94a..9384070 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -8,11 +8,13 @@ module Contexts , poetryCtx , fictionCtx , compositionCtx + , contentKindField ) where import Data.Aeson (Value (..)) import qualified Data.Aeson.KeyMap as KM import qualified Data.Vector as V +import Data.List (isPrefixOf) import Data.Maybe (catMaybes, fromMaybe) import Data.Time.Calendar (toGregorian) import Data.Time.Clock (getCurrentTime, utctDay) @@ -81,6 +83,25 @@ buildTimeField = field "build-time" $ \_ -> | n `mod` 10 == 3 = "rd" | 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 -- --------------------------------------------------------------------------- diff --git a/build/Site.hs b/build/Site.hs index 3131a4f..b24d550 100644 --- a/build/Site.hs +++ b/build/Site.hs @@ -13,7 +13,8 @@ import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fi compositionCompiler) import Catalog (musicCatalogCtx) 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 Pagination (blogPaginateRules) import Stats (statsRules) @@ -309,6 +310,31 @@ rules = do >>= loadAndApplyTemplate "templates/default.html" ctx >>= 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 -- --------------------------------------------------------------------------- diff --git a/cabal.project.freeze b/cabal.project.freeze index b0fe771..71f1281 100644 --- a/cabal.project.freeze +++ b/cabal.project.freeze @@ -132,7 +132,7 @@ constraints: any.Glob ==0.10.2, any.optparse-applicative ==0.18.1.0, any.ordered-containers ==0.2.4, any.os-string ==2.0.8, - any.pandoc ==3.5, + any.pandoc ==3.6, any.pandoc-types ==1.23.1, any.parsec ==3.1.16.1, any.pem ==0.2.4, @@ -177,14 +177,14 @@ constraints: any.Glob ==0.10.2, any.tagsoup ==0.14.8, any.template-haskell ==2.20.0.0, any.temporary ==1.3, - any.texmath ==0.12.8.11, + any.texmath ==0.12.8.12, any.text ==2.0.2, any.text-conversions ==0.3.1.1, any.text-icu ==0.8.0.5, any.text-iso8601 ==0.1.1, any.text-short ==0.1.6, 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-lift ==0.8.6, 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-compat ==0.7.2, any.typed-process ==0.2.13.0, - any.typst ==0.6, - any.typst-symbols ==0.1.6, + any.typst ==0.6.1, + any.typst-symbols ==0.1.7, any.unicode-collation ==0.1.3.6, any.unicode-data ==0.6.0, any.unicode-transforms ==0.4.0.1, diff --git a/static/css/new.css b/static/css/new.css new file mode 100644 index 0000000..a75582c --- /dev/null +++ b/static/css/new.css @@ -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; +} diff --git a/templates/new.html b/templates/new.html new file mode 100644 index 0000000..5a59016 --- /dev/null +++ b/templates/new.html @@ -0,0 +1,54 @@ +
+

New

+
+ Show +
+ + + + +
+
+ +
+ diff --git a/templates/partials/head.html b/templates/partials/head.html index 0e45bfd..6c70520 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -14,6 +14,7 @@ $if(home)$Levi Neuwirth$else$$title$ — Levi Neuwirth</ti <link rel="stylesheet" href="/css/images.css"> $if(home)$<link rel="stylesheet" href="/css/home.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(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$ $if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$