New page
This commit is contained in:
parent
b06b1e741c
commit
aee326bfec
117
CLAUDE.md
117
CLAUDE.md
|
|
@ -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
|
||||
|
|
@ -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
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -14,6 +14,7 @@ $if(home)$<title>Levi Neuwirth</title>$else$<title>$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$
|
||||
|
|
|
|||
Loading…
Reference in New Issue