initial deploy! whoop

This commit is contained in:
Levi Neuwirth 2026-03-17 21:56:14 -04:00
parent c572645445
commit 714824a0b5
121 changed files with 14393 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Copy this file to .env and fill in the values.
# .env is gitignored — never commit it.
#
# Used by `make deploy` to push to GitHub before rsyncing to the VPS.
# If either variable is unset, the push step is skipped (rsync still runs).
# A GitHub fine-grained personal access token with Contents: read+write
# on the levineuwirth.org repository.
# Generate at: https://github.com/settings/tokens
GITHUB_TOKEN=
# The GitHub repository in owner/repo format.
GITHUB_REPO=levineuwirth/levineuwirth.org

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
dist-newstyle/
_site/
_cache/
.DS_Store
.env
# Editor backup/swap files
*~
*.swp
*.swo
# Data files that are generated or external (not version-controlled)
data/embeddings.json
data/similar-links.json
data/backlinks.json
data/build-stats.json

117
CLAUDE.md Normal file
View File

@ -0,0 +1,117 @@
# 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

32
Makefile Normal file
View File

@ -0,0 +1,32 @@
.PHONY: build deploy watch clean dev
# Source .env for GITHUB_TOKEN and GITHUB_REPO if it exists.
# .env format: KEY=value (one per line, no `export` prefix, no quotes needed).
-include .env
export
build:
@git add content/
@git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cabal run site -- build
pagefind --site _site
> IGNORE.txt
deploy: build
@if [ -z "$(GITHUB_TOKEN)" ] || [ -z "$(GITHUB_REPO)" ]; then \
echo "Skipping push: set GITHUB_TOKEN and GITHUB_REPO in .env"; \
else \
git push "https://$(GITHUB_TOKEN)@github.com/$(GITHUB_REPO).git" main; \
fi
rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/
watch:
cabal run site -- watch
clean:
cabal run site -- clean
dev:
cabal run site -- clean
cabal run site -- build
python3 -m http.server 8000 --directory _site

745
WRITING.md Normal file
View File

@ -0,0 +1,745 @@
# Writing Guide
Reference for creating content on levineuwirth.org. Covers file placement, all
frontmatter fields, and every authoring feature available in the Markdown source.
---
## File placement
| Type | Location | Output URL |
|------|----------|------------|
| Essay | `content/essays/my-essay.md` | `/essays/my-essay.html` |
| Blog post | `content/blog/my-post.md` | `/blog/my-post.html` |
| Poetry | `content/poetry/my-poem.md` | `/poetry/my-poem.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` |
File names become URL slugs. Use lowercase, hyphen-separated words.
If a standalone page embeds co-located SVG score fragments or other relative assets,
place it in its own directory (`content/my-page/index.md`) rather than as a flat file.
Score fragment paths are resolved relative to the source file's directory; a flat
`content/my-page.md` would resolve them from `content/`, which is wrong.
---
## Frontmatter
Every file begins with a YAML block fenced by `---`. Keys are read by Hakyll
and passed to templates; unknown keys are silently ignored and can be used as
custom template variables.
### Essays
```yaml
---
title: "The Title of the Essay"
date: 2026-03-15 # required; used for ordering, feed, and display
abstract: > # optional; shown in the metadata block and link previews
A one-paragraph description of the piece.
tags: # optional; see Tags section
- nonfiction
- nonfiction/philosophy
authors: # optional; overrides the default "Levi Neuwirth" link
- "Levi Neuwirth | /me.html"
- "Collaborator | https://their.site"
- "Plain Name" # URL optional; omit for plain-text credit
further-reading: # optional; see Citations section
- someKey
- anotherKey
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
js: scripts/my-widget.js # optional; per-page JS file (see Page scripts)
# js: [scripts/a.js, scripts/b.js] # or a list
# Epistemic Effort — all optional; the entire section is hidden unless `status` is set
status: "Working model" # Draft | Working model | Durable | Refined | Superseded | Deprecated
confidence: 72 # 0100 integer (%)
importance: 3 # 15 integer (rendered as filled/empty dots ●●●○○)
evidence: 2 # 15 integer (same)
scope: average # personal | local | average | broad | civilizational
novelty: moderate # conventional | moderate | idiosyncratic | innovative
practicality: moderate # abstract | low | moderate | high | exceptional
confidence-history: # list of integers; trend arrow derived from last two entries
- 55
- 63
- 72
# Version history — optional; falls back to git log, then to date frontmatter
history:
- date: "2026-03-01" # ISO date as a quoted string (prevent YAML date parsing)
note: Initial draft
- date: "2026-03-14"
note: Expanded typography section; added citations
---
```
### Blog posts
Same fields as essays. `date` formats the `<time>` element in the post header
and blog index. Posts appear in `/feed.xml`.
### Poetry
Same fields as essays. Poetry uses a narrow-measure codex reading mode
(`reading.html`): 52ch measure, 1.85 line-height, stanza spacing, no drop cap.
`poetryCompiler` enables `Ext_hard_line_breaks` — each source newline becomes
a `<br>`, so verse lines render without trailing-space tricks.
**External / non-original poems** — use `poet:` instead of `authors:` to credit
an external author without generating a (broken) author index page:
```yaml
---
title: Sonnet 60
date: 1609-05-20
poet: William Shakespeare
tags: [poetry]
---
```
`poet:` is a plain string. The metadata block shows "by William Shakespeare" as
plain text. Do not use `authors:` for poems you did not write; that would create
an `/authors/william-shakespeare/` index page linking to the poem.
### Fiction
Same fields as essays. Fiction uses the same codex reading mode as poetry:
62ch measure, chapter drop caps + smallcaps lead-in on `h2 + p`.
### Standalone pages
Only `title` is required. Pages use `pageCtx`: no TOC, no bibliography, no
reading-time, no epistemic footer.
```yaml
---
title: About
---
```
Add `js:` for any page-specific interactivity (see Page scripts).
### Music compositions
Compositions live in their own directory. See the Music section for full details.
```yaml
---
title: "Symphony No. 1"
date: 2026-01-15
abstract: >
A four-movement work for large orchestra.
tags: [music]
instrumentation: "orchestra (2222/4231/timp+2perc/str)"
duration: "ca. 32'"
premiere: "2026-05-20"
commissioned-by: "—" # optional
recording: audio/full-recording.mp3 # optional; full-piece audio player
pdf: scores/symphony.pdf # optional; download link
category: orchestral # orchestral | chamber | solo | vocal | choral | electronic
featured: true # optional; appears in Featured section of /music/
score-pages: # required for score reader; omit if no score
- scores/page-01.svg
- scores/page-02.svg
movements: # optional
- name: "I. Allegro"
page: 1 # 1-indexed starting page in the reader
duration: "10'"
audio: audio/movement-1.mp3 # optional; per-movement player
- name: "II. Adagio"
page: 18
duration: "12'"
---
```
---
## Tags
Tags use slash-separated hierarchy. `nonfiction/philosophy` expands to both
`nonfiction` and `nonfiction/philosophy`, so the piece appears on both index
pages.
```yaml
tags:
- nonfiction/philosophy
- research/epistemology
```
The top-level segment maps to a **portal** in the nav:
| Portal | URL |
|--------|-----|
| AI | `/ai/` |
| Fiction | `/fiction/` |
| Miscellany | `/miscellany/` |
| Music | `/music/` |
| Nonfiction | `/nonfiction/` |
| Poetry | `/poetry/` |
| Research | `/research/` |
| Tech | `/tech/` |
Tag index pages are paginated at 20 items and auto-generated on build.
---
## Authors
The `authors` key controls the author line in the metadata block. When omitted,
it defaults to "Levi Neuwirth" linking to `/authors/levi-neuwirth/`.
Author pages are generated at `/authors/{slug}/` and list all attributed content.
Pipe syntax: `"Name | URL"` — name is the link text, URL is the `href`.
The URL part is optional.
---
## Citations
The citation pipeline uses Chicago Author-Date style. The bibliography lives at
`data/bibliography.bib` (BibLaTeX format) by default; override per-page with
`bibliography` and `csl`.
### Inline citations
```markdown
This claim is contested.[@smith2020]
Multiple sources agree.[@jones2019; @brown2021]
```
Inline citations render as numbered superscripts `[1]`, `[2]`, etc. The
bibliography section appears automatically in the page footer. `citations.js`
adds hover previews showing the full reference.
### Further reading
Keys under `further-reading` appear in a separate **Further Reading** section
in the page footer. These are not cited inline.
```yaml
further-reading:
- keyNotCitedInline
- anotherBackgroundSource
```
A key can appear both inline and in `further-reading`; it is numbered in the
bibliography and not duplicated.
---
## Footnotes → sidenotes
Standard Markdown footnotes are converted to sidenotes at build time.
```markdown
This sentence has a note.[^1]
[^1]: The note content, which may contain **bold**, *italic*, links, etc.
```
On wide viewports the note floats into the right margin. On narrow viewports
it falls back to a standard footnote. Sidenotes are numbered automatically.
Avoid block-level elements (headings, lists) inside footnotes.
---
## Wikilinks
Internal links can be written as wikilinks. The title is slugified to produce
the URL.
```markdown
[[Page Title]] → links to /page-title
[[Page Title|Link text]] → links to /page-title with custom display text
```
Slug rules: lowercase, spaces → hyphens, non-alphanumeric characters removed.
`[[My Essay (2024)]]``/my-essay-2024`.
Use standard Markdown link syntax `[text](/path)` for any link whose path is
not derivable from the page title.
---
## Math
KaTeX renders client-side from raw LaTeX. CSS and JS are loaded conditionally
on pages that have `math: true` set in their context (all essays and posts have
this by default).
| Syntax | Usage |
|--------|-------|
| `$...$` | Inline math |
| `$$...$$` on its own line | Display math |
```markdown
The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.
$$
\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}
$$
```
---
## Links
Internal links: standard Markdown or wikilinks.
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`
| Domain | Icon |
|--------|------|
| `wikipedia.org` | Wikipedia |
| `arxiv.org` | arXiv |
| `doi.org` | DOI |
| `github.com` | GitHub |
| 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.
---
## Code
Fenced code blocks with a language identifier get syntax highlighting (JetBrains
Mono). The language annotation also enables the selection popup's docs-search
feature (maps to MDN, Hoogle, docs.python.org, etc.).
````markdown
```haskell
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)
```
````
Inline code: `` `like this` ``
---
## Images
Place images in `static/images/`. All images automatically get `loading="lazy"`.
```markdown
![Alt text](/images/my-image.png)
```
For a captioned figure, add a title string. Pandoc wraps it in `<figure>`/`<figcaption>`:
```markdown
![Alt text](/images/my-image.png "Caption shown below.")
```
**Lightbox:** standalone images automatically get `data-lightbox="true"`.
Clicking opens a fullscreen overlay; close with ×, click outside, or Escape.
Images inside hyperlinks get lazy loading only — no lightbox.
---
## Section collapsing
By default, every `h2` and `h3` heading in an essay gets a collapse toggle.
State persists in `localStorage`.
To disable on a specific page:
```yaml
no-collapse: true
```
---
## Exhibits and annotations
Pandoc fenced divs (`:::`) create structured callout blocks.
### Exhibits
Always-visible numbered blocks with overlay zoom on click. Use for equations or
proofs you want to reference structurally. Listed under their parent heading
in the TOC.
```markdown
::: {.exhibit .exhibit--equation}
$$E = mc^2$$
:::
::: {.exhibit .exhibit--proof}
*Proof.* Suppose for contradiction that … ∎
:::
```
### Annotation callouts
Editorial sidebars — context, caveats, tangential detail.
```markdown
::: {.annotation .annotation--static}
Always visible. Use for important caveats or context.
:::
::: {.annotation .annotation--collapsible}
Collapsed by default. Use for tangential detail.
:::
```
---
## Score fragments
Inline SVG music notation, integrated with the gallery/exhibit system. Clicking
a fragment opens the shared overlay alongside any math exhibits on the page.
```markdown
::: {.score-fragment score-name="Main Theme, mm. 18" score-caption="The opening gesture."}
![](scores/main-theme.svg)
:::
```
Both attributes are optional, but `score-name` is strongly recommended — it
drives the overlay label and the TOC badge.
The image path is resolved **relative to the source file's directory**:
| Source file | SVG path | Reference as |
|---|---|---|
| `content/essays/my-essay.md` | `content/essays/scores/theme.svg` | `scores/theme.svg` |
| `content/music/symphony/index.md` | `content/music/symphony/scores/motif.svg` | `scores/motif.svg` |
| `content/me/index.md` | `content/me/scores/vln.svg` | `scores/vln.svg` |
SVGs are inlined at build time. Black `fill`/`stroke` values are replaced with
`currentColor` so notation renders correctly in dark mode.
---
## Excerpts
Pull-quotes from other works, displayed as indented blockquotes with a small
attribution line and a stretched invisible link covering the whole figure.
### Poem excerpt
Use when quoting verse that has a dedicated page on the site. The entire figure
becomes a click target pointing to the poem; the attribution line remains an
independent link.
```html
<figure class="poem-excerpt">
<blockquote>
Like as the waves make towards the pebbled shore,
So do our minutes hasten to their end;
</blockquote>
<figcaption><a href="/poetry/sonnet-60.html">William Shakespeare — <em>Sonnet 60</em></a></figcaption>
</figure>
```
The blockquote renders with the default dashed left border and italic muted text.
No box or background. The `figcaption` is small-caps and left-aligned.
### Prose excerpt
For quoting prose or for excerpts that do not have a page on the site:
```html
<figure class="prose-excerpt">
<blockquote>
The passage you want to quote goes here.
</blockquote>
<figcaption><a href="https://example.com">Author — <em>Source Title</em></a></figcaption>
</figure>
```
`prose-excerpt` is full-width (`width: auto; max-width: 100%`) rather than
`fit-content`-wide like `poem-excerpt`. Both reset the image-figure box styles
(no background, no border, no padding).
### Commonplace book
The `/commonplace` page is a YAML-driven quotation collection. Add entries to
`data/commonplace.yaml`; the page rebuilds automatically.
```yaml
- text: |-
Like as the waves make towards the pebbled shore,
So do our minutes hasten to their end;
Each changing place with that which goes before,
In sequent toil all forwards do contend.
attribution: William Shakespeare
source: Sonnet 60
source-url: /poetry/sonnet-60.html
tags: [time, mortality]
commentary: >
Optional note. Shown below the quote in muted italic. Omit entirely if
not needed — most entries will not have one.
date-added: 2026-03-17
```
| Field | Required | Notes |
|-------|----------|-------|
| `text` | yes | Use `|-` block scalar; newlines become `<br>` |
| `attribution` | yes | Author name, plain text |
| `source` | no | Title of the source work |
| `source-url` | no | Makes `source` a link |
| `tags` | no | Separate from content tags; used for "by theme" grouping |
| `commentary` | no | Your own remark on the passage |
| `date-added` | no | ISO date; used for chronological sort |
The page has a **by theme / chronological** toggle (state persists in
`localStorage`). Untagged entries appear under "miscellany" in the themed view.
---
## Music
### Catalog index (`/music/`)
`content/music/index.md` is the catalog homepage. Write prose about your
compositional work in the body; provide an `abstract` for the intro line.
The composition listing is auto-generated from all `content/music/*/index.md`
files — no manual list needed.
```yaml
---
title: Music
abstract: Compositions spanning orchestral, chamber, and solo writing.
tags: [music]
---
[Your prose here — influences, preoccupations, approach to the craft.]
```
### Composition pages
Each composition lives in its own subdirectory:
```
content/music/symphony-no-1/
├── index.md ← frontmatter + program notes prose
├── scores/
│ ├── page-01.svg ← one file per score page
│ └── symphony.pdf ← optional PDF download
└── audio/
├── full.mp3 ← optional full-piece recording
└── movement-1.mp3 ← optional per-movement recordings
```
Two URLs are generated automatically from one source:
- `/music/symphony-no-1/` — prose landing page with metadata, audio players, movements
- `/music/symphony-no-1/score/` — minimal page-turn score reader
The score reader is only generated when `score-pages` is non-empty.
**Catalog indicators** — the `/music/` catalog auto-derives:
- ◼ (score available): `score-pages` list is non-empty
- ♫ (recording available): `recording` key is present, or any movement has `audio`
**Catalog grouping** — `category` controls which section the work appears in.
Valid values: `orchestral`, `chamber`, `solo`, `vocal`, `choral`, `electronic`.
Anything else appears under "Other". Omitting `category` defaults to "other".
**Featured works** — set `featured: true` to also appear in the Featured section
at the top of the catalog.
---
## Page scripts
For pages that need custom JavaScript (interactive widgets, visualisations, etc.),
place the JS file alongside the content and reference it via the `js:` frontmatter
key. The file is copied to `_site/` and injected as a deferred `<script>` at the
bottom of `<body>`.
```yaml
js: scripts/memento-mori.js # single file
```
or a list:
```yaml
js:
- scripts/widget-a.js
- scripts/widget-b.js
```
Paths are relative to the content file. A composition at
`content/music/symphony/index.md` with `js: scripts/widget.js` serves the
script at `/music/symphony/scripts/widget.js`.
No changes to the build system are needed — the `content/**/*.js` glob rule
copies all JS files from `content/` to `_site/` automatically.
---
## Epistemic profile
The epistemic footer section appears when `status` is set. All other fields
are optional and are shown or hidden independently.
| Field | Compact display | Expanded (`<details>`) |
|-------|----------------|------------------------|
| `status` | chip (always shown if present) | — |
| `confidence` | `72%` | — |
| `importance` | `●●●○○` | — |
| `evidence` | `●●○○○` | — |
| `stability` | — | auto-computed from git history |
| `scope` | — | if set |
| `novelty` | — | if set |
| `practicality` | — | if set |
| `last-reviewed` | — | most recent commit date |
| `confidence-trend` | — | ↑/↓/→ from last two `confidence-history` entries |
**Stability** is auto-computed from `git log --follow` at every build. The
heuristic: ≤1 commits or age <14 days *volatile*; 5 commits and age <90
days → *revising*; ≤15 commits or age <365 days *fairly stable*; 30
commits or age <730 days *stable*; otherwise *established*.
To pin a manual stability label for one build, add the file path to `IGNORE.txt`
(one path per line). The file is cleared automatically by `make build`.
---
## Version history
The version history footer section uses a three-tier fallback:
1. **`history:` frontmatter** — your authored notes, shown exactly as written.
2. **Git log** — if no `history:` key, dates are extracted from `git log --follow`.
Entries have no message (date only).
3. **`date:` frontmatter** — if git has no commits for the file, falls back to
the `date` field as a single "Created" entry.
`make build` auto-commits `content/` before running the Hakyll build, so the
git log stays current without manual commits.
Write authored notes when the git log would be too noisy or insufficiently
descriptive:
```yaml
history:
- date: "2026-03-01"
note: Initial draft
- date: "2026-03-14"
note: Expanded section 3; incorporated feedback from peer review
```
---
## Typography features
Applied automatically at build time; no markup needed.
| Feature | What it does |
|---------|-------------|
| Smart quotes | `"foo"` / `'bar'` → curly quotes |
| Em dashes | `---` → — |
| En dashes | `--` |
| Smallcaps | `[TEXT]{.smallcaps}``font-variant-caps: small-caps` |
| Drop cap | First letter of the first `<p>` gets a decorative large initial |
| Explicit drop cap | `::: dropcap` fenced div applies drop cap to any paragraph |
| Ligatures | Spectral `liga`, `dlig` OT features active for body text |
| Old-style figures | Spectral `onum` active; use `.lining-nums` class to override |
To mark a phrase as small caps explicitly:
```markdown
The [CPU]{.smallcaps} handles this.
```
### Explicit drop cap
Wrap any paragraph in a `::: dropcap` fenced div to get a drop cap regardless
of its position in the document. The first line is automatically rendered in
small caps.
```markdown
::: dropcap
COMPOSITION [IS]{.smallcaps} PERHAPS MORE THAN ANYTHING ELSE THE PRACTICE OF MY
LIFE. I say these strong words because I feel strongly about this process.
:::
```
The opening word (or words, before a space) should be written in ALL CAPS in
source — they will render as small caps via `::first-line`. The `[IS]{.smallcaps}`
span is not strictly necessary but can force specific words into the smallcaps
run if needed.
A paragraph that immediately follows a `::: dropcap` block will be indented
correctly (`text-indent: 1.5em`), matching the paragraph-after-paragraph rule.
---
## Text selection popup
Selecting any text (≥ 2 characters) shows a context-aware toolbar after 450 ms.
| Context | Buttons |
|---------|---------|
| Prose (multi-word) | BibTeX · Copy · DuckDuckGo · Here · Wikipedia |
| Prose (single word) | BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia |
| Math | Copy · nLab · OEIS · Wolfram |
| Code (known language) | Copy · \<MDN / Hoogle / Docs…\> |
| Code (unknown) | Copy |
**BibTeX** generates a `@online{...}` BibLaTeX entry with the selected text in
`note={\enquote{...}}` and copies it to the clipboard. **Define** opens English
Wiktionary. **Here** opens the Pagefind search page pre-filled with the selection.
---
## Page footer sections
Essays get a structured footer. Sections with no data are hidden.
| Section | Shown when |
|---------|-----------|
| Version history | Always (falls back through three-tier system) |
| Epistemic | `status` frontmatter key is present |
| Bibliography | At least one inline citation |
| Further Reading | `further-reading` key is present |
| Backlinks | Other pages link to this page |
Backlinks are auto-generated at build time. No markup needed — any internal
link from another page creates an entry here, showing the source title and the
surrounding paragraph as context.
---
## Build
```bash
make build # auto-commit content/, compile, run pagefind, clear IGNORE.txt
make deploy # build + 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 clean # wipe _site/ and _cache/
```
`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.

127
build/Authors.hs Normal file
View File

@ -0,0 +1,127 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Author system — treats authors like tags.
--
-- Author pages live at /authors/{slug}/index.html.
-- Items with no "authors" frontmatter key default to Levi Neuwirth.
--
-- Frontmatter format (name-only or name|url — url part is ignored now):
-- authors:
-- - "Levi Neuwirth"
-- - "Alice Smith | https://alice.example" -- url ignored; link goes to /authors/alice-smith/
module Authors
( buildAllAuthors
, applyAuthorRules
, authorLinksField
) where
import Data.Char (isAlphaNum, toLower)
import Data.Maybe (fromMaybe)
import Hakyll
import Hakyll.Core.Metadata (lookupStringList)
import Pagination (sortAndGroup)
import Tags (tagLinksField)
-- ---------------------------------------------------------------------------
-- Slug helpers
-- ---------------------------------------------------------------------------
-- | Lowercase, replace spaces with hyphens, strip anything else.
slugify :: String -> String
slugify = map (\c -> if c == ' ' then '-' else c)
. filter (\c -> isAlphaNum c || c == ' ')
. map toLower
-- | Extract the author name from a "Name | url" entry, trimming whitespace.
nameOf :: String -> String
nameOf s = strip $ case break (== '|') s of { (n, _) -> n }
where
strip = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
defaultAuthor :: String
defaultAuthor = "Levi Neuwirth"
allContent :: Pattern
allContent = ("content/essays/*.md" .||. "content/blog/*.md") .&&. hasNoVersion
-- ---------------------------------------------------------------------------
-- Tag-like helpers (mirror of Tags.hs)
-- ---------------------------------------------------------------------------
-- | Returns all author names for an identifier.
-- Defaults to ["Levi Neuwirth"] when no "authors" key is present.
getAuthors :: MonadMetadata m => Identifier -> m [String]
getAuthors ident = do
meta <- getMetadata ident
let entries = fromMaybe [] (lookupStringList "authors" meta)
return $ if null entries
then [defaultAuthor]
else map nameOf entries
-- | Canonical identifier for an author's index page (page 1).
authorIdentifier :: String -> Identifier
authorIdentifier name = fromFilePath $ "authors/" ++ slugify name ++ "/index.html"
-- | Paginated identifier: page 1 → authors/{slug}/index.html
-- page N → authors/{slug}/page/N/index.html
authorPageId :: String -> PageNumber -> Identifier
authorPageId slug 1 = fromFilePath $ "authors/" ++ slug ++ "/index.html"
authorPageId slug n = fromFilePath $ "authors/" ++ slug ++ "/page/" ++ show n ++ "/index.html"
-- ---------------------------------------------------------------------------
-- Build + rules
-- ---------------------------------------------------------------------------
buildAllAuthors :: Rules Tags
buildAllAuthors = buildTagsWith getAuthors allContent authorIdentifier
applyAuthorRules :: Tags -> Context String -> Rules ()
applyAuthorRules authors baseCtx = tagsRules authors $ \name pat -> do
let slug = slugify name
paginate <- buildPaginateWith sortAndGroup pat (authorPageId slug)
paginateRules paginate $ \pageNum pat' -> do
route idRoute
compile $ do
items <- recentFirst =<< loadAll (pat' .&&. hasNoVersion)
let ctx = listField "items" itemCtx (return items)
<> paginateContext paginate pageNum
<> constField "author" name
<> constField "title" name
<> baseCtx
makeItem ""
>>= loadAndApplyTemplate "templates/author-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
where
itemCtx = dateField "date" "%-d %B %Y"
<> tagLinksField "item-tags"
<> defaultContext
-- ---------------------------------------------------------------------------
-- Context field
-- ---------------------------------------------------------------------------
-- | Exposes each item's authors as @author-name@ / @author-url@ pairs.
-- All links point to /authors/{slug}/, regardless of any URL in frontmatter.
-- Defaults to Levi Neuwirth when no "authors" frontmatter key is present.
--
-- Usage in templates:
-- $for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$
authorLinksField :: Context a
authorLinksField = listFieldWith "author-links" ctx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let entries = fromMaybe [] (lookupStringList "authors" meta)
names = if null entries then [defaultAuthor] else map nameOf entries
return $ map (\n -> Item (fromFilePath "") (n, "/authors/" ++ slugify n ++ "/")) names
where
ctx = field "author-name" (return . fst . itemBody)
<> field "author-url" (return . snd . itemBody)

301
build/Backlinks.hs Normal file
View File

@ -0,0 +1,301 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Backlinks with context: build-time computation of which pages link to
-- each page, including the paragraph that contains each link.
--
-- Architecture (dependency-correct, no circular deps):
--
-- 1. Each content file is compiled under @version "links"@: a lightweight
-- pass that parses the source, walks the AST block-by-block, and for
-- every internal link records the URL *and* the HTML of its surrounding
-- paragraph. The result is serialised as a JSON array of
-- @{url, context}@ objects.
--
-- 2. A @create ["data/backlinks.json"]@ rule loads all "links" items,
-- inverts the map, and serialises
-- @target → [{url, title, abstract, context}]@ as JSON.
--
-- 3. @backlinksField@ loads that JSON at page render time and injects
-- an HTML list showing each source's title and context paragraph.
-- The @load@ call establishes a proper Hakyll dependency so pages
-- recompile when backlinks change.
--
-- Dependency order (no cycles):
-- content "links" versions → data/backlinks.json → content default versions
module Backlinks
( backlinkRules
, backlinksField
) where
import Data.List (nubBy, sortBy)
import Data.Ord (comparing)
import Data.Maybe (fromMaybe)
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Data.Text.Encoding as TE
import qualified Data.Aeson as Aeson
import Data.Aeson ((.=))
import Text.Pandoc.Class (runPure)
import Text.Pandoc.Writers (writeHtml5String)
import Text.Pandoc.Definition (Block (..), Inline (..), Pandoc (..),
nullMeta)
import Text.Pandoc.Options (WriterOptions (..), HTMLMathMethod (..))
import Text.Pandoc.Walk (query)
import Hakyll
import Compilers (readerOpts, writerOpts)
import Filters (preprocessSource)
-- ---------------------------------------------------------------------------
-- Link-with-context entry (intermediate, saved by the "links" pass)
-- ---------------------------------------------------------------------------
data LinkEntry = LinkEntry
{ leUrl :: T.Text -- internal URL (as found in the AST)
, leContext :: String -- HTML of the surrounding paragraph
} deriving (Show, Eq)
instance Aeson.ToJSON LinkEntry where
toJSON e = Aeson.object ["url" .= leUrl e, "context" .= leContext e]
instance Aeson.FromJSON LinkEntry where
parseJSON = Aeson.withObject "LinkEntry" $ \o ->
LinkEntry <$> o Aeson..: "url" <*> o Aeson..: "context"
-- ---------------------------------------------------------------------------
-- Backlink source record (stored in data/backlinks.json)
-- ---------------------------------------------------------------------------
data BacklinkSource = BacklinkSource
{ blUrl :: String
, blTitle :: String
, blAbstract :: String
, blContext :: String -- raw HTML of the paragraph containing the link
} deriving (Show, Eq, Ord)
instance Aeson.ToJSON BacklinkSource where
toJSON bl = Aeson.object
[ "url" .= blUrl bl
, "title" .= blTitle bl
, "abstract" .= blAbstract bl
, "context" .= blContext bl
]
instance Aeson.FromJSON BacklinkSource where
parseJSON = Aeson.withObject "BacklinkSource" $ \o ->
BacklinkSource
<$> o Aeson..: "url"
<*> o Aeson..: "title"
<*> o Aeson..: "abstract"
<*> o Aeson..: "context"
-- ---------------------------------------------------------------------------
-- Writer options for context rendering
-- ---------------------------------------------------------------------------
-- | Minimal writer options for rendering paragraph context: no template
-- (fragment only), plain math fallback (context excerpts are previews, not
-- full renders, and KaTeX CSS may not be loaded on all target pages).
contextWriterOpts :: WriterOptions
contextWriterOpts = writerOpts
{ writerTemplate = Nothing
, writerHTMLMathMethod = PlainMath
}
-- ---------------------------------------------------------------------------
-- Context extraction
-- ---------------------------------------------------------------------------
-- | URL filter: skip external links, pseudo-schemes, anchor-only fragments,
-- and static-asset paths.
isPageLink :: T.Text -> Bool
isPageLink u =
not (T.isPrefixOf "http://" u) &&
not (T.isPrefixOf "https://" u) &&
not (T.isPrefixOf "#" u) &&
not (T.isPrefixOf "mailto:" u) &&
not (T.isPrefixOf "tel:" u) &&
not (T.null u) &&
not (hasStaticExt u)
where
staticExts = [".pdf",".svg",".png",".jpg",".jpeg",".webp",
".mp3",".mp4",".woff2",".woff",".ttf",".ico",
".json",".asc",".xml",".gz",".zip"]
hasStaticExt x = any (`T.isSuffixOf` T.toLower x) staticExts
-- | Render a list of inlines to an HTML fragment string.
-- Uses Plain (not Para) to avoid a wrapping <p> — callers add their own.
renderInlines :: [Inline] -> String
renderInlines inlines =
case runPure (writeHtml5String contextWriterOpts doc) of
Left _ -> ""
Right txt -> T.unpack txt
where
doc = Pandoc nullMeta [Plain inlines]
-- | Extract @(internal-url, context-html)@ pairs from a Pandoc document.
-- Context is the HTML of the immediate surrounding paragraph.
-- Recurses into Div, BlockQuote, BulletList, and OrderedList.
extractLinksWithContext :: Pandoc -> [LinkEntry]
extractLinksWithContext (Pandoc _ blocks) = concatMap go blocks
where
go :: Block -> [LinkEntry]
go (Para inlines) = paraEntries inlines
go (BlockQuote bs) = concatMap go bs
go (Div _ bs) = concatMap go bs
go (BulletList items) = concatMap (concatMap go) items
go (OrderedList _ items) = concatMap (concatMap go) items
go _ = []
-- For a Para block: find all internal links it contains, and for each
-- return a LinkEntry with the paragraph's HTML as context.
paraEntries :: [Inline] -> [LinkEntry]
paraEntries inlines =
let urls = filter isPageLink (query getUrl inlines)
in if null urls then []
else
let ctx = renderInlines inlines
in map (\u -> LinkEntry u ctx) urls
getUrl :: Inline -> [T.Text]
getUrl (Link _ _ (url, _)) = [url]
getUrl _ = []
-- ---------------------------------------------------------------------------
-- Lightweight links compiler
-- ---------------------------------------------------------------------------
-- | Compile a source file lightly: parse the Markdown (wikilinks preprocessed),
-- extract internal links with their paragraph context, and serialise as JSON.
linksCompiler :: Compiler (Item String)
linksCompiler = do
body <- getResourceBody
let src = itemBody body
let body' = itemSetBody (preprocessSource src) body
pandocItem <- readPandocWith readerOpts body'
let entries = nubBy (\a b -> leUrl a == leUrl b && leContext a == leContext b)
(extractLinksWithContext (itemBody pandocItem))
makeItem . TL.unpack . TLE.decodeUtf8 . Aeson.encode $ entries
-- ---------------------------------------------------------------------------
-- URL normalisation
-- ---------------------------------------------------------------------------
-- | Normalise an internal URL as a map key: strip query string, fragment,
-- and trailing @.html@; ensure a leading slash.
normaliseUrl :: String -> String
normaliseUrl url =
let t = T.pack url
t1 = fst (T.breakOn "?" (fst (T.breakOn "#" t)))
t2 = if T.isPrefixOf "/" t1 then t1 else "/" `T.append` t1
t3 = fromMaybe t2 (T.stripSuffix ".html" t2)
in T.unpack t3
-- ---------------------------------------------------------------------------
-- Content patterns (must match the rules in Site.hs)
-- ---------------------------------------------------------------------------
allContent :: Pattern
allContent =
"content/essays/*.md"
.||. "content/blog/*.md"
.||. "content/poetry/*.md"
.||. "content/fiction/*.md"
.||. "content/music/*/index.md"
.||. "content/*.md"
-- ---------------------------------------------------------------------------
-- Hakyll rules
-- ---------------------------------------------------------------------------
-- | Register the @version "links"@ rules for all content and the
-- @create ["data/backlinks.json"]@ rule. Call this from 'Site.rules'.
backlinkRules :: Rules ()
backlinkRules = do
-- Pass 1: extract links + context from each content file.
match allContent $ version "links" $
compile linksCompiler
-- Pass 2: invert the map and write the backlinks JSON.
create ["data/backlinks.json"] $ do
route idRoute
compile $ do
items <- loadAll (allContent .&&. hasVersion "links")
:: Compiler [Item String]
pairs <- concat <$> mapM toSourcePairs items
makeItem . TL.unpack . TLE.decodeUtf8 . Aeson.encode
$ Map.fromListWith (++) [(k, [v]) | (k, v) <- pairs]
-- | For one "links" item, produce @(normalised-target-url, BacklinkSource)@
-- pairs — one per internal link found in the source file.
toSourcePairs :: Item String -> Compiler [(T.Text, BacklinkSource)]
toSourcePairs item = do
let ident0 = setVersion Nothing (itemIdentifier item)
mRoute <- getRoute ident0
meta <- getMetadata ident0
let srcUrl = maybe "" (\r -> "/" ++ r) mRoute
let title = fromMaybe "(untitled)" (lookupString "title" meta)
let abstract = fromMaybe "" (lookupString "abstract" meta)
case mRoute of
Nothing -> return []
Just _ ->
case Aeson.decodeStrict (TE.encodeUtf8 (T.pack (itemBody item)))
:: Maybe [LinkEntry] of
Nothing -> return []
Just entries ->
return [ ( T.pack (normaliseUrl (T.unpack (leUrl e)))
, BacklinkSource srcUrl title abstract (leContext e)
)
| e <- entries ]
-- ---------------------------------------------------------------------------
-- Context field
-- ---------------------------------------------------------------------------
-- | Context field @$backlinks$@ that injects an HTML list of pages that link
-- to the current page, each with its paragraph context.
-- Returns @noResult@ (so @$if(backlinks)$@ is false) when there are none.
backlinksField :: Context String
backlinksField = field "backlinks" $ \item -> do
blItem <- load (fromFilePath "data/backlinks.json") :: Compiler (Item String)
case Aeson.decodeStrict (TE.encodeUtf8 (T.pack (itemBody blItem)))
:: Maybe (Map T.Text [BacklinkSource]) of
Nothing -> fail "backlinks: could not parse data/backlinks.json"
Just blMap -> do
mRoute <- getRoute (itemIdentifier item)
case mRoute of
Nothing -> fail "backlinks: item has no route"
Just r ->
let key = T.pack (normaliseUrl ("/" ++ r))
sources = fromMaybe [] (Map.lookup key blMap)
sorted = sortBy (comparing blTitle) sources
in if null sorted
then fail "no backlinks"
else return (renderBacklinks sorted)
-- ---------------------------------------------------------------------------
-- HTML rendering
-- ---------------------------------------------------------------------------
-- | Render backlink sources as an HTML list.
-- Each item shows the source title as a link (always visible) and a
-- <details> element containing the context paragraph (collapsed by default).
-- @blContext@ is already HTML produced by the Pandoc writer — not escaped.
renderBacklinks :: [BacklinkSource] -> String
renderBacklinks sources =
"<ul class=\"backlinks-list\">\n"
++ concatMap renderOne sources
++ "</ul>"
where
renderOne bl =
"<li class=\"backlink-item\">"
++ "<a class=\"backlink-source\" href=\"" ++ escapeHtml (blUrl bl) ++ "\">"
++ escapeHtml (blTitle bl) ++ "</a>"
++ ( if null (blContext bl) then ""
else "<details class=\"backlink-details\">"
++ "<summary class=\"backlink-summary\">context</summary>"
++ "<div class=\"backlink-context\">" ++ blContext bl ++ "</div>"
++ "</details>" )
++ "</li>\n"

202
build/Catalog.hs Normal file
View File

@ -0,0 +1,202 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Music catalog: featured works + grouped-by-category listing.
-- Renders HTML directly (same pattern as Backlinks.hs) to avoid the
-- complexity of nested listFieldWith.
module Catalog
( musicCatalogCtx
) where
import Data.List (groupBy, sortBy)
import Data.Maybe (fromMaybe)
import Data.Ord (comparing)
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import qualified Data.Text as T
import Hakyll
import Hakyll.Core.Metadata (lookupStringList)
import Contexts (siteCtx)
-- ---------------------------------------------------------------------------
-- Entry type
-- ---------------------------------------------------------------------------
data CatalogEntry = CatalogEntry
{ ceTitle :: String
, ceUrl :: String
, ceYear :: Maybe String
, ceDuration :: Maybe String
, ceInstrumentation :: Maybe String
, ceCategory :: String -- defaults to "other"
, ceFeatured :: Bool
, ceHasScore :: Bool
, ceHasRecording :: Bool
}
-- ---------------------------------------------------------------------------
-- Category helpers
-- ---------------------------------------------------------------------------
categoryOrder :: [String]
categoryOrder = ["orchestral","chamber","solo","vocal","choral","electronic","other"]
categoryLabel :: String -> String
categoryLabel "orchestral" = "Orchestral"
categoryLabel "chamber" = "Chamber"
categoryLabel "solo" = "Solo"
categoryLabel "vocal" = "Vocal"
categoryLabel "choral" = "Choral"
categoryLabel "electronic" = "Electronic"
categoryLabel _ = "Other"
categoryRank :: String -> Int
categoryRank c = fromMaybe (length categoryOrder)
(lookup c (zip categoryOrder [0..]))
-- ---------------------------------------------------------------------------
-- Parsing helpers
-- ---------------------------------------------------------------------------
-- | @featured: true@ in YAML becomes Bool True in Aeson; also accept the
-- string "true" in case the author quotes it.
isFeatured :: Metadata -> Bool
isFeatured meta =
case KM.lookup "featured" meta of
Just (Bool True) -> True
Just (String "true") -> True
_ -> False
-- | True if a @recording@ key is present, or any movement has an @audio@ key.
hasRecordingMeta :: Metadata -> Bool
hasRecordingMeta meta =
KM.member "recording" meta || anyMovHasAudio meta
where
anyMovHasAudio m =
case KM.lookup "movements" m of
Just (Array v) -> any movHasAudio (V.toList v)
_ -> False
movHasAudio (Object o) = KM.member "audio" o
movHasAudio _ = False
-- | Parse a year: accepts Number (e.g. @year: 2019@) or String.
parseYear :: Metadata -> Maybe String
parseYear meta =
case KM.lookup "year" meta of
Just (Number n) -> Just $ show (floor (fromRational (toRational n) :: Double) :: Int)
Just (String t) -> Just (T.unpack t)
_ -> Nothing
parseCatalogEntry :: Item String -> Compiler (Maybe CatalogEntry)
parseCatalogEntry item = do
meta <- getMetadata (itemIdentifier item)
mRoute <- getRoute (itemIdentifier item)
case mRoute of
Nothing -> return Nothing
Just r -> do
let title = fromMaybe "(untitled)" (lookupString "title" meta)
url = "/" ++ r
year = parseYear meta
dur = lookupString "duration" meta
instr = lookupString "instrumentation" meta
cat = fromMaybe "other" (lookupString "category" meta)
return $ Just CatalogEntry
{ ceTitle = title
, ceUrl = url
, ceYear = year
, ceDuration = dur
, ceInstrumentation = instr
, ceCategory = cat
, ceFeatured = isFeatured meta
, ceHasScore = not (null (fromMaybe [] (lookupStringList "score-pages" meta)))
, ceHasRecording = hasRecordingMeta meta
}
-- ---------------------------------------------------------------------------
-- HTML rendering
-- ---------------------------------------------------------------------------
renderIndicators :: CatalogEntry -> String
renderIndicators e = concatMap render
[ (ceHasScore e, "<span class=\"catalog-ind\" title=\"Score available\">&#9724;</span>")
, (ceHasRecording e, "<span class=\"catalog-ind\" title=\"Recording available\">&#9834;</span>")
]
where
render (True, s) = s
render (False, _) = ""
renderEntry :: CatalogEntry -> String
renderEntry e = concat
[ "<li class=\"catalog-entry\">"
, "<div class=\"catalog-entry-main\">"
, "<a class=\"catalog-title\" href=\"", ceUrl e, "\">", ceTitle e, "</a>"
, renderIndicators e
, maybe "" (\y -> "<span class=\"catalog-year\">" ++ y ++ "</span>") (ceYear e)
, maybe "" (\d -> "<span class=\"catalog-duration\">" ++ d ++ "</span>") (ceDuration e)
, "</div>"
, maybe "" (\i -> "<div class=\"catalog-instrumentation\">" ++ i ++ "</div>") (ceInstrumentation e)
, "</li>"
]
renderCategorySection :: String -> [CatalogEntry] -> String
renderCategorySection cat entries = concat
[ "<section class=\"catalog-section\">"
, "<h2 class=\"catalog-section-title\">", categoryLabel cat, "</h2>"
, "<ul class=\"catalog-list\">"
, concatMap renderEntry entries
, "</ul>"
, "</section>"
]
-- ---------------------------------------------------------------------------
-- Load all compositions (excluding the catalog index itself)
-- ---------------------------------------------------------------------------
loadEntries :: Compiler [CatalogEntry]
loadEntries = do
items <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
mItems <- mapM parseCatalogEntry items
return [e | Just e <- mItems]
-- ---------------------------------------------------------------------------
-- Context fields
-- ---------------------------------------------------------------------------
-- | @$featured-works$@: HTML list of featured entries; noResult when none.
featuredWorksField :: Context String
featuredWorksField = field "featured-works" $ \_ -> do
entries <- loadEntries
let featured = filter ceFeatured entries
if null featured
then fail "no featured works"
else return $
"<ul class=\"catalog-list catalog-featured-list\">"
++ concatMap renderEntry featured
++ "</ul>"
-- | @$has-featured$@: present when at least one composition is featured.
hasFeaturedField :: Context String
hasFeaturedField = field "has-featured" $ \_ -> do
entries <- loadEntries
if any ceFeatured entries then return "true" else fail "no featured works"
-- | @$catalog-by-category$@: HTML for all category sections.
-- Sorted by canonical category order; if no compositions exist yet,
-- returns a placeholder paragraph.
catalogByCategoryField :: Context String
catalogByCategoryField = field "catalog-by-category" $ \_ -> do
entries <- loadEntries
if null entries
then return "<p class=\"catalog-empty\">Works forthcoming.</p>"
else do
let sorted = sortBy (comparing (categoryRank . ceCategory)) entries
grouped = groupBy (\a b -> ceCategory a == ceCategory b) sorted
return $ concatMap (\g -> renderCategorySection (ceCategory (head g)) g) grouped
musicCatalogCtx :: Context String
musicCatalogCtx =
constField "catalog" "true"
<> hasFeaturedField
<> featuredWorksField
<> catalogByCategoryField
<> siteCtx

217
build/Citations.hs Normal file
View File

@ -0,0 +1,217 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Citation processing pipeline.
--
-- Steps:
-- 1. Skip if the document contains no Cite nodes and frKeys is empty.
-- 2. Inject default bibliography / CSL metadata if absent.
-- 3. Inject nocite entries for further-reading keys.
-- 4. Run Pandoc's citeproc to resolve references and generate bibliography.
-- 5. Walk the AST and replace Cite nodes with numbered superscripts.
-- 6. Extract the citeproc bibliography div from the body, reorder by
-- first-appearance, split into cited / further-reading sections,
-- and render to an HTML string for the template's $bibliography$ field.
--
-- Returns (Pandoc without refs div, bibliography HTML).
-- The bibliography HTML is empty when there are no citations.
--
-- NOTE: processCitations with in-text CSL leaves Cite nodes as Cite nodes
-- in the AST — it only populates their inline content and creates the refs
-- div. The HTML writer later wraps them in <span class="citation">. We must
-- therefore match Cite nodes (not Span nodes) in our transform pass.
--
-- NOTE: Hakyll strips YAML frontmatter before passing to readPandocWith, so
-- the Pandoc Meta is empty. further-reading keys are passed explicitly by the
-- caller (read from Hakyll's own metadata via lookupStringList).
--
-- NOTE: Does not import Contexts to avoid cycles.
module Citations (applyCitations) where
import Data.List (intercalate, nub, partition, sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Ord (comparing)
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc
import Text.Pandoc.Citeproc (processCitations)
import Text.Pandoc.Walk
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
-- | Process citations in a Pandoc document.
-- @frKeys@: further-reading citation keys (read from Hakyll metadata by
-- the caller, since Hakyll strips YAML frontmatter before parsing).
-- Returns @(body, citedHtml, furtherHtml)@ where @body@ has Cite nodes
-- replaced with numbered superscripts and no bibliography div,
-- @citedHtml@ is the inline-cited references HTML, and @furtherHtml@ is
-- the further-reading-only references HTML (each empty when absent).
applyCitations :: [Text] -> Pandoc -> IO (Pandoc, Text, Text)
applyCitations frKeys doc
| not (hasCitations frKeys doc) = return (doc, "", "")
| otherwise = do
let doc1 = injectMeta frKeys doc
processed <- runIOorExplode $ processCitations doc1
let (body, citedHtml, furtherHtml) = transformAndExtract frKeys processed
return (body, citedHtml, furtherHtml)
-- ---------------------------------------------------------------------------
-- Detection
-- ---------------------------------------------------------------------------
-- | True if the document has inline [@key] cites or a further-reading list.
hasCitations :: [Text] -> Pandoc -> Bool
hasCitations frKeys doc =
not (null (query collectCites doc))
|| not (null frKeys)
where
collectCites (Cite {}) = [()]
collectCites _ = []
-- ---------------------------------------------------------------------------
-- Metadata injection
-- ---------------------------------------------------------------------------
-- | Inject default bibliography / CSL paths and nocite for further-reading.
injectMeta :: [Text] -> Pandoc -> Pandoc
injectMeta frKeys (Pandoc meta blocks) =
let meta1 = if null frKeys then meta
else insertMeta "nocite" (nociteVal frKeys) meta
meta2 = case lookupMeta "bibliography" meta1 of
Nothing -> insertMeta "bibliography"
(MetaString "data/bibliography.bib") meta1
Just _ -> meta1
meta3 = case lookupMeta "csl" meta2 of
Nothing -> insertMeta "csl"
(MetaString "data/chicago-notes.csl") meta2
Just _ -> meta2
in Pandoc meta3 blocks
where
-- Each key becomes its own Cite node (matching what pandoc parses from
-- nocite: "@key1 @key2" in YAML frontmatter).
nociteVal keys = MetaInlines (intercalate [Space] (map mkCiteNode keys))
mkCiteNode k = [Cite [Citation k [] [] AuthorInText 1 0] [Str ("@" <> k)]]
-- | Insert a key/value pair into Pandoc Meta.
insertMeta :: Text -> MetaValue -> Meta -> Meta
insertMeta k v (Meta m) = Meta (Map.insert k v m)
-- ---------------------------------------------------------------------------
-- Transform pass
-- ---------------------------------------------------------------------------
-- | Number citation Cite nodes and extract the bibliography div.
transformAndExtract :: [Text] -> Pandoc -> (Pandoc, Text, Text)
transformAndExtract frKeys doc@(Pandoc meta _) =
let citeOrder = collectCiteOrder doc -- keys, first-appearance order
keyNums = Map.fromList (zip citeOrder [1 :: Int ..])
-- Replace Cite nodes with numbered superscript markers
doc' = walk (transformInline keyNums) doc
-- Pull bibliography div out of body and render to HTML
(bodyBlocks, citedHtml, furtherHtml) = extractBibliography citeOrder frKeys
(pandocBlocks doc')
in (Pandoc meta bodyBlocks, citedHtml, furtherHtml)
where
pandocBlocks (Pandoc _ bs) = bs
-- | Collect citation keys in order of first appearance (body only).
-- NOTE: after processCitations, Cite nodes remain as Cite in the AST;
-- they are not converted to Span nodes with in-text CSL.
-- We query only blocks (not metadata) so that nocite Cite nodes injected
-- into the 'nocite' meta field are not mistakenly treated as inline citations.
collectCiteOrder :: Pandoc -> [Text]
collectCiteOrder (Pandoc _ blocks) = nub (query extractKeys blocks)
where
extractKeys (Cite citations _) = map citationId citations
extractKeys _ = []
-- | Replace a Cite node with a numbered superscript marker.
transformInline :: Map Text Int -> Inline -> Inline
transformInline keyNums (Cite citations _) =
let keys = map citationId citations
nums = mapMaybe (`Map.lookup` keyNums) keys
in if null nums
then Str ""
else RawInline "html" (markerHtml (head keys) (head nums) nums)
transformInline _ x = x
markerHtml :: Text -> Int -> [Int] -> Text
markerHtml firstKey firstNum nums =
let label = "[" <> T.intercalate "," (map tshow nums) <> "]"
in "<sup class=\"cite-marker\" id=\"cite-back-" <> tshow firstNum <> "\">"
<> "<a href=\"#ref-" <> firstKey <> "\" class=\"cite-link\">"
<> label <> "</a></sup>"
where tshow = T.pack . show
-- ---------------------------------------------------------------------------
-- Bibliography extraction + rendering
-- ---------------------------------------------------------------------------
-- | Separate the @refs@ div from body blocks and render it to HTML.
-- Returns @(bodyBlocks, citedHtml, furtherHtml)@.
extractBibliography :: [Text] -> [Text] -> [Block] -> ([Block], Text, Text)
extractBibliography citeOrder frKeys blocks =
let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks
(citedHtml, furtherHtml) = case refDivs of
[] -> ("", "")
(d:_) -> renderBibDiv citeOrder frKeys d
in (bodyBlocks, citedHtml, furtherHtml)
where
isRefsDiv (Div ("refs", _, _) _) = True
isRefsDiv _ = False
-- | Render the citeproc @refs@ Div into two HTML strings:
-- @(citedHtml, furtherHtml)@ — each is empty when there are no entries
-- in that section. Headings are rendered in the template, not here.
renderBibDiv :: [Text] -> [Text] -> Block -> (Text, Text)
renderBibDiv citeOrder _frKeys (Div _ children) =
let keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
(citedEntries, furtherEntries) =
partition (isCited keyIndex) children
sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries
numbered = zipWith addNumber [1..] sorted
citedHtml = renderEntries "csl-bib-body cite-refs" numbered
furtherHtml
| null furtherEntries = ""
| otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries
in (citedHtml, furtherHtml)
renderBibDiv _ _ _ = ("", "")
isCited :: Map Text Int -> Block -> Bool
isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex
isCited _ _ = False
entryOrder :: Map Text Int -> Block -> Int
entryOrder keyIndex (Div (rid, _, _) _) =
fromMaybe maxBound $ Map.lookup (stripRefPrefix rid) keyIndex
entryOrder _ _ = maxBound
-- | Prepend [N] marker to a bibliography entry block.
addNumber :: Int -> Block -> Block
addNumber n (Div attrs content) =
Div attrs
( Plain [ RawInline "html"
("<span class=\"ref-num\">[" <> T.pack (show n) <> "]</span>") ]
: content )
addNumber _ b = b
-- | Strip the @ref-@ prefix that citeproc adds to div IDs.
stripRefPrefix :: Text -> Text
stripRefPrefix t = fromMaybe t (T.stripPrefix "ref-" t)
-- | Render a list of blocks as an HTML string (used for bibliography sections).
renderEntries :: Text -> [Block] -> Text
renderEntries cls entries =
case runPure (writeHtml5String wOpts (Pandoc nullMeta entries)) of
Left _ -> ""
Right html -> "<div class=\"" <> cls <> "\">\n" <> html <> "</div>\n"
where
wOpts = def { writerWrapText = WrapNone }

161
build/Commonplace.hs Normal file
View File

@ -0,0 +1,161 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Commonplace book: loads data/commonplace.yaml and renders
-- themed and chronological HTML views for /commonplace.
module Commonplace
( commonplaceCtx
) where
import Data.Aeson (FromJSON (..), withObject, (.:), (.:?), (.!=))
import Data.List (nub, sortBy)
import Data.Ord (comparing, Down (..))
import qualified Data.ByteString.Char8 as BS
import qualified Data.Yaml as Y
import Hakyll hiding (escapeHtml, renderTags)
import Contexts (siteCtx)
import Utils (escapeHtml)
-- ---------------------------------------------------------------------------
-- Entry type
-- ---------------------------------------------------------------------------
data CPEntry = CPEntry
{ cpText :: String
, cpAttribution :: String
, cpSource :: Maybe String
, cpSourceUrl :: Maybe String
, cpTags :: [String]
, cpCommentary :: Maybe String
, cpDateAdded :: String
}
instance FromJSON CPEntry where
parseJSON = withObject "CPEntry" $ \o -> CPEntry
<$> o .: "text"
<*> o .: "attribution"
<*> o .:? "source"
<*> o .:? "source-url"
<*> o .:? "tags" .!= []
<*> o .:? "commentary"
<*> o .:? "date-added" .!= ""
-- ---------------------------------------------------------------------------
-- HTML rendering
-- ---------------------------------------------------------------------------
-- | Escape HTML, then replace newlines with <br> for multi-line verse.
renderText :: String -> String
renderText = concatMap tr . escapeHtml . stripTrailingNL
where
tr '\n' = "<br>\n"
tr c = [c]
stripTrailingNL = reverse . dropWhile (== '\n') . reverse
renderAttribution :: CPEntry -> String
renderAttribution e =
"<p class=\"cp-attribution\">\x2014\x202f"
++ escapeHtml (cpAttribution e)
++ maybe "" renderSource (cpSource e)
++ "</p>"
where
renderSource src = case cpSourceUrl e of
Just url -> ", <a href=\"" ++ escapeHtml url ++ "\">"
++ escapeHtml src ++ "</a>"
Nothing -> ", " ++ escapeHtml src
renderTags :: [String] -> String
renderTags [] = ""
renderTags ts =
"<div class=\"cp-tags\">"
++ concatMap (\t -> "<span class=\"cp-tag\">" ++ escapeHtml t ++ "</span>") ts
++ "</div>"
renderEntry :: CPEntry -> String
renderEntry e = concat
[ "<article class=\"cp-entry\">"
, "<blockquote class=\"cp-quote\"><p>"
, renderText (cpText e)
, "</p></blockquote>"
, renderAttribution e
, maybe "" renderCommentary (cpCommentary e)
, renderTags (cpTags e)
, "</article>"
]
where
renderCommentary c =
"<p class=\"cp-commentary\">" ++ escapeHtml c ++ "</p>"
-- ---------------------------------------------------------------------------
-- Themed view
-- ---------------------------------------------------------------------------
-- | All distinct tags in first-occurrence order (preserves YAML ordering).
allTags :: [CPEntry] -> [String]
allTags = nub . concatMap cpTags
renderTagSection :: String -> [CPEntry] -> String
renderTagSection tag entries = concat
[ "<section class=\"cp-theme-section\">"
, "<h2 class=\"cp-theme-heading\">" ++ escapeHtml tag ++ "</h2>"
, concatMap renderEntry entries
, "</section>"
]
renderThemedView :: [CPEntry] -> String
renderThemedView [] =
"<div class=\"cp-themed\" id=\"cp-themed\">"
++ "<p class=\"cp-empty\">No entries yet.</p>"
++ "</div>"
renderThemedView entries =
"<div class=\"cp-themed\" id=\"cp-themed\">"
++ concatMap renderSection (allTags entries)
++ (if null untagged then ""
else renderTagSection "miscellany" untagged)
++ "</div>"
where
renderSection t =
let es = filter (elem t . cpTags) entries
in if null es then "" else renderTagSection t es
untagged = filter (null . cpTags) entries
-- ---------------------------------------------------------------------------
-- Chronological view
-- ---------------------------------------------------------------------------
renderChronoView :: [CPEntry] -> String
renderChronoView entries =
"<div class=\"cp-chrono\" id=\"cp-chrono\" hidden>"
++ if null sorted
then "<p class=\"cp-empty\">No entries yet.</p>"
else concatMap renderEntry sorted
++ "</div>"
where
sorted = sortBy (comparing (Down . cpDateAdded)) entries
-- ---------------------------------------------------------------------------
-- Load entries from data/commonplace.yaml
-- ---------------------------------------------------------------------------
loadCommonplace :: Compiler [CPEntry]
loadCommonplace = do
rawItem <- load (fromFilePath "data/commonplace.yaml") :: Compiler (Item String)
let raw = itemBody rawItem
case Y.decodeEither' (BS.pack raw) of
Left err -> fail ("commonplace.yaml: " ++ show err)
Right entries -> return entries
-- ---------------------------------------------------------------------------
-- Context
-- ---------------------------------------------------------------------------
commonplaceCtx :: Context String
commonplaceCtx =
constField "commonplace" "true"
<> themedField
<> chronoField
<> siteCtx
where
themedField = field "cp-themed-html" $ \_ ->
renderThemedView <$> loadCommonplace
chronoField = field "cp-chrono-html" $ \_ ->
renderChronoView <$> loadCommonplace

206
build/Compilers.hs Normal file
View File

@ -0,0 +1,206 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Compilers
( essayCompiler
, postCompiler
, pageCompiler
, poetryCompiler
, fictionCompiler
, compositionCompiler
, readerOpts
, writerOpts
) where
import Hakyll
import Hakyll.Core.Metadata (lookupStringList)
import Text.Pandoc.Definition (Pandoc (..), Block (..),
Inline (..))
import Text.Pandoc.Options (ReaderOptions (..), WriterOptions (..),
HTMLMathMethod (..))
import Text.Pandoc.Extensions (enableExtension, Extension (..))
import qualified Data.Text as T
import Data.Maybe (fromMaybe)
import System.FilePath (takeDirectory)
import Utils (wordCount, readingTime, escapeHtml)
import Filters (applyAll, preprocessSource)
import qualified Citations
import qualified Filters.Score as Score
-- ---------------------------------------------------------------------------
-- Reader / writer options
-- ---------------------------------------------------------------------------
readerOpts :: ReaderOptions
readerOpts = defaultHakyllReaderOptions
-- | Reader options with hard_line_breaks enabled — every source newline within
-- a paragraph becomes a <br>. Used for poetry so stanza lines render as-is.
poetryReaderOpts :: ReaderOptions
poetryReaderOpts = readerOpts
{ readerExtensions = enableExtension Ext_hard_line_breaks
(readerExtensions readerOpts) }
writerOpts :: WriterOptions
writerOpts = defaultHakyllWriterOptions
{ writerHTMLMathMethod = KaTeX ""
, writerHighlightStyle = Nothing
, writerNumberSections = False
, writerTableOfContents = False
}
-- ---------------------------------------------------------------------------
-- Inline stringification (local, avoids depending on Text.Pandoc.Shared)
-- ---------------------------------------------------------------------------
stringify :: [Inline] -> T.Text
stringify = T.concat . map inlineToText
where
inlineToText (Str t) = t
inlineToText Space = " "
inlineToText SoftBreak = " "
inlineToText LineBreak = " "
inlineToText (Emph ils) = stringify ils
inlineToText (Strong ils) = stringify ils
inlineToText (Strikeout ils) = stringify ils
inlineToText (Superscript ils) = stringify ils
inlineToText (Subscript ils) = stringify ils
inlineToText (SmallCaps ils) = stringify ils
inlineToText (Quoted _ ils) = stringify ils
inlineToText (Cite _ ils) = stringify ils
inlineToText (Code _ t) = t
inlineToText (RawInline _ t) = t
inlineToText (Link _ ils _) = stringify ils
inlineToText (Image _ ils _) = stringify ils
inlineToText (Note _) = ""
inlineToText (Span _ ils) = stringify ils
inlineToText _ = ""
-- ---------------------------------------------------------------------------
-- TOC extraction
-- ---------------------------------------------------------------------------
-- | Collect (level, identifier, title-text) for h2/h3 headings.
collectHeadings :: Pandoc -> [(Int, T.Text, String)]
collectHeadings (Pandoc _ blocks) = concatMap go blocks
where
go (Header lvl (ident, _, _) inlines)
| lvl == 2 || lvl == 3
= [(lvl, ident, T.unpack (stringify inlines))]
go _ = []
-- ---------------------------------------------------------------------------
-- TOC tree
-- ---------------------------------------------------------------------------
data TOCNode = TOCNode T.Text String [TOCNode]
buildTree :: [(Int, T.Text, String)] -> [TOCNode]
buildTree = go 2
where
go _ [] = []
go lvl ((l, i, t) : rest)
| l == lvl =
let (childItems, remaining) = span (\(l', _, _) -> l' > lvl) rest
children = go (lvl + 1) childItems
in TOCNode i t children : go lvl remaining
| l < lvl = []
| otherwise = go lvl rest -- skip unexpected deeper items at this level
renderTOC :: [TOCNode] -> String
renderTOC [] = ""
renderTOC nodes = "<ol>\n" ++ concatMap renderNode nodes ++ "</ol>\n"
where
renderNode (TOCNode i t children) =
"<li><a href=\"#" ++ T.unpack i ++ "\" data-target=\"" ++ T.unpack i ++ "\">"
++ Utils.escapeHtml t ++ "</a>" ++ renderTOC children ++ "</li>\n"
-- | Build a TOC HTML string from a Pandoc document.
buildTOC :: Pandoc -> String
buildTOC doc = renderTOC (buildTree (collectHeadings doc))
-- ---------------------------------------------------------------------------
-- Compilers
-- ---------------------------------------------------------------------------
-- | Shared compiler pipeline parameterised on reader options.
-- Saves toc/word-count/reading-time/bibliography snapshots.
essayCompilerWith :: ReaderOptions -> Compiler (Item String)
essayCompilerWith rOpts = do
-- Raw Markdown source (used for word count / reading time).
body <- getResourceBody
let src = itemBody body
-- Apply source-level preprocessors (wikilinks, etc.) before parsing.
let body' = itemSetBody (preprocessSource src) body
-- Parse to Pandoc AST.
pandocItem <- readPandocWith rOpts body'
-- Get further-reading keys from Hakyll metadata (YAML frontmatter is stripped
-- before being passed to readPandocWith, so we read it from Hakyll instead).
ident <- getUnderlying
meta <- getMetadata ident
let frKeys = map T.pack $ fromMaybe [] (lookupStringList "further-reading" meta)
-- Run citeproc, transform citation spans → superscripts, extract bibliography.
(pandocWithCites, bibHtml, furtherHtml) <- unsafeCompiler $
Citations.applyCitations frKeys (itemBody pandocItem)
-- Inline SVG score fragments (reads SVG files relative to the source file).
filePath <- getResourceFilePath
pandocWithScores <- unsafeCompiler $
Score.inlineScores (takeDirectory filePath) pandocWithCites
-- Apply remaining AST-level filters (sidenotes, smallcaps, links, etc.).
let pandocFiltered = applyAll pandocWithScores
let pandocItem' = itemSetBody pandocFiltered pandocItem
-- Build TOC from the filtered AST.
let toc = buildTOC pandocFiltered
-- Write HTML.
let htmlItem = writePandocWith writerOpts pandocItem'
-- Save snapshots keyed to this item's identifier.
_ <- saveSnapshot "toc" (itemSetBody toc htmlItem)
_ <- saveSnapshot "word-count" (itemSetBody (show (wordCount src)) htmlItem)
_ <- saveSnapshot "reading-time" (itemSetBody (show (readingTime src)) htmlItem)
_ <- saveSnapshot "bibliography" (itemSetBody (T.unpack bibHtml) htmlItem)
_ <- saveSnapshot "further-reading-refs" (itemSetBody (T.unpack furtherHtml) htmlItem)
return htmlItem
-- | Compiler for essays.
essayCompiler :: Compiler (Item String)
essayCompiler = essayCompilerWith readerOpts
-- | Compiler for blog posts: same pipeline as essays.
postCompiler :: Compiler (Item String)
postCompiler = essayCompiler
-- | Compiler for poetry: enables hard_line_breaks so each source line becomes
-- a <br>, preserving verse line endings without manual trailing-space markup.
poetryCompiler :: Compiler (Item String)
poetryCompiler = essayCompilerWith poetryReaderOpts
-- | Compiler for fiction: same pipeline as essays; visual differences are
-- handled entirely by the reading template and reading.css.
fictionCompiler :: Compiler (Item String)
fictionCompiler = essayCompiler
-- | Compiler for music composition landing pages: full essay pipeline
-- (TOC, sidenotes, score fragments, citations, smallcaps, etc.).
compositionCompiler :: Compiler (Item String)
compositionCompiler = essayCompiler
-- | Compiler for simple pages: filters applied, no TOC snapshot.
pageCompiler :: Compiler (Item String)
pageCompiler = do
body <- getResourceBody
let src = itemBody body
body' = itemSetBody (preprocessSource src) body
pandocItem <- fmap (fmap applyAll) (readPandocWith readerOpts body')
let htmlItem = writePandocWith writerOpts pandocItem
_ <- saveSnapshot "word-count" (itemSetBody (show (wordCount src)) htmlItem)
_ <- saveSnapshot "reading-time" (itemSetBody (show (readingTime src)) htmlItem)
return htmlItem

317
build/Contexts.hs Normal file
View File

@ -0,0 +1,317 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Contexts
( siteCtx
, essayCtx
, postCtx
, pageCtx
, poetryCtx
, fictionCtx
, compositionCtx
) where
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.Maybe (catMaybes, fromMaybe)
import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale)
import System.FilePath (takeDirectory, takeFileName)
import Text.Read (readMaybe)
import qualified Data.Text as T
import Hakyll
import Hakyll.Core.Metadata (lookupStringList)
import Authors (authorLinksField)
import Backlinks (backlinksField)
import Stability (stabilityField, lastReviewedField, versionHistoryField)
import Tags (tagLinksField)
-- ---------------------------------------------------------------------------
-- Build time field
-- ---------------------------------------------------------------------------
-- | Resolves to the time the current item was compiled, formatted as
-- "Saturday, November 15th, 2025 15:05:55" (UTC).
buildTimeField :: Context String
buildTimeField = field "build-time" $ \_ ->
unsafeCompiler $ do
t <- getCurrentTime
let (_, _, d) = toGregorian (utctDay t)
prefix = formatTime defaultTimeLocale "%A, %B " t
suffix = formatTime defaultTimeLocale ", %Y %H:%M:%S" t
return (prefix ++ show d ++ ordSuffix d ++ suffix)
where
ordSuffix n
| n `elem` [11,12,13] = "th"
| n `mod` 10 == 1 = "st"
| n `mod` 10 == 2 = "nd"
| n `mod` 10 == 3 = "rd"
| otherwise = "th"
-- ---------------------------------------------------------------------------
-- Site-wide context
-- ---------------------------------------------------------------------------
-- | @$page-scripts$@ — list field providing @$script-src$@ for each entry
-- in the @js:@ frontmatter key (accepts a scalar string or a YAML list).
-- Returns an empty list when absent; $for iterates zero times, emitting nothing.
-- NOTE: do not use fail here — $for does not catch noResult the way $if does.
pageScriptsField :: Context String
pageScriptsField = listFieldWith "page-scripts" ctx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let scripts = case lookupStringList "js" meta of
Just xs -> xs
Nothing -> maybe [] (:[]) (lookupString "js" meta)
return $ map (\s -> Item (fromFilePath s) s) scripts
where
ctx = field "script-src" (return . itemBody)
siteCtx :: Context String
siteCtx =
constField "site-title" "Levi Neuwirth"
<> constField "site-url" "https://levineuwirth.org"
<> buildTimeField
<> pageScriptsField
<> defaultContext
-- ---------------------------------------------------------------------------
-- Helper: load a named snapshot as a context field
-- ---------------------------------------------------------------------------
-- | @snapshotField name snap@ creates a context field @name@ whose value is
-- the body of the snapshot @snap@ saved for the current item.
snapshotField :: String -> Snapshot -> Context String
snapshotField name snap = field name $ \item ->
itemBody <$> loadSnapshot (itemIdentifier item) snap
-- ---------------------------------------------------------------------------
-- Essay context
-- ---------------------------------------------------------------------------
-- | Bibliography field: loads the citation HTML saved by essayCompiler.
-- Returns noResult (making $if(bibliography)$ false) when empty.
-- Also provides $has-citations$ for conditional JS loading.
bibliographyField :: Context String
bibliographyField = bibContent <> hasCitations
where
bibContent = field "bibliography" $ \item -> do
bib <- itemBody <$> loadSnapshot (itemIdentifier item) "bibliography"
if null bib then fail "no bibliography" else return bib
hasCitations = field "has-citations" $ \item -> do
bib <- itemBody <$> (loadSnapshot (itemIdentifier item) "bibliography"
:: Compiler (Item String))
if null bib then fail "no citations" else return "true"
-- | Further-reading field: loads the further-reading HTML saved by essayCompiler.
-- Returns noResult (making $if(further-reading-refs)$ false) when empty.
furtherReadingField :: Context String
furtherReadingField = field "further-reading-refs" $ \item -> do
fr <- itemBody <$> (loadSnapshot (itemIdentifier item) "further-reading-refs"
:: Compiler (Item String))
if null fr then fail "no further reading" else return fr
-- ---------------------------------------------------------------------------
-- Epistemic fields
-- ---------------------------------------------------------------------------
-- | Render an integer 15 frontmatter key as filled/empty dot chars.
-- Returns @noResult@ when the key is absent or unparseable.
dotsField :: String -> String -> Context String
dotsField ctxKey metaKey = field ctxKey $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString metaKey meta >>= readMaybe of
Nothing -> fail (ctxKey ++ ": not set")
Just (n :: Int) ->
let v = max 0 (min 5 n)
in return (replicate v '\x25CF' ++ replicate (5 - v) '\x25CB')
-- | @$confidence-trend$@: ↑, ↓, or → derived from the last two entries
-- in the @confidence-history@ frontmatter list. Returns @noResult@ when
-- there is no history or only a single entry.
confidenceTrendField :: Context String
confidenceTrendField = field "confidence-trend" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupStringList "confidence-history" meta of
Nothing -> fail "no confidence history"
Just xs | length xs < 2 -> fail "no confidence history"
Just xs ->
let prev = readMaybe (xs !! (length xs - 2)) :: Maybe Int
cur = readMaybe (last xs) :: Maybe Int
in case (prev, cur) of
(Just p, Just c)
| c - p > 5 -> return "\x2191" -- ↑
| p - c > 5 -> return "\x2193" -- ↓
| otherwise -> return "\x2192" -- →
_ -> return "\x2192"
-- | All epistemic context fields composed.
epistemicCtx :: Context String
epistemicCtx =
dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence"
<> confidenceTrendField
<> stabilityField
<> lastReviewedField
-- ---------------------------------------------------------------------------
-- Essay context
-- ---------------------------------------------------------------------------
essayCtx :: Context String
essayCtx =
authorLinksField
<> snapshotField "toc" "toc"
<> snapshotField "word-count" "word-count"
<> snapshotField "reading-time" "reading-time"
<> bibliographyField
<> furtherReadingField
<> backlinksField
<> epistemicCtx
<> versionHistoryField
<> dateField "date-created" "%-d %B %Y"
<> dateField "date-modified" "%-d %B %Y"
<> constField "math" "true"
<> tagLinksField "essay-tags"
<> siteCtx
-- ---------------------------------------------------------------------------
-- Post context
-- ---------------------------------------------------------------------------
postCtx :: Context String
postCtx =
authorLinksField
<> backlinksField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> constField "math" "true"
<> siteCtx
-- ---------------------------------------------------------------------------
-- Page context
-- ---------------------------------------------------------------------------
pageCtx :: Context String
pageCtx = authorLinksField <> siteCtx
-- ---------------------------------------------------------------------------
-- Reading contexts (fiction + poetry)
-- ---------------------------------------------------------------------------
-- | Base reading context: essay fields + the "reading" flag (activates
-- reading.css / reading.js via head.html and body class via default.html).
readingCtx :: Context String
readingCtx = essayCtx <> constField "reading" "true"
-- | Poetry context: reading mode + "poetry" flag for CSS body class.
poetryCtx :: Context String
poetryCtx = readingCtx <> constField "poetry" "true"
-- | Fiction context: reading mode + "fiction" flag for CSS body class.
fictionCtx :: Context String
fictionCtx = readingCtx <> constField "fiction" "true"
-- ---------------------------------------------------------------------------
-- Composition context (music landing pages + score reader)
-- ---------------------------------------------------------------------------
data Movement = Movement
{ movName :: String
, movPage :: Int
, movDuration :: String
, movAudio :: Maybe String
}
parseMovements :: Metadata -> [Movement]
parseMovements meta =
case KM.lookup "movements" meta of
Just (Array v) -> catMaybes $ map parseOne (V.toList v)
_ -> []
where
parseOne (Object o) = Movement
<$> (getString =<< KM.lookup "name" o)
<*> (getInt =<< KM.lookup "page" o)
<*> (getString =<< KM.lookup "duration" o)
<*> pure (getString =<< KM.lookup "audio" o)
parseOne _ = Nothing
getString (String t) = Just (T.unpack t)
getString _ = Nothing
getInt (Number n) = Just (floor (fromRational (toRational n) :: Double))
getInt _ = Nothing
-- | Extract the composition slug from an item's identifier.
-- "content/music/symphonic-dances/index.md" → "symphonic-dances"
compSlug :: Item a -> String
compSlug = takeFileName . takeDirectory . toFilePath . itemIdentifier
-- | Context for music composition landing pages and the score reader.
-- Extends essayCtx with composition-specific fields:
-- $slug$ — URL slug (e.g. "symphonic-dances")
-- $score-url$ — absolute URL of the score reader page
-- $has-score$ — present when score-pages frontmatter is non-empty
-- $score-page-count$ — total number of score pages
-- $score-pages$ — list of {score-page-url} items
-- $has-movements$ — present when movements frontmatter is non-empty
-- $movements$ — list of {movement-name, movement-page,
-- movement-duration, movement-audio, has-audio}
-- All other frontmatter keys (instrumentation, duration, premiere,
-- commissioned-by, pdf, abstract, etc.) are available via defaultContext.
compositionCtx :: Context String
compositionCtx =
constField "composition" "true"
<> slugField
<> scoreUrlField
<> hasScoreField
<> scorePageCountField
<> scorePagesListField
<> hasMovementsField
<> movementsListField
<> essayCtx
where
slugField = field "slug" (return . compSlug)
scoreUrlField = field "score-url" $ \item ->
return $ "/music/" ++ compSlug item ++ "/score/"
hasScoreField = field "has-score" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let pages = fromMaybe [] (lookupStringList "score-pages" meta)
if null pages then fail "no score pages" else return "true"
scorePageCountField = field "score-page-count" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let pages = fromMaybe [] (lookupStringList "score-pages" meta)
return $ show (length pages)
scorePagesListField = listFieldWith "score-pages" spCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let slug = compSlug item
base = "/music/" ++ slug ++ "/"
pages = fromMaybe [] (lookupStringList "score-pages" meta)
return $ map (\p -> Item (fromFilePath p) (base ++ p)) pages
where
spCtx = field "score-page-url" (return . itemBody)
hasMovementsField = field "has-movements" $ \item -> do
meta <- getMetadata (itemIdentifier item)
if null (parseMovements meta) then fail "no movements" else return "true"
movementsListField = listFieldWith "movements" movCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let mvs = parseMovements meta
return $ zipWith
(\idx mv -> Item (fromFilePath ("mv" ++ show (idx :: Int))) mv)
[1..] mvs
where
movCtx =
field "movement-name" (return . movName . itemBody)
<> field "movement-page" (return . show . movPage . itemBody)
<> field "movement-duration" (return . movDuration . itemBody)
<> field "movement-audio"
(\i -> maybe (fail "no audio") return (movAudio (itemBody i)))
<> field "has-audio"
(\i -> maybe (fail "no audio") (const (return "true"))
(movAudio (itemBody i)))

37
build/Filters.hs Normal file
View File

@ -0,0 +1,37 @@
{-# LANGUAGE GHC2021 #-}
-- | Re-exports all Pandoc AST filter modules and provides a single
-- @applyAll@ combinator that chains them in the correct order.
module Filters
( applyAll
, preprocessSource
) where
import Text.Pandoc.Definition (Pandoc)
import qualified Filters.Sidenotes as Sidenotes
import qualified Filters.Typography as Typography
import qualified Filters.Links as Links
import qualified Filters.Smallcaps as Smallcaps
import qualified Filters.Dropcaps as Dropcaps
import qualified Filters.Math as Math
import qualified Filters.Wikilinks as Wikilinks
import qualified Filters.Code as Code
import qualified Filters.Images as Images
-- | Apply all AST-level filters in pipeline order.
-- Run on the Pandoc document after reading, before writing.
applyAll :: Pandoc -> Pandoc
applyAll
= Sidenotes.apply
. Typography.apply
. Links.apply
. Smallcaps.apply
. Dropcaps.apply
. Math.apply
. Code.apply
. Images.apply
-- | Apply source-level preprocessors to the raw Markdown string.
-- Run before 'readPandocWith'.
preprocessSource :: String -> String
preprocessSource = Wikilinks.preprocess

29
build/Filters/Code.hs Normal file
View File

@ -0,0 +1,29 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Prepend "language-" to fenced-code-block class names so that
-- Prism.js can find and highlight them.
--
-- Pandoc (with writerHighlightStyle = Nothing) outputs
-- <pre class="python"><code>
-- Prism.js requires
-- <pre class="language-python"><code class="language-python">
--
-- We transform the AST before writing rather than post-processing HTML,
-- so the class appears on both <pre> and <code> via Pandoc's normal output.
module Filters.Code (apply) where
import qualified Data.Text as T
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
apply :: Pandoc -> Pandoc
apply = walk addLangPrefix
addLangPrefix :: Block -> Block
addLangPrefix (CodeBlock (ident, classes, kvs) code) =
CodeBlock (ident, map prefix classes, kvs) code
where
prefix c
| "language-" `T.isPrefixOf` c = c
| otherwise = "language-" <> c
addLangPrefix x = x

15
build/Filters/Dropcaps.hs Normal file
View File

@ -0,0 +1,15 @@
{-# LANGUAGE GHC2021 #-}
-- | Dropcap support.
--
-- The dropcap on the opening paragraph is implemented entirely in CSS
-- via @#markdownBody > p:first-of-type::first-letter@, so no AST
-- transformation is required. This module is a placeholder for future
-- work (e.g. adding a @.lead-paragraph@ class when the first block is
-- not a Para, or decorative initial-capital images).
module Filters.Dropcaps (apply) where
import Text.Pandoc.Definition (Pandoc)
-- | Identity — dropcaps are handled by CSS.
apply :: Pandoc -> Pandoc
apply = id

56
build/Filters/Images.hs Normal file
View File

@ -0,0 +1,56 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Image attribute filter.
--
-- Walks all @Image@ inlines and:
-- * Adds @loading="lazy"@ to every image.
-- * Adds @data-lightbox="true"@ to images that are NOT already wrapped in
-- a @Link@ inline (i.e. the image is not itself a hyperlink).
--
-- The wrapping-link check is done by walking the document with two passes:
-- a block-level walk that handles the common @Link [Image …] …@ pattern,
-- and a plain image walk that stamps @loading="lazy"@ on everything else.
module Filters.Images (apply) where
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
-- | Apply image attribute injection to the entire document.
apply :: Pandoc -> Pandoc
apply = walk transformInline
-- ---------------------------------------------------------------------------
-- Core transformation
-- ---------------------------------------------------------------------------
-- | Process a single inline node.
--
-- * @Link … [Image …] …@ — image inside a link: add only @loading="lazy"@.
-- * @Image …@ — standalone image: add both @loading="lazy"@ and
-- @data-lightbox="true"@.
-- * Anything else — pass through unchanged.
transformInline :: Inline -> Inline
transformInline (Link lAttr ils lTarget) =
-- Recurse into link contents, but mark any images inside as linked
-- (so they receive lazy loading only, no lightbox marker).
Link lAttr (map (addLazyOnly) ils) lTarget
where
addLazyOnly (Image iAttr alt iTarget) =
Image (addAttr "loading" "lazy" iAttr) alt iTarget
addLazyOnly x = x
transformInline (Image attr alt target) =
Image (addAttr "data-lightbox" "true" (addAttr "loading" "lazy" attr)) alt target
transformInline x = x
-- ---------------------------------------------------------------------------
-- Attribute helpers
-- ---------------------------------------------------------------------------
-- | Prepend a key=value pair to an @Attr@'s key-value list (if not already
-- present, to avoid duplicating attributes that come from Markdown).
addAttr :: Text -> Text -> Attr -> Attr
addAttr k v (ident, classes, kvs)
| any ((== k) . fst) kvs = (ident, classes, kvs)
| otherwise = (ident, classes, (k, v) : kvs)

51
build/Filters/Links.hs Normal file
View File

@ -0,0 +1,51 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | External link classification.
--
-- Walks all @Link@ inlines and:
-- * Adds @class="link-external"@ to any link whose URL starts with
-- @http://@ or @https://@ and is not on the site's own domain.
-- * Adds @data-link-icon@ / @data-link-icon-type@ attributes for
-- per-domain brand icons (wikipedia, arxiv, doi, github, external).
-- * Adds @target="_blank" rel="noopener noreferrer"@ to external links.
module Filters.Links (apply) where
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
-- | Apply link classification to the entire document.
apply :: Pandoc -> Pandoc
apply = walk classifyLink
classifyLink :: Inline -> Inline
classifyLink (Link (ident, classes, kvs) ils (url, title))
| isExternal url =
let icon = domainIcon url
classes' = classes ++ ["link-external"]
kvs' = kvs
++ [("target", "_blank")]
++ [("rel", "noopener noreferrer")]
++ [("data-link-icon", icon)]
++ [("data-link-icon-type", "svg")]
in Link (ident, classes', kvs') ils (url, title)
classifyLink x = x
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
isExternal :: Text -> Bool
isExternal url =
("http://" `T.isPrefixOf` url || "https://" `T.isPrefixOf` url)
&& not ("levineuwirth.org" `T.isInfixOf` url)
-- | Icon name for the link, matching a file in /images/link-icons/<name>.svg.
domainIcon :: Text -> Text
domainIcon url
| "wikipedia.org" `T.isInfixOf` url = "wikipedia"
| "arxiv.org" `T.isInfixOf` url = "arxiv"
| "doi.org" `T.isInfixOf` url = "doi"
| "github.com" `T.isInfixOf` url = "github"
| otherwise = "external"

14
build/Filters/Math.hs Normal file
View File

@ -0,0 +1,14 @@
{-# LANGUAGE GHC2021 #-}
-- | Math filter placeholder.
--
-- The spec calls for converting simple LaTeX to Unicode at build time.
-- For now, all math (inline and display) is handled client-side by KaTeX,
-- which is loaded conditionally on pages that contain math. Server-side
-- KaTeX rendering is a Phase 3 task.
module Filters.Math (apply) where
import Text.Pandoc.Definition (Pandoc)
-- | Identity — math rendering is handled by KaTeX.
apply :: Pandoc -> Pandoc
apply = id

92
build/Filters/Score.hs Normal file
View File

@ -0,0 +1,92 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Inline SVG score fragments into the Pandoc AST.
--
-- Fenced-div syntax in Markdown:
--
-- > :::score-fragment{score-name="Main Theme, mm. 18" score-caption="The opening gesture."}
-- > ![](scores/main-theme.svg)
-- > :::
--
-- The filter reads the referenced SVG from disk (path resolved relative to
-- the source file's directory), replaces hardcoded black fills/strokes with
-- @currentColor@ for dark-mode compatibility, and emits a @\<figure\>@ with
-- the appropriate exhibit attributes for gallery.js TOC integration.
module Filters.Score (inlineScores) where
import Data.Maybe (listToMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.FilePath ((</>))
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
-- | Walk the Pandoc AST and inline all score-fragment divs.
-- @baseDir@ is the directory of the source file; image paths in the
-- fenced-div are resolved relative to it.
inlineScores :: FilePath -> Pandoc -> IO Pandoc
inlineScores baseDir = walkM (inlineScore baseDir)
inlineScore :: FilePath -> Block -> IO Block
inlineScore baseDir (Div (_, cls, attrs) blocks)
| "score-fragment" `elem` cls = do
let mName = lookup "score-name" attrs
mCaption = lookup "score-caption" attrs
mPath = findImagePath blocks
case mPath of
Nothing -> return $ Div ("", cls, attrs) blocks
Just path -> do
let fullPath = baseDir </> T.unpack path
svgRaw <- TIO.readFile fullPath
let html = buildHtml mName mCaption (processColors svgRaw)
return $ RawBlock (Format "html") html
inlineScore _ block = return block
-- | Extract the image src from the first Para that contains an Image inline.
findImagePath :: [Block] -> Maybe T.Text
findImagePath blocks = listToMaybe
[ src
| Para inlines <- blocks
, Image _ _ (src, _) <- inlines
]
-- | Replace hardcoded black fill/stroke values with @currentColor@ so the
-- SVG inherits the CSS @color@ property in both light and dark modes.
--
-- 6-digit hex patterns are at the bottom of the composition chain
-- (applied first) so they are replaced before the 3-digit shorthand,
-- preventing partial matches (e.g. @#000@ matching the prefix of @#000000@).
processColors :: T.Text -> T.Text
processColors
-- 3-digit hex and keyword patterns (applied after 6-digit replacements)
= T.replace "fill=\"#000\"" "fill=\"currentColor\""
. T.replace "fill=\"black\"" "fill=\"currentColor\""
. T.replace "stroke=\"#000\"" "stroke=\"currentColor\""
. T.replace "stroke=\"black\"" "stroke=\"currentColor\""
. T.replace "fill:#000" "fill:currentColor"
. T.replace "fill:black" "fill:currentColor"
. T.replace "stroke:#000" "stroke:currentColor"
. T.replace "stroke:black" "stroke:currentColor"
-- 6-digit hex patterns (applied first — bottom of the chain)
. T.replace "fill=\"#000000\"" "fill=\"currentColor\""
. T.replace "stroke=\"#000000\"" "stroke=\"currentColor\""
. T.replace "fill:#000000" "fill:currentColor"
. T.replace "stroke:#000000" "stroke:currentColor"
buildHtml :: Maybe T.Text -> Maybe T.Text -> T.Text -> T.Text
buildHtml mName mCaption svgContent = T.concat
[ "<figure class=\"score-fragment exhibit\""
, maybe "" (\n -> " data-exhibit-name=\"" <> escHtml n <> "\"") mName
, " data-exhibit-type=\"score\">"
, "<div class=\"score-fragment-inner\">"
, svgContent
, "</div>"
, maybe "" (\c -> "<figcaption class=\"score-caption\">" <> escHtml c <> "</figcaption>") mCaption
, "</figure>"
]
escHtml :: T.Text -> T.Text
escHtml = T.replace "\"" "&quot;"
. T.replace ">" "&gt;"
. T.replace "<" "&lt;"
. T.replace "&" "&amp;"

View File

@ -0,0 +1,67 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Convert Pandoc @Note@ inlines to inline sidenote HTML.
--
-- Each footnote becomes:
-- * A @<sup class="sidenote-ref">@ anchor in the body text.
-- * An @<aside class="sidenote">@ immediately following it, containing
-- the rendered note content.
--
-- On wide viewports, sidenotes.css floats asides into the right margin.
-- On narrow viewports they are hidden; the standard Pandoc-generated
-- @<section class="footnotes">@ at the document end serves as fallback.
module Filters.Sidenotes (apply) where
import Control.Monad.State.Strict
import Data.Default (def)
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Class (runPure)
import Text.Pandoc.Definition
import Text.Pandoc.Options (WriterOptions)
import Text.Pandoc.Walk (walkM)
import Text.Pandoc.Writers.HTML (writeHtml5String)
-- | Transform all @Note@ inlines in the document to inline sidenote HTML.
apply :: Pandoc -> Pandoc
apply doc = evalState (walkM convertNote doc) (1 :: Int)
convertNote :: Inline -> State Int Inline
convertNote (Note blocks) = do
n <- get
put (n + 1)
return $ RawInline "html" (renderNote n blocks)
convertNote x = return x
-- | Convert a 1-based counter to a letter label: 1→a, 2→b, … 26→z.
toLabel :: Int -> Text
toLabel n = T.singleton (toEnum (fromEnum 'a' + (n - 1) `mod` 26))
renderNote :: Int -> [Block] -> Text
renderNote n blocks =
let inner = replacePTags (blocksToHtml blocks)
lbl = toLabel n
in T.concat
[ "<sup class=\"sidenote-ref\" id=\"snref-", lbl, "\">"
, "<a href=\"#sn-", lbl, "\">", lbl, "</a>"
, "</sup>"
, "<span class=\"sidenote\" id=\"sn-", lbl, "\">"
, "<sup class=\"sidenote-num\">", lbl, "</sup>\x00a0"
, inner
, "</span>"
]
-- | Replace <p> / </p> with inline-block spans so that sidenote content
-- stays valid inside the outer <span class="sidenote">. A bare <p> inside
-- a <span> is invalid HTML and causes browsers to implicitly close the span.
replacePTags :: Text -> Text
replacePTags =
T.replace "<p>" "<span class=\"sidenote-para\">"
. T.replace "</p>" "</span>"
-- | Render a list of Pandoc blocks to an HTML fragment via a pure writer run.
blocksToHtml :: [Block] -> Text
blocksToHtml blocks =
case runPure (writeHtml5String (def :: WriterOptions) (Pandoc mempty blocks)) of
Left _ -> T.empty
Right t -> t

View File

@ -0,0 +1,71 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Automatic small-caps wrapping for abbreviations in body text.
--
-- Any @Str@ token that consists entirely of uppercase letters (and
-- hyphens) and is at least three characters long is wrapped in
-- @<abbr class="smallcaps">@. This catches CSS, HTML, API, NASA, etc.
-- while avoiding single-character tokens (\"I\", \"A\") and mixed-case
-- words.
--
-- Authors can also use Pandoc span syntax for explicit control:
-- @[TEXT]{.smallcaps}@ — Pandoc already emits the @smallcaps@ class on
-- those spans, and typography.css styles @.smallcaps@ directly, so no
-- extra filter logic is needed for that case.
--
-- The filter is /not/ applied inside headings (where Fira Sans uppercase
-- text looks intentional) or inside @Code@/@RawInline@ inlines.
module Filters.Smallcaps (apply) where
import Data.Char (isUpper, isAlpha)
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
-- | Apply smallcaps detection to paragraph-level content.
-- Skips heading blocks to avoid false positives.
apply :: Pandoc -> Pandoc
apply (Pandoc meta blocks) = Pandoc meta (map applyBlock blocks)
applyBlock :: Block -> Block
applyBlock b@(Header {}) = b -- leave headings untouched
applyBlock b = walk wrapCaps b
-- | Wrap an all-caps Str token in an abbr element, preserving any trailing
-- punctuation (comma, period, colon, semicolon, closing paren/bracket)
-- outside the abbr element.
wrapCaps :: Inline -> Inline
wrapCaps (Str t) =
let (core, trail) = stripTrailingPunct t
in if isAbbreviation core
then RawInline "html" $
"<abbr class=\"smallcaps\">" <> escHtml core <> "</abbr>"
<> trail
else Str t
wrapCaps x = x
-- | Split trailing punctuation from the token body.
stripTrailingPunct :: Text -> (Text, Text)
stripTrailingPunct t =
let isPunct c = c `elem` (",.:;!?)]\'" :: String)
trail = T.takeWhileEnd isPunct t
core = T.dropEnd (T.length trail) t
in (core, trail)
-- | True if the token looks like an abbreviation: all uppercase (plus
-- hyphens), at least 3 characters, contains at least one alpha character.
isAbbreviation :: Text -> Bool
isAbbreviation t =
T.length t >= 3
&& T.all (\c -> isUpper c || c == '-') t
&& T.any isAlpha t
escHtml :: Text -> Text
escHtml = T.concatMap esc
where
esc '<' = "&lt;"
esc '>' = "&gt;"
esc '&' = "&amp;"
esc '"' = "&quot;"
esc c = T.singleton c

View File

@ -0,0 +1,58 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Typographic refinements applied to the Pandoc AST.
--
-- Currently: expands common Latin abbreviations to @<abbr>@ elements
-- (e.g. → exempli gratia, i.e. → id est, etc.). Pandoc's @smart@
-- reader extension already handles em-dashes, en-dashes, ellipses,
-- and curly quotes, so those are not repeated here.
module Filters.Typography (apply) where
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
-- | Apply all typographic transformations to the document.
apply :: Pandoc -> Pandoc
apply = walk expandAbbrev
-- ---------------------------------------------------------------------------
-- Abbreviation expansion
-- ---------------------------------------------------------------------------
-- | Abbreviations that should be wrapped in @<abbr title="…">@.
-- Each entry is (verbatim text as it appears in the Pandoc Str token,
-- long-form title for the tooltip).
abbrevMap :: [(Text, Text)]
abbrevMap =
[ ("e.g.", "exempli gratia")
, ("i.e.", "id est")
, ("cf.", "confer")
, ("viz.", "videlicet")
, ("ibid.", "ibidem")
, ("op.", "opere") -- usually followed by "cit." in a separate token
, ("NB", "nota bene")
, ("NB:", "nota bene")
]
-- | If the Str token exactly matches a known abbreviation, replace it with
-- a @RawInline "html"@ @<abbr>@ element; otherwise leave it unchanged.
expandAbbrev :: Inline -> Inline
expandAbbrev (Str t) =
case lookup t abbrevMap of
Just title ->
RawInline "html" $
"<abbr title=\"" <> title <> "\">" <> escHtml t <> "</abbr>"
Nothing -> Str t
expandAbbrev x = x
-- | Minimal HTML escaping for the abbr content (should be plain text).
escHtml :: Text -> Text
escHtml = T.concatMap esc
where
esc '<' = "&lt;"
esc '>' = "&gt;"
esc '&' = "&amp;"
esc '"' = "&quot;"
esc c = T.singleton c

View File

@ -0,0 +1,59 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Wikilink syntax preprocessor.
--
-- Applied to the raw Markdown source string /before/ Pandoc parsing.
-- Transforms:
--
-- * @[[Page Title]]@ → @[Page Title](/page-title)@
-- * @[[Page Title|Display]]@ → @[Display](/page-title)@
--
-- The URL slug is derived from the page title: lowercased, spaces
-- replaced with hyphens, non-alphanumeric characters stripped.
-- nginx's @try_files $uri $uri.html@ resolves the extension-free URL.
module Filters.Wikilinks (preprocess) where
import Data.Char (isAlphaNum, toLower, isSpace)
import Data.List (intercalate)
-- | Scan the raw Markdown source for @[[…]]@ wikilinks and replace them
-- with standard Markdown link syntax.
preprocess :: String -> String
preprocess [] = []
preprocess ('[':'[':rest) =
case break (== ']') rest of
(inner, ']':']':after)
| not (null inner) ->
toMarkdownLink inner ++ preprocess after
_ -> '[' : '[' : preprocess rest
preprocess (c:rest) = c : preprocess rest
-- | Convert the inner content of @[[…]]@ to a Markdown link.
toMarkdownLink :: String -> String
toMarkdownLink inner =
let (title, display) = splitOnPipe inner
url = "/" ++ slugify title
in "[" ++ display ++ "](" ++ url ++ ")"
-- | Split on the first @|@; if none, display = title.
splitOnPipe :: String -> (String, String)
splitOnPipe s =
case break (== '|') s of
(title, '|':display) -> (trim title, trim display)
_ -> (trim s, trim s)
-- | Produce a URL slug: lowercase, words joined by hyphens,
-- non-alphanumeric characters removed.
slugify :: String -> String
slugify = intercalate "-" . words . map toLowerAlnum
where
toLowerAlnum c
| isAlphaNum c = toLower c
| isSpace c = ' '
| c == '-' = '-'
| otherwise = ' ' -- replace punctuation with a space so words
-- split correctly and double-hyphens are
-- collapsed by 'words'
trim :: String -> String
trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')

7
build/Main.hs Normal file
View File

@ -0,0 +1,7 @@
module Main where
import Hakyll (hakyll)
import Site (rules)
main :: IO ()
main = hakyll rules

2
build/Metadata.hs Normal file
View File

@ -0,0 +1,2 @@
-- | Metadata utilities (Phase 2+).
module Metadata where

48
build/Pagination.hs Normal file
View File

@ -0,0 +1,48 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Pagination helpers.
--
-- NOTE: This module must not import Contexts or Tags to avoid cycles.
-- Callers (Site.hs) pass contexts in as parameters.
module Pagination
( pageSize
, sortAndGroup
, blogPaginateRules
) where
import Hakyll
-- | Items per page across all paginated lists.
pageSize :: Int
pageSize = 20
-- | Sort identifiers by date (most recent first) and split into pages.
sortAndGroup :: (MonadMetadata m, MonadFail m) => [Identifier] -> m [[Identifier]]
sortAndGroup ids = paginateEvery pageSize <$> sortRecentFirst ids
-- | Page identifier for the blog index.
-- Page 1 → blog/index.html
-- Page N → blog/page/N/index.html
blogPageId :: PageNumber -> Identifier
blogPageId 1 = fromFilePath "blog/index.html"
blogPageId n = fromFilePath $ "blog/page/" ++ show n ++ "/index.html"
-- | Build and rule-ify a paginated blog index.
-- @itemCtx@: context for individual posts (postCtx).
-- @baseCtx@: site-level context (siteCtx).
blogPaginateRules :: Context String -> Context String -> Rules ()
blogPaginateRules itemCtx baseCtx = do
paginate <- buildPaginateWith sortAndGroup ("content/blog/*.md" .&&. hasNoVersion) blogPageId
paginateRules paginate $ \pageNum pat -> do
route idRoute
compile $ do
posts <- recentFirst =<< loadAll (pat .&&. hasNoVersion)
let ctx = listField "posts" itemCtx (return posts)
<> paginateContext paginate pageNum
<> constField "title" "Blog"
<> baseCtx
makeItem ""
>>= loadAndApplyTemplate "templates/blog-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls

362
build/Site.hs Normal file
View File

@ -0,0 +1,362 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Site (rules) where
import Control.Monad (filterM)
import Data.List (intercalate, isPrefixOf)
import Data.Maybe (fromMaybe)
import System.FilePath (takeDirectory, takeFileName)
import Hakyll
import Authors (buildAllAuthors, applyAuthorRules)
import Backlinks (backlinkRules)
import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler,
compositionCompiler)
import Catalog (musicCatalogCtx)
import Commonplace (commonplaceCtx)
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx)
import Tags (buildAllTags, applyTagRules)
import Pagination (blogPaginateRules)
feedConfig :: FeedConfiguration
feedConfig = FeedConfiguration
{ feedTitle = "Levi Neuwirth"
, feedDescription = "Essays, notes, and creative work by Levi Neuwirth"
, feedAuthorName = "Levi Neuwirth"
, feedAuthorEmail = "levi@levineuwirth.org"
, feedRoot = "https://levineuwirth.org"
}
musicFeedConfig :: FeedConfiguration
musicFeedConfig = FeedConfiguration
{ feedTitle = "Levi Neuwirth — Music"
, feedDescription = "New compositions by Levi Neuwirth"
, feedAuthorName = "Levi Neuwirth"
, feedAuthorEmail = "levi@levineuwirth.org"
, feedRoot = "https://levineuwirth.org"
}
rules :: Rules ()
rules = do
-- ---------------------------------------------------------------------------
-- Backlinks (pass 1: link extraction; pass 2: JSON generation)
-- Must run before content rules so dependencies resolve correctly.
-- ---------------------------------------------------------------------------
backlinkRules
-- ---------------------------------------------------------------------------
-- Author index pages
-- ---------------------------------------------------------------------------
authors <- buildAllAuthors
applyAuthorRules authors siteCtx
-- ---------------------------------------------------------------------------
-- Tag index pages
-- ---------------------------------------------------------------------------
tags <- buildAllTags
applyTagRules tags siteCtx
-- Per-page JS files — authored alongside content in content/**/*.js
match "content/**/*.js" $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- CSS — must be matched before the broad static/** rule to avoid
-- double-matching (compressCssCompiler vs. copyFileCompiler).
match "static/css/*" $ do
route $ gsubRoute "static/" (const "")
compile compressCssCompiler
-- All other static files (fonts, JS, images, …)
match ("static/**" .&&. complement "static/css/*") $ do
route $ gsubRoute "static/" (const "")
compile copyFileCompiler
-- Templates
match "templates/**" $ compile templateBodyCompiler
-- Link annotations — author-defined previews for any URL
match "data/annotations.json" $ do
route idRoute
compile copyFileCompiler
-- Commonplace YAML — compiled as a raw string so it can be loaded
-- with dependency tracking by the commonplace page compiler.
match "data/commonplace.yaml" $ compile getResourceBody
-- ---------------------------------------------------------------------------
-- Homepage
-- ---------------------------------------------------------------------------
match "content/index.md" $ do
route $ constRoute "index.html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/home.html" pageCtx
>>= loadAndApplyTemplate "templates/default.html" pageCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Standalone pages (me/, colophon.md, …)
-- ---------------------------------------------------------------------------
-- me/index.md — compiled as a full essay (TOC, metadata block, sidenotes).
-- Lives in its own directory so co-located SVG score fragments resolve
-- correctly: the Score filter reads paths relative to the source file's
-- directory (content/me/), not the content root.
match "content/me/index.md" $ do
route $ constRoute "me.html"
compile $ essayCompiler
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- SVG score fragments co-located with me/index.md.
match "content/me/scores/*.svg" $ do
route $ gsubRoute "content/me/" (const "")
compile copyFileCompiler
-- memento-mori/index.md — lives in its own directory so co-located SVG
-- score fragments resolve correctly (same pattern as me/index.md).
match "content/memento-mori/index.md" $ do
route $ constRoute "memento-mori.html"
compile $ essayCompiler
>>= loadAndApplyTemplate "templates/essay.html"
(constField "memento-mori" "true" <> essayCtx)
>>= loadAndApplyTemplate "templates/default.html"
(constField "memento-mori" "true" <> essayCtx)
>>= relativizeUrls
-- SVG score fragments co-located with memento-mori/index.md.
match "content/memento-mori/scores/*.svg" $ do
route $ gsubRoute "content/memento-mori/" (const "")
compile copyFileCompiler
-- ---------------------------------------------------------------------------
-- Commonplace book
-- ---------------------------------------------------------------------------
match "content/commonplace.md" $ do
route $ constRoute "commonplace.html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/commonplace.html" commonplaceCtx
>>= loadAndApplyTemplate "templates/default.html" commonplaceCtx
>>= relativizeUrls
match ("content/*.md"
.&&. complement "content/index.md"
.&&. complement "content/commonplace.md") $ do
route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/page.html" pageCtx
>>= loadAndApplyTemplate "templates/default.html" pageCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Essays
-- ---------------------------------------------------------------------------
match "content/essays/*.md" $ do
route $ gsubRoute "content/essays/" (const "essays/")
`composeRoutes` setExtension "html"
compile $ essayCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Blog posts
-- ---------------------------------------------------------------------------
match "content/blog/*.md" $ do
route $ gsubRoute "content/blog/" (const "blog/")
`composeRoutes` setExtension "html"
compile $ postCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/blog-post.html" postCtx
>>= loadAndApplyTemplate "templates/default.html" postCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Poetry
-- ---------------------------------------------------------------------------
match "content/poetry/*.md" $ do
route $ gsubRoute "content/poetry/" (const "poetry/")
`composeRoutes` setExtension "html"
compile $ poetryCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/reading.html" poetryCtx
>>= loadAndApplyTemplate "templates/default.html" poetryCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Fiction
-- ---------------------------------------------------------------------------
match "content/fiction/*.md" $ do
route $ gsubRoute "content/fiction/" (const "fiction/")
`composeRoutes` setExtension "html"
compile $ fictionCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/reading.html" fictionCtx
>>= loadAndApplyTemplate "templates/default.html" fictionCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Music — catalog index
-- ---------------------------------------------------------------------------
match "content/music/index.md" $ do
route $ constRoute "music/index.html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/music-catalog.html" musicCatalogCtx
>>= loadAndApplyTemplate "templates/default.html" musicCatalogCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Music — composition landing pages + score reader
-- ---------------------------------------------------------------------------
-- Static assets (SVG score pages, audio, PDF) served unchanged.
match "content/music/**/*.svg" $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
match "content/music/**/*.mp3" $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
match "content/music/**/*.pdf" $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Landing page — full essay pipeline.
match "content/music/*/index.md" $ do
route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html"
compile $ compositionCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/composition.html" compositionCtx
>>= loadAndApplyTemplate "templates/default.html" compositionCtx
>>= relativizeUrls
-- Score reader — separate URL, minimal chrome.
-- Compiled from the same source with version "score-reader".
match "content/music/*/index.md" $ version "score-reader" $ do
route $ customRoute $ \ident ->
let slug = takeFileName . takeDirectory . toFilePath $ ident
in "music/" ++ slug ++ "/score/index.html"
compile $ do
makeItem ""
>>= loadAndApplyTemplate "templates/score-reader.html"
compositionCtx
>>= loadAndApplyTemplate "templates/score-reader-default.html"
compositionCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Blog index (paginated)
-- ---------------------------------------------------------------------------
blogPaginateRules postCtx siteCtx
-- ---------------------------------------------------------------------------
-- Essay index
-- ---------------------------------------------------------------------------
create ["essays/index.html"] $ do
route idRoute
compile $ do
essays <- recentFirst =<< loadAll ("content/essays/*.md" .&&. hasNoVersion)
let ctx =
listField "essays" essayCtx (return essays)
<> constField "title" "Essays"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/essay-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Library — comprehensive portal-grouped index of all content
-- ---------------------------------------------------------------------------
create ["library.html"] $ do
route idRoute
compile $ do
-- Helper: filter all content to items whose tags include a given portal.
-- A tag matches portal P if it equals "P" or starts with "P/".
let hasPortal p item = do
meta <- getMetadata (itemIdentifier item)
let ts = fromMaybe [] (lookupStringList "tags" meta)
return $ any (\t -> t == p || (p ++ "/") `isPrefixOf` t) ts
portalList name p = listField name essayCtx $ do
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
recentFirst filtered
let ctx = portalList "ai-entries" "ai"
<> portalList "fiction-entries" "fiction"
<> portalList "miscellany-entries" "miscellany"
<> portalList "music-entries" "music"
<> portalList "nonfiction-entries" "nonfiction"
<> portalList "poetry-entries" "poetry"
<> portalList "research-entries" "research"
<> portalList "tech-entries" "tech"
<> constField "title" "Library"
<> constField "library" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/library.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Random page manifest — essays + blog posts only (no pagination/index pages)
-- ---------------------------------------------------------------------------
create ["random-pages.json"] $ do
route idRoute
compile $ do
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion) :: Compiler [Item String]
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) :: Compiler [Item String]
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) :: Compiler [Item String]
poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion) :: Compiler [Item String]
routes <- mapM (getRoute . itemIdentifier) (essays ++ posts ++ fiction ++ poetry)
let urls = [ "/" ++ r | Just r <- routes ]
makeItem $ "[" ++ intercalate "," (map show urls) ++ "]"
-- ---------------------------------------------------------------------------
-- Atom feed — all content sorted by date
-- ---------------------------------------------------------------------------
create ["feed.xml"] $ do
route idRoute
compile $ do
posts <- fmap (take 30) . recentFirst
=<< loadAllSnapshots
( ( "content/essays/*.md"
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. "content/poetry/*.md"
.||. "content/music/*/index.md"
)
.&&. hasNoVersion
)
"content"
let feedCtx =
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
<> bodyField "description"
<> defaultContext
renderAtom feedConfig feedCtx posts
-- ---------------------------------------------------------------------------
-- Music feed — compositions only
-- ---------------------------------------------------------------------------
create ["music/feed.xml"] $ do
route idRoute
compile $ do
compositions <- recentFirst
=<< loadAllSnapshots
("content/music/*/index.md" .&&. hasNoVersion)
"content"
let feedCtx =
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
<> bodyField "description"
<> defaultContext
renderAtom musicFeedConfig feedCtx compositions

178
build/Stability.hs Normal file
View File

@ -0,0 +1,178 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Stability auto-calculation, last-reviewed derivation, and version history.
--
-- For each content page:
-- * If the page's source path appears in @IGNORE.txt@, the stability and
-- last-reviewed fields fall back to the frontmatter values.
-- * Otherwise, @git log --follow@ is used. Stability is derived from
-- commit count + age; last-reviewed is the most-recent commit date.
--
-- Version history (@$version-history$@):
-- * Prioritises frontmatter @history:@ list (date + note pairs).
-- * Falls back to the raw git log dates (date-only, no message).
-- * Falls back to nothing (template shows created/modified dates instead).
--
-- @IGNORE.txt@ is cleared by the build target in the Makefile after
-- every successful build, so pins are one-shot.
module Stability
( stabilityField
, lastReviewedField
, versionHistoryField
) where
import Control.Exception (catch, IOException)
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
import Data.Time.Calendar (Day, diffDays)
import Data.Time.Format (parseTimeM, formatTime, defaultTimeLocale)
import qualified Data.Text as T
import System.Exit (ExitCode (..))
import System.Process (readProcessWithExitCode)
import Hakyll
-- ---------------------------------------------------------------------------
-- IGNORE.txt
-- ---------------------------------------------------------------------------
-- | Read @IGNORE.txt@ (paths relative to project root, one per line).
-- Returns an empty list when the file is absent or empty.
readIgnore :: IO [FilePath]
readIgnore =
(filter (not . null) . lines <$> readFile "IGNORE.txt")
`catch` \(_ :: IOException) -> return []
-- ---------------------------------------------------------------------------
-- Git helpers
-- ---------------------------------------------------------------------------
-- | Return commit dates (ISO "YYYY-MM-DD", newest-first) for @fp@.
gitDates :: FilePath -> IO [String]
gitDates fp = do
(ec, out, _) <- readProcessWithExitCode
"git" ["log", "--follow", "--format=%ad", "--date=short", "--", fp] ""
case ec of
ExitFailure _ -> return []
ExitSuccess -> return $ filter (not . null) (lines out)
-- | Parse an ISO "YYYY-MM-DD" string to a 'Day'.
parseIso :: String -> Maybe Day
parseIso = parseTimeM True defaultTimeLocale "%Y-%m-%d"
-- | Approximate day-span between the oldest and newest ISO date strings.
daySpan :: String -> String -> Int
daySpan oldest newest =
case (parseIso oldest, parseIso newest) of
(Just o, Just n) -> fromIntegral (abs (diffDays n o))
_ -> 0
-- | Derive stability label from commit dates (newest-first).
stabilityFromDates :: [String] -> String
stabilityFromDates [] = "volatile"
stabilityFromDates dates =
classify (length dates) (daySpan (last dates) (head dates))
where
classify n age
| n <= 1 || age < 14 = "volatile"
| n <= 5 && age < 90 = "revising"
| n <= 15 || age < 365 = "fairly stable"
| n <= 30 || age < 730 = "stable"
| otherwise = "established"
-- | Format an ISO date as "%-d %B %Y" (e.g. "16 March 2026").
fmtIso :: String -> String
fmtIso s = case parseIso s of
Nothing -> s
Just day -> formatTime defaultTimeLocale "%-d %B %Y" (day :: Day)
-- ---------------------------------------------------------------------------
-- Stability and last-reviewed context fields
-- ---------------------------------------------------------------------------
-- | Context field @$stability$@.
-- Always resolves to a label; prefers frontmatter when the file is pinned.
stabilityField :: Context String
stabilityField = field "stability" $ \item -> do
let srcPath = toFilePath (itemIdentifier item)
meta <- getMetadata (itemIdentifier item)
unsafeCompiler $ do
ignored <- readIgnore
if srcPath `elem` ignored
then return $ fromMaybe "volatile" (lookupString "stability" meta)
else stabilityFromDates <$> gitDates srcPath
-- | Context field @$last-reviewed$@.
-- Returns the formatted date of the most-recent commit, or @noResult@ when
-- unavailable (making @$if(last-reviewed)$@ false in templates).
lastReviewedField :: Context String
lastReviewedField = field "last-reviewed" $ \item -> do
let srcPath = toFilePath (itemIdentifier item)
meta <- getMetadata (itemIdentifier item)
mDate <- unsafeCompiler $ do
ignored <- readIgnore
if srcPath `elem` ignored
then return $ lookupString "last-reviewed" meta
else fmap fmtIso . listToMaybe <$> gitDates srcPath
case mDate of
Nothing -> fail "no last-reviewed"
Just d -> return d
-- ---------------------------------------------------------------------------
-- Version history
-- ---------------------------------------------------------------------------
data VHEntry = VHEntry
{ vhDate :: String
, vhMessage :: Maybe String -- Nothing for git-log-only entries
}
-- | Parse the optional frontmatter @history:@ list.
-- Each item must have @date:@ and @note:@ keys.
parseFmHistory :: Metadata -> [VHEntry]
parseFmHistory meta =
case KM.lookup "history" meta of
Just (Array v) -> catMaybes (map parseOne (V.toList v))
_ -> []
where
parseOne (Object o) =
case getString =<< KM.lookup "date" o of
Nothing -> Nothing
Just d -> Just $ VHEntry (fmtIso d) (getString =<< KM.lookup "note" o)
parseOne _ = Nothing
getString (String t) = Just (T.unpack t)
getString _ = Nothing
-- | Get git log for a file as version history entries (date-only, no message).
gitLogHistory :: FilePath -> IO [VHEntry]
gitLogHistory fp = map (\d -> VHEntry (fmtIso d) Nothing) <$> gitDates fp
-- | Context list field @$version-history$@ providing @$vh-date$@ and
-- (when present) @$vh-message$@ per entry.
--
-- Priority:
-- 1. Frontmatter @history:@ list — dates + authored notes.
-- 2. Git log dates — date-only, no annotation.
-- 3. Empty list — template falls back to @$date-created$@ / @$date-modified$@.
versionHistoryField :: Context String
versionHistoryField = listFieldWith "version-history" vhCtx $ \item -> do
let srcPath = toFilePath (itemIdentifier item)
meta <- getMetadata (itemIdentifier item)
let fmEntries = parseFmHistory meta
entries <-
if not (null fmEntries)
then return fmEntries
else unsafeCompiler (gitLogHistory srcPath)
if null entries
then fail "no version history"
else return $ zipWith
(\i e -> Item (fromFilePath ("vh" ++ show (i :: Int))) e)
[1..] entries
where
vhCtx =
field "vh-date" (return . vhDate . itemBody)
<> field "vh-message" (\i -> case vhMessage (itemBody i) of
Nothing -> fail "no message"
Just m -> return m)

123
build/Tags.hs Normal file
View File

@ -0,0 +1,123 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Hierarchical tag system.
--
-- Tags are slash-separated strings in YAML frontmatter:
-- tags: [research/mathematics, nonfiction/essays, typography]
--
-- "research/mathematics" expands to ["research", "research/mathematics"]
-- so /research/ aggregates everything tagged with any research/* sub-tag.
--
-- Pages live at /<tag>/index.html — no /tags/ namespace:
-- research → /research/
-- research/mathematics → /research/mathematics/
-- typography → /typography/
module Tags
( buildAllTags
, applyTagRules
, tagLinksField
) where
import Data.List (intercalate, nub)
import Hakyll
import Pagination (pageSize, sortAndGroup)
-- ---------------------------------------------------------------------------
-- Hierarchy expansion
-- ---------------------------------------------------------------------------
wordsBy :: (Char -> Bool) -> String -> [String]
wordsBy p s = case dropWhile p s of
"" -> []
s' -> w : wordsBy p rest
where (w, rest) = break p s'
-- | "research/mathematics" → ["research", "research/mathematics"]
-- "a/b/c" → ["a", "a/b", "a/b/c"]
-- "typography" → ["typography"]
expandTag :: String -> [String]
expandTag t =
let segs = wordsBy (== '/') t
in [ intercalate "/" (take n segs) | n <- [1 .. length segs] ]
-- | All expanded tags for an item (reads the "tags" metadata field).
getExpandedTags :: MonadMetadata m => Identifier -> m [String]
getExpandedTags ident = nub . concatMap expandTag <$> getTags ident
-- ---------------------------------------------------------------------------
-- Identifiers and URLs
-- ---------------------------------------------------------------------------
tagFilePath :: String -> FilePath
tagFilePath tag = tag ++ "/index.html"
tagIdentifier :: String -> Identifier
tagIdentifier = fromFilePath . tagFilePath
-- ---------------------------------------------------------------------------
-- Building the Tags index
-- ---------------------------------------------------------------------------
-- | Scan all essays and blog posts and build the Tags index.
buildAllTags :: Rules Tags
buildAllTags =
buildTagsWith getExpandedTags allContent tagIdentifier
where
allContent = ("content/essays/*.md" .||. "content/blog/*.md") .&&. hasNoVersion
-- ---------------------------------------------------------------------------
-- Tag index page rules
-- ---------------------------------------------------------------------------
tagItemCtx :: Context String
tagItemCtx =
dateField "date" "%-d %B %Y"
<> tagLinksField "item-tags"
<> defaultContext
-- | Page identifier for a tag index page.
-- Page 1 → <tag>/index.html
-- Page N → <tag>/page/N/index.html
tagPageId :: String -> PageNumber -> Identifier
tagPageId tag 1 = fromFilePath $ tag ++ "/index.html"
tagPageId tag n = fromFilePath $ tag ++ "/page/" ++ show n ++ "/index.html"
-- | Generate paginated index pages for every tag.
-- @baseCtx@ should be @siteCtx@ (passed in to avoid a circular import).
applyTagRules :: Tags -> Context String -> Rules ()
applyTagRules tags baseCtx = tagsRules tags $ \tag pat -> do
paginate <- buildPaginateWith sortAndGroup pat (tagPageId tag)
paginateRules paginate $ \pageNum pat' -> do
route idRoute
compile $ do
items <- recentFirst =<< loadAll (pat' .&&. hasNoVersion)
let ctx = listField "items" tagItemCtx (return items)
<> paginateContext paginate pageNum
<> constField "tag" tag
<> constField "title" tag
<> baseCtx
makeItem ""
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Tag links context field
-- ---------------------------------------------------------------------------
-- | List context field exposing an item's own (non-expanded) tags as
-- @tag-name@ / @tag-url@ objects.
--
-- $for(essay-tags)$<a href="$tag-url$">$tag-name$</a>$endfor$
tagLinksField :: String -> Context a
tagLinksField fieldName = listFieldWith fieldName itemCtx $ \item ->
map toItem <$> getTags (itemIdentifier item)
where
toItem t = Item (tagIdentifier t) t
itemCtx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")

26
build/Utils.hs Normal file
View File

@ -0,0 +1,26 @@
{-# LANGUAGE GHC2021 #-}
module Utils
( wordCount
, readingTime
, escapeHtml
) where
-- | Count the number of words in a string (split on whitespace).
wordCount :: String -> Int
wordCount = length . words
-- | Estimate reading time in minutes (assumes 200 words per minute).
-- Minimum is 1 minute.
readingTime :: String -> Int
readingTime s = max 1 (wordCount s `div` 200)
-- | Escape HTML special characters: <, >, &, ", '.
escapeHtml :: String -> String
escapeHtml = concatMap escChar
where
escChar '<' = "&lt;"
escChar '>' = "&gt;"
escChar '&' = "&amp;"
escChar '"' = "&quot;"
escChar '\'' = "&#39;"
escChar c = [c]

8
cabal.project Normal file
View File

@ -0,0 +1,8 @@
packages: .
with-compiler: ghc-9.6.6
-- Optimise the build program itself. -O1 is sufficient and much faster
-- to compile than -O2.
program-options
ghc-options: -O1

235
cabal.project.freeze Normal file
View File

@ -0,0 +1,235 @@
active-repositories: hackage.haskell.org:merge
constraints: any.Glob ==0.10.2,
any.HUnit ==1.6.2.0,
any.JuicyPixels ==3.3.9,
any.OneTuple ==0.4.2,
any.Only ==0.1,
any.QuickCheck ==2.15.0.1,
any.StateVar ==1.2.2,
any.aeson ==2.2.0.0,
any.aeson-pretty ==0.8.10,
any.ansi-terminal ==1.1,
any.ansi-terminal-types ==1.1,
any.appar ==0.1.8,
any.array ==0.5.6.0,
any.asn1-encoding ==0.9.6,
any.asn1-parse ==0.9.5,
any.asn1-types ==0.3.4,
any.assoc ==1.1.1,
any.async ==2.2.5,
any.attoparsec ==0.14.4,
any.attoparsec-aeson ==2.2.0.0,
any.auto-update ==0.1.6,
any.base ==4.18.2.1,
any.base-compat ==0.14.1,
any.base-orphans ==0.9.3,
any.base16-bytestring ==1.0.2.0,
any.base64-bytestring ==1.2.1.0,
any.basement ==0.0.16,
any.bifunctors ==5.6.2,
any.binary ==0.8.9.1,
any.bitvec ==1.1.5.0,
any.blaze-builder ==0.4.4.1,
any.blaze-html ==0.9.2.0,
any.blaze-markup ==0.8.3.0,
any.bsb-http-chunked ==0.0.0.4,
any.byteorder ==1.0.4,
any.bytestring ==0.11.5.3,
any.call-stack ==0.4.0,
any.case-insensitive ==1.2.1.0,
any.cassava ==0.5.4.1,
any.cborg ==0.2.10.0,
any.cereal ==0.5.8.3,
any.citeproc ==0.8.1.1,
any.colour ==2.3.6,
any.commonmark ==0.2.6.1,
any.commonmark-extensions ==0.2.5.6,
any.commonmark-pandoc ==0.2.2.3,
any.comonad ==5.0.9,
any.conduit ==1.3.6.1,
any.conduit-extra ==1.3.8,
any.containers ==0.6.7,
any.contravariant ==1.5.5,
any.cookie ==0.5.0,
any.crypton ==1.0.4,
any.crypton-connection ==0.4.5,
any.crypton-socks ==0.6.2,
any.crypton-x509 ==1.7.7,
any.crypton-x509-store ==1.6.12,
any.crypton-x509-system ==1.6.7,
any.crypton-x509-validation ==1.6.14,
any.data-default ==0.7.1.3,
any.data-default-class ==0.1.2.2,
any.data-default-instances-containers ==0.1.0.3,
any.data-default-instances-dlist ==0.0.1.2,
any.data-default-instances-old-locale ==0.0.1.2,
any.data-fix ==0.3.4,
any.deepseq ==1.4.8.1,
any.digest ==0.0.2.1,
any.directory ==1.3.8.5,
any.distributive ==0.6.2.1,
any.djot ==0.1.2.3,
any.dlist ==1.0,
any.doclayout ==0.5,
any.doctemplates ==0.11.0.1,
any.easy-file ==0.2.5,
any.emojis ==0.1.4.1,
any.exceptions ==0.10.7,
any.fast-logger ==3.2.4,
any.file-embed ==0.0.16.0,
any.filepath ==1.4.300.1,
any.fsnotify ==0.4.4.0,
any.generically ==0.1.1,
any.ghc-bignum ==1.3,
any.ghc-boot-th ==9.6.6,
any.ghc-prim ==0.10.0,
any.gridtables ==0.1.1.0,
any.haddock-library ==1.11.0,
any.hakyll ==4.16.7.1,
hakyll -buildwebsite +checkexternal +previewserver +usepandoc +watchserver,
any.half ==0.3.3,
any.hashable ==1.4.7.0,
any.haskell-lexer ==1.2,
any.haskell-src-exts ==1.23.1,
any.haskell-src-meta ==0.8.15,
any.hinotify ==0.4.2,
any.hourglass ==0.2.12,
any.http-client ==0.7.19,
any.http-client-tls ==0.3.6.4,
any.http-conduit ==2.3.9.1,
http-conduit +aeson,
any.http-date ==0.0.11,
any.http-types ==0.12.4,
any.http2 ==5.1.0,
any.indexed-traversable ==0.1.4,
any.indexed-traversable-instances ==0.1.2,
any.integer-conversion ==0.1.1,
any.integer-gmp ==1.1,
any.integer-logarithms ==1.0.4,
any.iproute ==1.7.15,
any.ipynb ==0.2,
any.jira-wiki-markup ==1.5.1,
any.libyaml ==0.1.4,
any.lifted-base ==0.2.3.12,
any.lrucache ==1.2.0.1,
any.memory ==0.18.0,
any.mime-types ==0.1.2.0,
any.monad-control ==1.0.3.1,
any.monad-logger ==0.3.42,
monad-logger +template_haskell,
any.monad-loops ==0.4.3,
monad-loops +base4,
any.mono-traversable ==1.0.21.0,
any.mtl ==2.3.1,
any.mtl-compat ==0.2.2,
mtl-compat -two-point-one -two-point-two,
any.network ==3.1.4.0,
any.network-byte-order ==0.1.7,
any.network-control ==0.1.3,
any.network-uri ==2.6.4.2,
any.old-locale ==1.0.0.7,
any.old-time ==1.1.0.4,
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-types ==1.23.1,
any.parsec ==3.1.16.1,
any.pem ==0.2.4,
any.pretty ==1.1.3.6,
any.pretty-show ==1.10,
any.prettyprinter ==1.7.1,
any.prettyprinter-ansi-terminal ==1.1.3,
any.primitive ==0.9.1.0,
any.process ==1.6.19.0,
any.psqueues ==0.2.8.2,
any.random ==1.2.1.3,
any.recv ==0.1.1,
any.regex-base ==0.94.0.3,
any.regex-tdfa ==1.3.2.5,
any.resourcet ==1.2.6,
any.retry ==0.9.3.1,
retry -lib-werror,
any.rts ==1.0.2,
any.safe ==0.3.21,
any.safe-exceptions ==0.1.7.4,
any.scientific ==0.3.8.0,
any.semialign ==1.3.1,
any.semigroupoids ==6.0.1,
any.serialise ==0.2.6.1,
any.simple-sendfile ==0.2.32,
any.skylighting ==0.14.3,
any.skylighting-core ==0.14.3,
any.skylighting-format-ansi ==0.1,
any.skylighting-format-blaze-html ==0.1.1.3,
any.skylighting-format-context ==0.1.0.2,
any.skylighting-format-latex ==0.1,
any.split ==0.2.5,
any.splitmix ==0.1.3,
any.stm ==2.5.1.0,
any.stm-chans ==3.0.0.11,
any.streaming-commons ==0.2.3.1,
any.strict ==0.5.1,
any.string-interpolate ==0.3.4.0,
string-interpolate -bytestring-builder -extended-benchmarks -text-builder,
any.syb ==0.7.3,
any.tagged ==0.8.9,
any.tagsoup ==0.14.8,
any.template-haskell ==2.20.0.0,
any.temporary ==1.3,
any.texmath ==0.12.8.11,
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.5.0.0,
any.th-compat ==0.1.6,
any.th-expand-syns ==0.4.12.0,
any.th-lift ==0.8.6,
any.th-lift-instances ==0.1.20,
any.th-orphans ==0.13.17,
any.th-reify-many ==0.1.10,
any.these ==1.2.1,
any.time ==1.12.2,
any.time-compat ==1.9.8,
any.time-locale-compat ==0.1.1.5,
any.time-manager ==0.0.1,
any.tls ==2.0.6,
any.toml-parser ==2.0.1.2,
any.transformers ==0.6.1.0,
any.transformers-base ==0.4.6,
any.transformers-compat ==0.7.2,
any.typed-process ==0.2.13.0,
any.typst ==0.6,
any.typst-symbols ==0.1.6,
any.unicode-collation ==0.1.3.6,
any.unicode-data ==0.6.0,
any.unicode-transforms ==0.4.0.1,
any.uniplate ==1.6.13,
any.unix ==2.8.4.0,
any.unix-compat ==0.7.4.1,
any.unix-time ==0.4.17,
any.unliftio ==0.2.25.1,
any.unliftio-core ==0.2.1.0,
any.unordered-containers ==0.2.20.1,
any.utf8-string ==1.0.2,
any.uuid-types ==1.0.6,
any.vault ==0.3.1.5,
any.vector ==0.13.2.0,
any.vector-algorithms ==0.9.1.0,
any.vector-stream ==0.1.0.1,
any.wai ==3.2.4,
any.wai-app-static ==3.1.9,
any.wai-extra ==3.1.18,
any.wai-logger ==2.5.0,
any.warp ==3.4.0,
any.witherable ==0.4.2,
any.word8 ==0.1.3,
any.xml ==1.3.14,
any.xml-conduit ==1.9.1.4,
any.xml-types ==0.3.8,
any.yaml ==0.11.11.2,
any.zip-archive ==0.4.3.2,
any.zlib ==0.7.0.0
index-state: hackage.haskell.org 2026-03-14T15:45:34Z

1
data/annotations.json Normal file
View File

@ -0,0 +1 @@
{}

199
data/bibliography.bib Normal file
View File

@ -0,0 +1,199 @@
% bibliography.bib — levineuwirth.org
% BibLaTeX format. Add entries here; reference with [@key] in essay Markdown.
@book{tufte1983,
author = {Tufte, Edward R.},
title = {The Visual Display of Quantitative Information},
publisher = {Graphics Press},
address = {Cheshire, CT},
year = {1983}
}
@book{butterick2019,
author = {Butterick, Matthew},
title = {Practical Typography},
publisher = {Matthew Butterick},
year = {2019},
url = {https://practicaltypography.com}
}
@misc{pandoc,
author = {MacFarlane, John},
title = {Pandoc: A Universal Document Converter},
year = {2006},
url = {https://pandoc.org}
}
@book{knuth1984,
author = {Knuth, Donald E.},
title = {The \TeX{}book},
publisher = {Addison-Wesley},
address = {Reading, MA},
year = {1984}
}
@article{lamport1994,
author = {Lamport, Leslie},
title = {{\LaTeX}: A Document Preparation System},
journal = {Communications of the ACM},
volume = {37},
number = {7},
year = {1994},
pages = {47--52}
}
% ---------------------------------------------------------------------------
% Philosophy / Literature
% ---------------------------------------------------------------------------
@article{Frank,
ISSN = {00373052, 1934421X},
URL = {http://www.jstor.org/stable/27540632},
author = {Frank, Joseph},
journal = {The Sewanee Review},
number = {1},
pages = {1--33},
publisher = {Johns Hopkins University Press},
title = {Nihilism and ``Notes from Underground''},
volume = {69},
year = {1961}
}
@article{Reynolds,
URL = {https://repository.lsu.edu/cgi/viewcontent.cgi?article=2235&context=honors_etd},
author = {Reynolds, Andrew S.},
number = {1},
publisher = {LSU Scholarly Repository},
year = {1996}
}
@article{Somer,
URL = {https://journals.lww.com/jonmd/FullText/2017/07000/The_Comorbidity_of_Daydreaming_Disorder.4.aspx},
author = {Somer, Eli},
journal = {The Journal of Nervous and Mental Disease},
number = {7},
pages = {525--530},
year = {2017}
}
@book{Heidegger_Letter,
title = {Basic Writings},
author = {Heidegger, Martin},
isbn = {9780061627019},
pages = {219},
series = {Harper Perennial Modern Thought},
year = {2008},
publisher = {HarperCollins}
}
@book{Heidegger_Building,
title = {Basic Writings},
author = {Heidegger, Martin},
isbn = {9780061627019},
pages = {361},
series = {Harper Perennial Modern Thought},
year = {2008},
publisher = {HarperCollins}
}
@book{Notes,
title = {Notes from Underground},
author = {Dostoevsky, Fyodor and Pevear, Richard and Volokhonsky, Larissa},
isbn = {9780679734529},
series = {Vintage Classics},
year = {1994},
publisher = {Knopf Doubleday Publishing Group}
}
@book{Nietzsche_Gene,
title = {On the Genealogy of Morals},
author = {Nietzsche, Friedrich and Holub, Robert C. and Scarpitti, Michael A.},
isbn = {9780141195384},
year = {2013},
publisher = {Penguin Books Limited}
}
@book{Nietzsche_Tragedy,
title = {The Birth of Tragedy: Out of the Spirit of Music},
author = {Nietzsche, Friedrich and Tanner, Michael and Whiteside, Shaun},
isbn = {9780141935072},
series = {Penguin Classics},
year = {2003},
publisher = {Penguin Books Limited}
}
@book{Shestov,
title = {All Things are Possible and Penultimate Words and Other Essays},
author = {Shestov, Lev},
isbn = {9780821402375},
year = {1977},
publisher = {Ohio University Press}
}
@book{Ellul,
title = {The Technological Society},
author = {Ellul, Jacques},
isbn = {9780394703909},
series = {A Vintage Book},
year = {1964},
publisher = {Knopf Doubleday Publishing Group}
}
@book{Kacz,
title = {Industrial Society and Its Future},
author = {Kaczynski, Theodore J.},
isbn = {9781365394294},
year = {1995},
publisher = {The Washington Post}
}
@book{Sartre,
title = {Being and Nothingness: An Essay in Phenomenological Ontology},
author = {Sartre, Jean-Paul and Barnes, Hazel E.},
isbn = {9780806522760},
year = {2001},
publisher = {Citadel Press}
}
@book{Camus_Myth,
title = {The Myth of Sisyphus and Other Essays},
author = {Camus, Albert},
isbn = {9780307827821},
series = {Vintage International},
year = {2012},
publisher = {Knopf Doubleday Publishing Group}
}
@book{Cooper,
title = {The Totalizing Act: Key to Husserl's Early Philosophy},
author = {Cooper-Wiele, Jonathan K.},
isbn = {9789400922594},
series = {Phaenomenologica},
year = {2012},
publisher = {Springer Netherlands}
}
@book{Husserl_Crisis,
title = {The Crisis of European Sciences and Transcendental Phenomenology},
author = {Husserl, Edmund and Carr, David},
isbn = {9780810104587},
series = {Northwestern University Studies in Phenomenology \& Existential Philosophy},
year = {1970},
publisher = {Northwestern University Press}
}
@book{Husserl_Meditations,
title = {Cartesian Meditations: An Introduction to Phenomenology},
author = {Husserl, Edmund and Cairns, Dorothea},
isbn = {9789400999978},
year = {2012},
publisher = {Springer Netherlands}
}
@book{Arendt,
title = {The Human Condition},
author = {Arendt, Hannah},
isbn = {9780226586748},
year = {2022},
publisher = {University of Chicago Press}
}

248
data/chicago-notes.csl Normal file
View File

@ -0,0 +1,248 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0"
demote-non-dropping-particle="sort-only"
default-locale="en-US">
<info>
<title>Chicago Notes Bibliography</title>
<id>chicago-notes-levineuwirth</id>
<link href="http://www.chicagomanualofstyle.org/tools_citationguide.html" rel="documentation"/>
<author><name>Levi Neuwirth</name></author>
<updated>2026-03-15T00:00:00+00:00</updated>
</info>
<!-- ============================================================
MACROS
============================================================ -->
<!-- Author names: Last, First for first author; First Last for rest -->
<macro name="contributors">
<names variable="author">
<name name-as-sort-order="first" and="text"
sort-separator=", " delimiter=", "
delimiter-precedes-last="always"/>
<label form="short" prefix=", "/>
<substitute>
<names variable="editor"/>
<names variable="translator"/>
<text macro="title"/>
</substitute>
</names>
</macro>
<!-- Editor names (for edited volumes) -->
<macro name="editor">
<names variable="editor">
<name and="text" delimiter=", "/>
<label form="short" prefix=", "/>
</names>
</macro>
<!-- Title: italics for book-like, quotes for articles/chapters -->
<macro name="title">
<choose>
<if type="book report" match="any">
<text variable="title" font-style="italic"/>
</if>
<else-if type="thesis">
<text variable="title" font-style="italic"/>
</else-if>
<else>
<text variable="title" quotes="true"/>
</else>
</choose>
</macro>
<!-- Container title (journal, book, website) -->
<macro name="container-title">
<choose>
<if type="chapter paper-conference" match="any">
<group delimiter=" ">
<text term="in" text-case="capitalize-first"/>
<text variable="container-title" font-style="italic"/>
</group>
</if>
<else-if type="article-journal article-magazine article-newspaper" match="any">
<text variable="container-title" font-style="italic"/>
</else-if>
<else-if type="webpage post post-weblog" match="any">
<text variable="container-title"/>
</else-if>
</choose>
</macro>
<!-- Year only -->
<macro name="year">
<choose>
<if variable="issued">
<date variable="issued">
<date-part name="year"/>
</date>
</if>
<else>
<text term="no date" form="short"/>
</else>
</choose>
</macro>
<!-- Full date (for web pages, newspaper articles) -->
<macro name="date-full">
<date variable="issued" delimiter=" ">
<date-part name="month" form="long"/>
<date-part name="day" suffix=","/>
<date-part name="year"/>
</date>
</macro>
<!-- Publisher: Place: Publisher -->
<macro name="publisher">
<group delimiter=": ">
<text variable="publisher-place"/>
<text variable="publisher"/>
</group>
</macro>
<!-- DOI or URL access link -->
<macro name="access">
<choose>
<if variable="DOI">
<text variable="DOI" prefix="https://doi.org/"/>
</if>
<else-if variable="URL">
<text variable="URL"/>
</else-if>
</choose>
</macro>
<!-- ============================================================
CITATION (inline — our Haskell filter replaces this entirely)
============================================================ -->
<citation>
<sort>
<key macro="contributors"/>
<key macro="year"/>
</sort>
<layout prefix="(" suffix=")" delimiter="; ">
<group delimiter=" ">
<names variable="author">
<name form="short" and="text" delimiter=", "/>
<substitute>
<names variable="editor"/>
<text macro="title"/>
</substitute>
</names>
<text macro="year"/>
</group>
</layout>
</citation>
<!-- ============================================================
BIBLIOGRAPHY
============================================================ -->
<bibliography entry-spacing="0" hanging-indent="false">
<sort>
<!-- Sorted by appearance order in our Haskell post-processor.
This CSL sort is a fallback only. -->
<key macro="contributors"/>
<key macro="year"/>
</sort>
<layout suffix=".">
<group delimiter=". ">
<!-- Author(s) -->
<text macro="contributors"/>
<!-- Title -->
<text macro="title"/>
<!-- Type-specific publication details -->
<choose>
<!-- Book -->
<if type="book" match="any">
<group delimiter=". ">
<text macro="editor"/>
<group delimiter=", ">
<text macro="publisher"/>
<text macro="year"/>
</group>
</group>
</if>
<!-- Thesis / Dissertation -->
<else-if type="thesis">
<group delimiter=", ">
<text variable="genre"/>
<text variable="publisher"/>
<text macro="year"/>
</group>
</else-if>
<!-- Book chapter / conference paper -->
<else-if type="chapter paper-conference" match="any">
<group delimiter=". ">
<group delimiter=", ">
<text macro="container-title"/>
<text macro="editor"/>
</group>
<group delimiter=", ">
<text variable="page"/>
<group delimiter=", ">
<text macro="publisher"/>
<text macro="year"/>
</group>
</group>
</group>
</else-if>
<!-- Journal article -->
<else-if type="article-journal">
<group delimiter=" ">
<text macro="container-title"/>
<group delimiter=", ">
<text variable="volume"/>
<group>
<text term="issue" form="short" suffix=". "/>
<text variable="issue"/>
</group>
</group>
<group prefix="(" suffix="):">
<text macro="year"/>
</group>
<text variable="page"/>
</group>
</else-if>
<!-- Magazine / newspaper -->
<else-if type="article-magazine article-newspaper" match="any">
<group delimiter=", ">
<text macro="container-title"/>
<text macro="date-full"/>
</group>
</else-if>
<!-- Web page / blog post -->
<else-if type="webpage post post-weblog" match="any">
<group delimiter=". ">
<text macro="container-title"/>
<text macro="date-full"/>
</group>
</else-if>
<!-- Fallback -->
<else>
<group delimiter=", ">
<text macro="publisher"/>
<text macro="year"/>
</group>
</else>
</choose>
</group>
<!-- DOI / URL appended after the period -->
<text macro="access" prefix=" "/>
</layout>
</bibliography>
</style>

10
data/commonplace.yaml Normal file
View File

@ -0,0 +1,10 @@
- text: |-
Like as the waves make towards the pebbled shore,
So do our minutes hasten to their end;
Each changing place with that which goes before,
In sequent toil all forwards do contend.
attribution: William Shakespeare
source: Sonnet 60
source-url: /poetry/sonnet-60.html
tags: [time, mortality]
date-added: 2026-03-17

60
levineuwirth.cabal Normal file
View File

@ -0,0 +1,60 @@
cabal-version: 3.0
name: levineuwirth
version: 0.1.0.0
synopsis: Build system for levineuwirth.org
license: MIT
license-file: LICENSE
author: Levi Neuwirth
maintainer: levi@levineuwirth.org
build-type: Simple
executable site
main-is: Main.hs
hs-source-dirs: build
other-modules:
Site
Authors
Catalog
Commonplace
Backlinks
Compilers
Contexts
Stability
Metadata
Tags
Pagination
Citations
Filters
Filters.Typography
Filters.Sidenotes
Filters.Dropcaps
Filters.Smallcaps
Filters.Wikilinks
Filters.Links
Filters.Math
Filters.Code
Filters.Images
Filters.Score
Utils
build-depends:
base >= 4.18 && < 5,
hakyll >= 4.16 && < 4.17,
pandoc >= 3.1 && < 3.7,
pandoc-types >= 1.23 && < 1.24,
text >= 2.0 && < 2.2,
containers >= 0.6 && < 0.8,
filepath >= 1.4 && < 1.6,
directory >= 1.3 && < 1.4,
time >= 1.12 && < 1.15,
aeson >= 2.1 && < 2.3,
vector >= 0.12 && < 0.14,
yaml >= 0.11 && < 0.12,
bytestring >= 0.11 && < 0.13,
process >= 1.6 && < 1.7,
data-default >= 0.7 && < 0.8,
mtl >= 2.3 && < 2.4
default-language: GHC2021
ghc-options:
-threaded
-Wall
-Wno-unused-imports

1044
sample_music.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 71 KiB

638
spec.md Normal file
View File

@ -0,0 +1,638 @@
# levineuwirth.org — Design Specification v8
**Author:** Levi Neuwirth
**Date:** March 2026 (v8: 16 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. 650700px 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 `/<tag>/` 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 (13 sentences)
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)
# Epistemic profile (all optional; section shown only if `status` is present)
status: # Draft | Working model | Durable | Refined | Superseded | Deprecated
confidence: # 0100 integer (%)
importance: # 15 integer (rendered as filled/empty dots)
evidence: # 15 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 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) — compact: status chip · confidence % · importance dots · evidence dots; expanded `<details>`: stability · scope · novelty · practicality · 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:
cabal run site -- build
pagefind --site _site
> IGNORE.txt # clear stability pins after each build
deploy: build
rsync -avz --delete _site/ vps:/var/www/levineuwirth.org/
watch:
cabal run site -- watch
clean:
cabal run site -- clean
```
### 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;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; 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
│ │ ├── 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
│ │ ├── search.js # Pagefind UI init + ?q= pre-fill
│ │ └── prism.min.js # Syntax highlighting
│ ├── fonts/ # Self-hosted WOFF2 (subsetted with OT features)
│ └── 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
│ │ └── Score.hs # Score fragment SVG inlining + currentColor replacement
│ ├── 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
├── 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)
- [ ] Deploy to DreamHost via rsync
### 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 so selection popup "Here" button lands ready
- [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
- [~] Annotations — `annotations.js` / `annotations.css`; localStorage infrastructure + highlight re-anchoring written; UI (button in selection popup) deferred
### Phase 4: Creative Content & Polish
- [x] Image handling (lazy load, lightbox, figures)
- [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 — matplotlib / Altair figures generated at build time; each visualization lives in its own directory (e.g. `content/viz/my-chart/`) alongside a `generate.py` and a versioned dataset; Hakyll rule invokes `python generate.py` to produce SVG/HTML output and copies it into `_site/`; datasets can be updated independently and graphs regenerate on next build
- [ ] Content migration — migrate existing essays, poems, fiction, and music landing pages from prior formats into `content/`
### Phase 5: Infrastructure & Advanced
- [ ] **Arch Linux VPS + nginx + certbot + DNS migration** — Provision Hetzner VPS, install nginx (config in §III), obtain TLS cert via certbot, migrate DNS from DreamHost. Update `make deploy` target. Serve `_site/` as static files; no server-side logic needed.
- [ ] **Semantic embedding pipeline** — Generate per-page embeddings (OpenAI `text-embedding-3-small` or local model). Store as `data/embeddings.json` (identifier → vector). At build time, compute nearest neighbors and write `data/similar-links.json`. Serve as static JSON; JS loads it client-side to populate a "Similar" section in the page footer.
- [x] **Backlinks with context** — Two-pass build-time system (`build/Backlinks.hs`). Pass 1: `version "links"` compiles each page lightly (wikilinks preprocessed, links + context extracted, serialised as JSON). Pass 2: `create ["data/backlinks.json"]` inverts the map. `backlinksField` in `essayCtx` / `postCtx` loads the JSON and renders `<details>`-collapsible per-entry lists. `popups.js` excludes `.backlink-source` links from the preview popup. Context paragraph uses `runPure . writeHtml5String` on the surrounding `Para` block. See Implementation Notes.
- [ ] **Link archiving** — For all external links in `data/bibliography.bib` and in page bodies, check availability and save snapshots (Wayback Machine `save` API or local archivebox instance). Store archive URLs in `data/link-archive.json`; `Filters.Links` injects `data-archive-url` attributes; `popups.js` falls back to the archive if the live URL returns 404.
- [ ] **Self-hosted git (Forgejo)** — Run Forgejo on the VPS. Mirror the build repo. Link from the colophon. Not essential; can remain on GitHub indefinitely.
- [ ] **Reader mode** — Distraction-free reading overlay: hides nav, TOC, sidenotes; widens the body column to ~70ch; activated via a keyboard shortcut or settings panel toggle. Distinct from focus mode (which affects the nav) — reader mode affects the content layout.
### Phase 6: Deferred Features
- [ ] **Annotation UI** — The `annotations.js` / `annotations.css` infrastructure exists (localStorage storage, re-anchoring on load, four highlight colors, hover tooltip). The selection popup "Annotate" button was removed pending a design decision on the color-picker and note-entry UX. Revisit: a popover with four color swatches and an optional text field, triggered from the selection popup.
- [ ] **Visualization pipeline** — Each visualization lives in `content/viz/{slug}/` alongside `generate.py` and a versioned dataset CSV/JSON. Hakyll rule: `unsafeCompiler (callProcess "python" ["generate.py"])` writes SVG/HTML output into the item body. Output is embedded in the page or served as a static asset. Datasets can be updated independently; graphs regenerate on next `make build`. Matplotlib for static figures; Altair for interactive (Vega-Lite JSON embedded, rendered client-side by Vega-Lite JS — loaded conditionally).
- [x] **Music catalog page**`/music/` index listing all compositions grouped by instrumentation category (orchestral → chamber → solo → vocal → choral → electronic → other), with an optional Featured section. Auto-generated from composition frontmatter by `build/Catalog.hs`; renders HTML in Haskell (same pattern as backlinks). Category, year, duration, instrumentation, and ◼/♫ indicators for score/recording availability. `content/music/index.md` provides prose intro + abstract. Template: `templates/music-catalog.html`. CSS: `static/css/catalog.css`. Context: `musicCatalogCtx` (provides `catalog: true` flag, `featured-works`, `has-featured`, `catalog-by-category`).
- [x] **Score reader swipe gestures**`touchstart`/`touchend` listeners on `#score-reader-stage` with passive: true. Threshold: ≥ 50 px horizontal, < 30 px vertical drift. Left swipe next page; right swipe previous page.
- [x] **Full-piece audio on composition pages**`recording` frontmatter key (path relative to the composition directory). Rendered as a full-width `<audio>` player in `composition.html`, above the per-movement list. Styled via `.comp-recording` / `.comp-recording-audio` in `components.css`. Per-movement `<audio>` players and `.comp-btn` / `.comp-movement-*` styles also added in the same pass.
- [x] **RSS/feed improvements**`/feed.xml` now includes compositions (`content/music/*/index.md`) alongside essays, posts, fiction, poetry. New `/music/feed.xml` (compositions only, `musicFeedConfig`). Compositions already had `"content"` snapshots saved by the landing-page rule; no compiler changes needed.
- [ ] **Pagefind improvements** — Currently a basic full-text search. Consider: sub-result excerpts, portal-scoped search filters, weighting by `importance` frontmatter field.
- [ ] **Audio essays / podcast feed** — Record readings of select essays. Embed a native `<audio>` player at the top of the essay page, activated by an `audio` frontmatter key (path to MP3, relative to the content dir). Generate a separate `/podcast.xml` Atom feed with `<enclosure>` elements pointing to the MP3s so readers can subscribe in any podcast app. Stretch goal: a paragraph-sync mode where the player emits `timeupdate` events that highlight the paragraph currently being read — requires a `data/audio/{slug}-timestamps.json` file mapping paragraph indices to timestamps, authored manually or via forced-alignment tooling (e.g. `whisper` with word timestamps).
- [ ] **Build telemetry page** — A `/build` page generated at build time showing infrastructure statistics: total build time (wall clock), number of pages compiled by type, total output size, Pandoc AST statistics aggregated across the whole corpus (paragraph count, heading count, code blocks, math blocks, inline citations, word count distribution). Could also include a dependency graph of which pages triggered rebuilds. A meta-page about the site's own construction — fits the "configuration is code" philosophy. Implementation: `unsafeCompiler` calls to gather stats during build, written to a `data/build-stats.json` snapshot, rendered via a dedicated template.
- [x] **Epistemic profile** — Replaces the old `certainty` / `importance` fields with a richer multi-axis system. **Compact** (always visible in footer): status chip · confidence % · importance dots · evidence dots. **Expanded** (`<details>`): stability (auto) · scope · novelty · practicality · last reviewed · confidence trend. Auto-calculation in `build/Stability.hs` via `git log --follow`; `IGNORE.txt` pins overrides. See Metadata section and Implementation Notes for full schema and vocabulary.
- [ ] **Writing statistics dashboard** — A `/stats` page computed entirely at build time from the corpus. Contents: total word count across all content types, essay/post/poem count, words written per month rendered as a GitHub-style contribution heatmap (SVG generated by Haskell or a Python script), average and median essay length, longest essay, most-cited essay (by backlink count), tag distribution as a treemap, reading-time histogram, site growth over time (cumulative word count by date). All data collected during the Hakyll build from compiled items and their snapshots; serialized to `data/stats.json` and rendered into a dedicated `stats.html` template.
- [ ] **Memento mori** — An interactive widget, likely placed on the homepage or `/me` page, that confronts the reader (and author) with time. Exact form TBD, but the spirit is: a live display of time elapsed and time statistically remaining, computed from a birthdate and actuarial life expectancy. Could manifest as a progress bar, a grid of weeks (in the style of Tim Urban's "Your Life in Weeks"), or a running clock. Interactive via JavaScript — requires support for **custom inline JavaScript** in Pandoc-compiled pages (a `RawBlock "html"` passthrough or a dedicated fenced-div filter that emits `<script>` tags). The inline JS requirement is a prerequisite; implement that first. No tracking, no external data — all computation client-side from a hardcoded birthdate.
- [ ] **Embedding-powered similar links** — Precompute dense vector embeddings for every page using a local model (e.g. `nomic-embed-text` or `gte-large` via `ollama` or `llama.cpp`) on personal hardware — no API dependency, no per-call cost. At build time, a Python script reads `_site/` HTML, embeds each page, computes top-N cosine neighbors, and writes `data/similar-links.json` (slug → [{slug, title, score}]). Hakyll injects this into each page's context (via `Metadata.hs` reading the JSON); template renders a "Related" section in the page footer. Analogous to gwern's `GenerateSimilar.hs` but model-agnostic and self-hosted. Note: supersedes the Phase 5 "Semantic embedding pipeline" stub — that stub should be replaced by this when implemented.
- [x] **Bidirectional backlinks with context** — See Phase 5 above; implemented with full context-paragraph extraction. Merged with the Phase 5 stub.
- [ ] **Signed pages / content integrity** — GPG-sign each HTML output file at build time using a detached ASCII-armored signature (`.sig` file per page). The signing step runs as a final Makefile target after Hakyll and Pagefind complete: `find _site -name '*.html' -exec gpg --batch --yes --detach-sign --armor {} \;`. Signatures are served alongside their pages (e.g. `/essays/my-essay.html.sig`). The page footer displays a verification block near the license: the signing key fingerprint, a link to `/gpg/` where the public key is published, and a link to the `.sig` file for that page — so readers can verify without hunting for the key. The public key is also available at the standard WKD location and published to keyservers. **Operational requirement:** a dedicated signing subkey (no passphrase) on the build machine; the master certifying key stays offline and passphrase-protected. A `tools/setup-signing.sh` script will walk through creating the signing subkey, exporting it, and configuring the build — so the setup is repeatable when moving between machines or provisioning the VPS. Philosophically consistent with the FOSS/privacy ethos and the "configuration is code" principle; extreme, but the site is already committed to doing things properly.
- [ ] **Full-text semantic search** — A secondary search mode alongside Pagefind's keyword index. Precompute embeddings for every paragraph (same pipeline as similar links). Store as a compact binary or JSON index. At query time, either: (a) compute the query embedding client-side using a small WASM model (e.g. `transformers.js` with a quantized MiniLM) and run cosine similarity against the stored paragraph vectors, or (b) use a precomputed query-expansion table (top-K words → relevant slugs, offline). Surfaced as a "Semantic search" toggle on `/search.html`. Returns paragraphs rather than pages as the result unit, with the source page title and a link to the specific section. This finds conceptually related content even when exact keywords differ — searching "the relationship between music and mathematics" surfaces relevant essays regardless of vocabulary.
---
## VI. Implementation Notes
### sidenotes.js — Written from scratch
The spec called for adopting Said Achmiz's `sidenotes.js` directly. Instead a purpose-built version was written for the `<span class="sidenote">` structure produced by `Filters.Sidenotes`. Features: JS collision avoidance (`positionSidenotes`), bidirectional hover highlight, click-to-focus (sticky highlight on wide viewport, anchor scroll fallback on narrow), document-click dismissal. `window.resize` is used as the reposition signal; `collapse.js` dispatches it after each section transition.
### Gallery / Exhibit system — Added (not in original spec)
- **Exhibits** (`.exhibit--equation`, `.exhibit--proof`): always-visible inline blocks with overlay zoom on click.
- **Annotations** (`.annotation--static`, `.annotation--collapsible`): editorial callout boxes.
- **TOC integration**: exhibits are listed under their parent heading.
- Implementation: `gallery.js`, `gallery.css`; Pandoc fenced-div syntax (`:::`) to avoid the 4-space code block trap.
### LaTeX Math — Client-side KaTeX
The spec described pure build-time SSR. In practice: Pandoc outputs `class="math"` spans, KaTeX renders client-side from a deferred script. Fully static (no server per request). Revisit if build-time SSR becomes important.
### Citation pipeline — key subtleties
1. **`Cite` nodes, not `Span` nodes.** `processCitations` with `class="in-text"` CSL does *not* convert `Cite` nodes to `Span class="citation"` nodes in the Pandoc AST — it only populates their inline content and creates the refs div. The HTML writer wraps them in `<span class="citation">` at write time. Our `Citations.hs` must match `Cite` nodes directly.
2. **Hakyll strips YAML frontmatter.** Hakyll reads frontmatter separately; the body passed to `readPandocWith` has no YAML block, so Pandoc `Meta` is empty. `further-reading` keys are read from Hakyll's metadata API (`lookupStringList`) in `Compilers.hs` and passed explicitly to `Citations.applyCitations`.
3. **`nocite` format.** Each further-reading key must be a *separate* `Cite` node with `AuthorInText` mode and non-empty fallback content — matching what pandoc produces from `"@key1 @key2"` in YAML. A single `Cite` node with multiple citations is not recognized by citeproc's nocite processing.
4. **`collectCiteOrder` queries blocks only**, not the full `Pandoc` (which includes metadata). Querying metadata would pick up the injected `nocite` `Cite` nodes and incorrectly classify further-reading entries as inline citations.
### External link icons
Implemented via `data-link-icon` and `data-link-icon-type="svg"` attributes set by `Filters.Links`. CSS uses `mask-image: url(...)` with `background-color: currentColor` so icons inherit the text color and work in dark mode. Icons in `static/images/link-icons/` as SVG files.
### Tags — Hierarchical, no namespace
Tags are slash-separated (`research/mathematics`). A tag is auto-expanded into all ancestor prefixes so that `/research/` aggregates all `research/*` content. Tag pages live directly at `/<tag>/` with no `/tags/` namespace.
### Collapsible sections
`collapse.js` wraps each h2/h3's following siblings in a `.section-body` div and injects a `.section-toggle` button into the heading. State is persisted per heading in `localStorage` under `section-collapsed:<id>`. After each `transitionend`, dispatches `window.resize` to retrigger sidenote positioning. Headings themselves are never hidden, preserving `IntersectionObserver` targets for `toc.js`.
### Atom feed
`/feed.xml` covers all essays and blog posts (up to 30 most recent). A `"content"` snapshot is saved in `Site.hs` *before* template application, so the feed body is just the compiled article HTML (not the full page with nav/footer). Dates from the `date` frontmatter key, formatted as RFC 3339.
### Author system
Authors are treated as a second tag dimension. `build/Authors.hs` provides `buildAllAuthors` (a `buildTagsWith` call keyed to `authors` frontmatter) and `authorLinksField` (a `listFieldWith` context that defaults to `["Levi Neuwirth"]` when no `authors` key is present, so all unattributed content contributes to his author page). Author pages live at `/authors/{slug}/`. `slugify` lowercases and hyphenates; pipe-separated values (`"Name | role"`) strip the role portion via `nameOf`.
### Settings panel
`settings.js` manages four independent settings, all persisted in `localStorage`:
- **Theme** (`data-theme` on `<html>`): light / dark, with `syncThemeButtons()` toggling `.is-active`.
- **Text size**: three steps `[20, 23, 26]` px (small / default / large), written as `--text-size` CSS custom property on `<html>`. Default index is 1 (23 px).
- **Focus mode** (`data-focus-mode` on `<html>`): hides TOC, fades header to 7% opacity until hover.
- **Reduce motion** (`data-reduce-motion` on `<html>`): collapses all `animation-duration` / `transition-duration` to `0.01ms`.
`theme.js` (sync, not deferred) restores all four attributes from `localStorage` before first paint to avoid flash.
### Selection popup
`selection-popup.js` / `selection-popup.css`. A toolbar appears 450 ms after any text selection of ≥ 2 characters. Context is detected from the DOM ancestors of the selection range:
| Context | Detection | Buttons |
|---------|-----------|---------|
| **code** (known lang) | `closest('pre, code, .sourceCode')` + `language-*` class | Copy · \<MDN / Hoogle / Docs…\> |
| **code** (unknown) | same, no `language-*` | Copy |
| **math** | `closest('.math, .katex')` + `Range.intersectsNode` fallback | Copy · nLab · OEIS · Wolfram |
| **prose** (multi-word) | fallback | BibTeX · Copy · DuckDuckGo · Here · Wikipedia |
| **prose** (single word) | `!/\s/.test(text)` | BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia |
16 languages are mapped to documentation providers (MDN, Hoogle, docs.python.org, doc.rust-lang.org, etc.) via `DOC_PROVIDERS`. **BibTeX** generates a `@online{...}` BibLaTeX entry (key = `lastname + year + firstWord`; selected text in `note={\enquote{...}}`; year scraped from `#version-history li`). **Here** opens `/search.html?q=` in a new tab. **Define** opens English Wiktionary. Popup positions above the selection, flips below if insufficient space; hides on scroll, outside mousedown, or Escape.
### Reading mode (poetry + fiction)
Shared codex layout for creative content. `templates/reading.html` omits the TOC and emits a `<div id="reading-progress">` progress bar instead. `body.reading-mode` (set via `$if(reading)$` in `default.html`) triggers a slightly warmer background (`#fdf9f1` / `#1c1917`). Poetry pages (`body.reading-mode.poetry`) use a 52ch measure, 1.85 line-height, stanza paragraph spacing, and suppressed dropcap/smallcaps lead-in; `poetryCompiler` enables `Ext_hard_line_breaks` so each source newline becomes `<br>`. Fiction pages (`body.reading-mode.fiction`) use a 62ch measure, centered Fira Sans smallcaps chapter headings, and a dropcap + smallcaps lead-in on each `h2 + p`. Progress bar is driven by `reading.js` (scroll position → `width` on `#reading-progress`). CSS and JS loaded conditionally via `$if(reading)$`. Content goes in `content/poetry/*.md` and `content/fiction/*.md`; tags `poetry` / `fiction` route items to the correct portal and library section.
### Score fragment system (option A)
`Filters/Score.hs` walks the Pandoc AST for `Div` nodes with class `score-fragment`. It reads the referenced SVG from disk (path resolved relative to the source file's directory via `getResourceFilePath` + `takeDirectory`), replaces hardcoded black fill/stroke values with `currentColor` (6-digit before 3-digit to prevent partial matches on `#000` vs `#000000`), and emits a `RawBlock "html"` `<figure>` carrying `class="score-fragment exhibit"`, `data-exhibit-name`, and `data-exhibit-type="score"` for gallery.js TOC integration. SVGs are inlined at build time and never served as separate files. `gallery.js` discovers `.score-fragment` elements via `discoverFocusableScores`, adds them to the shared `focusables[]` array with `type: 'score'`, and the overlay's `renderOverlay` branches on type — score path clones the SVG into the overlay body (no font-size loop); math path keeps the KaTeX re-render. Overlay body receives class `is-score` for tighter horizontal padding (`2rem 1.5rem` vs `3.5rem 4.5rem`). CSS: background rect removed via `svg > rect:first-child { fill: none }`, SVG responsive via `width: 100%; height: auto`, dark mode via `color: var(--text)`.
**Authoring syntax:**
```markdown
::: {.score-fragment score-name="Main Theme, mm. 18" score-caption="The opening gesture."}
![](scores/main-theme.svg)
:::
```
### Music — Composition landing pages + full score reader (option C)
**Implemented.** Two URLs per composition from one source directory.
#### Architecture
| URL | Templates | Purpose |
|-----|-----------|---------|
| `/music/{slug}/` | `composition.html` + `default.html` | Rich prose landing page |
| `/music/{slug}/score/` | `score-reader.html` + `score-reader-default.html` | Minimal page-turn reader |
The Hakyll `version "score-reader"` mechanism compiles the same `index.md` twice: once as the landing page (default version) and once as the reader (`customRoute` to `music/{slug}/score/index.html`). Score reader uses `makeItem ""` — the prose body is irrelevant; only frontmatter fields are needed.
#### Source directory layout
```
content/music/symphonic-dances/
├── index.md ← composition frontmatter + program notes prose
├── scores/
│ ├── page-01.svg ← one file per score page (LilyPond SVG output)
│ ├── page-02.svg
│ └── symphonic-dances.pdf
└── audio/
├── movement-1.mp3
└── movement-2.mp3
```
SVG, MP3, and PDF files are copied to `_site/` via `copyFileCompiler`. Score reader SVGs are served as separate `<img>` files — inlining a full orchestral score is impractical.
#### Frontmatter schema
```yaml
---
title: "Symphonic Dances with Claude"
date: 2026-03-01
abstract: >
A five-movement work for orchestra.
tags: [music]
instrumentation: "orchestra (2+picc.2+ca.2+bcl.2 — 4.3.3.1 — timp+3perc — hp — str)"
duration: "ca. 24'"
premiere: "2026-05-01"
commissioned-by: "—" # optional
pdf: scores/symphonic-dances.pdf # optional; path relative to composition dir
score-pages: # required for reader; landing page works without it
- scores/page-01.svg
- scores/page-02.svg
movements: # optional; omit entirely if no movement structure
- name: "I. Allegro con brio"
page: 1 # 1-indexed starting page in the reader
duration: "8'"
audio: audio/movement-1.mp3 # optional; omit if no recording
- name: "II. Adagio cantabile"
page: 8
duration: "10'"
---
```
#### `compositionCtx` fields
Extends `essayCtx` (all essay fields available — `abstract`, `toc`, `word-count`, etc.). Additional fields:
| Field | Type | Value |
|-------|------|-------|
| `$slug$` | string | `takeFileName . takeDirectory` of source path |
| `$score-url$` | string | `/music/{slug}/score/` |
| `$has-score$` | boolean | present when `score-pages` non-empty |
| `$score-page-count$` | string | `show (length score-pages)` |
| `$score-pages$` | list | each item: `$score-page-url$` (absolute URL) |
| `$has-movements$` | boolean | present when `movements` non-empty |
| `$movements$` | list | each item: `$movement-name$`, `$movement-page$`, `$movement-duration$`, `$movement-audio$`, `$has-audio$` |
| `$composition$` | flag | `"true"` — gates `score-reader.css` in `head.html` |
`movements` is parsed from the nested YAML using `Data.Aeson.KeyMap` (Aeson 2.x API). `score-pages` are resolved to absolute URLs (`/music/{slug}/{path}`) inside the context so the `data-pages` attribute in the score reader template needs no further processing.
#### Score reader
The reader template embeds page URLs as a comma-separated `data-pages` attribute on `#score-reader-stage`. `score-reader.js` splits on commas and filters empties.
`score-reader-default.html` loads only: `base.css`, `components.css` (for settings panel styles), `score-reader.css`, `theme.js` (sync, pre-paint), `settings.js` (theme toggle in the top bar), `score-reader.js`. No nav, no TOC, no sidenotes, no popups, no gallery, no lightbox.
`score-reader.js` behaviors:
- `navigate(page)`: swaps `<img src>`, updates counter, toggles prev/next disabled states, updates active movement button (last movement whose start page ≤ current page), calls `history.replaceState` for `?p=` deep linking, preloads ±1 pages.
- Keyboard: `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown` for page turns; `Escape``history.back()`. Suppressed when settings panel is open.
- Dark mode: `[data-theme="dark"] .score-page { filter: invert(1); }` — clean for pure B&W notation; revisit if LilyPond embeds colored elements.
- Mobile: score scrolls horizontally at ≤ 640px (`min-width: 600px` on `<img>`); arrow buttons hidden; pinch-to-zoom is native.
#### Known limitations / future work
- **Full-piece audio**: a `recording` frontmatter key for a complete performance would add a top-level audio player on the landing page. Not yet implemented.
- **LilyPond margin cropping**: the `viewBox` drives scaling but LilyPond's default page includes margins. May need per-composition `viewBox` overrides or CSS `object-fit` once real scores are tested.
### Backlinks — Two-pass dependency-correct system
`build/Backlinks.hs`. The fundamental challenge: backlinks for page A require knowing what other pages link to A, but those pages haven't been compiled yet when A is compiled. Solved with a two-version architecture:
1. **Pass 1** (`version "links"`): each content file is compiled lightly — wikilinks preprocessed, Markdown parsed, AST walked block-by-block. For every internal link, the URL and the HTML of its surrounding `Para` block are recorded as a `LinkEntry { leUrl, leContext }`. Context rendered via `runPure (writeHtml5String opts (Pandoc nullMeta [Plain inlines]))` with `writerTemplate = Nothing` (fragment only). Result serialised as JSON per page.
2. **Pass 2** (`create ["data/backlinks.json"]`): loads all `version "links"` items, inverts the map (target → [source]), resolves each source's title and abstract from its metadata, emits `data/backlinks.json`.
3. **Context** (`backlinksField`): loads `data/backlinks.json` via `load`, looks up the current page's normalised URL, renders `<ul>` with `<details>`-collapsible context per entry.
**Key implementation details:**
- All `loadAll` / `loadAllSnapshots` / `buildTagsWith` / `buildPaginateWith` calls use `.&&. hasNoVersion` to prevent "links" version items from being picked up alongside default versions.
- `isPageLink` filters out `http://`, `https://`, `#`-anchors, `mailto:`, `tel:`, and static-asset extensions (`.pdf`, `.svg`, `.mp3`, etc.).
- JSON encoding uses `TL.unpack . TLE.decodeUtf8 . Aeson.encode` (not `LBSC.unpack`) to preserve non-ASCII characters in context paragraphs.
- Decoding uses `Aeson.decodeStrict (TE.encodeUtf8 (T.pack s))` symmetrically.
- `popups.js` excludes `.backlink-source` links from the internal-preview popup (same exception pattern as `.meta-authors`).
### Epistemic Profile
Implemented across `build/Stability.hs`, `build/Contexts.hs`, `templates/partials/page-footer.html`, `templates/partials/metadata.html`, and `static/css/components.css`.
**Context fields provided by `epistemicCtx`** (included in `essayCtx`):
| Field | Source | Notes |
|-------|--------|-------|
| `$status$` | frontmatter `status` | via `defaultContext` |
| `$confidence$` | frontmatter `confidence` | via `defaultContext` |
| `$importance-dots$` | frontmatter `importance` (15) | `●●●○○` rendered in Haskell |
| `$evidence-dots$` | frontmatter `evidence` (15) | same |
| `$confidence-trend$` | frontmatter `confidence-history` list | ↑ / ↓ / → from last two entries |
| `$stability$` | auto-computed via `git log --follow` | always resolves; never fails |
| `$last-reviewed$` | most recent commit date | formatted "%-d %B %Y"; `noResult` if no commits |
| `$scope$`, `$novelty$`, `$practicality$` | frontmatter | via `defaultContext` |
**Stability auto-calculation** (`build/Stability.hs`):
- Runs `git log --follow --format=%ad --date=short -- <filepath>` via `readProcessWithExitCode`.
- Heuristic: ≤ 1 commits or age < 14 days *volatile*; 5 commits and age < 90 days *revising*; 15 commits or age < 365 days *fairly stable*; 30 commits or age < 730 days *stable*; otherwise *established*.
- `IGNORE.txt`: paths listed here use frontmatter `stability`/`last-reviewed` verbatim. Cleared by `> IGNORE.txt` in the Makefile's `build` target (one-shot pins).
**Critical implementation note:** Fields that use `unsafeCompiler` must return `Maybe` from the IO block and call `fail` in the `Compiler` monad afterward — not inside the `IO` action. Calling `fail` inside `unsafeCompiler`'s IO block throws an `IOError` that Hakyll's `$if()$` template evaluation does not catch as `NoResult`, causing the entire item compilation to error silently.
### Annotations (infrastructure only)
`annotations.js` stores annotations as JSON in `localStorage` under `site-annotations`, scoped per `location.pathname`. On `DOMContentLoaded`, `applyAll()` re-anchors saved annotations via a `TreeWalker` text-stream search (concatenates all text nodes in `#markdownBody`, finds exact match by index, builds a `Range`, wraps with `<mark>`). Cross-element ranges use `extractContents()` + `insertNode()` fallback. Four highlight colors (amber / sage / steel / rose) defined in `annotations.css` as `rgba` overlays with `box-decoration-break: clone`. Hover tooltip shows note, date, and delete button. Public API: `window.Annotations.add(text, color, note)` / `.remove(id)`. The selection-popup "Annotate" button is currently removed pending a UI revision.
---
## VII. Reference: Inspirations
- **gwern.net** — Primary model (Gwern Branwen + Said Achmiz). Semantic zoom, sidenotes, popups, monochrome, Pandoc+Hakyll.
- **Edward Tufte** — Sidenotes, information design
- **Matthew Butterick's Practical Typography** — Web typography in practice
- **Traditional book design** — The standard to aspire to on screen
---
*This specification is a living document updated as implementation progresses.*

103
static/css/annotations.css Normal file
View File

@ -0,0 +1,103 @@
/* annotations.css — User highlight marks and annotation tooltip */
/* ============================================================
HIGHLIGHT MARKS
============================================================ */
mark.user-annotation {
/* Reset browser UA mark color (can be blue on dark system themes) */
background-color: transparent;
color: inherit;
border-radius: 3px;
padding: 0.18em 0.28em;
cursor: pointer;
transition: filter 0.1s ease;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
/* Double class = specificity (0,2,1), beats UA mark rule and the base reset above */
mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.32); }
mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.34); }
mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.34); }
mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.34); }
mark.user-annotation:hover { filter: brightness(0.80); }
/* Dark mode — slightly more opaque so highlights stay visible */
[data-theme="dark"] mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); }
[data-theme="dark"] mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); }
[data-theme="dark"] mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); }
[data-theme="dark"] mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); }
/* ============================================================
ANNOTATION TOOLTIP
Appears on hover over a mark. Inverted colours like the
selection popup (--text bg, --bg text).
============================================================ */
.ann-tooltip {
position: absolute;
z-index: 750;
min-width: 8rem;
max-width: 15rem;
background: var(--text);
color: var(--bg);
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22);
padding: 0.45rem 0.6rem;
font-family: var(--font-sans);
font-size: 0.73rem;
line-height: 1.45;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s ease;
pointer-events: none;
}
.ann-tooltip.is-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.ann-tooltip-note {
margin-bottom: 0.35rem;
white-space: pre-wrap;
word-break: break-word;
}
.ann-tooltip-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.ann-tooltip-date {
opacity: 0.55;
font-size: 0.68rem;
}
.ann-tooltip-delete {
background: none;
border: none;
color: var(--bg);
opacity: 0.65;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.7rem;
padding: 0;
line-height: 1;
transition: opacity 0.1s ease;
}
.ann-tooltip-delete:hover { opacity: 1; }
/* ============================================================
REDUCE MOTION
============================================================ */
[data-reduce-motion] mark.user-annotation { transition: none; }
[data-reduce-motion] .ann-tooltip { transition: none; }

217
static/css/base.css Normal file
View File

@ -0,0 +1,217 @@
/* base.css — Reset, custom properties, @font-face, dark mode */
/* ============================================================
FONTS
============================================================ */
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-semibold.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-semibold-italic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Spectral";
src: url("../fonts/spectral-bold-italic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Fira Sans";
src: url("../fonts/fira-sans-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Fira Sans";
src: url("../fonts/fira-sans-semibold.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("../fonts/jetbrains-mono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("../fonts/jetbrains-mono-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* ============================================================
CUSTOM PROPERTIES (light mode defaults)
============================================================ */
:root {
/* Color palette */
--bg: #faf8f4;
--bg-nav: #faf8f4;
--bg-offset: #f2f0eb;
--text: #1a1a1a;
--text-muted: #555555;
--text-faint: #888888;
--border: #cccccc;
--border-muted: #aaaaaa;
/* Link colors */
--link: #1a1a1a;
--link-underline: #888888;
--link-hover: #1a1a1a;
--link-hover-underline: #1a1a1a;
--link-visited: #444444;
/* Selection */
--selection-bg: #1a1a1a;
--selection-text: #faf8f4;
/* Typography */
--font-serif: "Spectral", "Georgia", "Times New Roman", serif;
--font-sans: "Fira Sans", "Helvetica Neue", "Arial", sans-serif;
--font-mono: "JetBrains Mono", "Consolas", "Menlo", monospace;
/* Scale & Rhythm (1 line = 33px or 1.65rem) */
--text-size: 20px;
--text-size-small: 0.85em;
--line-height: 1.65;
/* Layout */
--body-max-width: 800px;
--page-padding: 1.5rem;
/* Transitions */
--transition-fast: 0.15s ease;
}
/* ============================================================
DARK MODE (Refined to Charcoal & Ink)
============================================================ */
/* Explicit dark mode */
[data-theme="dark"] {
--bg: #121212;
--bg-nav: #181818;
--bg-offset: #1a1a1a;
--text: #d4d0c8;
--text-muted: #8c8881;
--text-faint: #6a6660;
--border: #333333;
--border-muted: #444444;
--link: #d4d0c8;
--link-underline: #6a6660;
--link-hover: #ffffff;
--link-hover-underline: #ffffff;
--link-visited: #a39f98;
--selection-bg: #d4d0c8;
--selection-text: #121212;
}
/* System dark mode fallback */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121212;
--bg-nav: #181818;
--bg-offset: #1a1a1a;
--text: #d4d0c8;
--text-muted: #8c8881;
--text-faint: #6a6660;
--border: #333333;
--border-muted: #444444;
--link: #d4d0c8;
--link-underline: #6a6660;
--link-hover: #ffffff;
--link-hover-underline: #ffffff;
--link-visited: #a39f98;
--selection-bg: #d4d0c8;
--selection-text: #121212;
}
}
/* ============================================================
RESET & BASE
============================================================ */
*, *::before, *::after {
box-sizing: border-box;
}
html {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-serif);
font-size: var(--text-size);
line-height: var(--line-height);
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
overflow-x: hidden;
background-color: var(--bg);
color: var(--text);
transition: background-color var(--transition-fast),
color var(--transition-fast);
}
::selection {
background-color: var(--selection-bg);
color: var(--selection-text);
}
img, video, svg {
max-width: 100%;
height: auto;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 3.3rem 0; /* Two strict line-heights */
}

119
static/css/catalog.css Normal file
View File

@ -0,0 +1,119 @@
/* ── Music Catalog ────────────────────────────────────────────────────────── */
.catalog-abstract {
font-size: 1.05rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.catalog-prose {
margin-bottom: 2.5rem;
}
/* ── Featured section ─────────────────────────────────────────────────────── */
.catalog-featured {
margin-bottom: 3rem;
}
.catalog-featured-title {
color: var(--text);
}
/* ── Per-category sections ────────────────────────────────────────────────── */
.catalog-page {
margin-top: 0.5rem;
}
.catalog-section {
margin-bottom: 2.5rem;
}
.catalog-section-title {
font-family: var(--font-ui);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem;
margin-top: 0;
margin-bottom: 0.6rem;
}
/* ── Entry list ───────────────────────────────────────────────────────────── */
.catalog-list {
list-style: none;
margin: 0;
padding: 0;
}
.catalog-entry {
padding: 0.55rem 0;
border-bottom: 1px solid var(--border-subtle, color-mix(in srgb, var(--border) 50%, transparent));
}
.catalog-entry:last-child {
border-bottom: none;
}
.catalog-entry-main {
display: flex;
align-items: baseline;
gap: 0.45rem;
flex-wrap: wrap;
}
.catalog-title {
font-weight: 600;
color: var(--text);
text-decoration: none;
}
.catalog-title:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.catalog-year,
.catalog-duration {
font-family: var(--font-ui);
font-size: 0.82rem;
color: var(--text-muted);
}
.catalog-year::before {
content: "·";
margin-right: 0.35rem;
color: var(--text-muted);
}
.catalog-duration::before {
content: "·";
margin-right: 0.35rem;
color: var(--text-muted);
}
.catalog-ind {
font-size: 0.7rem;
color: var(--text-muted);
cursor: default;
line-height: 1;
position: relative;
top: -0.05em;
}
.catalog-instrumentation {
font-family: var(--font-ui);
font-size: 0.82rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.catalog-empty {
color: var(--text-muted);
font-style: italic;
}

141
static/css/commonplace.css Normal file
View File

@ -0,0 +1,141 @@
/* ── Commonplace ──────────────────────────────────────────────────────────── */
/* ── Intro ────────────────────────────────────────────────────────────────── */
.cp-intro {
color: var(--text-muted);
margin-bottom: 0.5rem;
}
/* ── View toggle ──────────────────────────────────────────────────────────── */
.cp-view-toggle {
display: flex;
gap: 0.5rem;
margin: 1.5rem 0 2.5rem;
}
.cp-toggle-btn {
font-family: var(--font-ui);
font-size: 0.73rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
padding: 0.2rem 0.7rem;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.cp-toggle-btn:hover {
color: var(--text);
border-color: var(--text-muted);
}
.cp-toggle-btn.is-active {
color: var(--text);
border-color: var(--text);
}
/* ── Theme sections ───────────────────────────────────────────────────────── */
.cp-theme-heading {
font-family: var(--font-ui);
font-size: 0.73rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.1em;
color: var(--text-muted);
font-weight: 400;
margin: 2.5rem 0 1.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
.cp-theme-section:first-child .cp-theme-heading {
margin-top: 0;
}
/* ── Entry ────────────────────────────────────────────────────────────────── */
.cp-entry {
margin: 0 0 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-muted);
}
.cp-entry:last-child {
border-bottom: none;
padding-bottom: 0;
}
/* ── Quote block — overrides default #markdownBody blockquote ─────────────── */
#markdownBody .cp-quote {
background-image: none;
padding-left: 0;
color: var(--text);
margin: 0 0 0.5rem;
}
#markdownBody .cp-quote p {
margin: 0;
line-height: 1.75;
}
/* ── Attribution ──────────────────────────────────────────────────────────── */
.cp-attribution {
font-family: var(--font-ui);
font-size: 0.8rem;
color: var(--text-muted);
margin: 0;
}
.cp-attribution a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--border);
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.cp-attribution a:hover {
color: var(--text);
border-color: var(--text-muted);
}
/* ── Commentary ───────────────────────────────────────────────────────────── */
.cp-commentary {
font-family: var(--font-ui);
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
margin: 0.7rem 0 0;
}
/* ── Tags ─────────────────────────────────────────────────────────────────── */
.cp-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.7rem;
}
.cp-tag {
font-family: var(--font-ui);
font-size: 0.67rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
color: var(--text-faint);
padding: 0.1rem 0.4rem;
border: 1px solid var(--border-muted);
}
/* ── Empty state ──────────────────────────────────────────────────────────── */
.cp-empty {
color: var(--text-muted);
font-style: italic;
}

1075
static/css/components.css Normal file

File diff suppressed because it is too large Load Diff

537
static/css/gallery.css Normal file
View File

@ -0,0 +1,537 @@
/* gallery.css Exhibit system (always-visible inline blocks with overlay
expansion) and annotation system (static or collapsible callout boxes). */
/* ============================================================
EXHIBIT WRAPPER
Base styles shared by all exhibit types.
============================================================ */
.exhibit {
position: relative;
display: block;
margin: 1.65rem 0;
padding: 0; /* reset <figure> default margin */
}
/* ============================================================
MATH FOCUSABLE
Wrapper injected around every .katex-display by gallery.js.
Clicking anywhere on the wrapper opens the overlay.
============================================================ */
.math-focusable {
position: relative;
display: block;
cursor: zoom-in;
}
/* Expand glyph — decorative affordance, not interactive */
.exhibit-expand {
position: absolute;
top: 50%;
right: 0.4rem;
transform: translateY(-50%);
font-family: var(--font-sans);
font-size: 0.75rem;
line-height: 1;
color: var(--text-faint);
padding: 0.2rem 0.35rem;
border-radius: 2px;
pointer-events: none; /* click handled by wrapper */
opacity: 0;
transition: opacity var(--transition-fast);
user-select: none;
}
.math-focusable:hover .exhibit-expand {
opacity: 1;
}
/* Caption tooltip — appears above the math on hover, like alt text */
.math-focusable[data-caption]::after {
content: attr(data-caption);
position: absolute;
bottom: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 2px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
padding: 0.3rem 0.65rem;
font-family: var(--font-sans);
font-size: 0.72rem;
font-style: italic;
color: var(--text-muted);
/* Explicit width prevents the box from collapsing to a single-word column */
width: min(440px, 70vw);
white-space: normal;
text-align: center;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
z-index: 10;
}
.math-focusable[data-caption]:hover::after {
opacity: 1;
}
/* ============================================================
PROOF EXHIBIT
Always-visible inline block. Header is a non-interactive label.
============================================================ */
.exhibit--proof .exhibit-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.45rem;
margin-bottom: 0.6rem;
border-bottom: 1px solid var(--border-muted);
}
.exhibit--proof .exhibit-header-label {
font-style: italic;
font-weight: 600;
color: var(--text);
flex-shrink: 0;
}
.exhibit--proof .exhibit-header-name {
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--text-muted);
}
/* ============================================================
ANNOTATION
Editorial callout boxes: definitions, notes, remarks, warnings.
Two variants: static (always visible) and collapsible.
============================================================ */
.annotation {
position: relative;
margin: 1.65rem 0;
border: 1px solid var(--border);
border-radius: 2px;
}
.annotation-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
border-bottom: 1px solid var(--border);
background: var(--bg-offset);
}
.annotation-label {
font-family: var(--font-sans);
font-size: 0.68rem;
font-weight: 600;
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
color: var(--text-faint);
flex-shrink: 0;
}
.annotation-name {
font-family: var(--font-sans);
font-size: 0.78rem;
color: var(--text-muted);
font-style: italic;
flex: 1;
min-width: 0;
}
.annotation-toggle {
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.68rem;
color: var(--text-faint);
padding: 0;
line-height: 1;
flex-shrink: 0;
transition: color var(--transition-fast);
white-space: nowrap;
}
.annotation-toggle:hover {
color: var(--text-muted);
}
.annotation-body {
padding: 0.825rem 0.85rem;
}
/* Collapsible variant: body hidden until toggled.
Only max-height transitions padding applies instantly so that
scrollHeight reads the correct full height before the animation starts. */
.annotation--collapsible .annotation-body {
overflow: hidden;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
transition: max-height 0.35s ease;
}
.annotation--collapsible.is-open .annotation-body {
padding-top: 0.825rem;
padding-bottom: 0.825rem;
}
/* Static variant: always open, no toggle */
.annotation--static .annotation-header {
border-bottom: 1px solid var(--border);
}
/* ============================================================
OVERLAY
Full-screen dark stage. Content floats inside no panel box.
Modeled on Gwern's image-focus: the overlay IS the backdrop,
content is centered directly within it.
============================================================ */
#gallery-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: rgba(0, 0, 0, 0.92);
cursor: zoom-out;
display: flex;
align-items: center;
justify-content: center;
}
#gallery-overlay[hidden] {
display: none;
}
/* Content stage large light area centered in the dark screen.
Fixed width (not max-width) so JS can detect overflow for font fitting. */
#gallery-overlay-body {
background: var(--bg);
padding: 3.5rem 4.5rem;
width: 88vw;
max-height: 80vh;
overflow: hidden; /* JS shrinks font until content fits; auto as last resort */
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 80px rgba(0, 0, 0, 0.5);
cursor: default; /* undo zoom-out from parent */
}
#gallery-overlay-body .katex-display {
/* font-size is set entirely by JS after measuring — no CSS value here */
overflow-x: hidden;
margin: 0;
}
/* Close — top right of screen */
#gallery-overlay-close {
position: absolute;
top: 1.1rem;
right: 1.5rem;
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.5);
padding: 0.3rem 0.5rem;
line-height: 1;
transition: color var(--transition-fast);
}
#gallery-overlay-close:hover {
color: rgba(255, 255, 255, 0.95);
}
/* Group name — top center of screen */
#gallery-overlay-name {
position: absolute;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
font-variant-caps: all-small-caps;
letter-spacing: 0.09em;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
pointer-events: none;
}
/* Caption — bottom center of screen, above counter */
#gallery-overlay-caption {
position: absolute;
bottom: 3.5rem;
left: 50%;
transform: translateX(-50%);
font-family: var(--font-sans);
font-size: 1rem;
font-style: italic;
color: rgba(255, 255, 255, 0.65);
text-align: center;
max-width: 60vw;
pointer-events: none;
}
#gallery-overlay-caption:empty,
#gallery-overlay-caption[hidden] {
display: none;
}
/* Counter — bottom center of screen */
#gallery-overlay-counter {
position: absolute;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
font-family: var(--font-sans);
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.38);
pointer-events: none;
white-space: nowrap;
}
/* Nav buttons — floating at the screen edges, vertically centered */
.gallery-nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: 2.25rem;
color: rgba(255, 255, 255, 0.35);
padding: 0.75rem 1rem;
line-height: 1;
transition: color var(--transition-fast);
}
.gallery-nav-btn:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9);
}
.gallery-nav-btn:disabled {
opacity: 0;
pointer-events: none;
}
#gallery-overlay-prev { left: 1rem; }
#gallery-overlay-next { right: 1rem; }
/* ============================================================
SCORE FRAGMENT
Inline SVG music notation. Integrates with the gallery focusable
and named-exhibit systems. No header caption only.
============================================================ */
.score-fragment {
position: relative;
display: block;
margin: 2rem 0;
cursor: zoom-in;
}
/* Reset #markdownBody figure box styles must use ID+class to beat
the specificity of `#markdownBody figure` (0,1,1 vs 0,1,0). */
#markdownBody figure.score-fragment {
background: none;
border: none;
padding: 0;
box-shadow: none;
max-width: 100%; /* override fit-content so SVG fills the column */
margin: 2rem 0; /* override the base 3.3rem auto */
}
.score-fragment-inner {
display: block;
line-height: 0; /* collapse inline gap below the SVG block */
}
/* Make the LilyPond SVG responsive and theme-aware.
CSS width/height override the SVG's own width/height presentation
attributes. color: var(--text) is inherited by currentColor fills/strokes
(set by Filters.Score at build time). */
.score-fragment-inner svg {
width: 100%;
height: auto;
display: block;
color: var(--text);
}
/* Remove LilyPond's white background rectangle */
.score-fragment-inner svg > rect:first-child {
fill: none;
}
/* Expand glyph: top-right corner, not vertically centred */
.score-fragment .exhibit-expand {
top: 0.5rem;
transform: none;
}
.score-fragment:hover .exhibit-expand {
opacity: 1;
}
.score-caption {
font-family: var(--font-sans);
font-size: 0.82rem;
font-style: italic;
color: var(--text-muted);
text-align: center;
margin-top: 0.65rem;
line-height: 1.4;
}
/* ============================================================
OVERLAY SCORE MODE
Tighter horizontal padding; SVG fills available width.
============================================================ */
#gallery-overlay-body.is-score {
padding: 2rem 1.5rem;
/* Caption sits at bottom: 3.5rem in the viewport. The body is flex-centered,
so its bottom edge = (100vh + body-height) / 2. To guarantee clearance,
body-height must be 100vh 2 × caption-offset some margin.
calc(100vh 12rem) keeps the bottom edge 6rem above the viewport bottom
regardless of screen size. */
max-height: calc(100vh - 12rem);
}
#gallery-overlay-body.is-score svg {
width: 100%;
height: auto;
max-height: calc(100vh - 16rem); /* body height minus 2rem padding each side */
display: block;
color: var(--text);
}
#gallery-overlay-body.is-score svg > rect:first-child {
fill: none;
}
/* ============================================================
TOC EXHIBIT INTEGRATION
============================================================ */
/* Compact inline exhibit links under a heading entry */
.toc-exhibits-inline {
font-size: 0.69rem;
color: var(--text-faint);
line-height: 1.4;
margin-top: 0.1rem;
padding-left: 0.75rem;
}
.toc-exhibits-inline a {
display: flex;
align-items: baseline;
gap: 0.4em;
color: var(--text-faint);
text-decoration: none;
padding: 0.1rem 0;
font-size: 0.74rem;
transition: color var(--transition-fast);
}
.toc-exhibits-inline a:hover {
color: var(--text-muted);
}
/* Contained Herein — collapsible global exhibit index */
.toc-contained {
margin-top: 0.9rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border);
}
.toc-contained-toggle {
display: flex;
align-items: center;
gap: 0.35em;
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.69rem;
font-weight: 600;
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
color: var(--text-faint);
padding: 0;
line-height: 1.5;
width: 100%;
text-align: left;
transition: color var(--transition-fast);
}
.toc-contained-toggle:hover {
color: var(--text-muted);
}
.toc-contained-arrow {
font-size: 0.55rem;
display: inline-block;
transition: transform 0.15s ease;
flex-shrink: 0;
}
.toc-contained.is-open .toc-contained-arrow {
transform: rotate(90deg);
}
.toc-contained-list {
list-style: none;
padding: 0;
margin: 0.35rem 0 0 0;
display: none;
}
.toc-contained.is-open .toc-contained-list {
display: block;
}
.toc-contained-list li {
margin: 0.2rem 0;
}
.toc-contained-list a {
display: flex;
align-items: baseline;
gap: 0.4em;
font-size: 0.72rem;
color: var(--text-faint);
text-decoration: none;
transition: color var(--transition-fast);
}
.toc-contained-list a:hover {
color: var(--text-muted);
}
.toc-exhibit-type-badge {
font-size: 0.6rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.05em;
color: var(--text-faint);
flex-shrink: 0;
}

191
static/css/home.css Normal file
View File

@ -0,0 +1,191 @@
/* home.css — Homepage-specific styles (loaded only on index.html) */
/* ============================================================
CONTACT ROW
Professional links: Email · CV · About · GitHub · GPG · ORCID
============================================================ */
.contact-row {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
gap: 0.35rem 1rem;
margin: 1.75rem 0 0;
font-family: var(--font-sans);
font-size: var(--text-size-small);
}
.contact-row a {
display: inline-flex;
align-items: center;
gap: 0.3em;
color: var(--text-muted);
text-decoration: none;
transition: color var(--transition-fast);
}
.contact-row a:hover {
color: var(--text);
}
.contact-row a[data-contact-icon]::before {
content: '';
display: inline-block;
width: 0.85em;
height: 0.85em;
flex-shrink: 0;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.contact-row a[data-contact-icon="email"]::before {
mask-image: url('/images/link-icons/email.svg');
-webkit-mask-image: url('/images/link-icons/email.svg');
}
.contact-row a[data-contact-icon="document"]::before {
mask-image: url('/images/link-icons/document.svg');
-webkit-mask-image: url('/images/link-icons/document.svg');
}
.contact-row a[data-contact-icon="person"]::before {
mask-image: url('/images/link-icons/person.svg');
-webkit-mask-image: url('/images/link-icons/person.svg');
}
.contact-row a[data-contact-icon="github"]::before {
mask-image: url('/images/link-icons/github.svg');
-webkit-mask-image: url('/images/link-icons/github.svg');
}
.contact-row a[data-contact-icon="key"]::before {
mask-image: url('/images/link-icons/key.svg');
-webkit-mask-image: url('/images/link-icons/key.svg');
}
.contact-row a[data-contact-icon="orcid"]::before {
mask-image: url('/images/link-icons/orcid.svg');
-webkit-mask-image: url('/images/link-icons/orcid.svg');
}
/* ============================================================
SITE GUIDE (expandable <details>)
============================================================ */
.site-guide {
margin: 1.5rem 0 0;
border: 1px solid var(--border);
border-radius: 3px;
}
.site-guide summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
cursor: pointer;
list-style: none;
user-select: none;
transition: color var(--transition-fast);
}
.site-guide summary::-webkit-details-marker { display: none; }
.site-guide summary::before {
content: '▶';
font-size: 0.6rem;
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.site-guide[open] summary::before {
transform: rotate(90deg);
}
.site-guide summary:hover {
color: var(--text);
}
.site-guide-body {
padding: 0.75rem 1rem 1rem;
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
line-height: 1.6;
border-top: 1px solid var(--border);
}
.site-guide-body p { margin: 0 0 0.6rem; }
.site-guide-body p:last-child { margin-bottom: 0; }
/* ============================================================
CURATED GRID
Hand-picked entry points, one per portal.
============================================================ */
.curated-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 2.5rem;
}
@media (max-width: 580px) {
.curated-grid {
grid-template-columns: 1fr;
}
}
.curated-card {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.9rem 1.1rem;
border: 1px solid var(--border);
border-radius: 3px;
text-decoration: none;
color: inherit;
transition: border-color var(--transition-fast);
}
.curated-card:hover {
border-color: var(--text-muted);
}
.curated-portal {
font-family: var(--font-sans);
font-size: 0.68rem;
font-variant: small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
}
.curated-title {
font-family: var(--font-serif);
font-size: 1rem;
line-height: 1.35;
color: var(--text);
}
/* Reset <button> to look like a card */
button.curated-card {
background: none;
font: inherit;
text-align: left;
cursor: pointer;
width: 100%;
}
button.curated-card:hover {
border-color: var(--text-muted);
}
.curated-desc {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
line-height: 1.45;
}

115
static/css/images.css Normal file
View File

@ -0,0 +1,115 @@
/* images.css — Figure layout, captions, and lightbox overlay */
/* ============================================================
FIGURE BASE (supplements typography.css figure rules)
============================================================ */
figure {
margin: 2rem 0;
max-width: 100%;
text-align: center;
}
figure img {
max-width: 100%;
height: auto;
display: block;
border-radius: 3px;
}
figcaption {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
text-align: center;
margin-top: 0.6rem;
}
/* Zoom cursor for lightbox-enabled images */
img[data-lightbox] {
cursor: zoom-in;
}
/* ============================================================
LIGHTBOX OVERLAY
============================================================ */
.lightbox-overlay {
position: fixed;
inset: 0;
z-index: 800;
background: rgba(0, 0, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--transition-fast),
visibility var(--transition-fast);
}
.lightbox-overlay.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.lightbox-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 3px;
display: block;
}
.lightbox-caption {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: rgba(255, 255, 255, 0.65);
text-align: center;
max-width: 60ch;
line-height: 1.5;
}
.lightbox-close {
position: absolute;
top: 1rem;
right: 1.25rem;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 2em;
padding: 0.25em 0.75em;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1;
cursor: pointer;
transition: background var(--transition-fast);
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.22);
}
/* ============================================================
REDUCED MOTION
============================================================ */
[data-reduce-motion] .lightbox-overlay {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.lightbox-overlay {
transition: none;
}
}

213
static/css/layout.css Normal file
View File

@ -0,0 +1,213 @@
/* layout.css — Three-column page structure: TOC | body | sidenotes */
/* ============================================================
PAGE WRAPPER
The outer shell. Wide enough for TOC + body + sidenotes.
============================================================ */
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ============================================================
HEADER
Full-width, sits above the three-column area.
(Nav styles live in components.css)
============================================================ */
body > header {
width: 100%;
border-bottom: 1px solid var(--border);
background-color: var(--bg-nav);
position: sticky;
top: 0;
z-index: 100;
}
/* ============================================================
CONTENT AREA
Three-column grid: [toc 220px] [body] [phantom 220px]
The phantom right column mirrors the TOC width so that the
body column is geometrically centered on the viewport.
Sidenotes are absolutely positioned inside #markdownBody and
overflow into the phantom column naturally.
============================================================ */
#content {
display: grid;
grid-template-columns: 1fr minmax(0, var(--body-max-width)) 1fr;
align-items: start;
flex: 1;
width: 100%;
padding: 2rem var(--page-padding);
}
/* ============================================================
LEFT COLUMN TABLE OF CONTENTS
Sticky sidebar, collapses below a breakpoint.
(TOC content + scroll tracking in toc.js / components.css)
============================================================ */
#toc {
grid-column: 1;
position: sticky;
top: calc(var(--nav-height, 4rem) + 1.5rem);
max-height: calc(100vh - var(--nav-height, 4rem) - 3rem);
overflow-y: auto;
padding-right: 1.5rem;
align-self: start;
}
/* ============================================================
CENTER COLUMN MAIN BODY
Pinned to column 2 explicitly so it stays centered even when
the TOC is hidden (below 900px breakpoint).
#markdownBody must be position: relative sidenotes.js
appends absolutely-positioned sidenote columns inside it.
============================================================ */
#markdownBody {
grid-column: 2;
width: min(var(--body-max-width), 100%);
position: relative; /* REQUIRED by sidenotes.js */
min-width: 0;
}
/* ============================================================
STANDALONE PAGES (no #content wrapper)
essay-index, blog-index, tag-index, page, blog-post, search
these emit #markdownBody as a direct child of <body>. Without
the #content flex-row wrapper there is no centering; fix it here.
============================================================ */
body > #markdownBody {
align-self: center;
padding: 2rem var(--page-padding);
flex: 1 0 auto;
}
@media (max-width: 680px) {
body > #markdownBody {
padding: 1.25rem var(--page-padding);
}
}
/* ============================================================
FOOTER
============================================================ */
body > footer {
width: 100%;
border-top: 1px solid var(--border);
padding: 1.5rem var(--page-padding);
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
display: flex;
align-items: center;
gap: 1rem;
}
.footer-left a {
color: var(--text-faint);
text-decoration: none;
transition: color var(--transition-fast);
}
.footer-left a:hover {
color: var(--text-muted);
}
.footer-center {
font-size: 0.72rem;
color: var(--text-faint);
}
.footer-license a {
color: var(--text-faint);
text-decoration: none;
transition: color var(--transition-fast);
}
.footer-license a:hover {
color: var(--text-muted);
}
.footer-totop {
background: none;
border: none;
cursor: pointer;
color: var(--text-faint);
font-family: var(--font-sans);
font-size: var(--text-size-small);
padding: 0;
line-height: 1;
transition: color var(--transition-fast);
}
.footer-totop:hover {
color: var(--text-muted);
}
.footer-build {
font-size: 0.72rem;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
/* ============================================================
RESPONSIVE BREAKPOINTS
============================================================ */
/* Below ~1100px: not enough horizontal space for sidenotes.
The .sidenote asides are hidden by sidenotes.css; the Pandoc-generated
section.footnotes is shown instead (also handled by sidenotes.css). */
/* ============================================================
FOCUS MODE
Hides the TOC, fades the header until hovered.
Activated by [data-focus-mode] on <html> (settings.js).
============================================================ */
[data-focus-mode] #toc {
display: none;
}
/* Below ~900px: hide the TOC.
#markdownBody stays in grid-column 2, so it remains centered
with the phantom right column still balancing it. */
@media (max-width: 900px) {
#toc {
display: none;
}
}
/* Below ~680px: collapse to single-column, full-width body. */
@media (max-width: 680px) {
#content {
grid-template-columns: 1fr;
padding: 1.25rem var(--page-padding);
}
#markdownBody {
grid-column: 1;
width: 100%;
}
}
/* Below ~900px: body spans full width (TOC hidden, no phantom column). */
@media (max-width: 900px) and (min-width: 681px) {
#markdownBody {
grid-column: 1 / -1;
}
}

88
static/css/library.css Normal file
View File

@ -0,0 +1,88 @@
/* library.css — Comprehensive site index page */
.library-intro {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin-top: 0.25rem;
margin-bottom: 2.5rem;
}
/* ============================================================
PORTAL SECTIONS
============================================================ */
.library-section {
margin-bottom: 2.5rem;
}
.library-section h2 {
font-family: var(--font-sans);
font-size: 0.78rem;
font-variant: small-caps;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: lowercase;
font-weight: 600;
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
.library-section h2 a {
color: inherit;
text-decoration: none;
}
.library-section h2 a:hover {
color: var(--text);
}
/* ============================================================
ENTRY LIST
============================================================ */
.library-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.library-entry-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.library-entry-title {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text);
text-decoration: none;
line-height: 1.3;
}
.library-entry-title:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.library-entry-date {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
}
.library-entry-abstract {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
line-height: 1.5;
margin: 0.2rem 0 0;
}

297
static/css/memento-mori.css Normal file
View File

@ -0,0 +1,297 @@
/* ── Memento Mori ─────────────────────────────────────────────────────────── */
/* ── Grid container ───────────────────────────────────────────────────────── */
#weeks-grid-wrapper {
margin: 2.5rem 0 1.5rem;
}
.weeks-grid {
display: grid;
grid-template-columns: repeat(52, 1fr);
gap: 2px;
width: 100%;
}
/* ── Individual week cells ────────────────────────────────────────────────── */
.week {
aspect-ratio: 1;
border: 1px solid var(--border);
cursor: default;
}
.week--past {
background: var(--text);
border-color: var(--text);
}
.week--current {
background: var(--text-muted);
border-color: var(--text);
box-shadow: 0 0 0 1.5px var(--text);
z-index: 1;
position: relative;
}
.week--future {
background: transparent;
}
/* First week of each decade: more prominent border for visual orientation. */
.week--decade {
border-color: var(--text-muted);
}
.week:hover {
opacity: 0.6;
}
/* ── Legend ───────────────────────────────────────────────────────────────── */
.weeks-legend {
font-family: var(--font-ui);
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.6rem;
margin-bottom: 0;
}
/* ── Tooltip ──────────────────────────────────────────────────────────────── */
.weeks-tooltip {
position: fixed;
z-index: 500;
pointer-events: none;
background: var(--bg);
border: 1px solid var(--border);
padding: 0.45rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 190px;
max-width: 240px;
}
.weeks-tooltip[hidden] {
display: none;
}
.tt-age {
font-family: var(--font-ui);
font-size: 0.88rem;
font-weight: 600;
color: var(--text);
}
.tt-week {
font-family: var(--font-ui);
font-size: 0.78rem;
color: var(--text-muted);
}
.tt-date {
font-family: var(--font-ui);
font-size: 0.78rem;
color: var(--text-muted);
}
.tt-meta {
font-family: var(--font-ui);
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 0.15rem;
padding-top: 0.2rem;
border-top: 1px solid var(--border);
}
/* ── Week popup ───────────────────────────────────────────────────────────── */
.week-popup-backdrop {
position: fixed;
inset: 0;
z-index: 600;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.week-popup-backdrop[hidden] {
display: none;
}
.week-popup-card {
background: var(--bg);
border: 1px solid var(--border);
padding: 1.25rem 1.5rem 1.5rem;
width: min(640px, calc(100vw - 2rem));
}
.week-popup-header {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: start;
margin-bottom: 1.1rem;
}
.week-popup-title {
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
grid-column: 1;
grid-row: 1;
}
.week-popup-sub {
font-family: var(--font-ui);
font-size: 0.75rem;
color: var(--text-muted);
grid-column: 1;
grid-row: 2;
margin-top: 0.15rem;
}
.week-popup-close {
grid-column: 2;
grid-row: 1 / 3;
font-family: var(--font-ui);
font-size: 1.2rem;
line-height: 1;
padding: 0.1rem 0.4rem;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
cursor: pointer;
align-self: start;
}
.week-popup-close:hover {
color: var(--text);
border-color: var(--border);
}
/* ── Days row ─────────────────────────────────────────────────────────────── */
.week-popup-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.75rem;
}
.wday {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.wday-dot {
width: 0.75rem;
height: 0.75rem;
border: 1px solid var(--border);
}
.wday--past .wday-dot {
background: var(--text);
border-color: var(--text);
}
.wday--today .wday-dot {
background: var(--text-muted);
border-color: var(--text);
box-shadow: 0 0 0 1.5px var(--text);
}
.wday--future .wday-dot {
background: transparent;
}
.wday-name {
font-family: var(--font-ui);
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wday--today .wday-name {
color: var(--text);
}
.wday-date {
font-family: var(--font-ui);
font-size: 0.68rem;
color: var(--text-muted);
text-align: center;
}
.wday-daynum {
font-family: var(--font-ui);
font-size: 0.62rem;
color: var(--text-faint);
text-align: center;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
}
.wday:hover .wday-daynum {
opacity: 1;
}
/* ── Countdown ────────────────────────────────────────────────────────────── */
#countdown-wrapper {
margin: 3rem 0 2rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.countdown-label {
font-family: var(--font-ui);
font-size: 0.78rem;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.countdown-value {
font-family: var(--font-mono);
font-size: clamp(1.6rem, 5vw, 2.8rem);
font-weight: 400;
color: var(--text);
letter-spacing: -0.02em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.countdown-switcher {
display: flex;
gap: 0.5rem;
margin-top: 0.35rem;
flex-wrap: wrap;
}
.countdown-btn {
font-family: var(--font-ui);
font-size: 0.75rem;
padding: 0.2rem 0.65rem;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.countdown-btn:hover {
color: var(--text);
border-color: var(--text-muted);
}
.countdown-btn.is-active {
color: var(--text);
border-color: var(--text);
}

105
static/css/popups.css Normal file
View File

@ -0,0 +1,105 @@
/* popups.css — Hover preview popup styles. */
/* ============================================================
POPUP CONTAINER
Absolutely positioned, single shared element.
============================================================ */
.link-popup {
position: absolute;
z-index: 500;
max-width: 420px;
min-width: 200px;
padding: 0.7rem 0.9rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.09);
font-family: var(--font-sans);
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.55;
pointer-events: auto;
opacity: 0;
visibility: hidden;
transition: opacity 0.14s ease, visibility 0.14s ease;
}
.link-popup.is-visible {
opacity: 1;
visibility: visible;
}
/* ============================================================
SHARED POPUP CONTENT
============================================================ */
.popup-title {
font-weight: 600;
color: var(--text);
font-size: 0.82rem;
margin-bottom: 0.3rem;
line-height: 1.35;
}
.popup-abstract,
.popup-extract {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.55;
}
/* Source label ("Wikipedia", "arXiv") */
.popup-source {
font-size: 0.65rem;
font-weight: 600;
font-variant-caps: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
margin-bottom: 0.25rem;
}
/* Author list (arXiv, DOI, GitHub, etc.) */
.popup-authors {
font-size: 0.75rem;
color: var(--text-faint);
margin-bottom: 0.3rem;
font-style: italic;
}
/* Journal / year / language / stars line */
.popup-meta {
font-size: 0.72rem;
color: var(--text-faint);
margin-bottom: 0.3rem;
font-variant-numeric: tabular-nums;
}
/* ============================================================
CITATION POPUP
Shows the .csl-entry from the bibliography in-page.
============================================================ */
.popup-citation .csl-entry {
font-family: var(--font-sans);
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.6;
/* Override the hanging-indent style used in the bibliography list */
padding-left: 0;
text-indent: 0;
margin-bottom: 0;
}
.popup-citation .csl-entry a {
color: var(--text-faint);
word-break: break-all;
}
/* Hide the [N] number badge — already shown in the inline marker */
.popup-citation .ref-num {
display: none;
}

145
static/css/print.css Normal file
View File

@ -0,0 +1,145 @@
/* print.css Clean paper output.
Loaded on every page via <link media="print">.
Hides chrome, expands body full-width, renders in black on white. */
@media print {
/* ----------------------------------------------------------------
Force light on paper
---------------------------------------------------------------- */
:root,
[data-theme="dark"] {
--bg: #ffffff;
--bg-offset: #f5f5f5;
--text: #000000;
--text-muted: #333333;
--text-faint: #555555;
--border: #cccccc;
--border-muted: #aaaaaa;
}
/* ----------------------------------------------------------------
Hide chrome entirely
---------------------------------------------------------------- */
header,
footer,
#toc,
.settings-wrap,
.selection-popup,
.link-popup,
.toc-toggle,
.section-toggle,
.metadata .meta-pagelinks,
.page-meta-footer .meta-footer-section#backlinks,
.nav-portals {
display: none !important;
}
/* ----------------------------------------------------------------
Layout single full-width column
---------------------------------------------------------------- */
body {
font-size: 11pt;
line-height: 1.6;
background: #fff;
color: #000;
margin: 0;
padding: 0;
}
#content {
display: block !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
#markdownBody {
width: 100% !important;
max-width: 100% !important;
grid-column: unset !important;
margin: 0 !important;
padding: 0 !important;
}
/* Sidenotes: pull inline as footnote-like blocks */
.sidenote-ref {
display: none;
}
.sidenote {
display: block;
position: static !important;
width: auto !important;
margin: 0.5em 2em;
padding: 0.4em 0.8em;
border-left: 2px solid #ccc;
font-size: 9pt;
color: #555;
}
/* ----------------------------------------------------------------
Page setup
---------------------------------------------------------------- */
@page {
margin: 2cm 2.5cm;
}
@page :first {
margin-top: 3cm;
}
/* ----------------------------------------------------------------
Typography adjustments
---------------------------------------------------------------- */
h1, h2, h3, h4 {
page-break-after: avoid;
break-after: avoid;
}
p, li, blockquote {
orphans: 3;
widows: 3;
}
pre, figure, .exhibit {
page-break-inside: avoid;
break-inside: avoid;
}
/* Show href after external links */
a[href^="http"]::after {
content: " (" attr(href) ")";
font-size: 0.8em;
color: #555;
word-break: break-all;
}
/* But not for nav or obvious UI links */
.cite-link::after,
.meta-tag::after,
a[href^="#"]::after {
content: none !important;
}
/* ----------------------------------------------------------------
Code blocks strip background, border only
---------------------------------------------------------------- */
pre, code {
background: #f9f9f9 !important;
border: 1px solid #ddd !important;
box-shadow: none !important;
}
/* ----------------------------------------------------------------
Bibliography / footer keep but compact
---------------------------------------------------------------- */
.page-meta-footer {
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid #ccc;
}
.meta-footer-full,
.meta-footer-grid {
width: 100% !important;
max-width: 100% !important;
}
}

122
static/css/reading.css Normal file
View File

@ -0,0 +1,122 @@
/* reading.css Codex layout for fiction and poetry.
Loaded only on pages where the "reading" context field is set. */
/* ============================================================
SCROLL PROGRESS BAR
A 2px warm-gray line fixed at the very top of the viewport.
z-index 200 sits above the sticky nav (z-index 100).
============================================================ */
#reading-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0%;
background-color: var(--text-faint);
z-index: 200;
pointer-events: none;
transition: width 0.08s linear;
}
/* ============================================================
READING MODE BASE
Slightly warmer background; narrower, book-like measure.
The class is applied to <body> by default.html.
============================================================ */
body.reading-mode {
--bg: #fdf9f1;
background-color: var(--bg);
}
[data-theme="dark"] body.reading-mode {
--bg: #1c1917;
background-color: var(--bg);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) body.reading-mode {
--bg: #1c1917;
background-color: var(--bg);
}
}
/* Reading body: narrower than the essay default (800px ~62ch).
Since reading.html emits body > #markdownBody (no #content grid),
the centering is handled by the existing layout.css rule. */
body.reading-mode > #markdownBody {
max-width: 62ch;
}
/* ============================================================
POETRY
============================================================ */
/* Slightly narrower measure for verse */
body.reading-mode.poetry > #markdownBody {
max-width: 52ch;
}
/* Generous line height and stanza spacing */
body.reading-mode.poetry > #markdownBody > p {
line-height: 1.85;
margin-bottom: 1.6rem;
/* Ragged right — no hyphenation, no justification */
hyphens: none;
text-align: left;
}
/* Suppress the prose dropcap — poetry opens without a floated initial */
body.reading-mode.poetry > #markdownBody > p:first-of-type::first-letter {
float: none;
font-size: inherit;
line-height: inherit;
padding: 0;
color: inherit;
}
/* Suppress the smallcaps lead-in on the first line */
body.reading-mode.poetry > #markdownBody > p:first-of-type::first-line {
font-variant-caps: normal;
letter-spacing: normal;
}
/* ============================================================
FICTION
============================================================ */
/* Chapter headings: Fira Sans smallcaps, centered, spaced */
body.reading-mode.fiction > #markdownBody h2 {
text-align: center;
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 600;
font-variant: small-caps;
letter-spacing: 0.12em;
text-transform: lowercase;
color: var(--text-muted);
margin: 3.5rem 0 2.5rem;
}
/* Drop cap at the start of each chapter (h2 → next paragraph) */
body.reading-mode.fiction > #markdownBody h2 + p::first-letter {
float: left;
font-size: 3.8em;
line-height: 0.8;
padding-top: 0.05em;
padding-right: 0.1em;
color: var(--text);
font-variant-caps: normal;
}
/* Smallcaps lead-in for chapter-opening lines */
body.reading-mode.fiction > #markdownBody h2 + p::first-line {
font-variant-caps: small-caps;
letter-spacing: 0.05em;
}
/* Scene breaks (***) — reuse the asterism but give it more breathing room */
body.reading-mode.fiction > #markdownBody hr {
margin: 3rem 0;
}

246
static/css/score-reader.css Normal file
View File

@ -0,0 +1,246 @@
/* score-reader.css Full-page score reader layout.
Used only on /music/{slug}/score/ pages via score-reader-default.html. */
/* ------------------------------------------------------------------
Top bar
------------------------------------------------------------------ */
.score-reader-bar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
height: 2.75rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-family: var(--font-sans);
font-size: 0.825rem;
}
.score-reader-bar-left {
flex: 0 0 auto;
}
.score-reader-movements {
display: flex;
align-items: center;
gap: 0.25rem;
flex: 1 1 auto;
overflow-x: auto;
scrollbar-width: none;
}
.score-reader-movements::-webkit-scrollbar { display: none; }
.score-reader-bar-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 0 0 auto;
}
.score-reader-back {
color: var(--text-muted);
text-decoration: none;
white-space: nowrap;
}
.score-reader-back:hover { color: var(--text); }
.score-reader-mvt {
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
white-space: nowrap;
border-radius: 3px;
transition: background 0.15s, color 0.15s;
}
.score-reader-mvt:hover,
.score-reader-mvt.is-active {
background: var(--bg-code);
color: var(--text);
}
.score-reader-counter {
color: var(--text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.score-reader-pdf {
color: var(--text-muted);
text-decoration: none;
font-size: 0.8rem;
}
.score-reader-pdf:hover { color: var(--text); }
/* ------------------------------------------------------------------
Score stage
------------------------------------------------------------------ */
.score-reader-stage {
padding-top: 2.75rem;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background: var(--bg);
}
.score-reader-viewport {
position: relative;
width: 100%;
max-width: 960px;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
gap: 1rem;
}
.score-page {
width: 100%;
height: auto;
display: block;
}
/* Dark-mode inversion — appropriate for pure B&W notation. */
[data-theme="dark"] .score-page {
filter: invert(1);
}
/* ------------------------------------------------------------------
Prev / next buttons
------------------------------------------------------------------ */
.score-reader-prev,
.score-reader-next {
flex: 0 0 auto;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 1.25rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.score-reader-prev:hover,
.score-reader-next:hover {
color: var(--text);
border-color: var(--text-muted);
}
.score-reader-prev:disabled,
.score-reader-next:disabled {
opacity: 0.3;
cursor: default;
}
/* Narrow screens: score scrolls horizontally; arrow buttons hidden. */
@media (max-width: 640px) {
.score-reader-viewport {
overflow-x: auto;
justify-content: flex-start;
padding: 1rem 0;
}
.score-page {
min-width: 600px;
}
.score-reader-prev,
.score-reader-next {
display: none;
}
}
/* ------------------------------------------------------------------
Composition landing page additions (metadata block)
------------------------------------------------------------------ */
.composition-details {
display: flex;
flex-wrap: wrap;
gap: 0 1.5rem;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 0.875rem;
}
.comp-detail { white-space: nowrap; }
.composition-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.comp-btn {
display: inline-block;
padding: 0.35em 0.9em;
border: 1px solid var(--border);
border-radius: 3px;
font-family: var(--font-sans);
font-size: 0.825rem;
text-decoration: none;
color: var(--text);
background: var(--bg);
transition: background 0.15s, border-color 0.15s;
}
.comp-btn:hover {
background: var(--bg-code);
border-color: var(--text-muted);
}
.comp-btn--secondary { color: var(--text-muted); }
/* ------------------------------------------------------------------
Movement list
------------------------------------------------------------------ */
.composition-movements {
border-top: 1px solid var(--border);
margin: 1.5rem 0;
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.comp-movement-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-family: var(--font-sans);
font-size: 0.875rem;
}
.comp-movement-name {
font-weight: 600;
color: var(--text);
}
.comp-movement-duration {
color: var(--text-muted);
font-size: 0.8rem;
}
.comp-movement-score {
color: var(--text-muted);
font-size: 0.8rem;
text-decoration: none;
margin-left: auto;
}
.comp-movement-score:hover { color: var(--text); }
.movement-audio {
width: 100%;
margin-top: 0.4rem;
accent-color: var(--text-muted);
}

View File

@ -0,0 +1,184 @@
/* selection-popup.css — Custom text-selection toolbar */
/* ============================================================
CONTAINER
Inverted: --text as background, --bg as foreground.
This gives a dark pill in light mode and a light pill in dark
mode same contrast relationship as the page selection colour.
============================================================ */
.selection-popup {
position: absolute;
z-index: 700;
display: flex;
align-items: center;
padding: 0 0.25rem;
gap: 0;
background: var(--text);
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22);
opacity: 0;
visibility: hidden;
transform: translateY(5px);
transition: opacity 0.13s ease, visibility 0.13s ease, transform 0.13s ease;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.selection-popup.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
/* Caret pointing down (popup is above selection) */
.selection-popup::after {
content: '';
position: absolute;
top: 100%;
left: calc(var(--caret-left, 50%) - 5px);
border: 5px solid transparent;
border-top-color: var(--text);
pointer-events: none;
}
/* Flip: caret pointing up (popup is below selection) */
.selection-popup.is-below::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--text);
}
/* ============================================================
BUTTONS
============================================================ */
.selection-popup-btn {
background: none;
border: none;
color: var(--bg);
font-family: var(--font-sans);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.025em;
padding: 0.38rem 0.6rem;
cursor: pointer;
border-radius: 3px;
white-space: nowrap;
line-height: 1;
transition: background 0.1s ease;
}
.selection-popup-btn:hover,
.selection-popup-btn:focus-visible {
background: rgba(128, 128, 128, 0.2);
outline: none;
}
/* Placeholder buttons — visually present but muted, not interactive */
.selection-popup-btn--placeholder {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
/* ============================================================
SEPARATOR
============================================================ */
.selection-popup-sep {
display: block;
width: 1px;
height: 1rem;
background: var(--bg);
opacity: 0.2;
margin: 0 0.15rem;
flex-shrink: 0;
}
/* ============================================================
ANNOTATION FORM MODE
Replaces the button row with a compact form: colour swatches,
optional note textarea, Save / Cancel.
============================================================ */
.selection-popup.is-form-mode {
flex-direction: column;
align-items: stretch;
padding: 0.5rem;
gap: 0.4rem;
min-width: 13rem;
}
.selection-popup-ann-form {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* ── Colour swatches ── */
.ann-swatches {
display: flex;
gap: 0.4rem;
padding: 0 0.1rem;
}
.ann-swatch {
width: 1.05rem;
height: 1.05rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: border-color 0.1s ease, transform 0.1s ease;
}
.ann-swatch:hover { transform: scale(1.15); }
.ann-swatch.is-selected { border-color: var(--bg); }
.ann-swatch--amber { background: #f59e0b; }
.ann-swatch--sage { background: #6b9e78; }
.ann-swatch--steel { background: #7096b8; }
.ann-swatch--rose { background: #c87474; }
/* ── Note textarea ── */
.ann-note-input {
background: rgba(128, 128, 128, 0.15);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
color: var(--bg);
font-family: var(--font-sans);
font-size: 0.72rem;
line-height: 1.4;
padding: 0.3rem 0.45rem;
resize: none;
width: 100%;
box-sizing: border-box;
outline: none;
}
.ann-note-input::placeholder { opacity: 0.45; }
.ann-note-input:focus { border-color: rgba(255, 255, 255, 0.35); }
/* ── Save / Cancel row ── */
.ann-form-actions {
display: flex;
gap: 0.3rem;
justify-content: flex-end;
}
/* Reduce motion */
[data-reduce-motion] .ann-swatch { transition: none; }

157
static/css/sidenotes.css Normal file
View File

@ -0,0 +1,157 @@
/* sidenotes.css Inline sidenote layout.
The Haskell Sidenotes filter converts Pandoc footnotes to:
<sup class="sidenote-ref"><a href="#sn-N">N</a></sup>
<span class="sidenote" id="sn-N"><sup class="sidenote-num">N</sup> </span>
Layout strategy
On wide viewports ( 1500px) the sidenote <span> is positioned
absolutely relative to #markdownBody (which is position: relative).
We do NOT use float because float with negative margin is unreliable
across browsers when the float's margin box is effectively zero-width;
it tends to wrap below the paragraph rather than escaping to the right.
position: absolute with no explicit top/bottom uses the "hypothetical
static position" the spot the element would occupy if position: static.
For an inline <span> inside a <p>, this is roughly the line containing
the sidenote reference, giving correct vertical alignment without JS.
On narrow viewports the <span> is hidden and the Pandoc-generated
<section class="footnotes"> at document end is shown instead.
*/
/* ============================================================
SIDENOTE REFERENCE (in-text superscript)
============================================================ */
.sidenote-ref {
font-size: 0.7em;
line-height: 0;
position: relative;
top: -0.4em;
font-family: var(--font-sans);
font-feature-settings: normal;
}
.sidenote-ref a {
color: var(--text-faint);
text-decoration: none;
padding: 0 0.1em;
transition: color var(--transition-fast);
}
.sidenote-ref a:hover,
.sidenote-ref.is-active a {
color: var(--text);
}
/* Highlight the sidenote when its ref is hovered (CSS: adjacent sibling). */
.sidenote-ref:hover + .sidenote,
.sidenote.is-active {
color: var(--text);
}
/* ============================================================
SIDENOTE SPAN
position: absolute anchors to #markdownBody (position: relative).
left: 100% + gap puts the left edge just past the right side of
the body column. No top/bottom hypothetical static position.
============================================================ */
.sidenote {
position: absolute;
left: calc(100% + 1.5rem); /* 1.5rem gap from body right edge */
width: clamp(200px, calc(50vw - var(--body-max-width) / 2 - var(--page-padding) - 1.5rem), 320px);
font-family: var(--font-serif);
font-size: 0.82rem;
line-height: 1.55;
color: var(--text-muted);
text-indent: 0;
font-feature-settings: normal;
hyphens: none;
hanging-punctuation: none;
}
/* Number badge inside the sidenote — inline box, not a superscript */
.sidenote-num {
display: inline-block;
font-family: var(--font-sans);
font-size: 0.65em;
font-weight: 600;
line-height: 1.35;
vertical-align: baseline;
color: var(--text-faint);
border: 1px solid var(--border-muted);
border-radius: 2px;
padding: 0 0.3em;
margin-right: 0.35em;
}
/* Paragraphs injected by blocksToHtml (rendered as inline-block spans
to keep them valid inside the outer <span class="sidenote">) */
.sidenote-para {
display: block;
margin: 0;
text-indent: 0;
}
.sidenote-para + .sidenote-para {
margin-top: 0.4em;
}
.sidenote a {
color: var(--text-muted);
}
/* ============================================================
RESPONSIVE
Side columns are 1fr (fluid). Sidenotes need at least
body(800px) + gap(24px) + sidenote(200px) + padding(48px) 1072px,
but a comfortable threshold is kept at 1500px so sidenotes
have enough room not to feel cramped.
============================================================ */
@media (min-width: 1500px) {
section.footnotes {
display: none;
}
}
@media (max-width: 1499px) {
.sidenote {
display: none;
}
section.footnotes {
display: block;
margin-top: 3.3rem;
padding-top: 1.65rem;
border-top: 1px solid var(--border-muted);
}
}
/* ============================================================
FOOTNOTE REFERENCES shown on narrow viewports alongside
section.footnotes
============================================================ */
a.footnote-ref {
text-decoration: none;
color: var(--text-faint);
font-size: 0.75em;
line-height: 0;
position: relative;
top: -0.4em;
font-family: var(--font-sans);
transition: color var(--transition-fast);
}
a.footnote-ref:hover {
color: var(--text-muted);
}

93
static/css/syntax.css Normal file
View File

@ -0,0 +1,93 @@
/* syntax.css Monochrome Prism.js syntax highlighting.
No toggle always active on language-annotated code blocks.
No hue: only bold, italic, and opacity variations matching site palette.
Dark mode is automatic via CSS custom properties. */
/* Comments: faint + italic — least prominent */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata,
.token.shebang {
color: var(--text-faint);
font-style: italic;
}
/* Keywords, control flow: bold — most prominent */
.token.keyword,
.token.rule,
.token.important,
.token.atrule,
.token.builtin,
.token.deleted {
font-weight: 700;
}
/* Strings: muted */
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.template-string,
.token.inserted {
color: var(--text-muted);
}
/* Numbers and booleans: full text color (explicit for completeness) */
.token.number,
.token.boolean,
.token.constant {
color: var(--text);
}
/* Punctuation and operators: faint — structural noise, recede */
.token.punctuation,
.token.operator {
color: var(--text-faint);
}
/* Functions and class names: semibold */
.token.function,
.token.function-definition,
.token.class-name,
.token.maybe-class-name {
font-weight: 600;
}
/* Tags (HTML/XML): semibold */
.token.tag {
font-weight: 600;
}
/* Attribute names: muted */
.token.attr-name,
.token.property {
color: var(--text-muted);
}
/* Selectors (CSS): semibold */
.token.selector {
font-weight: 600;
}
/* Type annotations and namespaces: italic + muted */
.token.namespace,
.token.type-annotation,
.token.type {
font-style: italic;
color: var(--text-muted);
}
/* Variables and parameters: italic */
.token.variable,
.token.parameter {
font-style: italic;
}
/* URLs: muted underline */
.token.url {
color: var(--text-muted);
text-decoration: underline;
text-decoration-color: var(--border);
}

615
static/css/typography.css Normal file
View File

@ -0,0 +1,615 @@
/* typography.css — Spectral body text, Fira Sans headings, OT features, and editorial flourishes */
/* ============================================================
BODY TEXT
============================================================ */
#markdownBody {
font-family: var(--font-serif);
font-size: 1rem;
line-height: var(--line-height);
/* OT features: ligatures, old-style figures, kerning */
font-feature-settings: 'liga' 1, 'onum' 1, 'kern' 1;
font-variant-ligatures: common-ligatures;
font-variant-numeric: oldstyle-nums;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
hyphens: auto;
hanging-punctuation: first last;
}
/* ============================================================
LITERARY FLOURISHES
============================================================ */
/* 1. Automated Dropcap for the opening paragraph */
#markdownBody > p:first-of-type::first-letter {
float: left;
font-size: 3.8em;
line-height: 0.8;
padding-top: 0.05em;
padding-right: 0.1em;
color: var(--text);
font-variant-caps: normal; /* Prevent smcp collision */
}
/* 2. Magazine-style Lead-in (Small caps for the rest of the first line) */
#markdownBody > p:first-of-type::first-line {
font-variant-caps: small-caps;
letter-spacing: 0.05em;
color: var(--text);
}
/* 3. Explicit dropcap — wrap any paragraph in a ::: dropcap ::: fenced div */
#markdownBody .dropcap p::first-letter {
float: left;
font-size: 3.8em;
line-height: 0.8;
padding-top: 0.05em;
padding-right: 0.1em;
color: var(--text);
font-variant-caps: normal;
}
#markdownBody .dropcap p::first-line {
font-variant-caps: small-caps;
letter-spacing: 0.05em;
color: var(--text);
}
/* 3. Epigraphs: Whispered preludes */
.epigraph {
font-style: italic;
font-size: 0.95em;
margin: 0 0 3.3rem 0; /* 2x baseline grid */
padding-left: 1.5em;
border-left: 1px solid var(--border-muted);
color: var(--text-muted);
}
/* 4. Pull Quotes */
.pull-quote {
font-size: 1.25em;
font-style: italic;
text-align: center;
margin: 3.3rem 0; /* 2x baseline grid */
padding: 1.65rem 2rem; /* 1x baseline grid padding */
border-top: 1px solid var(--border-muted);
border-bottom: 1px solid var(--border-muted);
color: var(--text);
}
/* 5. The Typographic Asterism (Replaces standard <hr>) */
#markdownBody hr {
border: none;
text-align: center;
margin: 3.3rem 0; /* 2x baseline grid */
color: var(--text-muted);
}
#markdownBody hr::after {
content: "⁂";
font-size: 1.5em;
letter-spacing: 0.5em;
padding-left: 0.5em; /* Optically center the letter-spacing */
font-family: var(--font-serif);
}
/* ============================================================
PARAGRAPHS & SPACING
Anchored strictly to the 1.65rem baseline grid.
============================================================ */
#markdownBody p {
margin: 0;
}
#markdownBody p + p,
#markdownBody .dropcap + p {
text-indent: 1.5em;
}
/* Reset indent after any block-level element */
#markdownBody :is(blockquote, pre, ul, ol, figure, table, h1, h2, h3, h4, h5, h6, hr) + p {
text-indent: 0;
}
/* Space between block-level elements: Exactly 1 line-height */
#markdownBody :is(blockquote, pre, ul, ol, figure, table) {
margin: 1.65rem 0;
}
/* ============================================================
HEADINGS
Fira Sans Semibold. Margins tied to the 1.65rem rhythm.
============================================================ */
#markdownBody :is(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-sans);
font-weight: 600;
line-height: 1.25;
color: var(--text);
/* Disable Spectral OT features for Sans */
font-feature-settings: normal;
font-variant-numeric: normal;
font-variant-ligatures: normal;
text-rendering: optimizeLegibility;
}
/* Top margin: 3.3rem (2 lines). Bottom margin: 0.825rem (half line). */
#markdownBody h1 { font-size: 2.6rem; margin: 0 0 1.65rem 0; }
#markdownBody h2 { font-size: 1.85rem; margin: 3.3rem 0 0.825rem 0; }
#markdownBody h3 { font-size: 1.45rem; margin: 2.475rem 0 0.825rem 0; }
#markdownBody h4 { font-size: 1.15rem; margin: 1.65rem 0 0.825rem 0; }
#markdownBody h5 { font-size: 1rem; margin: 1.65rem 0 0.825rem 0; }
#markdownBody h6 { font-size: 1rem; margin: 1.65rem 0 0.825rem 0; font-weight: 400; font-style: italic; }
/* Section heading self-links (¶ pilcrow) */
#markdownBody .heading { position: relative; }
#markdownBody .heading a::after {
content: "\00B6";
font-size: 0.75em;
position: absolute;
bottom: 0.15em;
right: -1.25em;
visibility: hidden;
opacity: 0;
transition: opacity 0.1s ease;
}
#markdownBody .heading:hover a::after {
visibility: visible;
opacity: 0.4;
}
/* ============================================================
LINKS
Thick, descender-clearing strokes.
============================================================ */
a {
color: var(--link);
text-decoration: underline;
text-decoration-color: var(--border-muted);
text-decoration-thickness: 0.15em;
text-underline-offset: 0.15em;
text-decoration-skip-ink: auto; /* Clears descenders */
transition: text-decoration-color var(--transition-fast), color var(--transition-fast);
}
a:hover {
text-decoration-color: var(--link-hover);
}
a:visited {
color: var(--link-visited);
}
/* (external link icons moved to bottom of file — see data-link-icon section) */
/* ============================================================
INLINE ELEMENTS & HIGHLIGHTING
============================================================ */
strong { font-weight: 700; }
strong.semibold { font-weight: 600; }
em { font-style: italic; }
/* Abbreviations: force uppercase acronyms to x-height */
abbr {
font-variant-caps: all-small-caps;
letter-spacing: 0.03em;
text-decoration: none;
cursor: help;
}
/* True superscripts & subscripts */
sup { font-variant-position: super; line-height: 1; }
sub { font-variant-position: sub; line-height: 1; }
/* Realistic Ink Highlighting */
#markdownBody mark {
background-color: transparent;
background-image: linear-gradient(
104deg,
rgba(250, 235, 120, 0) 0%,
rgba(250, 235, 120, 0.8) 2%,
rgba(250, 220, 100, 0.9) 98%,
rgba(250, 220, 100, 0) 100%
);
background-size: 100% 0.7em;
background-position: 0 88%;
background-repeat: no-repeat;
padding: 0 0.2em;
color: inherit;
}
[data-theme="dark"] #markdownBody mark {
background-image: linear-gradient(
104deg,
rgba(100, 130, 180, 0) 0%,
rgba(100, 130, 180, 0.4) 2%,
rgba(80, 110, 160, 0.5) 98%,
rgba(80, 110, 160, 0) 100%
);
}
/* ============================================================
CODE
============================================================ */
code, kbd, samp {
font-family: var(--font-mono);
font-size: 0.88em;
font-feature-settings: 'liga' 1, 'calt' 1;
background-color: var(--bg-offset);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.1em 0.3em;
}
pre {
font-family: var(--font-mono);
font-size: 0.88em;
font-feature-settings: 'liga' 1, 'calt' 1;
background-color: var(--bg-offset);
border: 1px solid var(--border-muted);
border-radius: 4px;
padding: 1.65rem 1.5rem; /* Anchored to baseline grid */
overflow-x: auto;
line-height: 1.65;
}
pre code {
font-size: 1em;
background: none;
border: none;
padding: 0;
border-radius: 0;
}
/* ============================================================
BLOCKQUOTES
Woven/stitched edge instead of a solid line.
============================================================ */
#markdownBody blockquote {
margin-left: 0;
margin-right: 0;
padding-left: 1.5em;
border-left: none;
/* Woven/stitched border using background gradients */
background-image: linear-gradient(to bottom, var(--border-muted) 50%, transparent 50%);
background-position: left top;
background-repeat: repeat-y;
background-size: 2px 8px; /* 2px wide, 8px repeating pattern */
color: var(--text-muted);
font-style: italic;
}
#markdownBody blockquote em {
font-style: normal;
}
/* ── Poem excerpt ──────────────────────────────────────────────────────────── */
/* Usage: <figure class="poem-excerpt"><blockquote></blockquote>
<figcaption><a href="…">Title</a> Author</figcaption></figure>
The blockquote inherits the default #markdownBody blockquote styles entirely
(dashed left line, muted italic). The figure cancels the image-figure box
styling (bg / border / padding / shadow) and uses the inherited centering
(max-width: fit-content + margin: auto from #markdownBody figure).
When a source link is present, a stretched ::after covers the whole figure
so the entire excerpt is clickable. */
#markdownBody figure.poem-excerpt {
background: none;
border: none;
padding: 0;
box-shadow: none;
position: relative;
}
/* blockquote — no overrides; inherits default dashed-left-line treatment */
/* Figcaption: overrides the right-aligned italic image-caption default.
Raised above the stretched-link overlay via z-index: 2. */
#markdownBody figure.poem-excerpt figcaption {
text-align: left;
padding-left: 1.5em; /* aligns with blockquote text */
font-family: var(--font-ui);
font-size: 0.73rem;
font-style: normal;
font-variant-caps: all-small-caps;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-top: 0.5rem;
position: relative;
z-index: 2;
}
#markdownBody figure.poem-excerpt figcaption a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--border);
transition: color var(--transition-fast), border-color var(--transition-fast);
}
/* Stretched link: clicking anywhere on the figure navigates to the poem page. */
#markdownBody figure.poem-excerpt figcaption a::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
cursor: pointer;
}
#markdownBody figure.poem-excerpt figcaption a:hover {
color: var(--text);
border-color: var(--text-muted);
}
/* ── Prose excerpt (inline pull-quote from an article or book) ─────────────── */
/* Usage: <figure class="prose-excerpt"><blockquote></blockquote>
<figcaption><a href="…">Source</a> Author</figcaption></figure>
Left-aligned (overrides figure centering). Same stretched-link pattern. */
#markdownBody figure.prose-excerpt {
background: none;
border: none;
padding: 0;
box-shadow: none;
width: auto;
max-width: 100%;
margin: 2.5rem 0;
position: relative;
}
/* blockquote — no overrides; inherits default dashed-left-line treatment */
#markdownBody figure.prose-excerpt figcaption {
text-align: left;
padding-left: 1.5em;
font-family: var(--font-ui);
font-size: 0.73rem;
font-style: normal;
font-variant-caps: all-small-caps;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-top: 0.5rem;
position: relative;
z-index: 2;
}
#markdownBody figure.prose-excerpt figcaption a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--border);
transition: color var(--transition-fast), border-color var(--transition-fast);
}
#markdownBody figure.prose-excerpt figcaption a::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
cursor: pointer;
}
#markdownBody figure.prose-excerpt figcaption a:hover {
color: var(--text);
border-color: var(--text-muted);
}
/* ============================================================
LISTS
============================================================ */
#markdownBody ul,
#markdownBody ol {
padding-left: 1.75em;
}
#markdownBody li + li {
margin-top: 0.4125rem; /* Quarter line-height */
}
#markdownBody li > p {
margin: 0;
text-indent: 0;
}
/* ============================================================
FIGURES & CAPTIONS
Archival Photo Plates format.
============================================================ */
#markdownBody figure {
margin: 3.3rem auto;
background: var(--bg-offset);
padding: 1rem;
border: 1px solid var(--border);
border-radius: 2px;
box-shadow: 0 4px 12px rgba(0,0,0,0.03); /* Extremely subtle depth */
max-width: fit-content;
}
#markdownBody figure img {
display: block;
border: 1px solid var(--border-muted); /* Inner bounding box for the image */
}
#markdownBody figcaption {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
text-align: right; /* Editorial, museum-placard feel */
margin-top: 1rem;
font-style: italic;
letter-spacing: 0.02em;
}
/* ============================================================
TABLES
============================================================ */
#markdownBody table {
border-collapse: collapse;
width: 100%;
font-size: 0.9em;
font-feature-settings: 'lnum' 1, 'tnum' 1;
font-variant-numeric: lining-nums tabular-nums;
}
#markdownBody th,
#markdownBody td {
border: 1px solid var(--border);
padding: 0.4em 0.75em;
text-align: left;
}
#markdownBody th {
font-family: var(--font-sans);
font-weight: 600;
background-color: var(--bg-offset);
}
/* ============================================================
ABSTRACT BLOCK
============================================================ */
.abstract {
font-size: 0.95em;
margin: 1.65rem 0 3.3rem 0; /* Baseline grid */
padding-left: 1.5em;
border-left: 2px solid var(--border);
color: var(--text-muted);
}
.abstract p + p {
text-indent: 0;
margin-top: 0.825rem; /* Half line-height */
}
/* ============================================================
MATHEMATICS (KaTeX)
Display math is scaled slightly below body size so long equations
fit within the 680px column. KaTeX uses em units throughout its
output, so font-size here cascades and scales all rendered glyphs.
Inline math (.katex without .katex-display) is unaffected.
overflow-x: auto is a safety net for anything still too wide.
============================================================ */
.katex-display {
font-size: 0.85em;
overflow-x: auto;
overflow-y: hidden;
}
/* ============================================================
PROOF ENVIRONMENT
Traditional mathematical proof styling.
============================================================ */
.proof {
margin: 1.65rem 0;
}
/* "Proof." label — italic bold, matching LaTeX convention */
.proof-label {
font-style: italic;
font-weight: 600;
}
/* Paragraphs inside proof: no extra indent after first */
.proof p + p {
text-indent: 0;
margin-top: 0.825rem;
}
/* QED tombstone — floated right so it sits at the end of the last line */
.proof-qed {
float: right;
margin-left: 1em;
font-style: normal;
}
/* ============================================================
SMALLCAPS UTILITY
============================================================ */
.smallcaps {
font-variant-caps: all-small-caps;
letter-spacing: 0.03em;
}
/* ============================================================
EXTERNAL LINK ICONS
Uses data-link-icon attribute set at build time by Filters/Links.hs.
mask-image renders the SVG in currentColor so icons adapt to dark/light mode.
============================================================ */
a[data-link-icon-type="svg"]::after {
content: '';
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.2em;
vertical-align: 0.05em;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
opacity: 0.5;
transition: opacity var(--transition-fast);
}
a[data-link-icon-type="svg"]:hover::after {
opacity: 0.8;
}
a[data-link-icon="external"]::after {
mask-image: url('/images/link-icons/external.svg');
-webkit-mask-image: url('/images/link-icons/external.svg');
}
a[data-link-icon="wikipedia"]::after {
mask-image: url('/images/link-icons/wikipedia.svg');
-webkit-mask-image: url('/images/link-icons/wikipedia.svg');
}
a[data-link-icon="arxiv"]::after {
mask-image: url('/images/link-icons/arxiv.svg');
-webkit-mask-image: url('/images/link-icons/arxiv.svg');
}
a[data-link-icon="doi"]::after {
mask-image: url('/images/link-icons/doi.svg');
-webkit-mask-image: url('/images/link-icons/doi.svg');
}
a[data-link-icon="github"]::after {
mask-image: url('/images/link-icons/github.svg');
-webkit-mask-image: url('/images/link-icons/github.svg');
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

6
static/gpg/pubkey.asc Normal file
View File

@ -0,0 +1,6 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
[ Replace this entire block with your actual exported public key:
gpg --armor --export ln@levineuwirth.org ]
-----END PGP PUBLIC KEY BLOCK-----

BIN
static/images/canto31.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<!--
arXiv chi (χ): two thick diagonal bars.
The bottom-left→top-right diagonal is continuous.
The top-right→bottom-left diagonal is broken at center (chi, not X).
All shapes are filled so mask-image works reliably.
-->
<!-- Bar 1: top-left → bottom-right (continuous) -->
<polygon points="2,2.5 3.5,2.5 14,13.5 12.5,13.5" fill="black"/>
<!-- Bar 2 upper: top-right → center -->
<polygon points="14,2.5 12.5,2.5 8.8,7.6 10.3,7.6" fill="black"/>
<!-- Bar 2 lower: center → bottom-left -->
<polygon points="5.7,8.4 7.2,8.4 3.5,13.5 2,13.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 664 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="black" d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0H4zm0 1h5v4h4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zm5.5.25L13 4.5h-3.5V1.25zM5 8h6v1H5V8zm0 2.5h6v1H5v-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<!--
DOI: two chain-link rings side by side.
Stroked paths with no fill — stroke pixels are opaque and work with mask-image.
-->
<!-- Left ring -->
<rect x="1" y="5.5" width="7.5" height="5" rx="2.5"
stroke="black" stroke-width="1.5" fill="none"/>
<!-- Right ring -->
<rect x="7.5" y="5.5" width="7.5" height="5" rx="2.5"
stroke="black" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="black" d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2zm13 2.383-4.708 2.825L15 11.105V5.383zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741zM1 11.105l4.708-2.897L1 5.383v5.722z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<!--
Standard external-link icon: arrow emerging from a box.
Fill-based paths for reliable mask-image rendering.
-->
<!-- Box (open top-right corner) -->
<path fill="black" d="
M6.5 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9.5H12.5V12.5H3.5V3.5H6.5V2Z
"/>
<!-- Arrow shaft + head pointing up-right -->
<path fill="black" d="
M8 2h6v6h-1.5V4.6L7.06 10.06 5.94 8.94 11.4 3.5H8V2Z
"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<!-- GitHub Mark — official GitHub logo path -->
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="black" d="M0 8a4 4 0 0 1 7.465-2H14L15 7l1 1-1 1-1 1-1-1-1 1-1-1-1 1H7.465A4 4 0 0 1 0 8zm4 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="black" d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM5.5 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM5 7h1v6H5V7zm3 0h2.5a3 3 0 0 1 0 6H8V7zm1 1v4h1.5a2 2 0 0 0 0-4H9z"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="black" d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0 1a7 7 0 0 0-7 7h14a7 7 0 0 0-7-7z"/>
</svg>

After

Width:  |  Height:  |  Size: 165 B

View File

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<!--
Wikipedia globe: circle + central meridian ellipse + equator + two parallels.
All strokes converted to filled paths via stroke-width trick:
use actual SVG stroked paths with stroke="black" fill="none" —
but enclosed in an SVG that has no fill default so mask-image sees strokes.
Using path + stroke here since SVG stroke IS opaque for mask purposes.
-->
<!-- Outer circle -->
<circle cx="8" cy="8" r="6.3" stroke="black" stroke-width="1.2" fill="none"/>
<!-- Central meridian ellipse (longitude line) -->
<ellipse cx="8" cy="8" rx="2.6" ry="6.3" stroke="black" stroke-width="0.9" fill="none"/>
<!-- Equator -->
<line x1="1.7" y1="8" x2="14.3" y2="8" stroke="black" stroke-width="0.85" fill="none"/>
<!-- Upper parallel -->
<path d="M 3.2 4.8 Q 8 3.5 12.8 4.8" stroke="black" stroke-width="0.75" fill="none"/>
<!-- Lower parallel -->
<path d="M 3.2 11.2 Q 8 12.5 12.8 11.2" stroke="black" stroke-width="0.75" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

243
static/js/annotations.js Normal file
View File

@ -0,0 +1,243 @@
/* annotations.js localStorage-based personal highlights and annotations.
Persists across sessions via localStorage. Re-anchors on page load via
exact text match using a TreeWalker text-stream search.
Public API (window.Annotations):
.add(text, color, note) ann object
.remove(id)
*/
(function () {
'use strict';
var STORAGE_KEY = 'site-annotations';
var tooltip = null;
var tooltipTimer = null;
/* ------------------------------------------------------------------
Storage
------------------------------------------------------------------ */
function loadAll() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; }
catch (e) { return []; }
}
function saveAll(list) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); }
catch (e) {}
}
function forPage() {
var path = location.pathname;
return loadAll().filter(function (a) { return a.url === path; });
}
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/* ------------------------------------------------------------------
CRUD
------------------------------------------------------------------ */
function addRaw(ann) {
var list = loadAll();
list.push(ann);
saveAll(list);
}
function removeById(id) {
saveAll(loadAll().filter(function (a) { return a.id !== id; }));
document.querySelectorAll('mark[data-ann-id="' + id + '"]').forEach(function (mark) {
var parent = mark.parentNode;
if (!parent) return;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
parent.normalize();
});
hideTooltip(true);
}
/* ------------------------------------------------------------------
Text-stream search finds the first occurrence of searchText in
the visible text of root, skipping existing annotation marks.
------------------------------------------------------------------ */
function findTextRange(searchText, root) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
var nodes = [];
var node;
while ((node = walker.nextNode())) {
/* Skip text already inside an annotation mark */
if (node.parentElement && node.parentElement.closest('mark.user-annotation')) continue;
nodes.push(node);
}
/* Build one long string, tracking each node's span */
var full = '';
var spans = [];
nodes.forEach(function (n) {
spans.push({ node: n, start: full.length, end: full.length + n.nodeValue.length });
full += n.nodeValue;
});
var idx = full.indexOf(searchText);
if (idx === -1) return null;
var end = idx + searchText.length;
var startNode, startOff, endNode, endOff;
for (var i = 0; i < spans.length; i++) {
var s = spans[i];
if (startNode === undefined && idx >= s.start && idx < s.end) {
startNode = s.node; startOff = idx - s.start;
}
if (endNode === undefined && end > s.start && end <= s.end) {
endNode = s.node; endOff = end - s.start;
}
if (startNode && endNode) break;
}
if (!startNode || !endNode) return null;
var range = document.createRange();
range.setStart(startNode, startOff);
range.setEnd(endNode, endOff);
return range;
}
/* ------------------------------------------------------------------
Apply a single annotation to the DOM
------------------------------------------------------------------ */
function applyAnnotation(ann) {
var root = document.getElementById('markdownBody') || document.body;
var range = findTextRange(ann.text, root);
if (!range) return false;
var mark = document.createElement('mark');
mark.className = 'user-annotation user-annotation--' + ann.color;
mark.setAttribute('data-ann-id', ann.id);
if (ann.note) mark.setAttribute('data-note', ann.note);
mark.setAttribute('data-created', ann.created || '');
try {
range.surroundContents(mark);
} catch (e) {
/* Range crosses element boundaries — extract and re-insert */
var frag = range.extractContents();
mark.appendChild(frag);
range.insertNode(mark);
}
bindMarkEvents(mark, ann);
return true;
}
function applyAll() {
forPage().forEach(function (ann) { applyAnnotation(ann); });
}
/* ------------------------------------------------------------------
Tooltip
------------------------------------------------------------------ */
function initTooltip() {
tooltip = document.createElement('div');
tooltip.className = 'ann-tooltip';
tooltip.setAttribute('role', 'tooltip');
document.body.appendChild(tooltip);
tooltip.addEventListener('mouseenter', function () { clearTimeout(tooltipTimer); });
tooltip.addEventListener('mouseleave', function () { hideTooltip(false); });
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function showTooltip(mark, ann) {
clearTimeout(tooltipTimer);
var note = ann.note || '';
var created = ann.created ? new Date(ann.created).toLocaleDateString() : '';
tooltip.innerHTML =
(note ? '<div class="ann-tooltip-note">' + escHtml(note) + '</div>' : '') +
'<div class="ann-tooltip-meta">' +
(created ? '<span class="ann-tooltip-date">' + escHtml(created) + '</span>' : '') +
'<button class="ann-tooltip-delete" data-ann-id="' + escHtml(ann.id) + '">Delete</button>' +
'</div>';
tooltip.querySelector('.ann-tooltip-delete').addEventListener('click', function () {
removeById(ann.id);
});
/* Measure then position */
tooltip.style.visibility = 'hidden';
tooltip.classList.add('is-visible');
var rect = mark.getBoundingClientRect();
var tw = tooltip.offsetWidth;
var th = tooltip.offsetHeight;
var sx = window.scrollX, sy = window.scrollY;
var vw = window.innerWidth;
var left = rect.left + sx + rect.width / 2 - tw / 2;
left = Math.max(sx + 8, Math.min(left, sx + vw - tw - 8));
var top = rect.top + sy - th - 8;
if (top < sy + 8) top = rect.bottom + sy + 8;
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.style.visibility = '';
}
function hideTooltip(immediate) {
clearTimeout(tooltipTimer);
if (immediate) {
if (tooltip) tooltip.classList.remove('is-visible');
} else {
tooltipTimer = setTimeout(function () {
if (tooltip) tooltip.classList.remove('is-visible');
}, 120);
}
}
function bindMarkEvents(mark, ann) {
mark.addEventListener('mouseenter', function () {
clearTimeout(tooltipTimer);
showTooltip(mark, ann);
});
mark.addEventListener('mouseleave', function () { hideTooltip(false); });
}
/* ------------------------------------------------------------------
Public API
------------------------------------------------------------------ */
window.Annotations = {
add: function (text, color, note) {
var ann = {
id: uid(),
url: location.pathname,
text: text,
color: color || 'amber',
note: note || '',
created: new Date().toISOString(),
};
addRaw(ann);
applyAnnotation(ann);
return ann;
},
remove: removeById,
};
document.addEventListener('DOMContentLoaded', function () {
initTooltip();
applyAll();
});
}());

86
static/js/citations.js Normal file
View File

@ -0,0 +1,86 @@
/* citations.js hover tooltip for inline citation markers.
On hover of a .cite-marker, reads the matching bibliography entry from
the DOM and shows it in a floating tooltip. On click, follows the href
to jump to the bibliography section. Phase 3 popups.js can supersede this. */
(function () {
'use strict';
let activeTooltip = null;
let hideTimer = null;
function makeTooltip(html) {
const el = document.createElement('div');
el.className = 'cite-tooltip';
el.innerHTML = html;
el.addEventListener('mouseenter', () => clearTimeout(hideTimer));
el.addEventListener('mouseleave', scheduleHide);
return el;
}
function positionTooltip(tooltip, anchor) {
document.body.appendChild(tooltip);
const aRect = anchor.getBoundingClientRect();
const tRect = tooltip.getBoundingClientRect();
let left = aRect.left + window.scrollX;
let top = aRect.top + window.scrollY - tRect.height - 10;
// Keep horizontally within viewport with margin
const maxLeft = window.innerWidth - tRect.width - 12;
left = Math.max(8, Math.min(left, maxLeft));
// Flip below anchor if not enough room above
if (top < window.scrollY + 8) {
top = aRect.bottom + window.scrollY + 10;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function scheduleHide() {
hideTimer = setTimeout(() => {
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}, 180);
}
function getRefHtml(refEl) {
// Strip the [N] number span, return the remaining innerHTML
const clone = refEl.cloneNode(true);
const num = clone.querySelector('.ref-num');
if (num) num.remove();
return clone.innerHTML.trim();
}
function init() {
document.querySelectorAll('.cite-marker').forEach(marker => {
const link = marker.querySelector('a.cite-link');
if (!link) return;
const href = link.getAttribute('href');
if (!href || !href.startsWith('#')) return;
const refEl = document.getElementById(href.slice(1));
if (!refEl) return;
marker.addEventListener('mouseenter', () => {
clearTimeout(hideTimer);
if (activeTooltip) { activeTooltip.remove(); }
activeTooltip = makeTooltip(getRefHtml(refEl));
positionTooltip(activeTooltip, marker);
});
marker.addEventListener('mouseleave', scheduleHide);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

103
static/js/collapse.js Normal file
View File

@ -0,0 +1,103 @@
/* collapse.js Collapsible h2/h3 sections in essay body.
Self-guards via #markdownBody check; no-ops on non-essay pages.
Persists collapsed state per heading in localStorage.
Retriggered sidenote positioning after each transition via window resize.
*/
(function () {
'use strict';
var PREFIX = 'section-collapsed:';
document.addEventListener('DOMContentLoaded', function () {
var body = document.getElementById('markdownBody');
if (!body) return;
if (body.hasAttribute('data-no-collapse')) return;
var headings = Array.from(body.querySelectorAll('h2[id], h3[id]'));
if (!headings.length) return;
headings.forEach(function (heading) {
var level = parseInt(heading.tagName[1], 10);
var content = [];
var node = heading.nextElementSibling;
// Collect sibling elements until the next same-or-higher heading.
while (node) {
if (/^H[1-6]$/.test(node.tagName) &&
parseInt(node.tagName[1], 10) <= level) break;
content.push(node);
node = node.nextElementSibling;
}
if (!content.length) return;
// Wrap collected nodes in a .section-body div.
var wrapper = document.createElement('div');
wrapper.className = 'section-body';
wrapper.id = 'section-body-' + heading.id;
heading.parentNode.insertBefore(wrapper, content[0]);
content.forEach(function (el) { wrapper.appendChild(el); });
// Inject toggle button into the heading.
var btn = document.createElement('button');
btn.className = 'section-toggle';
btn.setAttribute('aria-label', 'Toggle section');
btn.setAttribute('aria-controls', wrapper.id);
heading.appendChild(btn);
// Restore persisted state without transition flash.
var key = PREFIX + heading.id;
var collapsed = localStorage.getItem(key) === '1';
function setCollapsed(c, animate) {
if (!animate) wrapper.style.transition = 'none';
if (c) {
wrapper.style.maxHeight = '0';
wrapper.classList.add('is-collapsed');
btn.setAttribute('aria-expanded', 'false');
} else {
// Animate: transition 0 → scrollHeight, then release to 'none'
// in transitionend so late-rendering content (e.g. KaTeX) is
// never clipped. No animation: go straight to 'none'.
wrapper.style.maxHeight = animate
? wrapper.scrollHeight + 'px'
: 'none';
wrapper.classList.remove('is-collapsed');
btn.setAttribute('aria-expanded', 'true');
}
if (!animate) {
// Re-enable transition after layout pass.
requestAnimationFrame(function () {
requestAnimationFrame(function () {
wrapper.style.transition = '';
});
});
}
}
setCollapsed(collapsed, false);
btn.addEventListener('click', function (e) {
e.stopPropagation();
var isCollapsed = wrapper.classList.contains('is-collapsed');
if (!isCollapsed) {
// Pin height before collapsing so CSS transition has a from-value.
wrapper.style.maxHeight = wrapper.scrollHeight + 'px';
void wrapper.offsetHeight; // force reflow
}
setCollapsed(!isCollapsed, true);
localStorage.setItem(key, isCollapsed ? '0' : '1');
});
// After open animation: release the height cap so late-rendering
// content (KaTeX, images) is never clipped.
// After close animation: cap is already 0, nothing to do.
// Also retrigger sidenote layout after each transition.
wrapper.addEventListener('transitionend', function () {
if (!wrapper.classList.contains('is-collapsed')) {
wrapper.style.maxHeight = 'none';
}
window.dispatchEvent(new Event('resize'));
});
});
});
}());

458
static/js/gallery.js Normal file
View File

@ -0,0 +1,458 @@
/* gallery.js Two orthogonal systems:
1. Named exhibits (.exhibit[data-exhibit-name]) TOC integration only.
2. Math focusables every .katex-display gets a hover expand button
that opens a full-size overlay. Navigation is global (all focusables
in document order). Group name in overlay comes from nearest exhibit
or heading. */
(function () {
'use strict';
function slugify(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
/* ============================================================
NAMED EXHIBITS (TOC integration)
============================================================ */
var exhibits = [];
function discoverExhibits() {
document.querySelectorAll('.exhibit[data-exhibit-name]').forEach(function (el) {
var name = el.dataset.exhibitName || '';
var type = el.dataset.exhibitType || 'equation';
var id = 'exhibit-' + slugify(name);
el.id = id;
exhibits.push({ el: el, type: type, name: name, id: id });
});
}
function initProofExhibit(entry) {
var body = entry.el.querySelector('.exhibit-body');
if (!body) return;
var header = document.createElement('div');
header.className = 'exhibit-header';
var label = document.createElement('span');
label.className = 'exhibit-header-label';
label.textContent = 'Proof.';
var name = document.createElement('span');
name.className = 'exhibit-header-name';
name.textContent = entry.name;
header.appendChild(label);
header.appendChild(name);
entry.el.insertBefore(header, body);
}
/* ============================================================
MATH FOCUSABLES
Auto-discover every .katex-display in #markdownBody.
KaTeX must be called with output:'htmlAndMathml' so the
original LaTeX survives in <annotation encoding="application/x-tex">.
============================================================ */
var focusables = []; /* { katexEl, wrapperEl, source, groupName } */
function getSource(katexEl) {
var ann = katexEl.querySelector('annotation[encoding="application/x-tex"]');
return ann ? ann.textContent.trim() : '';
}
function getGroupName(katexEl, markdownBody) {
/* Named exhibit takes priority */
var exhibit = katexEl.closest('.exhibit[data-exhibit-name]');
if (exhibit) return exhibit.dataset.exhibitName || '';
/* Otherwise: nearest preceding heading */
var headings = Array.from(markdownBody.querySelectorAll(':is(h1,h2,h3,h4,h5,h6)'));
var nearest = null;
headings.forEach(function (h) {
if (h.compareDocumentPosition(katexEl) & Node.DOCUMENT_POSITION_FOLLOWING) {
nearest = h;
}
});
return nearest ? nearest.textContent.trim() : '';
}
function getCaption(katexEl) {
var exhibit = katexEl.closest('.exhibit[data-exhibit-caption]');
if (!exhibit) return '';
/* A proof's caption belongs to the proof as a whole, not to each
individual equation line within it. Only propagate for equation
exhibits where the math IS the primary content. */
if (exhibit.dataset.exhibitType === 'proof') return '';
return exhibit.dataset.exhibitCaption || '';
}
function discoverFocusableMath(markdownBody) {
markdownBody.querySelectorAll('.katex-display').forEach(function (katexEl) {
var source = getSource(katexEl);
var groupName = getGroupName(katexEl, markdownBody);
var caption = getCaption(katexEl);
/* Wrap in .math-focusable — the entire wrapper is the click target */
var wrapper = document.createElement('div');
wrapper.className = 'math-focusable';
if (caption) wrapper.dataset.caption = caption; /* drives CSS ::after tooltip */
katexEl.parentNode.insertBefore(wrapper, katexEl);
wrapper.appendChild(katexEl);
/* Decorative expand glyph (pointer-events: none in CSS) */
var glyph = document.createElement('span');
glyph.className = 'exhibit-expand';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = '⤢';
wrapper.appendChild(glyph);
var entry = {
type: 'math',
katexEl: katexEl,
wrapperEl: wrapper,
source: source,
groupName: groupName,
caption: caption
};
focusables.push(entry);
/* Click anywhere on the wrapper opens the overlay */
wrapper.addEventListener('click', function () {
openOverlay(focusables.indexOf(entry));
});
});
}
function discoverFocusableScores(markdownBody) {
markdownBody.querySelectorAll('.score-fragment').forEach(function (figEl) {
var svgEl = figEl.querySelector('svg');
if (!svgEl) return;
var captionEl = figEl.querySelector('.score-caption');
var captionText = captionEl ? captionEl.textContent.trim() : '';
var name = figEl.dataset.exhibitName || '';
var groupName = name || getGroupName(figEl, markdownBody);
/* Expand glyph — decorative affordance, same as math focusables */
var glyph = document.createElement('span');
glyph.className = 'exhibit-expand';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = '⤢';
figEl.appendChild(glyph);
var entry = {
type: 'score',
wrapperEl: figEl,
svgEl: svgEl,
groupName: groupName,
caption: captionText
};
focusables.push(entry);
figEl.addEventListener('click', function () {
openOverlay(focusables.indexOf(entry));
});
});
}
/* ============================================================
OVERLAY
============================================================ */
var overlay, overlayGroup, overlayBody, overlayCaption;
var overlayPrev, overlayNext, overlayCounter, overlayClose;
var currentIdx = -1;
function buildOverlay() {
overlay = document.createElement('div');
overlay.id = 'gallery-overlay';
overlay.setAttribute('hidden', '');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
/* All children are absolute or flex-centered — no panel wrapper */
overlayClose = document.createElement('button');
overlayClose.id = 'gallery-overlay-close';
overlayClose.setAttribute('aria-label', 'Close');
overlayClose.textContent = '✕';
overlay.appendChild(overlayClose);
overlayGroup = document.createElement('div');
overlayGroup.id = 'gallery-overlay-name';
overlay.appendChild(overlayGroup);
overlayBody = document.createElement('div');
overlayBody.id = 'gallery-overlay-body';
overlay.appendChild(overlayBody);
overlayCaption = document.createElement('div');
overlayCaption.id = 'gallery-overlay-caption';
overlay.appendChild(overlayCaption);
overlayCounter = document.createElement('div');
overlayCounter.id = 'gallery-overlay-counter';
overlay.appendChild(overlayCounter);
overlayPrev = document.createElement('button');
overlayPrev.id = 'gallery-overlay-prev';
overlayPrev.className = 'gallery-nav-btn';
overlayPrev.setAttribute('aria-label', 'Previous equation');
overlayPrev.textContent = '←';
overlay.appendChild(overlayPrev);
overlayNext = document.createElement('button');
overlayNext.id = 'gallery-overlay-next';
overlayNext.className = 'gallery-nav-btn';
overlayNext.setAttribute('aria-label', 'Next equation');
overlayNext.textContent = '→';
overlay.appendChild(overlayNext);
document.body.appendChild(overlay);
/* Clicking the dark surround (not the content stage) closes */
overlay.addEventListener('click', function (e) {
if (e.target === overlay) closeOverlay();
});
overlayClose.addEventListener('click', closeOverlay);
overlayPrev.addEventListener('click', function (e) { e.stopPropagation(); navigate(-1); });
overlayNext.addEventListener('click', function (e) { e.stopPropagation(); navigate(+1); });
document.addEventListener('keydown', function (e) {
if (overlay.hasAttribute('hidden')) return;
if (e.key === 'Escape') { closeOverlay(); return; }
if (e.key === 'ArrowLeft') { navigate(-1); return; }
if (e.key === 'ArrowRight') { navigate(+1); return; }
});
}
function openOverlay(idx) {
currentIdx = idx;
/* Show before rendering measurements (scrollWidth etc.) return 0
on elements inside display:none, so the fit loop needs the overlay
to be visible before it runs. The browser will not repaint until
JS yields, so the user sees only the final fitted size. */
overlay.removeAttribute('hidden');
renderOverlay();
overlayClose.focus();
}
function closeOverlay() {
var returnTo = currentIdx >= 0 ? focusables[currentIdx].wrapperEl : null;
overlay.setAttribute('hidden', '');
currentIdx = -1;
if (returnTo) returnTo.focus();
}
function navigate(delta) {
var next = currentIdx + delta;
if (next < 0 || next >= focusables.length) return;
currentIdx = next;
renderOverlay();
focusables[currentIdx].wrapperEl.scrollIntoView({
behavior: 'instant',
block: 'center'
});
}
function renderOverlay() {
var entry = focusables[currentIdx];
overlayGroup.textContent = entry.groupName;
if (entry.type === 'score') {
overlayBody.className = 'is-score';
overlayBody.style.overflow = 'hidden';
overlayBody.innerHTML = '';
overlayBody.appendChild(entry.svgEl.cloneNode(true));
} else {
overlayBody.className = '';
overlayBody.style.overflow = 'hidden';
/* Re-render from source, or clone rendered HTML */
if (entry.source && typeof katex !== 'undefined') {
try {
overlayBody.innerHTML = katex.renderToString(entry.source, {
displayMode: true,
throwOnError: false
});
} catch (e) {
overlayBody.innerHTML = entry.katexEl.outerHTML;
}
} else {
overlayBody.innerHTML = entry.katexEl.outerHTML;
}
/* Fit font size set directly on .katex-display to avoid cascade.
The overlay must already be visible (not display:none) for
scrollWidth/clientWidth to return real values. */
var katexEl = overlayBody.querySelector('.katex-display');
if (katexEl) {
var maxSize = 1.4;
var minSize = 0.4;
var step = 0.05;
var fitted = false;
for (var fs = maxSize; fs >= minSize; fs -= step) {
katexEl.style.fontSize = fs + 'em';
if (overlayBody.scrollWidth <= overlayBody.clientWidth &&
overlayBody.scrollHeight <= overlayBody.clientHeight) {
fitted = true;
break;
}
}
if (!fitted) overlayBody.style.overflow = 'auto'; /* absolute last resort */
}
}
overlayCaption.textContent = entry.caption || '';
overlayCaption.hidden = !entry.caption;
var total = focusables.length;
overlayCounter.textContent = (currentIdx + 1) + ' / ' + total;
overlayPrev.disabled = (currentIdx === 0);
overlayNext.disabled = (currentIdx === total - 1);
}
/* ============================================================
TOC INTEGRATION
============================================================ */
function patchTOC() {
var toc = document.getElementById('toc');
if (!toc || exhibits.length === 0) return;
var headings = Array.from(
document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]')
);
var headingMap = new Map();
exhibits.forEach(function (entry) {
var nearest = null;
headings.forEach(function (h) {
if (h.compareDocumentPosition(entry.el) & Node.DOCUMENT_POSITION_FOLLOWING) {
nearest = h;
}
});
if (nearest) {
if (!headingMap.has(nearest.id)) headingMap.set(nearest.id, []);
headingMap.get(nearest.id).push(entry);
}
});
toc.querySelectorAll('a[data-target]').forEach(function (link) {
var list = headingMap.get(link.dataset.target);
if (!list || list.length === 0) return;
var row = document.createElement('div');
row.className = 'toc-exhibits-inline';
list.forEach(function (entry) {
var a = document.createElement('a');
a.href = '#' + entry.id;
var badge = document.createElement('span');
badge.className = 'toc-exhibit-type-badge';
badge.textContent = entry.type;
a.appendChild(badge);
a.appendChild(document.createTextNode(entry.name));
row.appendChild(a);
});
var li = link.closest('li');
if (li) li.appendChild(row);
});
/* Contained Herein */
var tocNav = toc.querySelector('.toc-nav');
if (!tocNav) return;
var contained = document.createElement('div');
contained.className = 'toc-contained';
var toggleBtn = document.createElement('button');
toggleBtn.className = 'toc-contained-toggle';
toggleBtn.setAttribute('aria-expanded', 'false');
var arrow = document.createElement('span');
arrow.className = 'toc-contained-arrow';
arrow.textContent = '▶';
toggleBtn.appendChild(arrow);
toggleBtn.appendChild(document.createTextNode(' Contained Herein'));
contained.appendChild(toggleBtn);
var ul = document.createElement('ul');
ul.className = 'toc-contained-list';
exhibits.forEach(function (entry) {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#' + entry.id;
var badge = document.createElement('span');
badge.className = 'toc-exhibit-type-badge';
badge.textContent = entry.type;
a.appendChild(badge);
a.appendChild(document.createTextNode(entry.name));
li.appendChild(a);
ul.appendChild(li);
});
contained.appendChild(ul);
tocNav.appendChild(contained);
toggleBtn.addEventListener('click', function () {
var open = contained.classList.toggle('is-open');
toggleBtn.setAttribute('aria-expanded', String(open));
});
}
/* ============================================================
ANNOTATIONS (collapsible .annotation--collapsible boxes)
============================================================ */
function initAnnotations() {
document.querySelectorAll('.annotation--collapsible').forEach(function (el) {
var toggle = el.querySelector('.annotation-toggle');
var body = el.querySelector('.annotation-body');
if (!toggle || !body) return;
function setOpen(open) {
el.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.textContent = open ? '▾ collapse' : '▸ expand';
body.style.maxHeight = open ? body.scrollHeight + 'px' : '0';
}
setOpen(false);
toggle.addEventListener('click', function () {
setOpen(!el.classList.contains('is-open'));
});
});
}
/* ============================================================
INIT
============================================================ */
document.addEventListener('DOMContentLoaded', function () {
var markdownBody = document.getElementById('markdownBody');
discoverExhibits();
exhibits.forEach(function (entry) {
if (entry.type === 'proof') initProofExhibit(entry);
});
if (markdownBody) {
discoverFocusableMath(markdownBody);
discoverFocusableScores(markdownBody);
if (focusables.length > 0) buildOverlay();
}
if (exhibits.length > 0) patchTOC();
initAnnotations();
});
})();

112
static/js/lightbox.js Normal file
View File

@ -0,0 +1,112 @@
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
// ----------------------------------------------------------------
// Build the overlay DOM
// ----------------------------------------------------------------
var overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Image lightbox');
var img = document.createElement('img');
img.className = 'lightbox-img';
img.alt = '';
var caption = document.createElement('p');
caption.className = 'lightbox-caption';
var closeBtn = document.createElement('button');
closeBtn.className = 'lightbox-close';
closeBtn.setAttribute('aria-label', 'Close lightbox');
closeBtn.textContent = '×';
overlay.appendChild(closeBtn);
overlay.appendChild(img);
overlay.appendChild(caption);
document.body.appendChild(overlay);
// ----------------------------------------------------------------
// Open / close helpers
// ----------------------------------------------------------------
var triggerEl = null;
function open(src, alt, captionText, trigger) {
triggerEl = trigger || null;
img.src = src;
img.alt = alt || '';
caption.textContent = captionText || '';
caption.hidden = !captionText;
overlay.classList.add('is-open');
document.documentElement.style.overflow = 'hidden';
closeBtn.focus();
}
function close() {
overlay.classList.remove('is-open');
document.documentElement.style.overflow = '';
if (triggerEl) {
triggerEl.focus();
triggerEl = null;
}
// Clear src after transition to stop background loading
var delay = parseFloat(
getComputedStyle(overlay).transitionDuration || '0'
) * 1000;
setTimeout(function () {
if (!overlay.classList.contains('is-open')) {
img.src = '';
}
}, delay + 50);
}
// ----------------------------------------------------------------
// Wire up lightbox-marked images
// ----------------------------------------------------------------
var images = document.querySelectorAll('img[data-lightbox]');
images.forEach(function (el) {
el.addEventListener('click', function () {
// Look for a sibling figcaption in the parent figure
var figcaptionText = '';
var parent = el.parentElement;
if (parent) {
var figcaption = parent.querySelector('figcaption');
if (figcaption) {
figcaptionText = figcaption.textContent.trim();
}
}
open(el.src, el.alt, figcaptionText, el);
});
});
// ----------------------------------------------------------------
// Close handlers
// ----------------------------------------------------------------
// Close button
closeBtn.addEventListener('click', close);
// Click on overlay background (not the image itself)
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
close();
}
});
// Escape key
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) {
close();
}
});
});
}());

37
static/js/nav.js Normal file
View File

@ -0,0 +1,37 @@
/* nav.js Portal row expand/collapse with localStorage persistence.
Loaded with defer.
*/
(function () {
const STORAGE_KEY = 'portals-open';
document.addEventListener('DOMContentLoaded', function () {
// Return-to-top button
var totop = document.querySelector('.footer-totop');
if (totop) {
totop.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
const portals = document.querySelector('.nav-portals');
const toggle = document.querySelector('.nav-portal-toggle');
if (!portals || !toggle) return;
function setOpen(open) {
portals.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
// Rotate arrow indicator if present.
const arrow = toggle.querySelector('.nav-portal-arrow');
if (arrow) arrow.textContent = open ? '▲' : '▼';
localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
}
// Restore persisted state; default is collapsed.
const stored = localStorage.getItem(STORAGE_KEY);
setOpen(stored === '1');
toggle.addEventListener('click', function () {
setOpen(!portals.classList.contains('is-open'));
});
});
})();

553
static/js/popups.js Normal file
View File

@ -0,0 +1,553 @@
/* popups.js Hover preview popups.
Content providers (in dispatch priority order):
1. Local annotations /data/annotations.json (any URL, author-defined)
2. Citations DOM lookup, cite-link[href^="#ref-"]
3. Internal pages same-origin fetch, title + authors + tags + abstract + stats
4. Wikipedia MediaWiki action API, full lead section
5. arXiv export.arxiv.org Atom API
6. DOI / CrossRef api.crossref.org, title/authors/abstract
7. GitHub api.github.com, repo description + stars
8. Open Library openlibrary.org JSON API, book description
9. bioRxiv / medRxiv api.biorxiv.org, abstract
10. YouTube oEmbed, title + channel (no key required)
11. Internet Archive archive.org/metadata, title + description
12. PubMed NCBI esummary, title + authors + journal
Production nginx CSP must add:
connect-src https://en.wikipedia.org https://export.arxiv.org
https://api.crossref.org https://api.github.com
https://openlibrary.org https://api.biorxiv.org
https://www.youtube.com https://archive.org
https://eutils.ncbi.nlm.nih.gov
*/
(function () {
'use strict';
var SHOW_DELAY = 250;
var HIDE_DELAY = 150;
var popup = null;
var showTimer = null;
var hideTimer = null;
var activeTarget = null;
var cache = Object.create(null); /* url → html; only successful results stored */
var annotations = null; /* null = not yet loaded */
/* ------------------------------------------------------------------
Init load annotations first, then bind all targets
------------------------------------------------------------------ */
function init() {
popup = document.createElement('div');
popup.className = 'link-popup';
popup.setAttribute('aria-live', 'polite');
popup.setAttribute('aria-hidden', 'true');
document.body.appendChild(popup);
popup.addEventListener('mouseenter', cancelHide);
popup.addEventListener('mouseleave', scheduleHide);
loadAnnotations().then(function () {
bindTargets(document.body);
});
}
function bindTargets(root) {
/* Citation markers */
root.querySelectorAll('a.cite-link[href^="#ref-"]').forEach(function (el) {
bind(el, citationContent);
});
/* Internal links absolute (/foo) and relative (../../foo) same-origin hrefs.
relativizeUrls in Hakyll makes index-page links relative, so we must match both. */
root.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]').forEach(function (el) {
/* Author links in .meta-authors and backlink source links always get popups */
var inAuthors = el.closest('.meta-authors');
var isBacklink = el.classList.contains('backlink-source');
if (!inAuthors && !isBacklink) {
if (el.closest('nav, #toc, footer, .page-meta-footer, .metadata')) return;
if (el.classList.contains('cite-link') || el.classList.contains('meta-tag')) return;
}
bind(el, internalContent);
});
/* External links — single dispatcher handles all providers */
root.querySelectorAll('a[href^="http"]').forEach(function (el) {
if (el.closest('nav, #toc, footer, .page-meta-footer')) return;
var provider = getProvider(el.getAttribute('href') || '');
if (provider) bind(el, provider);
});
}
/* Returns the appropriate provider function for a given URL, or null. */
function getProvider(href) {
if (!href) return null;
/* Local annotation takes priority over everything */
if (annotations && annotations[href]) return annotationContent;
if (/wikipedia\.org\/wiki\//.test(href)) return wikipediaContent;
if (/arxiv\.org\/(?:abs|pdf)\/\d{4}\.\d{4,5}/.test(href)) return arxivContent;
if (/(?:dx\.)?doi\.org\/10\./.test(href)) return doiContent;
if (/github\.com\/[^/]+\/[^/?#]+/.test(href)) return githubContent;
if (/openlibrary\.org\/(?:works|books)\//.test(href)) return openlibraryContent;
if (/(?:bio|med)rxiv\.org\/content\/10\./.test(href)) return biorxivContent;
if (/(?:youtube\.com\/watch|youtu\.be\/)/.test(href)) return youtubeContent;
if (/archive\.org\/details\//.test(href)) return archiveContent;
if (/pubmed\.ncbi\.nlm\.nih\.gov\/\d/.test(href)) return pubmedContent;
return null;
}
function bind(el, provider) {
el.addEventListener('mouseenter', function () { scheduleShow(el, provider); });
el.addEventListener('mouseleave', scheduleHide);
el.addEventListener('focus', function () { scheduleShow(el, provider); });
el.addEventListener('blur', scheduleHide);
}
/* ------------------------------------------------------------------
Lifecycle
------------------------------------------------------------------ */
function scheduleShow(target, provider) {
cancelHide();
clearTimeout(showTimer);
activeTarget = target;
showTimer = setTimeout(function () {
provider(target).then(function (html) {
if (!html || activeTarget !== target) return;
popup.innerHTML = html;
positionPopup(target);
popup.classList.add('is-visible');
popup.setAttribute('aria-hidden', 'false');
}).catch(function () { /* silently fail */ });
}, SHOW_DELAY);
}
function scheduleHide() {
clearTimeout(showTimer);
hideTimer = setTimeout(function () {
popup.classList.remove('is-visible');
popup.setAttribute('aria-hidden', 'true');
activeTarget = null;
}, HIDE_DELAY);
}
function cancelHide() { clearTimeout(hideTimer); }
/* ------------------------------------------------------------------
Positioning centres below target, flips above if clipped
------------------------------------------------------------------ */
function positionPopup(target) {
var rect = target.getBoundingClientRect();
var pw = popup.offsetWidth;
var ph = popup.offsetHeight;
var vw = window.innerWidth;
var vh = window.innerHeight;
var sy = window.scrollY;
var sx = window.scrollX;
var GAP = 10;
var left = rect.left + sx + rect.width / 2 - pw / 2;
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
var top = (rect.bottom + GAP + ph <= vh)
? rect.bottom + sy + GAP
: rect.top + sy - ph - GAP;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
}
/* ------------------------------------------------------------------
Content providers
------------------------------------------------------------------ */
/* 0. Local annotations — synchronous map lookup after eager load */
function loadAnnotations() {
if (annotations !== null) return Promise.resolve(annotations);
return fetch('/data/annotations.json', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : {}; })
.then(function (data) { annotations = data; return data; })
.catch(function () { annotations = {}; return {}; });
}
function annotationContent(target) {
var href = target.getAttribute('href');
var ann = href && annotations && annotations[href];
if (!ann) return Promise.resolve(null);
return Promise.resolve(
'<div class="popup-annotation">'
+ (ann.title ? '<div class="popup-title">' + esc(ann.title) + '</div>' : '')
+ (ann.annotation ? '<div class="popup-abstract">' + esc(ann.annotation) + '</div>' : '')
+ '</div>'
);
}
/* 1. Citations — synchronous DOM lookup */
function citationContent(target) {
return new Promise(function (resolve) {
var id = (target.getAttribute('href') || '').slice(1);
var entry = document.getElementById(id);
resolve(entry
? '<div class="popup-citation">' + entry.innerHTML + '</div>'
: null);
});
}
/* 2. Internal pages — same-origin fetch, rich preview */
function internalContent(target) {
/* Resolve relative hrefs (../../foo) to canonical path (/foo) for fetch + cache. */
var raw = target.getAttribute('href');
if (!raw) return Promise.resolve(null);
var href = new URL(raw, window.location.href).pathname;
if (cache[href]) return Promise.resolve(cache[href]);
return fetch(href, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (text) {
if (!text) return null;
var doc = new DOMParser().parseFromString(text, 'text/html');
var titleEl = doc.querySelector('h1.page-title');
if (!titleEl) return null;
/* Abstract */
var abstrEl = doc.querySelector('.meta-description');
var abstract = abstrEl ? abstrEl.textContent.trim() : '';
if (abstract.length > 300)
abstract = abstract.slice(0, 300).replace(/\s\S+$/, '') + '\u2026';
/* Authors */
var authorEls = doc.querySelectorAll('.meta-authors a');
var authors = Array.from(authorEls).map(function (a) {
return a.textContent.trim();
}).join(', ');
/* Tags */
var tagEls = doc.querySelectorAll('.meta-tags a');
var tags = Array.from(tagEls).map(function (a) {
return a.textContent.trim();
}).join(' · ');
/* Reading stats — word count and reading time from meta block */
var wcEl = doc.querySelector('.meta-word-count');
var rtEl = doc.querySelector('.meta-reading-time');
var stats = [
wcEl ? wcEl.textContent.trim() : '',
rtEl ? rtEl.textContent.trim() : ''
].filter(Boolean).join(' · ');
return store(href,
'<div class="popup-internal">'
+ (tags ? '<div class="popup-source">' + esc(tags) + '</div>' : '')
+ '<div class="popup-title">' + esc(titleEl.textContent.trim()) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ (stats ? '<div class="popup-meta">' + esc(stats) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 3. Wikipedia — MediaWiki action API, full lead section, text-only */
function wikipediaContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/wikipedia\.org\/wiki\/([^#?]+)/);
if (!m) return Promise.resolve(null);
var apiUrl = 'https://en.wikipedia.org/w/api.php'
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
+ '&titles=' + encodeURIComponent(decodeURIComponent(m[1])) + '&origin=*';
return fetch(apiUrl)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var pages = data && data.query && data.query.pages;
if (!pages) return null;
var page = Object.values(pages)[0];
if (!page || page.missing !== undefined) return null;
var doc = new DOMParser().parseFromString(page.extract || '', 'text/html');
/* Remove math elements before extracting text their DOM includes both
display characters and raw LaTeX source, producing garbled output. */
doc.querySelectorAll('.mwe-math-element').forEach(function (el) {
el.parentNode.removeChild(el);
});
var text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return null;
if (text.length > 600) text = text.slice(0, 600).replace(/\s\S+$/, '') + '\u2026';
return store(href,
'<div class="popup-wikipedia">'
+ '<div class="popup-source">Wikipedia</div>'
+ '<div class="popup-title">' + esc(page.title) + '</div>'
+ '<div class="popup-extract">' + esc(text) + '</div>'
+ '</div>');
})
.catch(function () { return null; });
}
/* 4. arXiv — Atom API, title + authors + abstract */
function arxivContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5}(?:v\d+)?)/);
if (!m) return Promise.resolve(null);
var id = m[1].replace(/v\d+$/, '');
return fetch('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (xml) {
if (!xml) return null;
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var titleEl = doc.querySelector('entry > title');
var summaryEl = doc.querySelector('entry > summary');
if (!titleEl || !summaryEl) return null;
var title = titleEl.textContent.trim().replace(/\s+/g, ' ');
var summary = summaryEl.textContent.trim().replace(/\s+/g, ' ');
if (summary.length > 500) summary = summary.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var authors = Array.from(doc.querySelectorAll('entry > author > name'))
.map(function (el) { return el.textContent.trim(); });
var authorStr = authors.slice(0, 3).join(', ');
if (authors.length > 3) authorStr += ' et\u00a0al.';
return store(href,
'<div class="popup-arxiv">'
+ '<div class="popup-source">arXiv</div>'
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (authorStr ? '<div class="popup-authors">' + esc(authorStr) + '</div>' : '')
+ '<div class="popup-abstract">' + esc(summary) + '</div>'
+ '</div>');
})
.catch(function () { return null; });
}
/* 5. DOI / CrossRef — title, authors, journal, year, abstract */
function doiContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/);
if (!m) return Promise.resolve(null);
return fetch('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var msg = data && data.message;
if (!msg) return null;
var title = (msg.title && msg.title[0]) || '';
if (!title) return null;
var authors = (msg.author || []).slice(0, 3)
.map(function (a) { return (a.given ? a.given + ' ' : '') + (a.family || ''); })
.join(', ');
if ((msg.author || []).length > 3) authors += ' et\u00a0al.';
var journal = (msg['container-title'] && msg['container-title'][0]) || '';
var parts = msg.issued && msg.issued['date-parts'];
var year = parts && parts[0] && parts[0][0];
var abstract = (msg.abstract || '').replace(/<[^>]+>/g, '').trim();
if (abstract.length > 500) abstract = abstract.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var meta = [journal, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-doi">'
+ '<div class="popup-source">CrossRef</div>'
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 6. GitHub — repo description, language, stars */
function githubContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/github\.com\/([^/]+)\/([^/?#]+)/);
if (!m) return Promise.resolve(null);
return fetch('https://api.github.com/repos/' + m[1] + '/' + m[2],
{ headers: { 'Accept': 'application/vnd.github.v3+json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.full_name) return null;
var meta = [data.language, data.stargazers_count != null ? '\u2605\u00a0' + data.stargazers_count : null]
.filter(Boolean).join(' \u00b7 ');
return store(href,
'<div class="popup-github">'
+ '<div class="popup-source">GitHub</div>'
+ '<div class="popup-title">' + esc(data.full_name) + '</div>'
+ (data.description ? '<div class="popup-abstract">' + esc(data.description) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 7. Open Library — book title + description */
function openlibraryContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var base = href.replace(/[?#].*$/, '');
var apiUrl = base + '.json';
return fetch(apiUrl)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.title) return null;
var desc = data.description;
if (desc && typeof desc === 'object') desc = desc.value;
desc = (desc || '').replace(/\s+/g, ' ').trim();
if (desc.length > 300) desc = desc.slice(0, 300).replace(/\s\S+$/, '') + '\u2026';
return store(href,
'<div class="popup-openlibrary">'
+ '<div class="popup-source">Open Library</div>'
+ '<div class="popup-title">' + esc(data.title) + '</div>'
+ (desc ? '<div class="popup-abstract">' + esc(desc) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 8. bioRxiv / medRxiv — abstract via biorxiv content server API */
function biorxivContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/(?:bio|med)rxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/);
if (!m) return Promise.resolve(null);
var doi = m[1].replace(/v\d+$/, '');
var server = /medrxiv/.test(href) ? 'medrxiv' : 'biorxiv';
var label = server === 'medrxiv' ? 'medRxiv' : 'bioRxiv';
return fetch('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var paper = data && data.collection && data.collection[0];
if (!paper || !paper.title) return null;
var abstract = (paper.abstract || '').replace(/\s+/g, ' ').trim();
if (abstract.length > 500) abstract = abstract.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var authorStr = '';
if (paper.authors) {
var list = paper.authors.split(';').map(function (s) { return s.trim(); }).filter(Boolean);
authorStr = list.slice(0, 3).join(', ');
if (list.length > 3) authorStr += ' et\u00a0al.';
}
return store(href,
'<div class="popup-biorxiv">'
+ '<div class="popup-source">' + esc(label) + '</div>'
+ '<div class="popup-title">' + esc(paper.title) + '</div>'
+ (authorStr ? '<div class="popup-authors">' + esc(authorStr) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 9. YouTube — oEmbed, title + channel name */
function youtubeContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
return fetch('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || !data.title) return null;
return store(href,
'<div class="popup-youtube">'
+ '<div class="popup-source">YouTube</div>'
+ '<div class="popup-title">' + esc(data.title) + '</div>'
+ (data.author_name ? '<div class="popup-authors">' + esc(data.author_name) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 10. Internet Archive — title, creator, description */
function archiveContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/archive\.org\/details\/([^/?#]+)/);
if (!m) return Promise.resolve(null);
return fetch('https://archive.org/metadata/' + encodeURIComponent(m[1]))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var meta = data && data.metadata;
if (!meta) return null;
var first = function (v) { return Array.isArray(v) ? v[0] : (v || ''); };
var title = first(meta.title);
var creator = first(meta.creator);
var year = first(meta.year);
var desc = first(meta.description);
if (!title) return null;
desc = desc.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (desc.length > 280) desc = desc.slice(0, 280).replace(/\s\S+$/, '') + '\u2026';
var byline = [creator, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-archive">'
+ '<div class="popup-source">Internet Archive</div>'
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (byline ? '<div class="popup-authors">' + esc(byline) + '</div>' : '')
+ (desc ? '<div class="popup-abstract">' + esc(desc) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 11. PubMed — NCBI esummary, title + authors + journal */
function pubmedContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/);
if (!m) return Promise.resolve(null);
var pmid = m[1];
var apiUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
+ '?db=pubmed&id=' + pmid + '&retmode=json';
return fetch(apiUrl)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var paper = data && data.result && data.result[pmid];
if (!paper || !paper.title) return null;
var authors = (paper.authors || []).slice(0, 3)
.map(function (a) { return a.name; }).join(', ');
if ((paper.authors || []).length > 3) authors += ' et\u00a0al.';
var journal = paper.fulljournalname || paper.source || '';
var year = (paper.pubdate || '').slice(0, 4);
var meta = [journal, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-pubmed">'
+ '<div class="popup-source">PubMed</div>'
+ '<div class="popup-title">' + esc(paper.title) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* ------------------------------------------------------------------
Helpers
------------------------------------------------------------------ */
function store(href, html) {
cache[href] = html;
return html;
}
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
document.addEventListener('DOMContentLoaded', init);
}());

48
static/js/prism.min.js vendored Normal file

File diff suppressed because one or more lines are too long

21
static/js/random.js Normal file
View File

@ -0,0 +1,21 @@
/* random.js "Random Page" button for the homepage.
Fetches /random-pages.json (essays + blog posts, generated at build time)
and navigates to a uniformly random entry on click. */
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('random-page-btn');
if (!btn) return;
btn.addEventListener('click', function () {
fetch('/random-pages.json')
.then(function (r) { return r.json(); })
.then(function (pages) {
if (!pages.length) return;
window.location.href = pages[Math.floor(Math.random() * pages.length)];
})
.catch(function () {});
});
});
}());

19
static/js/reading.js Normal file
View File

@ -0,0 +1,19 @@
/* reading.js — Scroll-progress bar for reading (fiction, poetry) mode. */
(function () {
'use strict';
function update() {
var bar = document.getElementById('reading-progress');
if (!bar) return;
var scrollTop = window.scrollY || document.documentElement.scrollTop;
var docHeight = document.documentElement.scrollHeight - window.innerHeight;
var pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
bar.style.width = Math.min(pct, 100).toFixed(2) + '%';
}
document.addEventListener('DOMContentLoaded', function () {
update();
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update, { passive: true });
});
}());

135
static/js/score-reader.js Normal file
View File

@ -0,0 +1,135 @@
/* score-reader.js Page-turn navigation for the full score reader.
Configuration is read from #score-reader-stage data attributes:
data-page-count total number of SVG pages
data-pages comma-separated list of absolute page image URLs
*/
(function () {
'use strict';
var stage = document.getElementById('score-reader-stage');
var img = document.getElementById('score-page-img');
var counter = document.getElementById('score-page-counter');
var prevBtn = document.getElementById('score-prev');
var nextBtn = document.getElementById('score-next');
if (!stage || !img || !counter || !prevBtn || !nextBtn) return;
var rawPages = stage.dataset.pages || '';
var pages = rawPages.split(',').filter(function (p) { return p.length > 0; });
var pageCount = pages.length;
var currentPage = 1;
if (pageCount === 0) return; /* nothing to display */
/* Read ?p= from the query string for deep linking. */
var qs = new URLSearchParams(window.location.search);
var initPage = parseInt(qs.get('p'), 10);
if (!isNaN(initPage) && initPage >= 1 && initPage <= pageCount) {
currentPage = initPage;
}
/* ------------------------------------------------------------------
Navigation
------------------------------------------------------------------ */
function navigate(page) {
if (page < 1 || page > pageCount) return;
currentPage = page;
img.src = pages[currentPage - 1];
img.alt = 'Score page ' + currentPage;
counter.textContent = 'p. ' + currentPage + ' / ' + pageCount;
prevBtn.disabled = (currentPage === 1);
nextBtn.disabled = (currentPage === pageCount);
updateActiveMovement();
/* Replace URL so the page is bookmarkable at the current position.
The back button still returns to the landing page. */
history.replaceState(null, '', '?p=' + currentPage);
/* Preload the adjacent pages for smooth turning. */
if (currentPage > 1) new Image().src = pages[currentPage - 2];
if (currentPage < pageCount) new Image().src = pages[currentPage];
}
/* ------------------------------------------------------------------
Movement buttons highlight the movement that contains currentPage
------------------------------------------------------------------ */
var mvtButtons = Array.from(document.querySelectorAll('.score-reader-mvt'));
function updateActiveMovement() {
/* Find the last movement whose start page ≤ currentPage. */
var active = null;
mvtButtons.forEach(function (btn) {
var p = parseInt(btn.dataset.page, 10);
if (!isNaN(p) && p <= currentPage) active = btn;
});
mvtButtons.forEach(function (btn) {
btn.classList.toggle('is-active', btn === active);
});
}
mvtButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
var p = parseInt(btn.dataset.page, 10);
if (!isNaN(p)) navigate(p);
});
});
/* ------------------------------------------------------------------
Prev / next buttons
------------------------------------------------------------------ */
prevBtn.addEventListener('click', function () { navigate(currentPage - 1); });
nextBtn.addEventListener('click', function () { navigate(currentPage + 1); });
/* ------------------------------------------------------------------
Keyboard
------------------------------------------------------------------ */
document.addEventListener('keydown', function (e) {
/* Ignore keypresses while focus is in the settings panel. */
var panel = document.querySelector('.settings-panel');
if (panel && panel.classList.contains('is-open')) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
navigate(currentPage + 1);
e.preventDefault();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
navigate(currentPage - 1);
e.preventDefault();
} else if (e.key === 'Escape') {
history.back();
}
});
/* ------------------------------------------------------------------
Touch swipe left/right swipe to turn pages
Threshold: 50px horizontal, <30px vertical drift
------------------------------------------------------------------ */
var touchStartX = 0;
var touchStartY = 0;
stage.addEventListener('touchstart', function (e) {
touchStartX = e.changedTouches[0].clientX;
touchStartY = e.changedTouches[0].clientY;
}, { passive: true });
stage.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].clientX - touchStartX;
var dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < 50 || Math.abs(dy) > 30) return;
if (dx < 0) navigate(currentPage + 1);
else navigate(currentPage - 1);
}, { passive: true });
/* ------------------------------------------------------------------
Init load the starting page
------------------------------------------------------------------ */
navigate(currentPage);
}());

22
static/js/search.js Normal file
View File

@ -0,0 +1,22 @@
/* search.js Pagefind UI initialisation for /search.html.
Loaded only on pages with search: true in frontmatter.
Pre-fills the search box from the ?q= query parameter so that
the selection popup's "Here" button lands ready to go. */
(function () {
'use strict';
window.addEventListener('DOMContentLoaded', function () {
var ui = new PagefindUI({
element: '#search',
showImages: false,
excerptLength: 30,
});
/* Pre-fill from URL parameter and trigger the search */
var params = new URLSearchParams(window.location.search);
var q = params.get('q');
if (q) {
ui.triggerSearch(q);
}
});
}());

View File

@ -0,0 +1,369 @@
/* selection-popup.js Custom text-selection toolbar.
Appears automatically after a short delay on any non-empty selection.
Adapts its buttons based on the context of the selection:
code (known lang) Copy · [MDN / Hoogle / Docs]
code (unknown) Copy
math Copy · nLab · OEIS · Wolfram
prose (multi-word) Annotate* · BibTeX · Copy · DuckDuckGo · Here · Wikipedia
prose (one word) Annotate* · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia
(* = placeholder, not yet wired)
*/
(function () {
'use strict';
var SHOW_DELAY = 450;
var popup = null;
var showTimer = null;
/* ------------------------------------------------------------------
Documentation providers keyed by Prism language identifier.
Label: short button text. url: base search URL (query appended).
------------------------------------------------------------------ */
var DOC_PROVIDERS = {
'javascript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'typescript': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'jsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'tsx': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'html': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'css': { label: 'MDN', url: 'https://developer.mozilla.org/en-US/search?q=' },
'haskell': { label: 'Hoogle', url: 'https://hoogle.haskell.org/?hoogle=' },
'python': { label: 'Docs', url: 'https://docs.python.org/3/search.html?q=' },
'rust': { label: 'Docs', url: 'https://doc.rust-lang.org/std/?search=' },
'c': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' },
'cpp': { label: 'Docs', url: 'https://en.cppreference.com/mwiki/index.php?search=' },
'java': { label: 'Docs', url: 'https://docs.oracle.com/en/java/javase/21/docs/api/search.html?q=' },
'go': { label: 'Docs', url: 'https://pkg.go.dev/search?q=' },
'ruby': { label: 'Docs', url: 'https://ruby-doc.org/core/search?q=' },
'r': { label: 'Docs', url: 'https://www.rdocumentation.org/search?q=' },
'lua': { label: 'Docs', url: 'https://www.lua.org/search.html?q=' },
'scala': { label: 'Docs', url: 'https://docs.scala-lang.org/search/?q=' },
};
/* ------------------------------------------------------------------
Init
------------------------------------------------------------------ */
function init() {
popup = document.createElement('div');
popup.className = 'selection-popup';
popup.setAttribute('role', 'toolbar');
popup.setAttribute('aria-label', 'Text selection options');
document.body.appendChild(popup);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('keyup', onKeyUp);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKeyDown);
window.addEventListener('scroll', hide, { passive: true });
}
/* ------------------------------------------------------------------
Event handlers
------------------------------------------------------------------ */
function onMouseUp(e) {
if (popup.contains(e.target)) return;
clearTimeout(showTimer);
showTimer = setTimeout(tryShow, SHOW_DELAY);
}
function onKeyUp(e) {
if (e.shiftKey || e.key === 'End' || e.key === 'Home') {
clearTimeout(showTimer);
showTimer = setTimeout(tryShow, SHOW_DELAY);
}
}
function onMouseDown(e) {
if (popup.contains(e.target)) return;
hide();
}
function onKeyDown(e) {
if (e.key === 'Escape') hide();
}
/* ------------------------------------------------------------------
Context detection
------------------------------------------------------------------ */
function getContext(sel) {
if (!sel.rangeCount) return 'prose';
var range = sel.getRangeAt(0);
var node = range.commonAncestorContainer;
var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
if (!el) return 'prose';
if (el.closest('pre, code, .sourceCode, .highlight')) return 'code';
if (el.closest('.math, .katex, .katex-html, .katex-display')) return 'math';
/* Fallback: commonAncestorContainer can land at <p> when selection
starts/ends just outside a KaTeX span check via intersectsNode. */
var mathEls = document.querySelectorAll('.math');
for (var i = 0; i < mathEls.length; i++) {
if (range.intersectsNode(mathEls[i])) return 'math';
}
return 'prose';
}
/* Returns the Prism language identifier for the code block containing
the current selection, or null if the language is not annotated. */
function getCodeLanguage(sel) {
if (!sel.rangeCount) return null;
var node = sel.getRangeAt(0).commonAncestorContainer;
var el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
if (!el) return null;
/* Prism puts language-* on the <code> element; our Code.hs filter
ensures the class is always present when a language is specified. */
var code = el.closest('code[class*="language-"]');
if (!code) return null;
var m = code.className.match(/language-(\w+)/);
return m ? m[1].toLowerCase() : null;
}
/* ------------------------------------------------------------------
Core logic
------------------------------------------------------------------ */
function tryShow() {
var sel = window.getSelection();
var text = sel ? sel.toString().trim() : '';
if (!text || text.length < 2 || !sel.rangeCount) { hide(); return; }
var range = sel.getRangeAt(0);
var rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) { hide(); return; }
var context = getContext(sel);
var oneWord = isSingleWord(text);
var codeLang = (context === 'code') ? getCodeLanguage(sel) : null;
popup.innerHTML = buildHTML(context, oneWord, codeLang);
popup.style.visibility = 'hidden';
popup.classList.add('is-visible');
position(rect);
popup.style.visibility = '';
bindActions(text);
}
function hide() {
clearTimeout(showTimer);
if (popup) popup.classList.remove('is-visible');
}
/* ------------------------------------------------------------------
Positioning centred above selection, flip below if needed
------------------------------------------------------------------ */
function position(rect) {
var pw = popup.offsetWidth;
var ph = popup.offsetHeight;
var GAP = 10;
var sy = window.scrollY;
var sx = window.scrollX;
var vw = window.innerWidth;
var left = rect.left + sx + rect.width / 2 - pw / 2;
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
var top = rect.top + sy - ph - GAP;
if (top < sy + GAP) {
top = rect.bottom + sy + GAP;
popup.classList.add('is-below');
} else {
popup.classList.remove('is-below');
}
popup.style.left = left + 'px';
popup.style.top = top + 'px';
}
/* ------------------------------------------------------------------
Helpers
------------------------------------------------------------------ */
function isSingleWord(text) {
return !/\s/.test(text);
}
/* ------------------------------------------------------------------
HTML builder context-aware button sets
------------------------------------------------------------------ */
function buildHTML(context, oneWord, codeLang) {
if (context === 'code') {
var provider = codeLang ? DOC_PROVIDERS[codeLang] : null;
return btn('copy', 'Copy')
+ (provider ? docsBtn(provider) : '');
}
if (context === 'math') {
/* Alphabetical: Copy · nLab · OEIS · Wolfram */
return btn('copy', 'Copy')
+ btn('nlab', 'nLab')
+ btn('oeis', 'OEIS')
+ btn('wolfram', 'Wolfram');
}
/* Prose — alphabetical: BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */
return btn('cite', 'BibTeX')
+ btn('copy', 'Copy')
+ (oneWord ? btn('define', 'Define') : '')
+ btn('search', 'DuckDuckGo')
+ btn('here', 'Here')
+ btn('wikipedia', 'Wikipedia');
}
function btn(action, label, placeholder) {
var cls = 'selection-popup-btn' + (placeholder ? ' selection-popup-btn--placeholder' : '');
var extra = placeholder ? ' aria-disabled="true" title="Coming soon"' : '';
return '<button class="' + cls + '" data-action="' + action + '"' + extra + '>'
+ label + '</button>';
}
/* Docs button embeds the base URL so dispatch can read it without a lookup. */
function docsBtn(provider) {
return '<button class="selection-popup-btn" data-action="docs"'
+ ' data-docs-url="' + provider.url + '">'
+ provider.label + '</button>';
}
/* ------------------------------------------------------------------
Action bindings
------------------------------------------------------------------ */
function bindActions(text) {
popup.querySelectorAll('[data-action]').forEach(function (el) {
if (el.getAttribute('aria-disabled') === 'true') return;
el.addEventListener('click', function () {
dispatch(el.getAttribute('data-action'), text, el);
hide();
});
});
}
/* ------------------------------------------------------------------
BibTeX/BibLaTeX builder for the Cite action
------------------------------------------------------------------ */
/* Escape LaTeX special characters in a BibTeX field value. */
function escBib(s) {
return String(s)
.replace(/\\/g, '\\textbackslash{}')
.replace(/[#$%&_{}]/g, function (c) { return '\\' + c; })
.replace(/~/g, '\\textasciitilde{}')
.replace(/\^/g, '\\textasciicircum{}');
}
/* "Levi Neuwirth" → "Neuwirth, Levi" */
function toBibAuthor(name) {
var parts = name.trim().split(/\s+/);
if (parts.length < 2) return name;
return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' ');
}
function buildBibTeX(selectedText) {
/* Title — h1.page-title is most reliable; fall back to document.title */
var titleEl = document.querySelector('h1.page-title');
var title = titleEl
? titleEl.textContent.trim()
: document.title.split(' \u2014 ')[0].trim();
/* Author(s) — read from .meta-authors, default to site owner */
var authorEls = document.querySelectorAll('.meta-authors a');
var authors = authorEls.length
? Array.from(authorEls).map(function (a) {
return toBibAuthor(a.textContent.trim());
}).join(' and ')
: 'Neuwirth, Levi';
/* Year scrape from the first version-history entry ("14 March 2026 · Created"),
fall back to current year. */
var year = String(new Date().getFullYear());
var vhEl = document.querySelector('#version-history li');
if (vhEl) {
var ym = vhEl.textContent.match(/\b(\d{4})\b/);
if (ym) year = ym[1];
}
/* Access date */
var now = new Date();
var urldate = now.getFullYear() + '-'
+ String(now.getMonth() + 1).padStart(2, '0') + '-'
+ String(now.getDate()).padStart(2, '0');
/* Citation key: lastname + year + first_content_word_of_title */
var lastName = authors.split(',')[0].toLowerCase().replace(/[^a-z]/g, '');
var stopwords = /^(the|and|for|with|from|that|this|into|about|over)$/i;
var keyWord = title.split(/\s+/).filter(function (w) {
return w.length > 2 && !stopwords.test(w);
})[0] || 'untitled';
keyWord = keyWord.toLowerCase().replace(/[^a-z0-9]/g, '');
var key = lastName + year + keyWord;
return [
'@online{' + key + ',',
' author = {' + escBib(authors) + '},',
' title = {' + escBib(title) + '},',
' year = {' + year + '},',
' url = {' + window.location.href + '},',
' urldate = {' + urldate + '},',
' note = {\\enquote{' + escBib(selectedText) + '}},',
'}',
].join('\n');
}
/* ------------------------------------------------------------------
Action dispatch
------------------------------------------------------------------ */
function dispatch(action, text, el) {
var q = encodeURIComponent(text);
if (action === 'search') {
window.open('https://duckduckgo.com/?q=' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'copy') {
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(function () {});
} else if (action === 'docs') {
var base = el.getAttribute('data-docs-url');
if (base) window.open(base + q, '_blank', 'noopener,noreferrer');
} else if (action === 'wolfram') {
window.open('https://www.wolframalpha.com/input?i=' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'oeis') {
window.open('https://oeis.org/search?q=' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'nlab') {
window.open('https://ncatlab.org/nlab/search?query=' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'wikipedia') {
/* Always use Special:Search never jumps to an article directly,
so phrases and ambiguous terms always show the results page. */
window.open('https://en.wikipedia.org/wiki/Special:Search?search=' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'define') {
/* English Wiktionary — only rendered for single-word selections. */
window.open('https://en.wiktionary.org/wiki/' + q, '_blank', 'noopener,noreferrer');
} else if (action === 'cite') {
var citation = buildBibTeX(text);
if (navigator.clipboard) navigator.clipboard.writeText(citation).catch(function () {});
} else if (action === 'here') {
/* Site search via Pagefind — opens search page with query pre-filled. */
window.open('/search.html?q=' + q, '_blank', 'noopener,noreferrer');
}
}
document.addEventListener('DOMContentLoaded', init);
}());

158
static/js/settings.js Normal file
View File

@ -0,0 +1,158 @@
/* settings.js Settings panel: theme, text size, print.
Must stay in sync with TEXT_SIZES in theme.js. */
(function () {
'use strict';
var TEXT_SIZES = [20, 23, 26];
var TEXT_SIZE_DEFAULT = 1; /* index of 23px */
var TEXT_SIZE_KEY = 'text-size';
/* ------------------------------------------------------------------
Init
------------------------------------------------------------------ */
function init() {
var toggle = document.querySelector('.settings-toggle');
var panel = document.querySelector('.settings-panel');
if (!toggle || !panel) return;
syncThemeButtons();
syncTextSizeButtons();
syncToggleButton('focus-mode', 'data-focus-mode');
syncToggleButton('reduce-motion', 'data-reduce-motion');
toggle.addEventListener('click', function (e) {
e.stopPropagation();
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
document.addEventListener('click', function (e) {
if (!panel.contains(e.target) && e.target !== toggle) setOpen(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); return; }
if (e.key !== 'Tab' || !panel.classList.contains('is-open')) return;
var focusable = Array.from(panel.querySelectorAll(
'button:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
if (focusable.length === 0) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
panel.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
handle(btn.getAttribute('data-action'));
});
});
}
/* ------------------------------------------------------------------
Panel open / close
------------------------------------------------------------------ */
function setOpen(open) {
var toggle = document.querySelector('.settings-toggle');
var panel = document.querySelector('.settings-panel');
var wasOpen = panel.classList.contains('is-open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
panel.setAttribute('aria-hidden', open ? 'false' : 'true');
panel.classList.toggle('is-open', open);
if (open) {
var first = panel.querySelector('button:not([disabled]), [tabindex]:not([tabindex="-1"])');
if (first) first.focus();
} else if (wasOpen && (panel.contains(document.activeElement) || document.activeElement === toggle)) {
/* Only return focus to toggle when the panel was open and focus
was inside the settings area. Clicking outside the panel to
dismiss it should not steal focus from wherever the user clicked. */
toggle.focus();
}
}
/* ------------------------------------------------------------------
Actions
------------------------------------------------------------------ */
function handle(action) {
if (action === 'theme-light') setTheme('light');
else if (action === 'theme-dark') setTheme('dark');
else if (action === 'text-smaller') shiftSize(-1);
else if (action === 'text-larger') shiftSize(+1);
else if (action === 'focus-mode') toggleDataAttr('focus-mode', 'data-focus-mode');
else if (action === 'reduce-motion') toggleDataAttr('reduce-motion', 'data-reduce-motion');
else if (action === 'print') { setOpen(false); window.print(); }
}
/* Theme ----------------------------------------------------------- */
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
syncThemeButtons();
}
function currentTheme() {
var attr = document.documentElement.getAttribute('data-theme');
if (attr) return attr;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function syncThemeButtons() {
var active = currentTheme();
document.querySelectorAll('[data-action^="theme-"]').forEach(function (btn) {
btn.classList.toggle('is-active', btn.getAttribute('data-action') === 'theme-' + active);
});
}
/* Text size ------------------------------------------------------- */
function getSizeIndex() {
var n = parseInt(localStorage.getItem(TEXT_SIZE_KEY), 10);
return (isNaN(n) || n < 0 || n >= TEXT_SIZES.length) ? TEXT_SIZE_DEFAULT : n;
}
function shiftSize(delta) {
var idx = Math.max(0, Math.min(TEXT_SIZES.length - 1, getSizeIndex() + delta));
localStorage.setItem(TEXT_SIZE_KEY, idx);
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[idx] + 'px');
syncTextSizeButtons();
}
/* Boolean toggles (focus-mode, reduce-motion) -------------------- */
function toggleDataAttr(storageKey, attrName) {
var html = document.documentElement;
var on = html.hasAttribute(attrName);
if (on) {
html.removeAttribute(attrName);
localStorage.removeItem(storageKey);
} else {
html.setAttribute(attrName, '');
localStorage.setItem(storageKey, '1');
}
syncToggleButton(storageKey, attrName);
}
function syncToggleButton(storageKey, attrName) {
var btn = document.querySelector('[data-action="' + storageKey + '"]');
if (btn) btn.classList.toggle('is-active', document.documentElement.hasAttribute(attrName));
}
function syncTextSizeButtons() {
var idx = getSizeIndex();
var smaller = document.querySelector('[data-action="text-smaller"]');
var larger = document.querySelector('[data-action="text-larger"]');
if (smaller) smaller.disabled = (idx === 0);
if (larger) larger.disabled = (idx === TEXT_SIZES.length - 1);
}
document.addEventListener('DOMContentLoaded', init);
}());

123
static/js/sidenotes.js Normal file
View File

@ -0,0 +1,123 @@
/* sidenotes.js Collision avoidance and hover linking for sidenotes.
*
* HTML structure produced by Filters/Sidenotes.hs:
* <sup class="sidenote-ref" id="snref-N"><a href="#sn-N">N</a></sup>
* <span class="sidenote" id="sn-N"></span>
*
* #markdownBody must be position: relative (layout.css guarantees this).
* The .sidenote spans are position: absolute; left: calc(100% + 2.5rem).
* Without an explicit top they sit at their "hypothetical static position",
* which is fine for isolated notes but causes overlaps when notes are close.
*
* This script:
* 1. Anchors each sidenote's top to its reference's offsetTop so the
* alignment is explicit and stable across all browsers.
* 2. Walks notes top-to-bottom and pushes each one below the previous
* if they would overlap (with a small gap between them).
* 3. Wires bidirectional hover highlights between each refsidenote pair.
*/
(function () {
'use strict';
const GAP = 12; /* minimum px gap between successive sidenotes */
function positionSidenotes() {
const body = document.getElementById('markdownBody');
if (!body) return;
const notes = Array.from(body.querySelectorAll('.sidenote'));
if (notes.length === 0) return;
/* Only run on wide viewports where sidenotes are visible. */
if (getComputedStyle(notes[0]).display === 'none') return;
let prevBottom = 0;
notes.forEach(function (sn) {
/* Reset any prior JS-applied top so offsetTop reads correctly. */
sn.style.top = '';
const id = sn.id; /* "sn-N" */
const refId = 'snref-' + id.slice(3); /* "snref-N" */
const ref = document.getElementById(refId);
/* Preferred top: align with the reference superscript. */
const preferred = ref ? ref.offsetTop : sn.offsetTop;
const top = Math.max(preferred, prevBottom + GAP);
sn.style.top = top + 'px';
prevBottom = top + sn.offsetHeight;
});
}
/* ------------------------------------------------------------------ */
/* Hover + click wiring */
/* ------------------------------------------------------------------ */
/* At most one sidenote is "focused" (click-sticky) at a time. */
let focusedPair = null;
function wireHover(ref, sn) {
let hovering = false;
let focused = false;
function update() {
const active = focused || hovering;
ref.classList.toggle('is-active', active);
sn.classList.toggle('is-active', active);
}
function unfocus() { focused = false; update(); }
ref.addEventListener('mouseenter', function () { hovering = true; update(); });
ref.addEventListener('mouseleave', function () { hovering = false; update(); });
sn.addEventListener('mouseenter', function () { hovering = true; update(); });
sn.addEventListener('mouseleave', function () { hovering = false; update(); });
/* Click on the superscript link: sticky focus on wide viewports,
normal anchor scroll on narrow viewports (sidenote hidden). */
const link = ref.querySelector('a');
if (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
if (getComputedStyle(sn).display === 'none') return; /* narrow: no sidenote to focus */
if (focused) {
focused = false;
focusedPair = null;
} else {
if (focusedPair) focusedPair.unfocus();
focused = true;
focusedPair = { ref: ref, sn: sn, unfocus: unfocus };
}
update();
});
}
}
/* Click anywhere outside a focused pair dismisses it. */
document.addEventListener('click', function (e) {
if (focusedPair &&
!focusedPair.ref.contains(e.target) &&
!focusedPair.sn.contains(e.target)) {
focusedPair.unfocus();
focusedPair = null;
}
});
function init() {
const body = document.getElementById('markdownBody');
if (!body) return;
body.querySelectorAll('.sidenote').forEach(function (sn) {
const refId = 'snref-' + sn.id.slice(3);
const ref = document.getElementById(refId);
if (ref) wireHover(ref, sn);
});
positionSidenotes();
}
document.addEventListener('DOMContentLoaded', init);
window.addEventListener('resize', positionSidenotes);
}());

29
static/js/theme.js Normal file
View File

@ -0,0 +1,29 @@
/* theme.js Restores theme and text size from localStorage before first paint.
Loaded synchronously (no defer/async) to prevent flash of wrong appearance.
DOM interaction (button wiring) is handled by settings.js (deferred).
*/
(function () {
var TEXT_SIZES = [20, 23, 26];
/* Theme */
var storedTheme = localStorage.getItem('theme');
if (storedTheme === 'dark' || storedTheme === 'light') {
document.documentElement.setAttribute('data-theme', storedTheme);
}
/* Text size */
var storedSize = parseInt(localStorage.getItem('text-size'), 10);
if (!isNaN(storedSize) && storedSize >= 0 && storedSize < TEXT_SIZES.length) {
document.documentElement.style.setProperty('--text-size', TEXT_SIZES[storedSize] + 'px');
}
/* Focus mode */
if (localStorage.getItem('focus-mode')) {
document.documentElement.setAttribute('data-focus-mode', '');
}
/* Reduce motion */
if (localStorage.getItem('reduce-motion')) {
document.documentElement.setAttribute('data-reduce-motion', '');
}
})();

116
static/js/toc.js Normal file
View File

@ -0,0 +1,116 @@
/* toc.js Sticky TOC with IntersectionObserver scroll tracking,
horizontal progress indicator, and expand/collapse.
Loaded with defer.
*/
(function () {
document.addEventListener('DOMContentLoaded', function () {
const toc = document.getElementById('toc');
if (!toc) return;
const links = Array.from(toc.querySelectorAll('a[data-target]'));
if (!links.length) return;
// Map: heading ID → TOC anchor element.
const linkMap = new Map(links.map(a => [a.dataset.target, a]));
// All headings in the body that have a matching TOC entry.
const headings = Array.from(
document.querySelectorAll('#markdownBody :is(h1,h2,h3,h4,h5,h6)[id]')
).filter(h => linkMap.has(h.id));
if (!headings.length) return;
const label = toc.querySelector('.toc-active-label');
const toggleBtn = toc.querySelector('.toc-toggle');
const pageTitleEl = document.querySelector('#markdownBody .page-title');
const pageTitle = pageTitleEl ? pageTitleEl.textContent.trim() : 'Contents';
function activateTitle() {
links.forEach(a => a.classList.remove('is-active'));
if (label) label.textContent = pageTitle;
}
function activate(id) {
links.forEach(a => a.classList.toggle('is-active', a.dataset.target === id));
if (label) {
const activeLink = linkMap.get(id);
label.textContent = activeLink ? activeLink.textContent : pageTitle;
}
}
// Collapse / expand
function setExpanded(open) {
if (!toggleBtn) return;
toc.classList.toggle('is-collapsed', !open);
toggleBtn.setAttribute('aria-expanded', String(open));
}
setExpanded(true);
if (toggleBtn) {
toggleBtn.addEventListener('click', function () {
setExpanded(toc.classList.contains('is-collapsed'));
});
}
// Auto-collapse once the first section becomes active via scrolling.
let autoCollapsed = false;
function collapseOnce() {
if (autoCollapsed) return;
autoCollapsed = true;
setExpanded(false);
}
// Progress indicator — drives the horizontal bar under .toc-header.
function updateProgress() {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
const progress = scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0;
toc.style.setProperty('--toc-progress', progress);
}
window.addEventListener('scroll', updateProgress, { passive: true });
updateProgress();
// The set of headings currently intersecting the trigger band.
const visible = new Set();
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) visible.add(e.target);
else visible.delete(e.target);
});
if (visible.size > 0) {
// Activate the topmost visible heading in document order.
const top = headings.find(h => visible.has(h));
if (top) { activate(top.id); collapseOnce(); }
} else {
// Nothing in the trigger band: activate the last heading
// whose top edge is above the sticky nav bar.
const navHeight = (document.querySelector('header') || {}).offsetHeight || 0;
let candidate = null;
for (const h of headings) {
if (h.getBoundingClientRect().top < navHeight + 16) candidate = h;
else break;
}
if (candidate) { activate(candidate.id); collapseOnce(); }
else activateTitle();
}
}, {
// Trigger band: a strip from 10% to 15% down from the top of the
// viewport. Headings become active when they enter this band.
rootMargin: '-10% 0px -85% 0px',
threshold: 0,
});
headings.forEach(h => observer.observe(h));
// Set initial active state based on URL hash, else first heading.
const hash = window.location.hash.slice(1);
if (hash && linkMap.has(hash)) {
activate(hash);
} else {
activateTitle();
}
});
})();

View File

@ -0,0 +1,26 @@
<main id="markdownBody">
<h1 class="page-title">$author$</h1>
$if(items)$
<ul class="essay-list">
$for(items)$
<li class="essay-list-item">
<a href="$url$">$title$</a>
$if(date)$
<span class="essay-list-date">$date$</span>
$endif$
$if(abstract)$
<p class="essay-list-abstract">$abstract$</p>
$endif$
$if(item-tags)$
<div class="essay-list-tags">
$for(item-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
</li>
$endfor$
</ul>
$else$
<p class="essay-list-empty">No items yet.</p>
$endif$
$partial("templates/partials/paginate-nav.html")$
</main>

17
templates/blog-index.html Normal file
View File

@ -0,0 +1,17 @@
<main id="markdownBody">
<h1 class="page-title">$title$</h1>
<ul class="post-list">
$for(posts)$
<li class="post-list-item">
<a href="$url$">$title$</a>
$if(date)$
<span class="post-list-date"><time datetime="$date-iso$">$date$</time></span>
$endif$
$if(abstract)$
<p class="post-list-abstract">$abstract$</p>
$endif$
</li>
$endfor$
</ul>
$partial("templates/partials/paginate-nav.html")$
</main>

Some files were not shown because too many files have changed in this diff Show More