levineuwirth.org/WRITING.md

1041 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` |
| Essay (with co-located assets) | `content/essays/my-essay/index.md` | `/essays/my-essay/index.html` |
| Blog post | `content/blog/my-post.md` | `/blog/my-post.html` |
| Poetry | `content/poetry/my-poem.md` | `/poetry/my-poem.html` |
| Poetry collection | `content/poetry/collection-name/*.md` | `/poetry/collection-name/*.html` |
| Fiction | `content/fiction/my-story.md` | `/fiction/my-story.html` |
| Composition | `content/music/{slug}/index.md` | `/music/{slug}/` |
| Standalone page | `content/my-page.md` | `/my-page.html` |
| Standalone page (with co-located assets) | `content/my-page/index.md` | `/my-page.html` |
| Draft essay | `content/drafts/essays/my-draft.md` | `/drafts/essays/my-draft.html` (dev only) |
File names become URL slugs. Use lowercase, hyphen-separated words.
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.
---
## Drafts
In-progress essays go under `content/drafts/essays/`. Both flat files
(`content/drafts/essays/my-draft.md`) and directory-based essays
(`content/drafts/essays/my-draft/index.md`) are supported.
Drafts are **included** in `make watch` and `make dev` (which set `SITE_ENV=dev`)
and **excluded** from every production build (`make build`, `make deploy`).
They are also invisible to tags, author pages, backlinks, stats, and feeds.
To preview a draft:
```bash
make watch # Hakyll live-reload with drafts visible
make dev # clean build + Python HTTP server with drafts visible
```
When the draft is ready, move it into `content/essays/`.
---
## Frontmatter
Every file begins with a YAML block fenced by `---`. Keys are read by Hakyll
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
affiliation: # optional; shown below author in metadata block
- "Brown University | https://cs.brown.edu"
- "Some Research Lab" # URL optional; scalar string also accepted
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
repository: https://git.levineuwirth.org/levi/repo # optional; "Repository" link in metadata
js: scripts/my-widget.js # optional; per-page JS file (see Page scripts)
# js: [scripts/a.js, scripts/b.js] # or a list
# Epistemic profile — 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.
**Poetry collections** — poems can be grouped into subdirectories:
```
content/poetry/shakespeare-sonnets/
├── index.md ← collection landing page (uses pageCtx)
├── sonnet-1.md ← individual poem
├── sonnet-2.md
└── …
```
Collection index pages (`content/poetry/*/index.md`) compile as standalone
pages. Poems inside collections (`content/poetry/*/*.md`, excluding
`index.md`) compile with `poetryCompiler` just like flat poems.
**External / non-original poems** — use `poet:` instead of `authors:` to credit
an external author without generating a (broken) author index page:
```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.
---
## Transclusion
Embed the rendered content of another page (or a section of it) inline using
`{{slug}}` directives. The directive must be on its own line.
```markdown
{{my-essay}} → embeds the full body of /my-essay.html
{{essays/deep-dive}} → embeds /essays/deep-dive.html
{{my-essay#introduction}} → embeds only the "introduction" section
```
At build time, `Filters.Transclusion` rewrites these to `<div class="transclude"
data-src="..." [data-section="..."]></div>` placeholders. At runtime,
`transclude.js` fetches the target page and injects the content.
Transclusions inside code blocks or inline prose are not replaced — only
block-level directives (sole content of a line) are processed.
---
## PDF Embeds
Embed a hosted PDF in a full PDF.js viewer (page navigation, zoom, text
selection) using the `{{pdf:...}}` directive on its own line:
```markdown
{{pdf:/papers/smith2023.pdf}}
{{pdf:/papers/smith2023.pdf#5}} ← open at page 5 (bare integer)
{{pdf:/papers/smith2023.pdf#page=5}} ← explicit form, same result
```
The directive produces an iframe pointing at the vendored PDF.js viewer at
`/pdfjs/web/viewer.html?file=...`. The PDF must be served from the same
origin (i.e. it must be a file you host).
**Storing papers.** Drop PDFs in `static/papers/`; Hakyll's static rule copies
everything under `static/` to `_site/` unchanged. Reference them as
`/papers/filename.pdf`.
**One-time vendor setup.** PDF.js is not included in the repo. Install it once:
```bash
npm install pdfjs-dist
mkdir -p static/pdfjs
cp -r node_modules/pdfjs-dist/web static/pdfjs/
cp -r node_modules/pdfjs-dist/build static/pdfjs/
rm -rf node_modules package-lock.json
```
Then commit `static/pdfjs/` (it is static and changes only when you want to
upgrade PDF.js). Hakyll's existing `static/**` rule copies it through without
any new build rules.
**Optional: disable the "Open file" button** in the viewer. Edit
`static/pdfjs/web/viewer.html` and set `AppOptions.set('disableOpenFile', true)`
in the `webViewerLoad` callback, or add a thin CSS rule to `viewer.css`:
```css
#openFile, #secondaryOpenFile { display: none !important; }
```
---
## Math
Pandoc parses LaTeX math and wraps it in `class="math inline"` / `class="math display"`
spans. KaTeX CSS is loaded conditionally on pages that contain math — this styles the
pre-rendered output. Client-side KaTeX JS rendering is not yet loaded; complex math
will appear as LaTeX source. Build-time server-side rendering is planned but not yet
implemented. Simple math currently renders through Pandoc's built-in KaTeX span output.
`math: true` is auto-set for all essays and blog posts. Standalone pages that use
math must set it explicitly in frontmatter to load the KaTeX CSS.
| 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. Internal links get a
`link-internal` class and an internal icon.
External links are classified automatically at build time:
- Opened in a new tab (`target="_blank" rel="noopener noreferrer"`)
- Decorated with a domain icon via CSS `mask-image`
Links to sibling subdomains (e.g. `git.levineuwirth.org`) are classified as
external — only `levineuwirth.org` and `www.levineuwirth.org` are treated as
the content host.
| Domain | Icon |
|--------|------|
| `wikipedia.org` | Wikipedia |
| `arxiv.org` | arXiv |
| `doi.org` | DOI |
| `worldcat.org` | WorldCat |
| `orcid.org` | ORCID |
| `archive.org` | Internet Archive |
| `github.com` | GitHub |
| `git.levineuwirth.org` | Forgejo |
| `tensorflow.org` | TensorFlow |
| `anthropic.com` | Anthropic |
| `openai.com` | OpenAI |
| `twitter.com` / `x.com` | Twitter/X |
| `reddit.com` | Reddit |
| `youtube.com` / `youtu.be` | YouTube |
| `tiktok.com` | TikTok |
| `substack.com` | Substack |
| `news.ycombinator.com` | Hacker News |
| `nytimes.com` | New York Times |
| `nasa.gov` | NASA |
| `apple.com` | Apple |
| Everything else | Generic external arrow |
### Link preview popups
Hovering over a link shows a context-aware popup. All automatic — no
markup needed.
| Target | Popup content |
|--------|--------------|
| Internal page | Title, abstract, authors, tags, word count, reading time |
| Citation marker (`[1]`) | Full bibliography reference (multi-cite groups supported) |
| Wikipedia | Lead section extract via MediaWiki API |
| arXiv | Title, authors, abstract via Atom API |
| DOI / CrossRef | Title, authors, journal, year, abstract |
| GitHub | Repo name, description, language, stars |
| Forgejo (`git.levineuwirth.org`) | Repo name, description, language, stars |
| Open Library | Book title, description |
| bioRxiv / medRxiv | Title, authors, abstract |
| YouTube | Video title, channel name (oEmbed) |
| Internet Archive | Title, creator, description |
| PubMed | Title, authors, journal, year |
| PDF link | First-page thumbnail (from build-time `pdftoppm`) |
| Epistemic jump link (`#epistemic`) | Clone of the full epistemic profile |
| Epistemic term label (`data-ep-term`) | Term definition from colophon |
| PGP signature link | ASCII armor of the `.sig` file |
**Custom annotations** — author-defined previews for any URL. Add entries
to `data/annotations.json`:
```json
{
"https://example.com/article": {
"title": "Article Title",
"annotation": "A brief note about why this link is relevant."
}
}
```
Annotation entries take priority over all other popup providers.
Popups are disabled on touch-primary devices and inside nav/TOC/footer
elements.
---
## 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 rows | Expanded `<dl>` |
|-------|-------------|------------------|
| *(trust score)* | bordered chip + small-caps "trust" label on the primary row | — |
| `status` | small-caps chip on the primary row, alongside the trust chip | — |
| `confidence` | `· 72% confidence` | — |
| `importance` | `· ●●●○○ importance` | — |
| `evidence` | `· ●●○○○ evidence quality` | — |
| `scope` | `· average scope` (if set) | — |
| `novelty` | `· moderate novelty` (if set) | — |
| `practicality` | `· moderate practicality` (if set) | — |
| `stability` | — | auto-computed from git history |
| `last-reviewed` | — | most recent commit date |
| `confidence-trend` | — | ↑/↓/→ from last two `confidence-history` entries |
The **trust score** is auto-computed as `confidence × 0.6 + ((evidence 1) / 4) × 0.4`,
clamped 0100. It is deliberately narrow: it answers "how much should you trust the
central claim?" and nothing else. Importance, scope, novelty, and practicality are
*not* folded in — they are orientation signals shown alongside the chip so a high
trust score on a personal essay cannot be misread as broad significance.
**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 via `::first-line { font-variant-caps: small-caps }`.
```markdown
::: dropcap
A personal website is not a publication. It is a position — something you
inhabit, argue from, and occasionally revise in public.
:::
```
Write in normal mixed case. The CSS applies `font-variant-caps: small-caps` to
the entire first rendered line, converting lowercase letters to small-cap glyphs.
Use `[WORD]{.smallcaps}` spans to force specific words into small-caps anywhere
in the paragraph.
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.
---
## Visualizations
Two types of figure are supported, authored as fenced divs. The Python script
runs at build time via the Pandoc filter; no client-side computation is needed
for static figures.
### Static figures (matplotlib)
```markdown
::: {.figure script="figures/my-plot.py" caption="Caption text."}
:::
```
The script path is resolved relative to the source file's directory. It should
import `viz_theme` from `tools/` and write SVG to stdout:
```python
import sys
sys.path.insert(0, 'tools')
from viz_theme import apply_monochrome, save_svg
import matplotlib.pyplot as plt
apply_monochrome()
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 9])
save_svg(fig)
```
`apply_monochrome()` sets transparent backgrounds and pure black elements so
the figure inherits the page's dark/light mode via CSS `currentColor`.
Multi-series charts should use `LINESTYLE_CYCLE` instead of color:
```python
from viz_theme import apply_monochrome, save_svg, LINESTYLE_CYCLE
apply_monochrome()
fig, ax = plt.subplots()
for i, style in enumerate(LINESTYLE_CYCLE[:3]):
ax.plot(x, data[i], **style, label=f"Series {i+1}")
save_svg(fig)
```
### Interactive figures (Altair / Vega-Lite)
```markdown
::: {.visualization script="figures/my-chart.py" caption="Caption text."}
:::
```
The script outputs Vega-Lite JSON to stdout:
```python
import sys, json
import altair as alt
import pandas as pd
df = pd.read_csv('figures/data.csv')
chart = alt.Chart(df).mark_line().encode(x='year:O', y='value:Q')
print(json.dumps(chart.to_dict()))
```
The site's monochrome Vega config is applied automatically, overriding the
spec's own `config`. Dark mode re-renders automatically.
Add `viz: true` to the frontmatter of any page using `.visualization` divs
this loads the Vega CDN scripts:
```yaml
viz: true
```
Pages with only static `.figure` divs do not need `viz: true`.
---
## 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 |
| Related | Similar pages exist (embedding-based; computed at build time) |
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.
Related pages are computed by `tools/embed.py` using semantic embeddings
(`nomic-embed-text-v1.5` + FAISS). Runs automatically during `make build`
when the Python environment is set up (`uv sync`). No markup needed.
---
## Auto-generated pages
These pages are built automatically and require no content files or markup:
| Page | URL | Description |
|------|-----|-------------|
| Essay index | `/essays/` | All essays, newest first |
| Blog index | `/blog/` | Paginated blog posts |
| New | `/new.html` | All content types sorted by date, newest first |
| Library | `/library.html` | All content grouped by portal (AI, Fiction, Music, etc.) |
| Build telemetry | `/build/` | Corpus stats, word-length distribution, tag frequencies, link analysis, epistemic coverage, output metrics, repository overview, build timing, and a 52-week writing activity heatmap |
| Tag indexes | `/<tag>/` | Paginated pages per tag, auto-generated |
| Author indexes | `/authors/<slug>/` | All content attributed to an author |
| Random manifest | `/random-pages.json` | JSON array of page URLs for the random-page button |
| Atom feeds | `/feed.xml`, `/music/feed.xml` | All content feed + music-only feed |
| Search | `/search.html` | Pagefind full-text search + client-side semantic search (`nomic-embed-text-v1.5` ONNX model) |
---
## Build
```bash
make build # auto-commit content/, convert images, PDF thumbs, compile,
# run pagefind + embeddings, clear IGNORE.txt
make sign # GPG detach-sign every _site/**/*.html → .html.sig
make deploy # clean + build + sign + rsync to VPS + git push
make watch # Hakyll live-reload dev server (SITE_ENV=dev — includes drafts)
make dev # clean build + Python HTTP server (SITE_ENV=dev — includes drafts)
make clean # wipe _site/ and _cache/
```
`make watch` and `make dev` set `SITE_ENV=dev`, which includes drafts under
`content/drafts/essays/` in the build. All other targets exclude drafts.
`make watch` hot-reloads changes to Markdown, CSS, JS, and templates.
**After any change to a `.hs` file, always run `make clean && make build`**
Hakyll's cache is keyed to source file mtimes and will serve stale output after
Haskell-side changes.
`make deploy` requires `VPS_USER`, `VPS_HOST`, and `VPS_PATH` to be set in
`.env` (see `.env.example`). It runs `clean → build → sign`, sends a desktop
notification, rsyncs `_site/` to the VPS, and pushes to `origin main`.
**GPG signing:** `make sign` and `make deploy` require the signing subkey
passphrase to be cached. Run once per boot (or per 24h expiry):
```bash
./tools/preset-signing-passphrase.sh
```
**Image conversion:** `make build` automatically runs `tools/convert-images.sh`
to generate WebP companions for JPEG/PNG images (requires `cwebp`). It also
generates first-page PDF thumbnails via `pdftoppm` (requires `poppler`).
Both are skipped silently when the tools are not installed.
**Python environment:** the embedding pipeline requires `uv sync` to be run
once. After that, `make build` invokes `uv run python tools/embed.py`
automatically. If `.venv` is absent, the step is skipped with a warning and
the build continues normally.