diff --git a/ *Minibuf-1* b/ *Minibuf-1* deleted file mode 100644 index 0e58e14..0000000 --- a/ *Minibuf-1* +++ /dev/null @@ -1 +0,0 @@ -Search: diff --git a/Makefile b/Makefile index 9b95bc2..e894e01 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,7 @@ deploy: clean build sign @test -n "$(VPS_USER)" || (echo "deploy: VPS_USER not set in .env" >&2; exit 1) @test -n "$(VPS_HOST)" || (echo "deploy: VPS_HOST not set in .env" >&2; exit 1) @test -n "$(VPS_PATH)" || (echo "deploy: VPS_PATH not set in .env" >&2; exit 1) + @command -v notify-send >/dev/null 2>&1 && notify-send "make deploy" "Ready to rsync — waiting for SSH auth" || true rsync -avz --delete _site/ $(VPS_USER)@$(VPS_HOST):$(VPS_PATH)/ git push -u origin main diff --git a/WRITING.md b/WRITING.md index 50e9fac..421e73a 100644 --- a/WRITING.md +++ b/WRITING.md @@ -10,12 +10,15 @@ frontmatter fields, and every authoring feature available in the Markdown source | Type | Location | Output URL | |------|----------|------------| | Essay | `content/essays/my-essay.md` | `/essays/my-essay.html` | +| Essay (with co-located assets) | `content/essays/my-essay/index.md` | `/essays/my-essay/index.html` | | Blog post | `content/blog/my-post.md` | `/blog/my-post.html` | | Poetry | `content/poetry/my-poem.md` | `/poetry/my-poem.html` | +| Poetry collection | `content/poetry/collection-name/*.md` | `/poetry/collection-name/*.html` | | Fiction | `content/fiction/my-story.md` | `/fiction/my-story.html` | | Composition | `content/music/{slug}/index.md` | `/music/{slug}/` | | Standalone page | `content/my-page.md` | `/my-page.html` | | Standalone page (with co-located assets) | `content/my-page/index.md` | `/my-page.html` | +| Draft essay | `content/drafts/essays/my-draft.md` | `/drafts/essays/my-draft.html` (dev only) | File names become URL slugs. Use lowercase, hyphen-separated words. @@ -26,6 +29,27 @@ Score fragment paths are resolved relative to the source file's directory; a fla --- +## Drafts + +In-progress essays go under `content/drafts/essays/`. Both flat files +(`content/drafts/essays/my-draft.md`) and directory-based essays +(`content/drafts/essays/my-draft/index.md`) are supported. + +Drafts are **included** in `make watch` and `make dev` (which set `SITE_ENV=dev`) +and **excluded** from every production build (`make build`, `make deploy`). +They are also invisible to tags, author pages, backlinks, stats, and feeds. + +To preview a draft: + +```bash +make watch # Hakyll live-reload with drafts visible +make dev # clean build + Python HTTP server with drafts visible +``` + +When the draft is ready, move it into `content/essays/`. + +--- + ## Frontmatter Every file begins with a YAML block fenced by `---`. Keys are read by Hakyll @@ -56,6 +80,7 @@ further-reading: # optional; see Citations section bibliography: data/custom.bib # optional; overrides data/bibliography.bib csl: data/custom.csl # optional; overrides Chicago Author-Date no-collapse: true # optional; disables collapsible h2/h3 sections +repository: https://git.levineuwirth.org/levi/repo # optional; "Repository" link in metadata js: scripts/my-widget.js # optional; per-page JS file (see Page scripts) # js: [scripts/a.js, scripts/b.js] # or a list @@ -93,6 +118,20 @@ Same fields as essays. Poetry uses a narrow-measure codex reading mode `poetryCompiler` enables `Ext_hard_line_breaks` — each source newline becomes a `
`, so verse lines render without trailing-space tricks. +**Poetry collections** — poems can be grouped into subdirectories: + +``` +content/poetry/shakespeare-sonnets/ +├── index.md ← collection landing page (uses pageCtx) +├── sonnet-1.md ← individual poem +├── sonnet-2.md +└── … +``` + +Collection index pages (`content/poetry/*/index.md`) compile as standalone +pages. Poems inside collections (`content/poetry/*/*.md`, excluding +`index.md`) compile with `poetryCompiler` just like flat poems. + **External / non-original poems** — use `poet:` instead of `authors:` to credit an external author without generating a (broken) author index page: @@ -272,6 +311,26 @@ not derivable from the page title. --- +## Transclusion + +Embed the rendered content of another page (or a section of it) inline using +`{{slug}}` directives. The directive must be on its own line. + +```markdown +{{my-essay}} → embeds the full body of /my-essay.html +{{essays/deep-dive}} → embeds /essays/deep-dive.html +{{my-essay#introduction}} → embeds only the "introduction" section +``` + +At build time, `Filters.Transclusion` rewrites these to `
` placeholders. At runtime, +`transclude.js` fetches the target page and injects the content. + +Transclusions inside code blocks or inline prose are not replaced — only +block-level directives (sole content of a line) are processed. + +--- + ## PDF Embeds Embed a hosted PDF in a full PDF.js viewer (page navigation, zoom, text @@ -343,26 +402,82 @@ $$ ## Links -Internal links: standard Markdown or wikilinks. +Internal links: standard Markdown or wikilinks. Internal links get a +`link-internal` class and an internal icon. External links are classified automatically at build time: - Opened in a new tab (`target="_blank" rel="noopener noreferrer"`) - Decorated with a domain icon via CSS `mask-image` +Links to sibling subdomains (e.g. `git.levineuwirth.org`) are classified as +external — only `levineuwirth.org` and `www.levineuwirth.org` are treated as +the content host. + | Domain | Icon | |--------|------| | `wikipedia.org` | Wikipedia | | `arxiv.org` | arXiv | | `doi.org` | DOI | +| `worldcat.org` | WorldCat | +| `orcid.org` | ORCID | +| `archive.org` | Internet Archive | | `github.com` | GitHub | +| `git.levineuwirth.org` | Forgejo | +| `tensorflow.org` | TensorFlow | +| `anthropic.com` | Anthropic | +| `openai.com` | OpenAI | +| `twitter.com` / `x.com` | Twitter/X | +| `reddit.com` | Reddit | +| `youtube.com` / `youtu.be` | YouTube | +| `tiktok.com` | TikTok | +| `substack.com` | Substack | +| `news.ycombinator.com` | Hacker News | +| `nytimes.com` | New York Times | +| `nasa.gov` | NASA | +| `apple.com` | Apple | | Everything else | Generic external arrow | ### Link preview popups -Hovering over an internal link shows a popup: title, abstract, authors, tags, -reading time. Hovering over a Wikipedia link shows an article excerpt. -Citation markers (`[1]`) show the full reference. All automatic. +Hovering over a link shows a context-aware popup. All automatic — no +markup needed. + +| Target | Popup content | +|--------|--------------| +| Internal page | Title, abstract, authors, tags, word count, reading time | +| Citation marker (`[1]`) | Full bibliography reference (multi-cite groups supported) | +| Wikipedia | Lead section extract via MediaWiki API | +| arXiv | Title, authors, abstract via Atom API | +| DOI / CrossRef | Title, authors, journal, year, abstract | +| GitHub | Repo name, description, language, stars | +| Forgejo (`git.levineuwirth.org`) | Repo name, description, language, stars | +| Open Library | Book title, description | +| bioRxiv / medRxiv | Title, authors, abstract | +| YouTube | Video title, channel name (oEmbed) | +| Internet Archive | Title, creator, description | +| PubMed | Title, authors, journal, year | +| PDF link | First-page thumbnail (from build-time `pdftoppm`) | +| Epistemic jump link (`#epistemic`) | Clone of the full epistemic profile | +| Epistemic term label (`data-ep-term`) | Term definition from colophon | +| PGP signature link | ASCII armor of the `.sig` file | + +**Custom annotations** — author-defined previews for any URL. Add entries +to `data/annotations.json`: + +```json +{ + "https://example.com/article": { + "title": "Article Title", + "annotation": "A brief note about why this link is relevant." + } +} +``` + +Annotation entries take priority over all other popup providers. + +Popups are disabled on touch-primary devices and inside nav/TOC/footer +elements. --- @@ -864,24 +979,48 @@ when the Python environment is set up (`uv sync`). No markup needed. --- +## Auto-generated pages + +These pages are built automatically and require no content files or markup: + +| Page | URL | Description | +|------|-----|-------------| +| Essay index | `/essays/` | All essays, newest first | +| Blog index | `/blog/` | Paginated blog posts | +| New | `/new.html` | All content types sorted by date, newest first | +| Library | `/library.html` | All content grouped by portal (AI, Fiction, Music, etc.) | +| Build telemetry | `/build/` | Corpus stats, word-length distribution, tag frequencies, link analysis, epistemic coverage, output metrics, repository overview, build timing, and a 52-week writing activity heatmap | +| Tag indexes | `//` | Paginated pages per tag, auto-generated | +| Author indexes | `/authors//` | All content attributed to an author | +| Random manifest | `/random-pages.json` | JSON array of page URLs for the random-page button | +| Atom feeds | `/feed.xml`, `/music/feed.xml` | All content feed + music-only feed | +| Search | `/search.html` | Pagefind full-text search + client-side semantic search (`nomic-embed-text-v1.5` ONNX model) | + +--- + ## Build ```bash -make build # auto-commit content/, compile, run pagefind + embeddings, clear IGNORE.txt -make sign # GPG detach-sign every _site/**/*.html → .html.sig (requires passphrase cached) -make deploy # build + sign + optional GitHub push + rsync to VPS -make watch # Hakyll live-reload dev server at http://localhost:8000 -make dev # clean build + python HTTP server at http://localhost:8000 +make build # auto-commit content/, convert images, PDF thumbs, compile, + # run pagefind + embeddings, clear IGNORE.txt +make sign # GPG detach-sign every _site/**/*.html → .html.sig +make deploy # clean + build + sign + rsync to VPS + git push +make watch # Hakyll live-reload dev server (SITE_ENV=dev — includes drafts) +make dev # clean build + Python HTTP server (SITE_ENV=dev — includes drafts) make clean # wipe _site/ and _cache/ ``` +`make watch` and `make dev` set `SITE_ENV=dev`, which includes drafts under +`content/drafts/essays/` in the build. All other targets exclude drafts. + `make watch` hot-reloads changes to Markdown, CSS, JS, and templates. **After any change to a `.hs` file, always run `make clean && make build`** — Hakyll's cache is keyed to source file mtimes and will serve stale output after Haskell-side changes. -`make deploy` pushes to GitHub if `GITHUB_TOKEN` and `GITHUB_REPO` are set in -`.env` (see `.env.example`), then rsyncs `_site/` to the VPS. +`make deploy` requires `VPS_USER`, `VPS_HOST`, and `VPS_PATH` to be set in +`.env` (see `.env.example`). It runs `clean → build → sign`, sends a desktop +notification, rsyncs `_site/` to the VPS, and pushes to `origin main`. **GPG signing:** `make sign` and `make deploy` require the signing subkey passphrase to be cached. Run once per boot (or per 24h expiry): @@ -890,6 +1029,11 @@ passphrase to be cached. Run once per boot (or per 24h expiry): ./tools/preset-signing-passphrase.sh ``` +**Image conversion:** `make build` automatically runs `tools/convert-images.sh` +to generate WebP companions for JPEG/PNG images (requires `cwebp`). It also +generates first-page PDF thumbnails via `pdftoppm` (requires `poppler`). +Both are skipped silently when the tools are not installed. + **Python environment:** the embedding pipeline requires `uv sync` to be run once. After that, `make build` invokes `uv run python tools/embed.py` automatically. If `.venv` is absent, the step is skipped with a warning and diff --git a/build/Contexts.hs b/build/Contexts.hs index 151ed41..8c1fa73 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -211,6 +211,28 @@ abstractField = field "abstract" $ \item -> do isPara (Para _) = True isPara _ = False +-- --------------------------------------------------------------------------- +-- Summary field +-- --------------------------------------------------------------------------- + +-- | Renders the @summary@ frontmatter key through Pandoc, preserving full +-- block structure (paragraphs, bold, lists). Unlike 'abstractField', no +-- paragraph flattening is applied because the summary renders inside its +-- own styled box rather than inline in the metadata strip. +summaryField :: Context String +summaryField = field "summary" $ \item -> do + meta <- getMetadata (itemIdentifier item) + case lookupString "summary" meta of + Nothing -> fail "no summary" + Just src -> do + let pandocResult = runPure $ do + doc <- readMarkdown defaultHakyllReaderOptions (T.pack src) + let wOpts = defaultHakyllWriterOptions { writerHTMLMathMethod = MathML } + writeHtml5String wOpts doc + case pandocResult of + Left err -> fail $ "Pandoc error rendering summary: " ++ show err + Right html -> return (T.unpack html) + siteCtx :: Context String siteCtx = constField "site-title" "Levi Neuwirth" @@ -218,6 +240,7 @@ siteCtx = <> buildTimeField <> pageScriptsField <> abstractField + <> summaryField <> defaultContext -- --------------------------------------------------------------------------- diff --git a/build/Filters.hs b/build/Filters.hs index 9f00073..0f532e1 100644 --- a/build/Filters.hs +++ b/build/Filters.hs @@ -19,6 +19,7 @@ import qualified Filters.Transclusion as Transclusion import qualified Filters.EmbedPdf as EmbedPdf import qualified Filters.Code as Code import qualified Filters.Images as Images +import qualified Filters.Aftermatter as Aftermatter -- | Apply all AST-level filters in pipeline order. -- Run on the Pandoc document after reading, before writing. @@ -33,6 +34,7 @@ applyAll :: FilePath -> Pandoc -> IO Pandoc applyAll srcDir doc = do imagesDone <- Images.apply srcDir doc pure + . Aftermatter.apply . Sidenotes.apply . Typography.apply . Links.apply diff --git a/build/Filters/Aftermatter.hs b/build/Filters/Aftermatter.hs new file mode 100644 index 0000000..a1535df --- /dev/null +++ b/build/Filters/Aftermatter.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE GHC2021 #-} +{-# LANGUAGE OverloadedStrings #-} +module Filters.Aftermatter (apply) where + +import Text.Pandoc.Definition (Pandoc (..), Block (..), Format (..)) + +apply :: Pandoc -> Pandoc +apply (Pandoc meta blocks) = Pandoc meta (concatMap go blocks) + where + go (Div attr@(_, classes, _) content) + | "aftermatter" `elem` classes + = [dividerBlock, Div attr content] + go b = [b] + +dividerBlock :: Block +dividerBlock = RawBlock (Format "html") + "
\ + \\ + \
" diff --git a/cabal.project.freeze b/cabal.project.freeze index c4a3e57..3897fe4 100644 --- a/cabal.project.freeze +++ b/cabal.project.freeze @@ -100,7 +100,7 @@ constraints: any.Glob ==0.10.2, http-conduit +aeson, any.http-date ==0.0.11, any.http-types ==0.12.4, - any.http2 ==5.1.0, + any.http2 ==5.1.1, any.indexed-traversable ==0.1.4, any.indexed-traversable-instances ==0.1.2, any.integer-conversion ==0.1.1, diff --git a/favicon.zip b/favicon.zip deleted file mode 100644 index 5f1eb7d..0000000 Binary files a/favicon.zip and /dev/null differ diff --git a/levineuwirth.cabal b/levineuwirth.cabal index 747f0a2..b27b131 100644 --- a/levineuwirth.cabal +++ b/levineuwirth.cabal @@ -39,6 +39,7 @@ executable site Filters.Code Filters.Images Filters.Score + Filters.Aftermatter Filters.Viz Utils build-depends: diff --git a/spec.md b/spec.md deleted file mode 100644 index 7e96da4..0000000 --- a/spec.md +++ /dev/null @@ -1,742 +0,0 @@ -# levineuwirth.org — Design Specification v11 - -**Author:** Levi Neuwirth -**Date:** March 2026 (v11: 21 March 2026) -**Status:** LIVING DOCUMENT — Updated as implementation progresses. - ---- - -## I. Vision & Philosophy - -This website is an **intellectual home** — the permanent residence of a mind that moves freely between computer science, music composition, poetry, fiction, and whatever else catches fire. - -### Commitments -1. **Long content over disposable content.** Essays are living documents. -2. **Semantic zoom.** Title → abstract → headers → body → sidenotes → citations → sources. -3. **Earned ornament.** Every decorative element serves a purpose. -4. **The site is the proof.** Entirely FOSS. No tracking. No analytics. No fingerprinting. -5. **Reader > Author.** -6. **Configuration is code.** The build system is a Haskell program. -7. **No homepage epigraph.** -8. **Extensible metadata.** Future-proofed for semantic embeddings via external JSON injection. - ---- - -## II. All Resolved Decisions - -### Typography - -| Role | Font | License | Notes | -|------|------|---------|-------| -| **Body** | **Spectral** | SIL OFL | Screen-first serif. True smallcaps (`smcp`), four figure styles, ligatures, seven weights + italics. Self-hosted from source — Google Fonts strips OT features. | -| **UI / Headers** | **Fira Sans** | SIL OFL | Humanist sans-serif. Complements Spectral. | -| **Code** | **JetBrains Mono** | SIL OFL | Ligatures, excellent legibility. | - -Font pairing has been tested across screens and confirmed. - -**Self-hosting workflow:** -```bash -pyftsubset Spectral-Regular.ttf \ - --output-file=spectral-regular.woff2 \ - --flavor=woff2 \ - --layout-features='liga,dlig,smcp,c2sc,onum,lnum,pnum,tnum,frac,ordn,sups,subs,ss01,ss02,ss03,ss04,ss05,kern' \ - --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD' \ - --no-hinting --desubroutinize -``` - -### LaTeX Math - -Client-side KaTeX (not pure build-time SSR — see Implementation Notes): -- Pandoc outputs math spans with `class="math inline"` / `class="math display"` -- KaTeX renders client-side from a deferred script -- KaTeX CSS/fonts loaded conditionally only on pages with math (`$if(math)$` in head template) - -### Navigation - -``` -Home | Me | Current | New | Links | Search [⚙] -─────────────────────────────────────────────── -▼ Portals -AI | Fiction | Miscellany | Music | Nonfiction | Poetry | Research | Tech -``` - -- **Primary row (always visible):** Home, Me, Current (now-page), New (changelog), Links, Search; settings gear (⚙) on the right -- **Settings panel** (⚙ button): Theme (Light/Dark), Text size (A−/A+), Focus Mode, Reduce Motion, Print — managed by `settings.js`; state persisted via `localStorage` -- **Expandable portal row:** AI, Fiction, Miscellany, Music, Nonfiction, Poetry, Research, Tech -- Portal row collapsed by default; expansion state persisted via `localStorage` -- Fira Sans smallcaps for primary row - -### Layout - -- **Left margin:** Interactive sticky TOC (`IntersectionObserver`). Collapses on narrow screens. -- **Center column:** Body text in Spectral. 650–700px max-width. -- **Right margin:** Sidenotes only (right column). - -### Color - -Pure monochrome. No accent color. Light mode default (`#faf8f4` background, `#1a1a1a` text). Dark mode via `[data-theme="dark"]` + `prefers-color-scheme`. - -### Content Systems - -- **Tag system:** Hierarchical, slash-separated (`research/mathematics`). Hakyll `buildTags` + custom hierarchy. Tag pages at `//` with no `/tags/` namespace prefix. -- **Pagination:** Blog index 20/page, tag pages 20/page. Essay index all on one page. -- **RSS:** Atom feed at `/feed.xml` (all content types, sorted by `date`) and `/music/feed.xml` (compositions only). -- **Citations:** Numbered superscript markers `[1]` linked to a bibliography section. Hover preview via `citations.js`. Further Reading section separate from cited works. `data/bibliography.bib` + Chicago Author-Date CSL. -- **Collapsible sections:** h2/h3 headings toggle their content via `collapse.js`. Smooth `max-height` transition. State persisted in `localStorage`. - -### Gwern Codebase: Selective Adoption - -| Component | Action | Actual outcome | -|-----------|--------|----------------| -| `sidenotes.js` | Adopt directly (Said Achmiz, MIT) | **Written from scratch** — purpose-built for our HTML structure | -| `popups.js` | Fork and simplify (Said Achmiz, MIT) | Exists in `static/js/popups.js`; Phase 3 | -| CSS typographic foundations | Extract and refactor | Done | -| Pandoc AST filters | Write from scratch | Done | -| Hakyll architecture | Rewrite, informed by gwern | Done | -| Everything else | Ignore | — | - -### Metadata - -Extensible YAML frontmatter. Hakyll strips frontmatter before passing to Pandoc, so all frontmatter access goes through Hakyll's metadata API (`lookupStringList`, `getMetadataField`, etc.), not through Pandoc `Meta`. - -**Frontmatter keys in use:** -```yaml -title: # page title -date: # ISO date (YYYY-MM-DD) — used for sorting, feed, reading-time -abstract: # short description (1–3 sentences). Rendered via Pandoc to support LaTeX math and Markdown. -tags: # hierarchical tag list -authors: # list of author names (defaults to Levi Neuwirth) -further-reading: # list of BibTeX keys for the Further Reading section -bibliography: # path to .bib file (optional; defaults to data/bibliography.bib) -csl: # path to .csl file (optional; defaults to data/chicago-notes.csl) -repository: # external URL pointing to the content's source code or data repository - -# Epistemic profile (all optional; section shown only if `status` is present) -status: # Draft | Working model | Durable | Refined | Superseded | Deprecated -confidence: # 0–100 integer (%) -importance: # 1–5 integer (rendered as filled/empty dots) -evidence: # 1–5 integer (rendered as filled/empty dots) -scope: # personal | local | average | broad | civilizational -novelty: # conventional | moderate | idiosyncratic | innovative -practicality: # abstract | low | moderate | high | exceptional -stability: # volatile | revising | fairly stable | stable | established - # (auto-computed from git history; use IGNORE.txt to pin) -last-reviewed: # ISO date — overrides git-derived date when in IGNORE.txt -confidence-history: # list of integers — trend derived from last two entries (↑↓→) - -# Version history (optional; falls back to git log, then to date-created/date-modified) -history: - - date: "2026-03-01" # ISO date string (quote to prevent YAML date parsing) - note: Initial draft # human-readable annotation - - date: "2026-03-14" - note: Expanded typography section; added citations -``` - -Auto-computed at build time: `word-count`, `reading-time`. -Auto-derived at build time: `stability` (from `git log --follow`), `last-reviewed` (most recent commit date), `confidence-trend` (from `confidence-history`). - -**`IGNORE.txt`:** A file in the project root listing content paths (one per line) whose `stability` and `last-reviewed` should not be recomputed. Cleared automatically after every `make build`. Useful for pinning manually-set stability labels on pages whose git history is misleading. - -**Top metadata block:** -1. **Tags** — hierarchical tag list with links to tag index pages -2. **Description** — the `abstract` field, rendered via Pandoc (supporting LaTeX math and Markdown formatting), typically in italic -3. **Authors** — `authors` list -4. **Page info** — jump links to bottom metadata sections (Epistemic/Bibliography/Backlinks shown conditionally) - -**Bottom metadata footer:** -- **Version history** — three-tier priority: (1) frontmatter `history` list with authored notes → (2) git log dates (date-only) → (3) `date-created` / `date-modified` fallback. `make build` auto-commits `content/` before building, keeping git history current. -- **Epistemic** (if `status` set) — primary row: trust-score chip + status; labeled rows beneath: confidence · importance · evidence · scope · novelty · practicality (each shown only if its frontmatter field is set); always-visible `
`: stability · last reviewed · confidence trend -- **Bibliography** — formatted citations + Further Reading -- **Backlinks** — auto-generated; each entry shows source title (link) + collapsible context paragraph - -### Licensing - -- **Content:** CC BY-SA-NC 4.0 -- **Code:** MIT - ---- - -## III. Deployment & Infrastructure - -### Deployment Pipeline - -``` -[Local machine] [Arch Linux VPS / DreamHost] - -content/*.md - ↓ -cabal run site -- build nginx serving - ↓ /var/www/levineuwirth.org/ -pagefind --site _site - ↓ -rsync -avz --delete \ - _site/ \ - vps:/var/www/levineuwirth.org/ ──→ Live site -``` - -```makefile -build: - @git add content/ - @git diff --cached --quiet || git commit -m "auto: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - @date +%s > data/build-start.txt - @./tools/convert-images.sh # WebP conversion (skipped if cwebp absent) - cabal run site -- build - pagefind --site _site - @if [ -d .venv ]; then \ - uv run python tools/embed.py || echo "Warning: embedding failed"; \ - fi - > IGNORE.txt # clear stability pins after each build - @BUILD_END=$(date +%s); BUILD_START=$(cat data/build-start.txt); \ - echo $((BUILD_END - BUILD_START)) > data/last-build-seconds.txt - -sign: - @./tools/sign-site.sh # detach-sign every _site/**/*.html; requires passphrase cached via preset-signing-passphrase.sh - -deploy: build sign - rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/ - -watch: - cabal run site -- watch - -clean: - cabal run site -- clean - -download-model: - @./tools/download-model.sh # fetch quantized ONNX model to static/models/ (once per machine) - -convert-images: - @./tools/convert-images.sh # manual trigger; also runs in build -``` - -### Hosting Timeline - -1. **Immediate:** Deploy to DreamHost (rsync static files) -2. **Phase 5:** Provision Arch VPS (Hetzner), configure nginx + certbot, migrate DNS - -### VPS: nginx config (Arch Linux) - -```nginx -server { - listen 443 ssl http2; - server_name levineuwirth.org www.levineuwirth.org; - root /var/www/levineuwirth.org; - - # TLS (managed by certbot) - ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem; - - # cdn.jsdelivr.net required for transformers.js (semantic search library). - # Model weights served same-origin from /models/ — connect-src stays 'self'. - add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self';" always; - - gzip on; - gzip_types text/html text/css application/javascript application/json image/svg+xml; - - location ~* \.(woff2|css|js|svg|png|jpg|webp)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - location ~* \.html$ { - expires 1h; - add_header Cache-Control "public, must-revalidate"; - } - - try_files $uri $uri.html $uri/ =404; - error_page 404 /404.html; -} -server { - listen 80; - server_name levineuwirth.org www.levineuwirth.org; - return 301 https://$host$request_uri; -} -``` - ---- - -## IV. Repository Structure - -``` -levineuwirth.org/ -├── content/ -│ ├── essays/ -│ │ └── test-essay.md # Feature test document -│ ├── blog/ -│ ├── music/ -│ │ └── {slug}/ -│ │ ├── index.md # Composition frontmatter + program notes -│ │ ├── scores/ # LilyPond SVG pages + PDF -│ │ └── audio/ # Per-movement MP3s -│ └── *.md # Standalone pages (me, colophon, etc.) -├── static/ -│ ├── css/ -│ │ ├── base.css # CSS variables, palette, dark mode -│ │ ├── typography.css # Spectral OT features, dropcaps, smallcaps, link icons -│ │ ├── layout.css # 3-column layout, responsive breakpoints -│ │ ├── sidenotes.css # Sidenote positioning -│ │ ├── popups.css # Link preview popup styles -│ │ ├── syntax.css # Monochrome code highlighting (JetBrains Mono) -│ │ ├── components.css # Nav (incl. settings panel), TOC, metadata, citations, collapsibles -│ │ ├── viz.css # Visualization figure layout (.viz-figure, .vega-container, .viz-caption) -│ │ ├── gallery.css # Exhibit system + annotation callouts -│ │ ├── selection-popup.css # Text-selection toolbar -│ │ ├── annotations.css # User highlight marks + annotation tooltip -│ │ ├── images.css # Figure layout, captions, lightbox overlay -│ │ ├── score-reader.css # Full-page score reader layout -│ │ ├── catalog.css # Music catalog page (`/music/`) -│ │ └── print.css # Print stylesheet (media="print") -│ ├── js/ -│ │ ├── theme.js # Dark/light toggle (sync, not deferred) -│ │ ├── sidenotes.js # Written from scratch — collision avoidance, hover/focus -│ │ ├── toc.js # Sticky TOC + scroll tracking + animated collapse -│ │ ├── nav.js # Portal row expand/collapse + localStorage -│ │ ├── collapse.js # Section collapsing with localStorage persistence -│ │ ├── citations.js # Citation hover previews -│ │ ├── gallery.js # Exhibit overlay + annotation toggle -│ │ ├── popups.js # Link preview popups (internal, Wikipedia, citations) -│ │ ├── settings.js # Settings panel (theme, text size, focus mode, reduce motion, print) -│ │ ├── selection-popup.js # Context-aware text-selection toolbar -│ │ ├── annotations.js # localStorage highlight/annotation engine (UI deferred) -│ │ ├── score-reader.js # Score reader: page-turn, movement jumps, deep linking -│ │ ├── viz.js # Vega-Lite render + dark mode re-render via MutationObserver -│ │ ├── semantic-search.js # Client-side semantic search: transformers.js + Float32Array cosine ranking -│ │ ├── search.js # Pagefind UI init + ?q= pre-fill + search timing (#search-timing) -│ │ └── prism.min.js # Syntax highlighting -│ ├── fonts/ # Self-hosted WOFF2 (subsetted with OT features) -│ ├── gpg/ -│ │ └── pubkey.asc # Ed25519 signing subkey public key (master: CD90AE96…; subkey: C9A42A6F…) -│ ├── models/ # Self-hosted ONNX model (gitignored; run: make download-model) -│ │ └── all-MiniLM-L6-v2/ # ~22 MB quantized — served at /models/ for semantic-search.js -│ └── images/ -│ └── link-icons/ # SVG icons for external link classification -│ ├── external.svg -│ ├── wikipedia.svg -│ ├── github.svg -│ ├── arxiv.svg -│ └── doi.svg -├── templates/ -│ ├── default.html # Outer shell: nav, head, footer JS -│ ├── essay.html # 3-column layout with TOC -│ ├── composition.html # Music landing page (metadata block, movements, body, recording player) -│ ├── music-catalog.html # Music catalog index (`/music/`) -│ ├── score-reader.html # Minimal score reader body (top bar + SVG stage) -│ ├── score-reader-default.html # Minimal HTML shell for score reader (no nav/footer) -│ ├── blog-post.html -│ ├── page.html # Simple standalone pages -│ ├── essay-index.html -│ ├── blog-index.html -│ ├── tag-index.html -│ └── partials/ -│ ├── head.html # CSS, conditional JS (citations, collapse) -│ ├── nav.html # Two-row nav with portals -│ ├── footer.html -│ ├── metadata.html # Essay metadata block (top) -│ └── page-footer.html # Essay footer (bibliography, backlinks) -├── build/ -│ ├── Main.hs # Entry point -│ ├── Site.hs # Hakyll rules (all routes + Atom feed) -│ ├── Compilers.hs # Pandoc compiler wrappers -│ ├── Contexts.hs # Template contexts (word-count, reading-time, bibliography) -│ ├── Citations.hs # citeproc pipeline: Cite→superscript + bibliography HTML -│ ├── Filters.hs # Re-exports all filter modules -│ ├── Filters/ -│ │ ├── Typography.hs # Smart quotes, dashes -│ │ ├── Sidenotes.hs # Footnote → sidenote conversion -│ │ ├── Dropcaps.hs # Decorative first-letter drop caps -│ │ ├── Smallcaps.hs # Smallcaps via smcp OT feature -│ │ ├── Wikilinks.hs # [[wikilink]] syntax -│ │ ├── Links.hs # External link classification + data-link-icon attributes -│ │ ├── Math.hs # Simple LaTeX → Unicode conversion -│ │ ├── Code.hs # Prepend language- prefix for Prism.js -│ │ ├── Images.hs # Lazy loading, lightbox data-attributes, WebP wrapper for local raster images -│ │ ├── Score.hs # Score fragment SVG inlining + currentColor replacement -│ │ └── Viz.hs # Visualization IO filter: runs Python scripts, inlines SVG / Vega-Lite JSON -│ ├── Authors.hs # Author-as-tag system (slugify, authorLinksField, author pages) -│ ├── Backlinks.hs # Two-pass build-time backlinks with context paragraph extraction -│ ├── Catalog.hs # Music catalog: featured works + grouped-by-category HTML rendering -│ ├── Stability.hs # Git-based stability auto-calculation + last-reviewed derivation -│ ├── Metadata.hs # Stub (Phase 2+) -│ ├── Tags.hs # Hierarchical tag system -│ ├── Pagination.hs # 20/page for blog + tag indexes -│ └── Utils.hs # Shared helpers (wordCount, readingTime) -├── data/ -│ ├── bibliography.bib # BibTeX references -│ ├── chicago-notes.csl # CSL style (in-text, Chicago Author-Date) -│ └── (future: embeddings.json, similar-links.json) -├── tools/ -│ ├── subset-fonts.sh -│ ├── viz_theme.py # Matplotlib monochrome helpers (apply_monochrome, save_svg, LINESTYLE_CYCLE) -│ ├── sign-site.sh # Detach-sign every _site/**/*.html → .html.sig (called by `make sign`) -│ ├── preset-signing-passphrase.sh # Cache signing subkey passphrase in gpg-agent (run once per boot) -│ ├── download-model.sh # Fetch quantized ONNX model to static/models/ (run once per machine) -│ ├── convert-images.sh # Convert JPEG/PNG → WebP companions via cwebp (runs automatically in build) -│ └── embed.py # Build-time embedding pipeline: similar-links + semantic search index -├── levineuwirth.cabal -├── cabal.project -├── cabal.project.freeze -├── Makefile -└── CLAUDE.md -``` - ---- - -## V. Implementation Phases - -### Phase 1: Foundation ✓ -- [x] Init Hakyll project, modular Haskell build system -- [x] Font subsetting + self-hosting (Spectral, Fira Sans, JetBrains Mono) -- [x] CSS: base (palette, variables, dark mode), typography (Spectral features), layout (3-column), sidenotes -- [x] `sidenotes.js` — written from scratch (not adopted; see Implementation Notes) -- [x] Two-row navigation with expandable portals -- [x] Templates: default, essay, blog-post, index -- [x] Dark/light toggle with `localStorage` + `prefers-color-scheme` -- [x] Basic Pandoc pipeline (Markdown → HTML, smart typography) -- [x] Deploy to DreamHost via rsync — deployed to Hetzner VPS instead - -### Phase 2: Content Features ✓ -- [x] Pandoc filters: sidenotes, dropcaps, smallcaps, wikilinks, typography, link classification, code, math -- [x] Interactive sticky TOC — IntersectionObserver, animated expand/collapse, page-title display, auto-collapse on scroll -- [x] Citation system — numbered superscript markers, hover preview, bibliography + Further Reading sections -- [x] Monochrome syntax highlighting (Prism.js + `Filters.Code`) -- [x] Collapsible h2/h3 sections (`collapse.js`) — `max-height` transition, localStorage persistence -- [x] Hierarchical tag system + tag index pages -- [x] Pagination (blog index and tag pages, 20/page) -- [x] Metadata: YAML frontmatter + auto-computed word count / reading time -- [x] Single Atom feed (`/feed.xml`, all content, sorted by date) -- [x] External link icons (SVG mask-image, domain-classified via `Filters.Links`) -- [x] Gallery / Exhibit system (`gallery.js`, `gallery.css`) — added (not in original spec) - -### Phase 3: Rich Interactions -- [x] Link preview popups (`popups.js`) — internal page previews (title, abstract, authors, tags, reading time), Wikipedia excerpts, citation previews; relative-URL fix for index pages -- [x] Pagefind search (`/search.html`) — `search.js` pre-fills from `?q=` param; `#search-timing` shows elapsed ms (mono, faint) via `MutationObserver` on search results subtree -- [x] Author system — authors treated as tags; `build/Authors.hs`; author pages at `/authors/{slug}/`; `authorLinksField` in all contexts; defaults to Levi Neuwirth -- [x] Settings panel — `settings.js` + `settings.css` section in `components.css`; theme, text size (3 steps), focus mode, reduce motion, print; all state in `localStorage`; `theme.js` restores all settings before first paint -- [x] Selection popup — `selection-popup.js` / `selection-popup.css`; context-aware toolbar appears 450 ms after text selection; see Implementation Notes -- [x] Print stylesheet — `print.css` (media="print"); single-column, light colors, sidenotes as indented blocks, external URLs shown -- [x] Current page (`/current.html`) — now-page; added to primary nav -- [x] Annotations — `annotations.js` / `annotations.css`; localStorage storage, text re-anchoring, highlight marks, tooltip with delete; color-picker UI in selection popup (four swatches + optional note field) - -### Phase 4: Creative Content & Polish -- [x] Image handling (lazy load, lightbox, figures, WebP `` wrapper for local raster images) -- [x] Homepage (replaces standalone index; gateway + curated recent content) -- [x] Poetry typesetting — codex reading mode (`reading.html`, `reading.css`, `reading.js`); `poetryCompiler` with `Ext_hard_line_breaks`; narrower measure, stanza spacing, drop-cap suppressed -- [x] Fiction reading mode — same codex layout; `fictionCompiler`; chapter drop caps + smallcaps lead-in via `h2 + p::first-letter`; reading mode infrastructure shared with poetry -- [x] Music section — score fragment system (A): inline SVG excerpts (motifs, passages) integrated into the gallery/exhibit system; named, TOC-listed, focusable in the shared overlay alongside equations; authored via `{.score-fragment score-name="..." score-caption="..."}` fenced-div; SVG inlined at build time by `Filters.Score`; black fills/strokes replaced with `currentColor` for dark-mode; see Implementation Notes -- [x] Music section — composition landing pages + full score reader (C): two-URL architecture per composition; `/music/{slug}/` (rich prose landing page with movement list, audio players, inline score fragments) and `/music/{slug}/score/` (minimal dedicated reader); Hakyll `version "score-reader"` mechanism; `compositionCtx` with `slug`, `score-url`, `has-score`, `score-page-count`, `score-pages` list, `has-movements`, `movements` list (Aeson-parsed nested YAML); `score-reader-default.html` minimal shell; `score-reader.js` (page navigation, movement jumps, `?p=` deep linking, preloading, keyboard); `score-reader.css`; dark mode via `filter: invert(1)`; see Implementation Notes -- [x] Accessibility audit — skip link, TOC collapsed-link tabbing (`visibility: hidden`), section-toggle focus visibility, lightbox/gallery/settings focus restoration, popup `aria-hidden`, metadata nav wrapping, footer `onclick` removal; settings panel focus-steal bug fixed (focus only returns to toggle when it was inside the panel, preventing interference with text-selection popup) -- [~] Visualization pipeline — Pandoc filter approach (`Filters.Viz`): `.figure` fenced divs run `python3