initial deploy! whoop
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 650–700px (center) | sidenotes (right). Collapses on narrow screens. |
|
||||
| `sidenotes.css` | Sidenote positioning |
|
||||
| `popups.css` | Link preview popups |
|
||||
| `syntax.css` | Monochrome code highlighting (JetBrains Mono) |
|
||||
| `components.css` | Two-row nav, metadata block, collapsibles |
|
||||
|
||||
### JavaScript (`static/js/`)
|
||||
|
||||
| File | Source | Purpose |
|
||||
|------|--------|---------|
|
||||
| `sidenotes.js` | Adopted — Said Achmiz (MIT) | Sidenote positioning |
|
||||
| `popups.js` | Forked + simplified — Said Achmiz (MIT) | Internal previews, Wikipedia, citation previews |
|
||||
| `theme.js` | Original | Dark/light toggle with `localStorage` |
|
||||
| `toc.js` | Original | Sticky TOC + scroll tracking via `IntersectionObserver` |
|
||||
| `search.js` | Original | Pagefind integration |
|
||||
| `nav.js` | Original | Portal row expand/collapse (state in `localStorage`) |
|
||||
| `collapse.js` | Original | Section collapsing |
|
||||
|
||||
### Typography
|
||||
|
||||
- **Body:** Spectral (SIL OFL) — self-hosted WOFF2, subsetted with full OT features (`liga`, `smcp`, `onum`, etc.)
|
||||
- **UI/Headers:** Fira Sans (SIL OFL) — smallcaps for primary nav row
|
||||
- **Code:** JetBrains Mono (SIL OFL)
|
||||
|
||||
All fonts self-hosted from source (not Google Fonts, which strips OT features). Subset with `pyftsubset`.
|
||||
|
||||
### Navigation Structure
|
||||
|
||||
```
|
||||
Home | Me | New | Index | [🔍] ← primary row (always visible), Fira Sans smallcaps
|
||||
────────────────────────────────
|
||||
▼ Fiction | Miscellany | Music | Nonfiction | Poetry | Research ← portal row
|
||||
```
|
||||
|
||||
Portal row collapsed by default; expansion state in `localStorage`.
|
||||
|
||||
### Content Portals
|
||||
|
||||
Six content portals map to `content/` subdirectories: Fiction, Miscellany, Music, Nonfiction, Poetry, Research. Essays live under Nonfiction; blog posts are a separate stream.
|
||||
|
||||
### Metadata
|
||||
|
||||
Frontmatter keys for Phase 1: `title`, `created`, `modified`, `status`, `tags`, `abstract`.
|
||||
Auto-computed at build: `word-count`, `reading-time`.
|
||||
External data loaded from `data/annotations.yaml` and `data/bibliography.bib`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Local build → `_site/` → `rsync` to VPS. No server-side processing; nginx serves static files. No Docker.
|
||||
|
||||
## Key Design Constraints
|
||||
|
||||
- **No tracking, no analytics, no fingerprinting** — enforced at the nginx CSP header level too
|
||||
- **No client-side math rendering** — KaTeX runs at build time
|
||||
- **Sidenotes right-column only** (matching gwern's `useLeftColumn: () => false`)
|
||||
- **Configuration is code** — the Makefile and Haskell build system are the deployment pipeline
|
||||
- **Content license:** CC BY-SA-NC 4.0 | **Code license:** MIT
|
||||
|
|
@ -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
|
||||
|
|
@ -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 # 0–100 integer (%)
|
||||
importance: 3 # 1–5 integer (rendered as filled/empty dots ●●●○○)
|
||||
evidence: 2 # 1–5 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
|
||||

|
||||
```
|
||||
|
||||
For a captioned figure, add a title string. Pandoc wraps it in `<figure>`/`<figcaption>`:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
**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. 1–8" score-caption="The opening gesture."}
|
||||

|
||||
:::
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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\">◼</span>")
|
||||
, (ceHasRecording e, "<span class=\"catalog-ind\" title=\"Recording available\">♪</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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 1–5 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)))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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. 1–8" score-caption="The opening gesture."}
|
||||
-- > 
|
||||
-- > :::
|
||||
--
|
||||
-- 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 "\"" """
|
||||
. T.replace ">" ">"
|
||||
. T.replace "<" "<"
|
||||
. T.replace "&" "&"
|
||||
|
|
@ -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
|
||||
|
|
@ -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 '<' = "<"
|
||||
esc '>' = ">"
|
||||
esc '&' = "&"
|
||||
esc '"' = """
|
||||
esc c = T.singleton c
|
||||
|
|
@ -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 '<' = "<"
|
||||
esc '>' = ">"
|
||||
esc '&' = "&"
|
||||
esc '"' = """
|
||||
esc c = T.singleton c
|
||||
|
|
@ -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 (== ' ')
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
module Main where
|
||||
|
||||
import Hakyll (hakyll)
|
||||
import Site (rules)
|
||||
|
||||
main :: IO ()
|
||||
main = hakyll rules
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- | Metadata utilities (Phase 2+).
|
||||
module Metadata where
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 ++ "/")
|
||||
|
|
@ -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 '<' = "<"
|
||||
escChar '>' = ">"
|
||||
escChar '&' = "&"
|
||||
escChar '"' = """
|
||||
escChar '\'' = "'"
|
||||
escChar c = [c]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -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}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
|
@ -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. 650–700px max-width.
|
||||
- **Right margin:** Sidenotes only (right column).
|
||||
|
||||
### Color
|
||||
|
||||
Pure monochrome. No accent color. Light mode default (`#faf8f4` background, `#1a1a1a` text). Dark mode via `[data-theme="dark"]` + `prefers-color-scheme`.
|
||||
|
||||
### Content Systems
|
||||
|
||||
- **Tag system:** Hierarchical, slash-separated (`research/mathematics`). Hakyll `buildTags` + custom hierarchy. Tag pages at `/<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 (1–3 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: # 0–100 integer (%)
|
||||
importance: # 1–5 integer (rendered as filled/empty dots)
|
||||
evidence: # 1–5 integer (rendered as filled/empty dots)
|
||||
scope: # personal | local | average | broad | civilizational
|
||||
novelty: # conventional | moderate | idiosyncratic | innovative
|
||||
practicality: # abstract | low | moderate | high | exceptional
|
||||
stability: # volatile | revising | fairly stable | stable | established
|
||||
# (auto-computed from git history; use IGNORE.txt to pin)
|
||||
last-reviewed: # ISO date — overrides git-derived date when in IGNORE.txt
|
||||
confidence-history: # list of integers — trend derived from last two entries (↑↓→)
|
||||
|
||||
# Version history (optional; falls back to git log, then to date-created/date-modified)
|
||||
history:
|
||||
- date: "2026-03-01" # ISO date string (quote to prevent YAML date parsing)
|
||||
note: Initial draft # human-readable annotation
|
||||
- date: "2026-03-14"
|
||||
note: Expanded typography section; added citations
|
||||
```
|
||||
|
||||
Auto-computed at build time: `word-count`, `reading-time`.
|
||||
Auto-derived at build time: `stability` (from `git log --follow`), `last-reviewed` (most recent commit date), `confidence-trend` (from `confidence-history`).
|
||||
|
||||
**`IGNORE.txt`:** A file in the project root listing content paths (one per line) whose `stability` and `last-reviewed` should not be recomputed. Cleared automatically after every `make build`. Useful for pinning manually-set stability labels on pages whose git history is misleading.
|
||||
|
||||
**Top metadata block:**
|
||||
1. **Tags** — hierarchical tag list with links to tag index pages
|
||||
2. **Description** — the `abstract` field, rendered 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. 1–8" score-caption="The opening gesture."}
|
||||

|
||||
:::
|
||||
```
|
||||
|
||||
### 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` (1–5) | `●●●○○` rendered in Haskell |
|
||||
| `$evidence-dots$` | frontmatter `evidence` (1–5) | 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.*
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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 */
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
|
@ -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-----
|
||||
|
After Width: | Height: | Size: 4.0 MiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}());
|
||||
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}());
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
}());
|
||||
|
|
@ -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 () {});
|
||||
});
|
||||
});
|
||||
}());
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}());
|
||||
|
|
@ -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);
|
||||
}());
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
|
@ -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);
|
||||
}());
|
||||
|
|
@ -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);
|
||||
}());
|
||||
|
|
@ -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 ref↔sidenote 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);
|
||||
}());
|
||||
|
|
@ -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', '');
|
||||
}
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||