Initial commit for ozymandias

This commit is contained in:
Levi Neuwirth 2026-04-12 12:34:02 -04:00
commit 96a7ef0516
164 changed files with 18601 additions and 0 deletions

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
dist-newstyle/
_site/
_cache/
.DS_Store
.env
# Editor backup/swap files
*~
*.swp
*.swo
# Python bytecode caches
**/__pycache__/
*.pyc
*.pyo
# LaTeX build artifacts (sitewide — covers paper/, any future TeX sources)
*.aux
*.bbl
*.blg
*.brf
*.fdb_latexmk
*.fls
*.glo
*.gls
*.idx
*.ilg
*.ind
*.lof
*.lot
*.nav
*.out
*.snm
*.synctex.gz
*.toc
*.vrb
# PGF/TikZ scratch outputs
pgftest*.pdf
pgftest*.log
pgftest*.aux
# LaTeX run logs (scoped to paper/ — bare *.log would be too broad sitewide)
paper/*.log
# Data files that are generated at build time (not version-controlled)
data/embeddings.json
data/similar-links.json
data/backlinks.json
data/build-stats.json
data/build-start.txt
data/last-build-seconds.txt
data/semantic-index.bin
data/semantic-meta.json
# IGNORE.txt is for the local build and need not be synced.
IGNORE.txt
# Model files for client-side semantic search (~22 MB binary artifacts).
# Download with: make download-model
static/models/
# Generated WebP companions (produced by tools/convert-images.sh at build time).
# To intentionally commit a WebP, use: git add -f path/to/file.webp
static/**/*.webp
content/**/*.webp

75
Makefile Normal file
View File

@ -0,0 +1,75 @@
.PHONY: build deploy sign download-model convert-images pdf-thumbs 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:
@date +%s > data/build-start.txt
@./tools/convert-images.sh
@$(MAKE) -s pdf-thumbs
cabal run site -- build
pagefind --site _site
@if [ -d .venv ]; then \
uv run python tools/embed.py || echo "Warning: embedding failed — data/similar-links.json not updated (build continues)"; \
else \
echo "Embedding skipped: run 'uv sync' to enable similar-links (build continues)"; \
fi
> IGNORE.txt
@BUILD_END=$$(date +%s); \
BUILD_START=$$(cat data/build-start.txt); \
echo $$((BUILD_END - BUILD_START)) > data/last-build-seconds.txt
sign:
@./tools/sign-site.sh
# Download the quantized ONNX model for client-side semantic search.
# Run once; files are gitignored. Safe to re-run (skips existing files).
download-model:
@./tools/download-model.sh
# Convert JPEG/PNG images to WebP companions (also runs automatically in build).
# Requires cwebp: pacman -S libwebp / apt install webp
convert-images:
@./tools/convert-images.sh
# Generate first-page thumbnails for PDFs in static/papers/ (also runs in build).
# Requires pdftoppm: pacman -S poppler / apt install poppler-utils
# Thumbnails are written as static/papers/foo.thumb.png alongside each PDF.
# Skipped silently when pdftoppm is not installed or static/papers/ is empty.
pdf-thumbs:
@if command -v pdftoppm >/dev/null 2>&1; then \
find static/papers -name '*.pdf' 2>/dev/null | while read pdf; do \
thumb="$${pdf%.pdf}.thumb"; \
if [ ! -f "$${thumb}.png" ] || [ "$$pdf" -nt "$${thumb}.png" ]; then \
echo " pdf-thumb $$pdf"; \
pdftoppm -r 100 -f 1 -l 1 -png -singlefile "$$pdf" "$$thumb"; \
fi; \
done; \
else \
echo "pdf-thumbs: pdftoppm not found — install poppler (skipping)"; \
fi
deploy: clean build sign
@test -n "$(VPS_USER)" || (echo "deploy: VPS_USER not set in .env" >&2; exit 1)
@test -n "$(VPS_HOST)" || (echo "deploy: VPS_HOST not set in .env" >&2; exit 1)
@test -n "$(VPS_PATH)" || (echo "deploy: VPS_PATH not set in .env" >&2; exit 1)
rsync -avz --delete _site/ $(VPS_USER)@$(VPS_HOST):$(VPS_PATH)/
git push -u origin main
watch: export SITE_ENV = dev
watch:
cabal run site -- watch
clean:
cabal run site -- clean
# Dev build includes any in-progress drafts under content/drafts/essays/.
# SITE_ENV=dev is read by build/Site.hs; drafts are otherwise invisible to
# every build (make build / make deploy / cabal run site -- build directly).
dev: export SITE_ENV = dev
dev:
cabal run site -- clean
cabal run site -- build
python3 -m http.server 8000 --directory _site

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# Ozymandias
A full-featured static site framework built with [Hakyll](https://jaspervdj.be/hakyll/) and [Pandoc](https://pandoc.org/). Designed for long-form writing, research, music, and creative work.
## What's included
- **Sidenotes** — footnotes render in the margin on wide screens, inline on mobile.
- **Epistemic profiles** — tag essays with confidence, evidence quality, importance, and stability; readers see a compact credibility signal before committing to read.
- **Backlinks** — two-pass wikilink resolution with automatic backlink sections.
- **Score reader** — swipeable SVG score viewer for music compositions.
- **Typography** — dropcaps, smallcaps auto-detection, abbreviation tooltips, old-style figures.
- **Math** — KaTeX rendering for inline and display equations.
- **Citations** — Pandoc citeproc with Chicago Notes; bibliography and further-reading sections.
- **Search** — Pagefind client-side full-text search.
- **Semantic search** — optional embedding pipeline (sentence-transformers + FAISS) for "similar links."
- **Settings** — dark mode, text size, focus mode, reduce motion.
- **Wikilinks**`[[Page Name]]` and `[[Page Name|display text]]` syntax.
- **Atom feeds** — site-wide and per-section (e.g., music-only).
- **Library** — configurable portal taxonomy that groups content by tag hierarchy.
- **Version history** — git-derived stability heuristic with manual history annotations.
- **Reading mode** — dedicated layout for poetry and fiction.
- **GPG signing** — optional per-page detached signatures.
## Quickstart
```sh
# Clone and enter the repo
git clone <your-fork-url> my-site && cd my-site
# Edit your identity and navigation
$EDITOR site.yaml
# Build and serve locally (requires GHC 9.6+, cabal, pagefind)
make dev
```
`make dev` builds with drafts visible and starts a local server on `:8000`.
For production: `make build` (one-shot build into `_site/`).
## Prerequisites
- **GHC 9.6+** and **cabal-install** — for the Haskell build pipeline.
- **Pagefind** — client-side search index (`npm i -g pagefind` or via your package manager).
- **cwebp** (optional) — for automatic WebP image conversion (`pacman -S libwebp` / `apt install webp`).
- **Python 3.12+ and uv** (optional) — for the embedding pipeline (`uv sync` to set up).
## Configuration
All site identity, navigation, and taxonomy live in `site.yaml`:
```yaml
site-name: "My Site"
site-url: "https://example.com"
author-name: "Your Name"
nav:
- { href: "/", label: "Home" }
- { href: "/library.html", label: "Library" }
portals:
- { slug: "writing", name: "Writing" }
- { slug: "code", name: "Code" }
```
See the comments in `site.yaml` for the full schema.
## Project structure
```
build/ Haskell source — Hakyll rules, Pandoc filters, compilers
templates/ Hakyll HTML templates and partials
static/ CSS, JS, fonts, images (copied to _site/)
content/ Markdown source — essays, blog, poetry, fiction, music
data/ Bibliography, annotations, citation style
tools/ Shell/Python build-time utilities
site.yaml Site-wide configuration
Makefile Build, deploy, dev targets
```
## Content types
| Type | Path | Template |
|:------------|:---------------------------------|:----------------|
| Essay | `content/essays/*.md` | essay.html |
| Blog post | `content/blog/*.md` | blog-post.html |
| Poetry | `content/poetry/*.md` | reading.html |
| Fiction | `content/fiction/*.md` | reading.html |
| Composition | `content/music/<slug>/index.md` | composition.html|
| Page | `content/*.md` | page.html |
## Deployment
The included `make deploy` target:
1. Runs `make clean && make build && make sign`
2. Rsyncs `_site/` to a VPS (configured via `.env`)
3. Pushes to the git remote
Set `VPS_USER`, `VPS_HOST`, and `VPS_PATH` in `.env` (gitignored).
## License
The framework code (everything outside `content/`) is [MIT](LICENSE). The demo content under `content/` is public domain. Your own content is yours — add whatever license you choose.
---
*"My name is Ozymandias, King of Kings; / Look on my Works, ye Mighty, and despair!"* — the name is a reminder that all frameworks are temporary, but the writing you put in them might not be.

111
build/Authors.hs Normal file
View File

@ -0,0 +1,111 @@
{-# 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 the site's
-- @author-name@ from @site.yaml@.
--
-- Frontmatter format (name-only or name|url — url part is ignored now):
-- authors:
-- - "Jane Doe"
-- - "Alice Smith | https://alice.example" -- url ignored; link goes to /authors/alice-smith/
module Authors
( buildAllAuthors
, applyAuthorRules
) where
import Data.Maybe (fromMaybe)
import Hakyll
import qualified Config
import Pagination (sortAndGroup)
import Patterns (authorIndexable)
import Contexts (abstractField, tagLinksField)
import Utils (authorSlugify, authorNameOf)
-- ---------------------------------------------------------------------------
-- Slug helpers
--
-- The slugify and nameOf helpers used to live here in their own
-- definitions; they now defer to 'Utils' so that they cannot drift from
-- the 'Contexts' versions on Unicode edge cases.
-- ---------------------------------------------------------------------------
slugify :: String -> String
slugify = authorSlugify
nameOf :: String -> String
nameOf = authorNameOf
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
defaultAuthor :: String
defaultAuthor = Config.defaultAuthor
-- | Content patterns indexed by author. Sourced from 'Patterns.authorIndexable'
-- so this stays in lockstep with Tags.hs and Backlinks.hs.
allContent :: Pattern
allContent = authorIndexable
-- ---------------------------------------------------------------------------
-- Tag-like helpers (mirror of Tags.hs)
-- ---------------------------------------------------------------------------
-- | Returns all author names for an identifier.
-- Defaults to the site's configured @author-name@ 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"
<> abstractField
<> defaultContext

324
build/Backlinks.hs Normal file
View File

@ -0,0 +1,324 @@
{-# 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.ByteString as BS
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.Text.Encoding.Error 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)
import qualified Patterns as P
-- ---------------------------------------------------------------------------
-- 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; percent-decode the path
-- so that @\/essays\/caf%C3%A9@ and @\/essays\/café@ collide on the same
-- key.
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 percentDecode (T.unpack t3)
-- | Decode percent-escapes (@%XX@) into raw bytes, then re-interpret the
-- resulting bytestring as UTF-8. Invalid escapes are passed through
-- verbatim so this is safe to call on already-decoded input.
percentDecode :: String -> String
percentDecode = T.unpack . TE.decodeUtf8With lenientDecode . pack . go
where
go [] = []
go ('%':a:b:rest)
| Just hi <- hexDigit a
, Just lo <- hexDigit b
= fromIntegral (hi * 16 + lo) : go rest
go (c:rest) = fromIntegral (fromEnum c) : go rest
hexDigit c
| c >= '0' && c <= '9' = Just (fromEnum c - fromEnum '0')
| c >= 'a' && c <= 'f' = Just (fromEnum c - fromEnum 'a' + 10)
| c >= 'A' && c <= 'F' = Just (fromEnum c - fromEnum 'A' + 10)
| otherwise = Nothing
pack = BS.pack
lenientDecode = TE.lenientDecode
-- ---------------------------------------------------------------------------
-- Content patterns (must match the rules in Site.hs — sourced from
-- Patterns.allContent so additions to the canonical list automatically
-- propagate to backlinks).
-- ---------------------------------------------------------------------------
allContent :: Pattern
allContent = P.allContent
-- ---------------------------------------------------------------------------
-- 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"

243
build/Catalog.hs Normal file
View File

@ -0,0 +1,243 @@
{-# 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.Char (isSpace, toLower)
import Data.List (groupBy, isPrefixOf, 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 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
-- ---------------------------------------------------------------------------
--
-- Trust model: per the site convention (see also Stats.hs:pageLink),
-- frontmatter @title@ values are author-controlled trusted HTML and may
-- contain inline markup such as @<em>...</em>@. They are emitted
-- pre-escaped — but we still escape every other interpolated frontmatter
-- value (year, duration, instrumentation) and sanitize hrefs through
-- 'safeHref', so a stray @<@ in those fields cannot break the markup.
-- | Defense-in-depth href sanitiser. Mirrors 'Stats.isSafeUrl'.
safeHref :: String -> String
safeHref u =
let norm = map toLower (dropWhile isSpace u)
in if not ("//" `isPrefixOf` norm)
&& any (`isPrefixOf` norm) ["/", "https://", "mailto:", "#"]
then escAttr u
else "#"
escAttr :: String -> String
escAttr = concatMap esc
where
esc '&' = "&amp;"
esc '<' = "&lt;"
esc '>' = "&gt;"
esc '"' = "&quot;"
esc '\'' = "&#39;"
esc c = [c]
escText :: String -> String
escText = concatMap esc
where
esc '&' = "&amp;"
esc '<' = "&lt;"
esc '>' = "&gt;"
esc c = [c]
renderIndicators :: CatalogEntry -> String
renderIndicators e = concatMap render
[ (ceHasScore e, "<span class=\"catalog-ind\" title=\"Score available\">&#9724;</span>")
, (ceHasRecording e, "<span class=\"catalog-ind\" title=\"Recording available\">&#9834;</span>")
]
where
render (True, s) = s
render (False, _) = ""
renderEntry :: CatalogEntry -> String
renderEntry e = concat
[ "<li class=\"catalog-entry\">"
, "<div class=\"catalog-entry-main\">"
, "<a class=\"catalog-title\" href=\"", safeHref (ceUrl e), "\">"
, ceTitle e
, "</a>"
, renderIndicators e
, maybe "" (\y -> "<span class=\"catalog-year\">" ++ escText y ++ "</span>") (ceYear e)
, maybe "" (\d -> "<span class=\"catalog-duration\">" ++ escText d ++ "</span>") (ceDuration e)
, "</div>"
, maybe "" (\i -> "<div class=\"catalog-instrumentation\">" ++ escText i ++ "</div>") (ceInstrumentation e)
, "</li>"
]
renderCategorySection :: String -> [CatalogEntry] -> String
renderCategorySection cat entries = concat
[ "<section class=\"catalog-section\">"
, "<h2 class=\"catalog-section-title\">", escText (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 renderGroup grouped
where
-- groupBy on a non-empty list yields non-empty sublists, but pattern
-- matching is total whereas 'head' is not.
renderGroup [] = ""
renderGroup g@(e : _) = renderCategorySection (ceCategory e) g
musicCatalogCtx :: Context String
musicCatalogCtx =
constField "catalog" "true"
<> hasFeaturedField
<> featuredWorksField
<> catalogByCategoryField
<> siteCtx

224
build/Citations.hs Normal file
View File

@ -0,0 +1,224 @@
{-# 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] -> Text -> Pandoc -> IO (Pandoc, Text, Text)
applyCitations frKeys bibPath doc
| not (hasCitations frKeys doc) = return (doc, "", "")
| otherwise = do
let doc1 = injectMeta frKeys bibPath 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] -> Text -> Pandoc -> Pandoc
injectMeta frKeys bibPath (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 bibPath) 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 case (keys, nums) of
-- Both lists are guaranteed non-empty by the @null nums@ check
-- below, but pattern-match to keep this total instead of
-- relying on @head@.
(firstKey : _, firstNum : _) ->
RawInline "html" (markerHtml keys firstKey firstNum nums)
_ ->
Str ""
transformInline _ x = x
markerHtml :: [Text] -> Text -> Int -> [Int] -> Text
markerHtml keys firstKey firstNum nums =
let label = "[" <> T.intercalate "," (map tshow nums) <> "]"
allIds = T.intercalate " " (map ("ref-" <>) keys)
in "<sup class=\"cite-marker\" id=\"cite-back-" <> tshow firstNum <> "\">"
<> "<a href=\"#ref-" <> firstKey <> "\" class=\"cite-link\""
<> " data-cite-keys=\"" <> allIds <> "\">"
<> 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@(divId, _, _) content) =
Div attrs
( Plain [ RawInline "html"
("<a class=\"ref-num\" href=\"#" <> divId <> "\">[" <> T.pack (show n) <> "]</a>") ]
: content )
addNumber _ b = b
-- | Strip the @ref-@ prefix that citeproc adds to div IDs.
stripRefPrefix :: Text -> Text
stripRefPrefix t = fromMaybe t (T.stripPrefix "ref-" t)
-- | Render a list of blocks as an HTML string (used for bibliography sections).
renderEntries :: Text -> [Block] -> Text
renderEntries cls entries =
case runPure (writeHtml5String wOpts (Pandoc nullMeta entries)) of
Left _ -> ""
Right html -> "<div class=\"" <> cls <> "\">\n" <> html <> "</div>\n"
where
wOpts = def { writerWrapText = WrapNone }

161
build/Commonplace.hs Normal file
View File

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

217
build/Compilers.hs Normal file
View File

@ -0,0 +1,217 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Compilers
( essayCompiler
, postCompiler
, pageCompiler
, poetryCompiler
, fictionCompiler
, compositionCompiler
, readerOpts
, writerOpts
) where
import Hakyll
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
import qualified Filters.Viz as Viz
-- ---------------------------------------------------------------------------
-- 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)
let bibPath = T.pack $ fromMaybe "data/bibliography.bib" (lookupString "bibliography" meta)
-- Run citeproc, transform citation spans → superscripts, extract bibliography.
(pandocWithCites, bibHtml, furtherHtml) <- unsafeCompiler $
Citations.applyCitations frKeys bibPath (itemBody pandocItem)
-- Inline SVG score fragments and data visualizations (both read files
-- relative to the source file's directory).
filePath <- getResourceFilePath
let srcDir = takeDirectory filePath
pandocWithScores <- unsafeCompiler $
Score.inlineScores srcDir pandocWithCites
pandocWithViz <- unsafeCompiler $
Viz.inlineViz srcDir pandocWithScores
-- Apply remaining AST-level filters (sidenotes, smallcaps, links, etc.).
-- applyAll touches the filesystem via Images.apply (webp existence
-- check), so it runs through unsafeCompiler.
pandocFiltered <- unsafeCompiler $ applyAll srcDir pandocWithViz
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
filePath <- getResourceFilePath
let srcDir = takeDirectory filePath
pandocItem <- readPandocWith readerOpts body'
pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
let pandocItem' = itemSetBody pandocFiltered pandocItem
let htmlItem = writePandocWith writerOpts pandocItem'
_ <- saveSnapshot "word-count" (itemSetBody (show (wordCount src)) htmlItem)
_ <- saveSnapshot "reading-time" (itemSetBody (show (readingTime src)) htmlItem)
return htmlItem

118
build/Config.hs Normal file
View File

@ -0,0 +1,118 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Site-wide configuration loaded from @site.yaml@ at the project root.
--
-- The config is exposed as top-level pure values via @unsafePerformIO@ +
-- @NOINLINE@. This is safe because:
-- 1. The config is a build-time input that never changes during a build.
-- 2. The static site generator is single-shot — no concurrency.
-- 3. NOINLINE prevents GHC from duplicating the value.
--
-- 'Main.main' forces 'siteConfig' before Hakyll starts so a missing or
-- malformed @site.yaml@ fails loudly with a parse error rather than
-- crashing midway through a build.
module Config
( SiteConfig(..)
, NavLink(..)
, Portal(..)
, siteConfig
, siteHost
, defaultAuthor
) where
import Data.Aeson (FromJSON(..), withObject, (.:), (.:?), (.!=))
import Data.Yaml (decodeFileThrow)
import Data.Text (Text)
import qualified Data.Text as T
import System.IO.Unsafe (unsafePerformIO)
-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------
data SiteConfig = SiteConfig
{ siteName :: Text
, siteUrl :: Text
, siteDescription :: Text
, siteLanguage :: Text
, authorName :: Text
, authorEmail :: Text
, feedTitle :: Text
, feedDescription :: Text
, license :: Text
, sourceUrl :: Text
, gpgFingerprint :: Text
, gpgPubkeyUrl :: Text
, navLinks :: [NavLink]
, portals :: [Portal]
} deriving (Show)
data NavLink = NavLink
{ navHref :: Text
, navLabel :: Text
} deriving (Show)
data Portal = Portal
{ portalSlug :: Text
, portalName :: Text
} deriving (Show)
-- ---------------------------------------------------------------------------
-- JSON instances
-- ---------------------------------------------------------------------------
instance FromJSON SiteConfig where
parseJSON = withObject "SiteConfig" $ \o -> SiteConfig
<$> o .: "site-name"
<*> o .: "site-url"
<*> o .: "site-description"
<*> o .:? "site-language" .!= "en"
<*> o .: "author-name"
<*> o .: "author-email"
<*> o .:? "feed-title" .!= ""
<*> o .:? "feed-description" .!= ""
<*> o .:? "license" .!= ""
<*> o .:? "source-url" .!= ""
<*> o .:? "gpg-fingerprint" .!= ""
<*> o .:? "gpg-pubkey-url" .!= "/gpg/pubkey.asc"
<*> o .:? "nav" .!= []
<*> o .:? "portals" .!= []
instance FromJSON NavLink where
parseJSON = withObject "NavLink" $ \o -> NavLink
<$> o .: "href"
<*> o .: "label"
instance FromJSON Portal where
parseJSON = withObject "Portal" $ \o -> Portal
<$> o .: "slug"
<*> o .: "name"
-- ---------------------------------------------------------------------------
-- Global config value
-- ---------------------------------------------------------------------------
-- | Loaded from @site.yaml@ at the project root on first access.
-- @NOINLINE@ prevents GHC from duplicating the I/O. If @site.yaml@ is
-- missing or invalid, evaluation throws a parse exception.
{-# NOINLINE siteConfig #-}
siteConfig :: SiteConfig
siteConfig = unsafePerformIO (decodeFileThrow "site.yaml")
-- | The site's hostname, derived from 'siteUrl'. Used by 'Filters.Links'
-- to distinguish self-links from external links.
siteHost :: Text
siteHost = extractHost (siteUrl siteConfig)
where
extractHost url
| Just rest <- T.stripPrefix "https://" url = hostOf rest
| Just rest <- T.stripPrefix "http://" url = hostOf rest
| otherwise = T.toLower url
hostOf rest =
let withPort = T.takeWhile (\c -> c /= '/' && c /= '?' && c /= '#') rest
in T.toLower (T.takeWhile (/= ':') withPort)
-- | Default author name as a 'String', for Hakyll metadata APIs that use
-- 'String' rather than 'Text'.
defaultAuthor :: String
defaultAuthor = T.unpack (authorName siteConfig)

572
build/Contexts.hs Normal file
View File

@ -0,0 +1,572 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Contexts
( siteCtx
, essayCtx
, postCtx
, pageCtx
, poetryCtx
, fictionCtx
, compositionCtx
, contentKindField
, abstractField
, tagLinksField
, authorLinksField
) where
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.List (intercalate, isPrefixOf)
import Data.Maybe (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 qualified Config
import Text.Pandoc (runPure, readMarkdown, writeHtml5String, Pandoc(..), Block(..), Inline(..))
import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
import Hakyll hiding (trim)
import Backlinks (backlinksField)
import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, versionHistoryField)
import Utils (authorSlugify, authorNameOf, trim)
-- ---------------------------------------------------------------------------
-- Affiliation field
-- ---------------------------------------------------------------------------
-- | Parses the @affiliation@ frontmatter key and exposes each entry as
-- @affiliation-name@ / @affiliation-url@ pairs.
--
-- Accepts a scalar string or a YAML list. Each entry may use pipe syntax:
-- @"Brown University | https://cs.brown.edu"@
-- Entries without a URL still produce a row; @affiliation-url@ fails
-- (evaluates to noResult), so @$if(affiliation-url)$@ works in templates.
--
-- Usage:
-- $for(affiliation-links)$
-- $if(affiliation-url)$<a href="$affiliation-url$">$affiliation-name$</a>
-- $else$$affiliation-name$$endif$$sep$ · $endfor$
affiliationField :: Context a
affiliationField = listFieldWith "affiliation-links" ctx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let entries = case lookupStringList "affiliation" meta of
Just xs -> xs
Nothing -> maybe [] (:[]) (lookupString "affiliation" meta)
return $ map (Item (fromFilePath "") . parseEntry) entries
where
ctx = field "affiliation-name" (return . fst . itemBody)
<> field "affiliation-url" (\i -> let u = snd (itemBody i)
in if null u then noResult "no url" else return u)
parseEntry s = case break (== '|') s of
(name, '|' : url) -> (trim name, trim url)
(name, _) -> (trim name, "")
-- ---------------------------------------------------------------------------
-- 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"
-- ---------------------------------------------------------------------------
-- Content kind field
-- ---------------------------------------------------------------------------
-- | @$item-kind$@: human-readable content type derived from the item's route.
-- Used on the New page to label each entry (Essay, Post, Poem, etc.).
contentKindField :: Context String
contentKindField = field "item-kind" $ \item -> do
r <- getRoute (itemIdentifier item)
return $ case r of
Nothing -> "Page"
Just r'
| "essays/" `isPrefixOf` r' -> "Essay"
| "blog/" `isPrefixOf` r' -> "Post"
| "poetry/" `isPrefixOf` r' -> "Poem"
| "fiction/" `isPrefixOf` r' -> "Fiction"
| "music/" `isPrefixOf` r' -> "Composition"
| otherwise -> "Page"
-- ---------------------------------------------------------------------------
-- 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.
--
-- Each child Item is keyed on @<parent-identifier>#js-<index>@ so that two
-- pages referencing the same script path (e.g. @shared.js@) do not collide
-- in Hakyll's item store.
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)
parent = toFilePath (itemIdentifier item)
return $ zipWith
(\i s -> Item (fromFilePath (parent ++ "#js-" ++ show (i :: Int))) s)
[0 ..]
scripts
where
ctx = field "script-src" (return . itemBody)
-- ---------------------------------------------------------------------------
-- Tag links 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 ctx $ \item ->
map toItem <$> getTags (itemIdentifier item)
where
toItem t = Item (fromFilePath (t ++ "/index.html")) t
ctx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
-- ---------------------------------------------------------------------------
-- Author links field
-- ---------------------------------------------------------------------------
--
-- 'authorSlugify' and 'authorNameOf' are imported from 'Utils' so that
-- they cannot drift from the copies in 'Authors'.
-- | Exposes each item's authors as @author-name@ / @author-url@ pairs.
-- Defaults to the site's configured @author-name@ when no "authors"
-- frontmatter key is present.
--
-- Entries that produce an empty name (e.g. @"| https://url"@) or an empty
-- slug (e.g. all-punctuation names) are dropped, so the field never emits
-- a @/authors//@ link.
--
-- $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)
rawNames = if null entries then [Config.defaultAuthor] else map authorNameOf entries
validNames = filter (\n -> not (null n) && not (null (authorSlugify n))) rawNames
names = if null validNames then [Config.defaultAuthor] else validNames
return $ map (\n -> Item (fromFilePath "") (n, "/authors/" ++ authorSlugify n ++ "/")) names
where
ctx = field "author-name" (return . fst . itemBody)
<> field "author-url" (return . snd . itemBody)
-- ---------------------------------------------------------------------------
-- Abstract field
-- ---------------------------------------------------------------------------
-- | Renders the abstract using Pandoc to support Markdown and LaTeX math.
-- Strips the outer @<p>@ wrapping. A single-paragraph abstract becomes a
-- bare @Plain@ so the rendered HTML is unwrapped inlines. A multi-paragraph
-- abstract (author used a blank line in the YAML literal block) is flattened
-- to a single @Plain@ with @LineBreak@ separators between what were
-- originally paragraph boundaries — the visual break is preserved without
-- emitting stray @<p>@ tags inside the metadata block. Mixed block content
-- (e.g. an abstract containing a blockquote) falls through unchanged.
abstractField :: Context String
abstractField = field "abstract" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "abstract" meta of
Nothing -> fail "no abstract"
Just src -> do
let pandocResult = runPure $ do
doc <- readMarkdown defaultHakyllReaderOptions (T.pack src)
let doc' = case doc of
Pandoc m [Para ils] -> Pandoc m [Plain ils]
Pandoc m blocks
| all isPara blocks && not (null blocks) ->
let joined = intercalate [LineBreak]
[ils | Para ils <- blocks]
in Pandoc m [Plain joined]
_ -> doc
let wOpts = defaultHakyllWriterOptions { writerHTMLMathMethod = MathML }
writeHtml5String wOpts doc'
case pandocResult of
Left err -> fail $ "Pandoc error rendering abstract: " ++ show err
Right html -> return (T.unpack html)
where
isPara (Para _) = True
isPara _ = False
siteCtx :: Context String
siteCtx =
constField "site-title" (T.unpack (Config.siteName Config.siteConfig))
<> constField "site-url" (T.unpack (Config.siteUrl Config.siteConfig))
<> constField "site-description" (T.unpack (Config.siteDescription Config.siteConfig))
<> constField "site-language" (T.unpack (Config.siteLanguage Config.siteConfig))
<> constField "author-name" (T.unpack (Config.authorName Config.siteConfig))
<> constField "author-email" (T.unpack (Config.authorEmail Config.siteConfig))
<> constField "license" (T.unpack (Config.license Config.siteConfig))
<> optionalConstField "source-url" (T.unpack (Config.sourceUrl Config.siteConfig))
<> optionalConstField "gpg-fingerprint" (T.unpack (Config.gpgFingerprint Config.siteConfig))
<> optionalConstField "gpg-pubkey-url" (T.unpack (Config.gpgPubkeyUrl Config.siteConfig))
<> navLinksField
<> portalsField
<> buildTimeField
<> pageScriptsField
<> abstractField
<> defaultContext
where
optionalConstField name value
| null value = field name (\_ -> fail (name ++ " is empty"))
| otherwise = constField name value
navLinksField = listField "nav-links" navCtx (return navItems)
navItems = zipWith
(\i nl -> Item (fromFilePath ("nav-" ++ show (i :: Int))) nl)
[0 :: Int ..]
(Config.navLinks Config.siteConfig)
navCtx = field "href" (return . T.unpack . Config.navHref . itemBody)
<> field "label" (return . T.unpack . Config.navLabel . itemBody)
portalsField = listField "portals" portalCtx (return portalItems)
portalItems = zipWith
(\i p -> Item (fromFilePath ("portal-" ++ show (i :: Int))) p)
[0 :: Int ..]
(Config.portals Config.siteConfig)
portalCtx = field "portal-slug" (return . T.unpack . Config.portalSlug . itemBody)
<> field "portal-name" (return . T.unpack . Config.portalName . itemBody)
-- ---------------------------------------------------------------------------
-- Helper: load a named snapshot as a context field
-- ---------------------------------------------------------------------------
-- | @snapshotField name snap@ creates a context field @name@ whose value is
-- the body of the snapshot @snap@ saved for the current item.
snapshotField :: String -> Snapshot -> Context String
snapshotField name snap = field name $ \item ->
itemBody <$> loadSnapshot (itemIdentifier item) snap
-- ---------------------------------------------------------------------------
-- Essay context
-- ---------------------------------------------------------------------------
-- | Bibliography field: loads the citation HTML saved by essayCompiler.
-- Returns noResult (making $if(bibliography)$ false) when empty.
-- Also provides $has-citations$ for conditional JS loading.
bibliographyField :: Context String
bibliographyField = bibContent <> hasCitations
where
bibContent = field "bibliography" $ \item -> do
bib <- itemBody <$> loadSnapshot (itemIdentifier item) "bibliography"
if null bib then fail "no bibliography" else return bib
hasCitations = field "has-citations" $ \item -> do
bib <- itemBody <$> (loadSnapshot (itemIdentifier item) "bibliography"
:: Compiler (Item String))
if null bib then fail "no citations" else return "true"
-- | Further-reading field: loads the further-reading HTML saved by essayCompiler.
-- Returns noResult (making $if(further-reading-refs)$ false) when empty.
furtherReadingField :: Context String
furtherReadingField = field "further-reading-refs" $ \item -> do
fr <- itemBody <$> (loadSnapshot (itemIdentifier item) "further-reading-refs"
:: Compiler (Item String))
if null fr then fail "no further reading" else return fr
-- ---------------------------------------------------------------------------
-- Epistemic fields
-- ---------------------------------------------------------------------------
-- | Render an integer 15 frontmatter key as filled/empty dot chars.
-- Returns @noResult@ when the key is absent or unparseable.
dotsField :: String -> String -> Context String
dotsField ctxKey metaKey = field ctxKey $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString metaKey meta >>= readMaybe of
Nothing -> fail (ctxKey ++ ": not set")
Just (n :: Int) ->
let v = max 0 (min 5 n)
in return (replicate v '\x25CF' ++ replicate (5 - v) '\x25CB')
-- | @$confidence-trend$@: ↑, ↓, or → derived from the last two entries
-- in the @confidence-history@ frontmatter list. Returns @noResult@ when
-- there is no history or only a single entry.
--
-- The arrow flips when the absolute change crosses 'trendThreshold'
-- (currently 5 percentage points). Smaller swings count as flat.
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 -> case lastTwo xs of
Nothing -> fail "no confidence history"
Just (prevS, curS) ->
let prev = readMaybe prevS :: Maybe Int
cur = readMaybe curS :: Maybe Int
in case (prev, cur) of
(Just p, Just c)
| c - p > trendThreshold -> return "\x2191" -- ↑
| p - c > trendThreshold -> return "\x2193" -- ↓
| otherwise -> return "\x2192" -- →
_ -> return "\x2192"
where
trendThreshold :: Int
trendThreshold = 5
-- Total replacement for @(xs !! (length xs - 2), last xs)@: returns
-- the last two elements of a list, in order, or 'Nothing' when the
-- list has fewer than two entries.
lastTwo :: [a] -> Maybe (a, a)
lastTwo [] = Nothing
lastTwo [_] = Nothing
lastTwo [a, b] = Just (a, b)
lastTwo (_ : rest) = lastTwo rest
-- | @$overall-score$@: weighted composite of confidence (60 %) and
-- evidence quality (40 %), expressed as an integer on a 0100 scale.
--
-- Importance is intentionally excluded from the score: it answers
-- "should you read this?", not "should you trust it?", and folding
-- the two together inflated the number and muddied its meaning.
-- It still appears in the footer as an independent orientation
-- signal — just not as a credibility input.
--
-- The 15 evidence scale is rescaled as @(ev 1) / 4@ rather than
-- plain @ev / 5@. The naive form left a hidden +6 floor (since
-- @1/5 = 0.2@) and a midpoint of 0.6 instead of 0.5; the rescale
-- makes evidence=1 contribute zero and evidence=3 contribute exactly
-- half, so a "true midpoint" entry (conf=50, ev=3) lands on 50.
--
-- Returns @noResult@ when confidence or evidence is absent, so
-- @$if(overall-score)$@ guards the template safely.
--
-- Formula: raw = conf/100 · 0.6 + (ev 1)/4 · 0.4 (01)
-- score = clamp₀₋₁₀₀(round(raw · 100))
overallScoreField :: Context String
overallScoreField = field "overall-score" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let readInt s = readMaybe s :: Maybe Int
case ( readInt =<< lookupString "confidence" meta
, readInt =<< lookupString "evidence" meta
) of
(Just conf, Just ev) ->
let raw :: Double
raw = fromIntegral conf / 100.0 * 0.6
+ fromIntegral (ev - 1) / 4.0 * 0.4
score = max 0 (min 100 (round (raw * 100.0) :: Int))
in return (show score)
_ -> fail "overall-score: confidence or evidence not set"
-- | All epistemic context fields composed.
epistemicCtx :: Context String
epistemicCtx =
dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence"
<> overallScoreField
<> confidenceTrendField
<> stabilityField
<> lastReviewedField
-- ---------------------------------------------------------------------------
-- Essay context
-- ---------------------------------------------------------------------------
essayCtx :: Context String
essayCtx =
authorLinksField
<> affiliationField
<> snapshotField "toc" "toc"
<> snapshotField "word-count" "word-count"
<> snapshotField "reading-time" "reading-time"
<> bibliographyField
<> furtherReadingField
<> backlinksField
<> similarLinksField
<> 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
<> affiliationField
<> backlinksField
<> similarLinksField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> constField "math" "true"
<> siteCtx
-- ---------------------------------------------------------------------------
-- Page context
-- ---------------------------------------------------------------------------
pageCtx :: Context String
pageCtx = authorLinksField <> affiliationField <> 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
}
-- | Parse the @movements@ frontmatter key. Returns parsed movements and a
-- list of human-readable warnings for any entries that failed to parse.
-- Callers can surface the warnings via 'unsafeCompiler' so silent typos
-- don't strip movements without diagnostic.
parseMovementsWithWarnings :: Metadata -> ([Movement], [String])
parseMovementsWithWarnings meta =
case KM.lookup "movements" meta of
Just (Array v) ->
let results = zipWith parseIndexed [1 :: Int ..] (V.toList v)
in ( [m | Right m <- results]
, [w | Left w <- results]
)
_ -> ([], [])
where
parseIndexed i value =
case parseOne value of
Just m -> Right m
Nothing -> Left $
"movement #" ++ show i ++ " is missing a required field "
++ "(name, page, or duration) — entry skipped"
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
parseMovements :: Metadata -> [Movement]
parseMovements = fst . parseMovementsWithWarnings
-- | 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, warnings) = parseMovementsWithWarnings meta
ident = toFilePath (itemIdentifier item)
unsafeCompiler $ mapM_
(\w -> putStrLn $ "[Movements] " ++ ident ++ ": " ++ w)
warnings
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)))

49
build/Filters.hs Normal file
View File

@ -0,0 +1,49 @@
{-# 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.Transclusion as Transclusion
import qualified Filters.EmbedPdf as EmbedPdf
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.
--
-- 'Filters.Images.apply' is the only IO-performing filter (it probes the
-- filesystem for @.webp@ companions before deciding whether to emit
-- @<picture>@). It runs first — i.e. innermost in the composition — and
-- every downstream filter stays pure. @srcDir@ is the directory of the
-- source Markdown file, passed through to Images for relative-path
-- resolution of co-located assets.
applyAll :: FilePath -> Pandoc -> IO Pandoc
applyAll srcDir doc = do
imagesDone <- Images.apply srcDir doc
pure
. Sidenotes.apply
. Typography.apply
. Links.apply
. Smallcaps.apply
. Dropcaps.apply
. Math.apply
. Code.apply
$ imagesDone
-- | Apply source-level preprocessors to the raw Markdown string.
-- Order matters: EmbedPdf must run before Transclusion, because the
-- transclusion parser would otherwise treat {{pdf:...}} as a broken slug.
preprocessSource :: String -> String
preprocessSource = Transclusion.preprocess . EmbedPdf.preprocess . Wikilinks.preprocess

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

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

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

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

81
build/Filters/EmbedPdf.hs Normal file
View File

@ -0,0 +1,81 @@
{-# LANGUAGE GHC2021 #-}
-- | Source-level preprocessor for inline PDF embeds.
--
-- Rewrites block-level @{{pdf:...}}@ directives to raw HTML that renders the
-- named file inside a vendored PDF.js viewer iframe.
--
-- Syntax (must be the sole content of a line after trimming):
--
-- > {{pdf:/papers/foo.pdf}} — embed from page 1
-- > {{pdf:/papers/foo.pdf#5}} — start at page 5 (bare integer)
-- > {{pdf:/papers/foo.pdf#page=5}} — start at page 5 (explicit form)
--
-- The file path must be root-relative (begins with @/@).
-- PDF.js is expected to be vendored at @/pdfjs/web/viewer.html@.
module Filters.EmbedPdf (preprocess) where
import Data.Char (isDigit)
import Data.List (isPrefixOf, isSuffixOf)
import qualified Utils as U
-- | Apply PDF-embed substitution to the raw Markdown source string.
preprocess :: String -> String
preprocess = unlines . map processLine . lines
processLine :: String -> String
processLine line =
case parseDirective (U.trim line) of
Nothing -> line
Just (filePath, pageHash) -> renderEmbed filePath pageHash
-- | Parse a @{{pdf:/path/to/file.pdf}}@ or @{{pdf:/path.pdf#N}}@ directive.
-- Returns @(filePath, pageHash)@ where @pageHash@ is either @""@ or @"#page=N"@.
parseDirective :: String -> Maybe (String, String)
parseDirective s
| not ("{{pdf:" `isPrefixOf` s) = Nothing
| not ("}}" `isSuffixOf` s) = Nothing
| otherwise =
let inner = take (length s - 2) (drop 6 s) -- strip "{{pdf:" and "}}"
(path, frag) = break (== '#') inner
in if null path
then Nothing
else Just (path, parsePageHash frag)
-- | Convert the fragment part of the directive (e.g. @#5@ or @#page=5@) to a
-- PDF.js-compatible @#page=N@ hash, or @""@ if absent/invalid.
parsePageHash :: String -> String
parsePageHash ('#' : rest)
| "page=" `isPrefixOf` rest =
let n = takeWhile isDigit (drop 5 rest)
in if null n then "" else "#page=" ++ n
| all isDigit rest && not (null rest) = "#page=" ++ rest
parsePageHash _ = ""
-- | Render the HTML for a PDF embed.
renderEmbed :: String -> String -> String
renderEmbed filePath pageHash =
let viewerUrl = "/pdfjs/web/viewer.html?file=" ++ encodeQueryValue filePath ++ pageHash
in "<div class=\"pdf-embed-wrapper\">"
++ "<iframe class=\"pdf-embed\""
++ " src=\"" ++ viewerUrl ++ "\""
++ " title=\"PDF document\""
++ " loading=\"lazy\""
++ " allowfullscreen></iframe>"
++ "</div>"
-- | Percent-encode characters that would break a query-string value.
-- Slashes are left unencoded so root-relative paths remain readable and
-- work correctly with PDF.js's internal fetch. @#@ is encoded for
-- defense-in-depth even though the directive parser already splits on it
-- before this function is called.
encodeQueryValue :: String -> String
encodeQueryValue = concatMap enc
where
enc ' ' = "%20"
enc '&' = "%26"
enc '?' = "%3F"
enc '+' = "%2B"
enc '"' = "%22"
enc '#' = "%23"
enc c = [c]

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

@ -0,0 +1,191 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Image filter: lazy loading, lightbox markers, and WebP <picture> wrappers.
--
-- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion
-- exists on disk at build time, emits a @<picture>@ element with a WebP
-- @<source>@ and the original format as the @<img>@ fallback. When the
-- webp companion is absent (cwebp not installed, @convert-images.sh@ not
-- yet run, or a single file missed), the filter emits a plain @<img>@ so
-- the image still renders. This matters because browsers do NOT fall back
-- from a 404'd @<source>@ inside @<picture>@ to the nested @<img>@ — the
-- source is selected up front and a broken one leaves the area blank.
--
-- @tools/convert-images.sh@ produces the companion .webp files at build
-- time. When cwebp is not installed the script is a no-op, and this
-- filter degrades gracefully to plain @<img>@.
--
-- SVG files and external URLs are passed through with only lazy loading
-- (and lightbox markers for standalone images).
module Filters.Images (apply) where
import Data.Char (toLower)
import Data.List (isPrefixOf)
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesFileExist)
import System.FilePath (replaceExtension, takeExtension, (</>))
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
import qualified Utils as U
-- | Apply image attribute injection and WebP wrapping to the entire document.
--
-- @srcDir@ is the directory of the source Markdown file, used to resolve
-- relative image paths when probing for the corresponding @.webp@
-- companion file. Absolute paths (leading @/@) are resolved against
-- @static/@ instead, matching the layout @convert-images.sh@ writes to.
apply :: FilePath -> Pandoc -> IO Pandoc
apply srcDir = walkM (transformInline srcDir)
-- ---------------------------------------------------------------------------
-- Core transformation
-- ---------------------------------------------------------------------------
transformInline :: FilePath -> Inline -> IO Inline
transformInline srcDir (Link lAttr ils lTarget) = do
-- Recurse into link contents; images inside a link get no lightbox marker.
ils' <- mapM (wrapLinkedImg srcDir) ils
pure (Link lAttr ils' lTarget)
transformInline srcDir (Image attr alt target) =
renderImg srcDir attr alt target True
transformInline _ x = pure x
wrapLinkedImg :: FilePath -> Inline -> IO Inline
wrapLinkedImg srcDir (Image iAttr alt iTarget) =
renderImg srcDir iAttr alt iTarget False
wrapLinkedImg _ x = pure x
-- | Dispatch on image type:
-- * Local raster with webp companion on disk → @<picture>@ with WebP @<source>@
-- * Local raster without companion → plain @<img>@ (graceful degradation)
-- * Everything else (SVG, URL) → plain @<img>@ with loading/lightbox attrs
renderImg :: FilePath -> Attr -> [Inline] -> Target -> Bool -> IO Inline
renderImg srcDir attr alt target@(src, _) lightbox
| isLocalRaster (T.unpack src) = do
hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
if hasWebp
then pure $ RawInline (Format "html")
(renderPicture attr alt target lightbox)
else pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr))
alt target
| otherwise =
pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target
where
addLightbox True a = addAttr "data-lightbox" "true" a
addLightbox False a = a
-- | Physical on-disk path of the @.webp@ companion for a Markdown image src.
--
-- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that
-- is where Hakyll's static-asset rule writes them from. Relative paths
-- resolve against the source file's directory, where Pandoc already
-- expects co-located assets to live.
webpPhysicalPath :: FilePath -> Text -> FilePath
webpPhysicalPath srcDir src =
let s = T.unpack src
physical = if "/" `isPrefixOf` s
then "static" ++ s
else srcDir </> s
in replaceExtension physical ".webp"
-- ---------------------------------------------------------------------------
-- <picture> rendering
-- ---------------------------------------------------------------------------
-- | Emit a @<picture>@ element with a WebP @<source>@ and an @<img>@ fallback.
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text
renderPicture (ident, classes, kvs) alt (src, title) lightbox =
T.concat
[ "<picture>"
, "<source srcset=\"", T.pack webpSrc, "\" type=\"image/webp\">"
, "<img"
, attrId ident
, attrClasses classes
, " src=\"", esc src, "\""
, attrAlt alt
, attrTitle title
, " loading=\"lazy\""
, if lightbox then " data-lightbox=\"true\"" else ""
, renderKvs passedKvs
, ">"
, "</picture>"
]
where
webpSrc = replaceExtension (T.unpack src) ".webp"
-- Strip attrs we handle explicitly above (id/class/alt/title) and the
-- attrs we always emit ourselves (loading, data-lightbox), so they don't
-- appear twice on the <img>.
passedKvs = filter
(\(k, _) -> k `notElem`
["loading", "data-lightbox", "id", "class", "alt", "title", "src"])
kvs
attrId :: Text -> Text
attrId t = if T.null t then "" else " id=\"" <> esc t <> "\""
attrClasses :: [Text] -> Text
attrClasses [] = ""
attrClasses cs = " class=\"" <> T.intercalate " " (map esc cs) <> "\""
attrAlt :: [Inline] -> Text
attrAlt ils = let t = stringify ils
in if T.null t then "" else " alt=\"" <> esc t <> "\""
attrTitle :: Text -> Text
attrTitle t = if T.null t then "" else " title=\"" <> esc t <> "\""
renderKvs :: [(Text, Text)] -> Text
renderKvs = T.concat . map (\(k, v) -> " " <> k <> "=\"" <> esc v <> "\"")
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- | True for local (non-URL) images with a raster format we can convert.
isLocalRaster :: FilePath -> Bool
isLocalRaster src = not (isUrl src) && lowerExt src `elem` [".jpg", ".jpeg", ".png", ".gif"]
isUrl :: String -> Bool
isUrl s = any (`isPrefixOf` s) ["http://", "https://", "//", "data:"]
-- | Extension of a path, lowercased (e.g. ".JPG" → ".jpg").
-- Returns the empty string for paths with no extension.
lowerExt :: FilePath -> String
lowerExt = map toLower . takeExtension
-- | Prepend a key=value pair if not already present.
addAttr :: Text -> Text -> Attr -> Attr
addAttr k v (i, cs, kvs)
| any ((== k) . fst) kvs = (i, cs, kvs)
| otherwise = (i, cs, (k, v) : kvs)
-- | Plain-text content of a list of inlines (for alt text).
stringify :: [Inline] -> Text
stringify = T.concat . map go
where
go (Str t) = t
go Space = " "
go SoftBreak = " "
go LineBreak = " "
go (Emph ils) = stringify ils
go (Strong ils) = stringify ils
go (Strikeout ils) = stringify ils
go (Superscript ils) = stringify ils
go (Subscript ils) = stringify ils
go (SmallCaps ils) = stringify ils
go (Underline ils) = stringify ils
go (Quoted _ ils) = stringify ils
go (Cite _ ils) = stringify ils
go (Code _ t) = t
go (Math _ t) = t
go (RawInline _ _) = ""
go (Link _ ils _) = stringify ils
go (Image _ ils _) = stringify ils
go (Span _ ils) = stringify ils
go (Note _) = ""
-- | HTML-escape a text value for use in attribute values.
-- Defers to the canonical 'Utils.escapeHtmlText'.
esc :: Text -> Text
esc = U.escapeHtmlText

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

@ -0,0 +1,133 @@
{-# 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 (see 'domainIcon' for the full list).
-- * 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)
import Config (siteHost)
-- | Apply link classification to the entire document.
-- Two passes: PDF links first (rewrites href to viewer URL), then external
-- link classification (operates on http/https, so no overlap).
apply :: Pandoc -> Pandoc
apply = walk classifyLink . walk classifyPdfLink
-- | Rewrite root-relative PDF links to open via the vendored PDF.js viewer.
-- Preserves the original path in @data-pdf-src@ so the popup thumbnail
-- provider can locate the corresponding @.thumb.png@ file.
-- Skips links that are already pointing at the viewer (idempotent).
--
-- Handles fragment identifiers (e.g. @/papers/foo.pdf#page=5@): the
-- fragment is stripped before the @.pdf@ suffix check and re-attached
-- after the viewer URL so PDF.js's anchor handling works.
classifyPdfLink :: Inline -> Inline
classifyPdfLink (Link (ident, classes, kvs) ils (url, title))
| "/" `T.isPrefixOf` url
, let (path, fragment) = T.break (== '#') url
, ".pdf" `T.isSuffixOf` T.toLower path
, "pdf-link" `notElem` classes =
let viewerUrl = "/pdfjs/web/viewer.html?file="
<> encodeQueryValue path <> fragment
classes' = classes ++ ["pdf-link"]
kvs' = kvs ++ [("data-pdf-src", path)]
in Link (ident, classes', kvs') ils (viewerUrl, title)
classifyPdfLink x = x
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
-- ---------------------------------------------------------------------------
-- | True if the URL points outside the site's domain.
--
-- Uses a strict hostname comparison rather than substring matching, so
-- that a hostile lookalike like @evil-example.com.attacker.com@ is
-- correctly classified as external (and gets @rel=noopener noreferrer@
-- plus @target=_blank@ applied).
isExternal :: Text -> Bool
isExternal url =
case extractHost url of
Nothing -> False
Just host ->
not (host == siteHost || ("." <> siteHost) `T.isSuffixOf` host)
-- | Extract the lowercased hostname from an absolute http(s) URL.
-- Returns 'Nothing' for non-http(s) URLs (relative paths, mailto:, etc.).
extractHost :: Text -> Maybe Text
extractHost url
| Just rest <- T.stripPrefix "https://" url = Just (hostOf rest)
| Just rest <- T.stripPrefix "http://" url = Just (hostOf rest)
| otherwise = Nothing
where
hostOf rest =
let withPort = T.takeWhile (\c -> c /= '/' && c /= '?' && c /= '#') rest
host = T.takeWhile (/= ':') withPort
in T.toLower host
-- | Icon name for the link, matching a file in /images/link-icons/<name>.svg.
domainIcon :: Text -> Text
domainIcon url
-- Scholarly / reference
| "wikipedia.org" `T.isInfixOf` url = "wikipedia"
| "arxiv.org" `T.isInfixOf` url = "arxiv"
| "doi.org" `T.isInfixOf` url = "doi"
| "worldcat.org" `T.isInfixOf` url = "worldcat"
| "orcid.org" `T.isInfixOf` url = "orcid"
| "archive.org" `T.isInfixOf` url = "internet-archive"
-- Code / software
| "github.com" `T.isInfixOf` url = "github"
| "tensorflow.org" `T.isInfixOf` url = "tensorflow"
-- AI companies
| "anthropic.com" `T.isInfixOf` url = "anthropic"
| "openai.com" `T.isInfixOf` url = "openai"
-- Social / media
| "twitter.com" `T.isInfixOf` url = "twitter"
| "x.com" `T.isInfixOf` url = "twitter"
| "reddit.com" `T.isInfixOf` url = "reddit"
| "youtube.com" `T.isInfixOf` url = "youtube"
| "youtu.be" `T.isInfixOf` url = "youtube"
| "tiktok.com" `T.isInfixOf` url = "tiktok"
| "substack.com" `T.isInfixOf` url = "substack"
| "news.ycombinator.com" `T.isInfixOf` url = "hacker-news"
-- News
| "nytimes.com" `T.isInfixOf` url = "new-york-times"
-- Institutions
| "nasa.gov" `T.isInfixOf` url = "nasa"
| "apple.com" `T.isInfixOf` url = "apple"
| otherwise = "external"
-- | Percent-encode characters that would break a @?file=@ query-string value.
-- Slashes are intentionally left unencoded so root-relative paths remain
-- readable and work correctly with PDF.js's internal fetch.
encodeQueryValue :: Text -> Text
encodeQueryValue = T.concatMap enc
where
enc ' ' = "%20"
enc '&' = "%26"
enc '?' = "%3F"
enc '+' = "%2B"
enc '"' = "%22"
enc c = T.singleton c

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

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

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

@ -0,0 +1,122 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Inline SVG score fragments into the Pandoc AST.
--
-- Fenced-div syntax in Markdown:
--
-- > :::score-fragment{score-name="Main Theme, mm. 18" score-caption="The opening gesture."}
-- > ![](scores/main-theme.svg)
-- > :::
--
-- The filter reads the referenced SVG from disk (path resolved relative to
-- the source file's directory), replaces hardcoded black fills/strokes with
-- @currentColor@ for dark-mode compatibility, and emits a @\<figure\>@ with
-- the appropriate exhibit attributes for gallery.js TOC integration.
module Filters.Score (inlineScores) where
import Control.Exception (IOException, try)
import Data.Maybe (listToMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Directory (doesFileExist)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
import qualified Utils as U
-- | 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
exists <- doesFileExist fullPath
if not exists
then do
hPutStrLn stderr $
"[Score] missing SVG: " ++ fullPath
++ " (referenced from a score-fragment in " ++ baseDir ++ ")"
return (errorBlock mName ("Missing score: " <> path))
else do
result <- try (TIO.readFile fullPath) :: IO (Either IOException T.Text)
case result of
Left e -> do
hPutStrLn stderr $
"[Score] read error on " ++ fullPath ++ ": " ++ show e
return (errorBlock mName ("Could not read score: " <> path))
Right svgRaw -> do
let html = buildHtml mName mCaption (processColors svgRaw)
return $ RawBlock (Format "html") html
inlineScore _ block = return block
-- | Render an inline error block in place of a missing or unreadable score.
-- Mirrors the convention in 'Filters.Viz.errorBlock' so build failures are
-- visible to the author without aborting the entire site build.
errorBlock :: Maybe T.Text -> T.Text -> Block
errorBlock mName message =
RawBlock (Format "html") $ T.concat
[ "<figure class=\"score-fragment score-fragment--error\""
, maybe "" (\n -> " data-exhibit-name=\"" <> escHtml n <> "\"") mName
, ">"
, "<div class=\"score-fragment-error\">"
, escHtml message
, "</div>"
, "</figure>"
]
-- | 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 = U.escapeHtmlText

View File

@ -0,0 +1,99 @@
{-# 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 using base-26 expansion
-- (Excel-column style): 1→a, 2→b, … 26→z, 27→aa, 28→ab, … 52→az,
-- 53→ba, … 702→zz, 703→aaa. Guarantees a unique label per counter so
-- no two sidenotes in a single document collide on @id="sn-…"@.
toLabel :: Int -> Text
toLabel n
| n <= 0 = "?"
| otherwise = T.pack (go n)
where
go k
| k <= 0 = ""
| otherwise =
let (q, r) = (k - 1) `divMod` 26
in go q ++ [toEnum (fromEnum 'a' + r)]
renderNote :: Int -> [Block] -> Text
renderNote n blocks =
let inner = blocksToInlineHtml 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>"
]
-- | Render a list of Pandoc blocks for inclusion inside an inline @<span
-- class="sidenote">@. Each top-level @Para@ is wrapped in a
-- @<span class="sidenote-para">@ instead of a @<p>@ (which would be
-- invalid inside a @<span>@); other block types are rendered with the
-- regular Pandoc HTML writer.
--
-- Operating on the AST is preferred over post-rendered string
-- substitution because the latter mangles content that legitimately
-- contains the literal text @<p>@ (e.g. code samples discussing HTML).
blocksToInlineHtml :: [Block] -> Text
blocksToInlineHtml = T.concat . map renderOne
where
renderOne :: Block -> Text
renderOne (Para inlines) =
"<span class=\"sidenote-para\">"
<> inlinesToHtml inlines
<> "</span>"
renderOne (Plain inlines) =
inlinesToHtml inlines
renderOne b =
blocksToHtml [b]
-- | Render a list of inlines to HTML (no surrounding @<p>@).
inlinesToHtml :: [Inline] -> Text
inlinesToHtml inlines =
case runPure (writeHtml5String (def :: WriterOptions) (Pandoc mempty [Plain inlines])) of
Left _ -> T.empty
Right t -> t
-- | Render a list of Pandoc blocks to an HTML fragment via a pure writer run.
blocksToHtml :: [Block] -> Text
blocksToHtml blocks =
case runPure (writeHtml5String (def :: WriterOptions) (Pandoc mempty blocks)) of
Left _ -> T.empty
Right t -> t

View File

@ -0,0 +1,66 @@
{-# 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)
import qualified Utils as U
-- | 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 = U.escapeHtmlText

View File

@ -0,0 +1,75 @@
{-# LANGUAGE GHC2021 #-}
-- | Source-level transclusion preprocessor.
--
-- Rewrites block-level {{slug}} and {{slug#section}} directives to raw
-- HTML placeholders that transclude.js resolves at runtime.
--
-- A directive must be the sole content of a line (after trimming) to be
-- replaced — this prevents accidental substitution inside prose or code.
--
-- Examples:
-- {{my-essay}} → full-page transclusion of /my-essay.html
-- {{essays/deep-dive}} → /essays/deep-dive.html (full body)
-- {{my-essay#introduction}} → section "introduction" of /my-essay.html
module Filters.Transclusion (preprocess) where
import Data.List (isSuffixOf, isPrefixOf, stripPrefix)
import qualified Utils as U
-- | Apply transclusion substitution to the raw Markdown source string.
preprocess :: String -> String
preprocess = unlines . map processLine . lines
processLine :: String -> String
processLine line =
case parseDirective (U.trim line) of
Nothing -> line
Just (url, secAttr) ->
"<div class=\"transclude\" data-src=\"" ++ escAttr url ++ "\""
++ secAttr ++ "></div>"
-- | Parse a {{slug}} or {{slug#section}} directive.
-- Returns (absolute-url, section-attribute-string) or Nothing.
--
-- The section name is HTML-escaped before being interpolated into the
-- @data-section@ attribute, so a stray @\"@, @&@, @<@, or @>@ in a
-- section name cannot break the surrounding markup.
parseDirective :: String -> Maybe (String, String)
parseDirective s = do
inner <- stripPrefix "{{" s >>= stripSuffix "}}"
case break (== '#') inner of
("", _) -> Nothing
(slug, "") -> Just (slugToUrl slug, "")
(slug, '#' : sec)
| null sec -> Just (slugToUrl slug, "")
| otherwise -> Just (slugToUrl slug,
" data-section=\"" ++ escAttr sec ++ "\"")
_ -> Nothing
-- | Convert a slug (possibly with leading slash, possibly with path segments)
-- to a root-relative .html URL. Idempotent for slugs that already end in
-- @.html@ so callers can safely pass either form.
slugToUrl :: String -> String
slugToUrl slug
| ".html" `isSuffixOf` slug, "/" `isPrefixOf` slug = slug
| ".html" `isSuffixOf` slug = "/" ++ slug
| "/" `isPrefixOf` slug = slug ++ ".html"
| otherwise = "/" ++ slug ++ ".html"
-- | Minimal HTML attribute-value escape.
escAttr :: String -> String
escAttr = concatMap esc
where
esc '&' = "&amp;"
esc '<' = "&lt;"
esc '>' = "&gt;"
esc '"' = "&quot;"
esc '\'' = "&#39;"
esc c = [c]
-- | Strip a suffix from a string, returning Nothing if not present.
stripSuffix :: String -> String -> Maybe String
stripSuffix suf str
| suf `isSuffixOf` str = Just (take (length str - length suf) str)
| otherwise = Nothing

View File

@ -0,0 +1,54 @@
{-# 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 Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
import Utils (escapeHtmlText)
-- | 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.
--
-- Both the @title@ attribute and the visible body pass through
-- 'escapeHtmlText' for consistency with every other raw-HTML emitter
-- in the filter pipeline. The abbreviations themselves are ASCII-safe
-- so this is defense-in-depth rather than a live hazard.
expandAbbrev :: Inline -> Inline
expandAbbrev (Str t) =
case lookup t abbrevMap of
Just title ->
RawInline "html" $
"<abbr title=\"" <> escapeHtmlText title <> "\">"
<> escapeHtmlText t <> "</abbr>"
Nothing -> Str t
expandAbbrev x = x

189
build/Filters/Viz.hs Normal file
View File

@ -0,0 +1,189 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Inline data visualizations into the Pandoc AST.
--
-- Two fenced-div classes are recognized in Markdown:
--
-- __Static figure__ (Matplotlib → SVG, no client-side JS required):
--
-- > ::: {.figure script="figures/myplot.py" caption="Caption text"}
-- > :::
--
-- Runs the Python script; stdout must be an SVG document with a
-- transparent background. Black fills and strokes are replaced with
-- @currentColor@ so figures adapt to dark mode automatically.
-- See @tools/viz_theme.py@ for the recommended matplotlib setup.
--
-- __Interactive figure__ (Altair/Vega-Lite → JSON spec):
--
-- > ::: {.visualization script="figures/myplot.py" caption="Caption text"}
-- > :::
--
-- Runs the Python script; stdout must be a Vega-Lite JSON spec. The spec
-- is embedded verbatim inside a @\<script type=\"application\/json\"\>@ tag;
-- @viz.js@ picks it up and renders it via Vega-Embed, applying a
-- monochrome theme that responds to the site\'s light/dark toggle.
--
-- __Authoring conventions:__
--
-- * Scripts are run from the project root; paths are relative to it.
-- * @script=@ paths are resolved relative to the source file\'s directory.
-- * For @.figure@ scripts: use pure black (@#000000@) for all drawn
-- elements and transparent backgrounds so @processColors@ and CSS
-- @currentColor@ handle dark mode.
-- * For @.visualization@ scripts: set encoding colours to @\"black\"@;
-- @viz.js@ applies the site palette via Vega-Lite @config@.
-- * Set @viz: true@ in the page\'s YAML frontmatter to load Vega JS.
module Filters.Viz (inlineViz) where
import Control.Exception (IOException, catch)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr)
import System.Process (readProcessWithExitCode)
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
import qualified Utils as U
-- ---------------------------------------------------------------------------
-- Public entry point
-- ---------------------------------------------------------------------------
-- | Walk the Pandoc AST and inline all @.figure@ and @.visualization@ divs.
-- @baseDir@ is the directory of the source file; @script=@ paths are
-- resolved relative to it.
inlineViz :: FilePath -> Pandoc -> IO Pandoc
inlineViz baseDir = walkM (transformBlock baseDir)
-- ---------------------------------------------------------------------------
-- Block transformation
-- ---------------------------------------------------------------------------
transformBlock :: FilePath -> Block -> IO Block
transformBlock baseDir blk@(Div (_, cls, attrs) _)
| "figure" `elem` cls = do
result <- runScript baseDir attrs
case result of
Left err ->
warn "figure" err >> return (errorBlock err)
Right out ->
let caption = attr "caption" attrs
in return $ RawBlock (Format "html")
(staticFigureHtml (processColors out) caption)
| "visualization" `elem` cls = do
result <- runScript baseDir attrs
case result of
Left err ->
warn "visualization" err >> return (errorBlock err)
Right out ->
let caption = attr "caption" attrs
in return $ RawBlock (Format "html")
(interactiveFigureHtml (escScriptTag out) caption)
| otherwise = return blk
transformBlock _ b = return b
-- ---------------------------------------------------------------------------
-- Script execution
-- ---------------------------------------------------------------------------
-- | Run @python3 <script>@. Returns the script\'s stdout on success, or an
-- error message on failure (non-zero exit, missing @script=@ attribute, or
-- missing script file).
runScript :: FilePath -> [(T.Text, T.Text)] -> IO (Either String T.Text)
runScript baseDir attrs =
case lookup "script" attrs of
Nothing -> return (Left "missing script= attribute")
Just p -> do
let fullPath = baseDir </> T.unpack p
exists <- doesFileExist fullPath
if not exists
then return (Left ("script not found: " ++ fullPath))
else do
(ec, out, err) <-
readProcessWithExitCode "python3" [fullPath] ""
`catch` (\e -> return (ExitFailure 1, "", show (e :: IOException)))
return $ case ec of
ExitSuccess -> Right (T.pack out)
ExitFailure _ -> Left $
"in " ++ fullPath ++ ": "
++ (if null err then "non-zero exit" else err)
-- ---------------------------------------------------------------------------
-- SVG colour post-processing (mirrors Filters.Score.processColors)
-- ---------------------------------------------------------------------------
-- | Replace hardcoded black fill/stroke values with @currentColor@ so the
-- embedded SVG inherits the CSS text colour in both light and dark modes.
processColors :: T.Text -> T.Text
processColors
= 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"
. T.replace "fill=\"#000000\"" "fill=\"currentColor\""
. T.replace "stroke=\"#000000\"" "stroke=\"currentColor\""
. T.replace "fill:#000000" "fill:currentColor"
. T.replace "stroke:#000000" "stroke:currentColor"
-- ---------------------------------------------------------------------------
-- JSON safety for <script> embedding
-- ---------------------------------------------------------------------------
-- | Replace @<\/@ with the JSON Unicode escape @\u003c\/@ so that Vega-Lite
-- JSON embedded inside a @\<script\>@ tag cannot accidentally close it.
-- JSON.parse decodes the escape back to @<\/@ transparently.
escScriptTag :: T.Text -> T.Text
escScriptTag = T.replace "</" "\\u003c/"
-- ---------------------------------------------------------------------------
-- HTML output
-- ---------------------------------------------------------------------------
staticFigureHtml :: T.Text -> T.Text -> T.Text
staticFigureHtml svgContent caption = T.concat
[ "<figure class=\"viz-figure\">"
, svgContent
, if T.null caption then ""
else "<figcaption class=\"viz-caption\">" <> escHtml caption <> "</figcaption>"
, "</figure>"
]
interactiveFigureHtml :: T.Text -> T.Text -> T.Text
interactiveFigureHtml jsonSpec caption = T.concat
[ "<figure class=\"viz-interactive\">"
, "<div class=\"vega-container\">"
, "<script type=\"application/json\" class=\"vega-spec\">"
, jsonSpec
, "</script>"
, "</div>"
, if T.null caption then ""
else "<figcaption class=\"viz-caption\">" <> escHtml caption <> "</figcaption>"
, "</figure>"
]
errorBlock :: String -> Block
errorBlock msg = RawBlock (Format "html") $ T.concat
[ "<div class=\"viz-error\"><strong>Visualization error:</strong> "
, escHtml (T.pack msg)
, "</div>"
]
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
attr :: T.Text -> [(T.Text, T.Text)] -> T.Text
attr key kvs = fromMaybe "" (lookup key kvs)
warn :: String -> String -> IO ()
warn kind msg = hPutStrLn stderr $ "[Viz] " ++ kind ++ " error: " ++ msg
escHtml :: T.Text -> T.Text
escHtml = U.escapeHtmlText

View File

@ -0,0 +1,87 @@
{-# 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, and
-- a @.html@ suffix appended so the link resolves identically under
-- the dev server, file:// previews, and nginx in production.
module Filters.Wikilinks (preprocess) where
import Data.Char (isAlphaNum, toLower, isSpace)
import Data.List (intercalate)
import qualified Utils as U
-- | 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.
--
-- Display text is escaped via 'escMdLinkText' so that a literal @]@, @[@,
-- or backslash in the display does not break the surrounding Markdown
-- link syntax. The URL itself is produced by 'slugify' and therefore only
-- ever contains @[a-z0-9-]@, so no URL-side encoding is needed — adding
-- one would be defense against a character set we can't produce.
toMarkdownLink :: String -> String
toMarkdownLink inner =
let (title, display) = splitOnPipe inner
url = "/" ++ slugify title ++ ".html"
in "[" ++ escMdLinkText display ++ "](" ++ url ++ ")"
-- | Escape the minimum set of characters that would prematurely terminate
-- a Markdown link's display-text segment: backslash (escape char), @[@,
-- and @]@. Backslash MUST be escaped first so the escapes we introduce
-- for @[@ and @]@ are not themselves re-escaped.
--
-- Deliberately NOT escaped: @_@, @*@, @\`@, @<@. Those are inline
-- formatting markers in Markdown and escaping them would strip the
-- author's ability to put emphasis, code, or inline HTML in a wikilink's
-- display text.
escMdLinkText :: String -> String
escMdLinkText = concatMap esc
where
esc '\\' = "\\\\"
esc '[' = "\\["
esc ']' = "\\]"
esc c = [c]
-- | Split on the first @|@; if none, display = title.
splitOnPipe :: String -> (String, String)
splitOnPipe s =
case break (== '|') s of
(title, '|':display) -> (U.trim title, U.trim display)
_ -> (U.trim s, U.trim s)
-- | Produce a URL slug: lowercase, words joined by hyphens,
-- non-alphanumeric characters removed.
--
-- Trailing punctuation is dropped rather than preserved as a dangling
-- hyphen — @slugify "end." == "end"@, not @"end-"@. This is intentional:
-- author-authored wikilinks tend to end sentences with a period and the
-- desired URL is almost always the terminal-punctuation-free form.
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'

12
build/Main.hs Normal file
View File

@ -0,0 +1,12 @@
module Main where
import Hakyll (hakyll)
import qualified Config
import Site (rules)
main :: IO ()
main = do
-- Force config evaluation early so a missing or malformed site.yaml
-- fails loudly before Hakyll starts.
Config.siteConfig `seq` return ()
hakyll rules

48
build/Pagination.hs Normal file
View File

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

100
build/Patterns.hs Normal file
View File

@ -0,0 +1,100 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Canonical content-pattern definitions, shared across modules.
--
-- Several modules need to enumerate "all author-written content" or
-- "all essays". Historically each module hard-coded its own slightly
-- different list, which produced silent omissions (e.g. directory-form
-- essays not appearing on author pages). This module is the single source
-- of truth — every place that needs a content pattern should import from
-- here, not write its own.
module Patterns
( -- * Per-section patterns
essayPattern
, draftEssayPattern
, blogPattern
, poetryPattern
, fictionPattern
, musicPattern
, standalonePagesPattern
-- * Aggregated patterns
, allWritings -- essays + blog + poetry + fiction
, allContent -- everything that backlinks should index
, authorIndexable -- everything that should appear on /authors/{slug}/
, tagIndexable -- everything that should appear on /<tag>/
) where
import Hakyll
-- ---------------------------------------------------------------------------
-- Per-section
-- ---------------------------------------------------------------------------
-- | All published essays — flat files and directory-based (with co-located
-- assets). Drafts under @content/drafts/essays/**@ are intentionally NOT
-- included; 'Site.rules' unions them in conditionally when @SITE_ENV=dev@.
essayPattern :: Pattern
essayPattern = "content/essays/*.md" .||. "content/essays/*/index.md"
-- | In-progress essay drafts. Matches the flat and directory forms under
-- @content/drafts/essays/@. Only 'Site.rules' consumes this, gated on
-- @SITE_ENV=dev@ — every other module that enumerates content (Authors,
-- Tags, Backlinks, Stats, feeds) sees only 'essayPattern', so drafts are
-- automatically invisible to listings, tags, authors, backlinks, and stats.
draftEssayPattern :: Pattern
draftEssayPattern =
"content/drafts/essays/*.md"
.||. "content/drafts/essays/*/index.md"
-- | All blog posts. Currently flat-only; co-located blog assets would
-- require a directory variant analogous to 'essayPattern'.
blogPattern :: Pattern
blogPattern = "content/blog/*.md"
-- | All poetry: flat poems plus collection poems, excluding collection
-- index pages (which are landing pages, not poems).
poetryPattern :: Pattern
poetryPattern =
"content/poetry/*.md"
.||. ("content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md")
-- | All fiction. Currently flat-only.
fictionPattern :: Pattern
fictionPattern = "content/fiction/*.md"
-- | Music compositions (landing pages live at @content/music/<slug>/index.md@).
musicPattern :: Pattern
musicPattern = "content/music/*/index.md"
-- | Top-level standalone pages (about, colophon, current, gpg, …).
standalonePagesPattern :: Pattern
standalonePagesPattern = "content/*.md"
-- ---------------------------------------------------------------------------
-- Aggregations
-- ---------------------------------------------------------------------------
-- | All long-form authored writings.
allWritings :: Pattern
allWritings = essayPattern .||. blogPattern .||. poetryPattern .||. fictionPattern
-- | Every content file the backlinks pass should index. Includes music
-- landing pages and top-level standalone pages, in addition to writings.
allContent :: Pattern
allContent =
essayPattern
.||. blogPattern
.||. poetryPattern
.||. fictionPattern
.||. musicPattern
.||. standalonePagesPattern
-- | Content shown on author index pages — essays + blog posts.
-- (Poetry and fiction have their own dedicated indexes and are not
-- aggregated by author.)
authorIndexable :: Pattern
authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
-- | Content shown on tag index pages — essays + blog posts.
tagIndexable :: Pattern
tagIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion

127
build/SimilarLinks.hs Normal file
View File

@ -0,0 +1,127 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Similar-links field: injects a "Related" list into essay/page contexts.
--
-- @data/similar-links.json@ is produced by @tools/embed.py@ at build time
-- (called from the Makefile after pagefind, before sign). It is a plain
-- JSON object mapping root-relative URL paths to lists of similar pages:
--
-- { "/essays/my-essay/": [{"url": "...", "title": "...", "score": 0.87}] }
--
-- This module loads that file with dependency tracking (so pages recompile
-- when embeddings change) and provides @similarLinksField@, which resolves
-- to an HTML list for the current page's URL.
--
-- If the file is absent (e.g. @.venv@ not set up, or first build) the field
-- returns @noResult@ — the @$if(similar-links)$@ guard in the template is
-- false and no "Related" section is rendered.
module SimilarLinks (similarLinksField) where
import Data.Maybe (fromMaybe)
import qualified Data.ByteString as BS
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TE
import qualified Data.Aeson as Aeson
import Hakyll
-- ---------------------------------------------------------------------------
-- JSON schema
-- ---------------------------------------------------------------------------
data SimilarEntry = SimilarEntry
{ seUrl :: String
, seTitle :: String
, seScore :: Double
} deriving (Show)
instance Aeson.FromJSON SimilarEntry where
parseJSON = Aeson.withObject "SimilarEntry" $ \o ->
SimilarEntry
<$> o Aeson..: "url"
<*> o Aeson..: "title"
<*> o Aeson..: "score"
-- ---------------------------------------------------------------------------
-- Context field
-- ---------------------------------------------------------------------------
-- | Provides @$similar-links$@ (HTML list) and @$has-similar-links$@
-- (boolean flag for template guards).
-- Returns @noResult@ when the JSON file is absent, unparseable, or the
-- current page has no similar entries.
similarLinksField :: Context String
similarLinksField = field "similar-links" $ \item -> do
-- Load with dependency tracking — pages recompile when the JSON changes.
slItem <- load (fromFilePath "data/similar-links.json") :: Compiler (Item String)
case Aeson.decodeStrict (TE.encodeUtf8 (T.pack (itemBody slItem)))
:: Maybe (Map T.Text [SimilarEntry]) of
Nothing -> fail "similar-links: could not parse data/similar-links.json"
Just slMap -> do
mRoute <- getRoute (itemIdentifier item)
case mRoute of
Nothing -> fail "similar-links: item has no route"
Just r ->
let key = T.pack (normaliseUrl ("/" ++ r))
entries = fromMaybe [] (Map.lookup key slMap)
in if null entries
then fail "no similar links"
else return (renderSimilarLinks entries)
-- ---------------------------------------------------------------------------
-- URL normalisation (mirrors embed.py's URL derivation)
-- ---------------------------------------------------------------------------
normaliseUrl :: String -> String
normaliseUrl url =
let t = T.pack url
-- strip query + fragment
t1 = fst (T.breakOn "?" (fst (T.breakOn "#" t)))
-- ensure leading slash
t2 = if T.isPrefixOf "/" t1 then t1 else "/" `T.append` t1
-- strip trailing index.html → keep the directory slash
t3 = fromMaybe t2 (T.stripSuffix "index.html" t2)
-- strip bare .html extension only for non-index pages
t4 = fromMaybe t3 (T.stripSuffix ".html" t3)
in percentDecode (T.unpack t4)
-- | Percent-decode @%XX@ escapes (UTF-8) so percent-encoded paths
-- collide with their decoded form on map lookup. Mirrors
-- 'Backlinks.percentDecode'; the two implementations are intentionally
-- duplicated because they apply different normalisations *before*
-- decoding (Backlinks strips @.html@ unconditionally; SimilarLinks
-- preserves the trailing-slash form for index pages).
percentDecode :: String -> String
percentDecode = T.unpack . TE.decodeUtf8With TE.lenientDecode . BS.pack . go
where
go [] = []
go ('%':a:b:rest)
| Just hi <- hexDigit a
, Just lo <- hexDigit b
= fromIntegral (hi * 16 + lo) : go rest
go (c:rest) = fromIntegral (fromEnum c) : go rest
hexDigit c
| c >= '0' && c <= '9' = Just (fromEnum c - fromEnum '0')
| c >= 'a' && c <= 'f' = Just (fromEnum c - fromEnum 'a' + 10)
| c >= 'A' && c <= 'F' = Just (fromEnum c - fromEnum 'A' + 10)
| otherwise = Nothing
-- ---------------------------------------------------------------------------
-- HTML rendering
-- ---------------------------------------------------------------------------
renderSimilarLinks :: [SimilarEntry] -> String
renderSimilarLinks entries =
"<ul class=\"similar-links-list\">\n"
++ concatMap renderOne entries
++ "</ul>"
where
renderOne se =
"<li class=\"similar-links-item\">"
++ "<a href=\"" ++ escapeHtml (seUrl se) ++ "\">"
++ escapeHtml (seTitle se)
++ "</a>"
++ "</li>\n"

457
build/Site.hs Normal file
View File

@ -0,0 +1,457 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Site (rules) where
import Control.Monad (filterM, when)
import Data.List (isPrefixOf)
import Data.Maybe (fromMaybe)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Text as T
import qualified Config
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,
contentKindField)
import qualified Patterns as P
import Tags (buildAllTags, applyTagRules)
import Pagination (blogPaginateRules)
import Stats (statsRules)
-- Poems inside collection subdirectories, excluding their index pages.
collectionPoems :: Pattern
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
-- All poetry content (flat + collection), excluding collection index pages.
allPoetry :: Pattern
allPoetry = "content/poetry/*.md" .||. collectionPoems
feedConfig :: FeedConfiguration
feedConfig = FeedConfiguration
{ feedTitle = T.unpack (Config.feedTitle Config.siteConfig)
, feedDescription = T.unpack (Config.feedDescription Config.siteConfig)
, feedAuthorName = T.unpack (Config.authorName Config.siteConfig)
, feedAuthorEmail = T.unpack (Config.authorEmail Config.siteConfig)
, feedRoot = T.unpack (Config.siteUrl Config.siteConfig)
}
musicFeedConfig :: FeedConfiguration
musicFeedConfig = FeedConfiguration
{ feedTitle = T.unpack (Config.siteName Config.siteConfig) ++ " — Music"
, feedDescription = "New compositions"
, feedAuthorName = T.unpack (Config.authorName Config.siteConfig)
, feedAuthorEmail = T.unpack (Config.authorEmail Config.siteConfig)
, feedRoot = T.unpack (Config.siteUrl Config.siteConfig)
}
rules :: Rules ()
rules = do
-- ---------------------------------------------------------------------------
-- Build mode. SITE_ENV=dev (set by `make dev` / `make watch`) includes
-- drafts under content/drafts/**; anything else (unset, "deploy", "build")
-- excludes them entirely from every match, listing, and asset rule below.
-- ---------------------------------------------------------------------------
isDev <- preprocess $ (== Just "dev") <$> lookupEnv "SITE_ENV"
let allEssays = if isDev
then P.essayPattern .||. P.draftEssayPattern
else P.essayPattern
-- ---------------------------------------------------------------------------
-- 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
statsRules tags
-- Per-page JS files — authored alongside content in content/**/*.js.
-- Draft JS is handled by a separate dev-only rule below.
match ("content/**/*.js" .&&. complement "content/drafts/**") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Per-page JS co-located with draft essays (dev-only).
when isDev $ match "content/drafts/**/*.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
-- Semantic search index — produced by tools/embed.py; fetched at runtime
-- by static/js/semantic-search.js from /data/semantic-index.bin and
-- /data/semantic-meta.json.
match ("data/semantic-index.bin" .||. "data/semantic-meta.json") $ do
route idRoute
compile copyFileCompiler
-- Similar links — produced by tools/embed.py; absent on first build or
-- when .venv is not set up. Compiled as a raw string for similarLinksField.
match "data/similar-links.json" $ compile getResourceBody
-- 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
-- ---------------------------------------------------------------------------
-- 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/colophon.md" $ do
route $ constRoute "colophon.html"
compile $ essayCompiler
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
match ("content/*.md"
.&&. complement "content/index.md"
.&&. complement "content/commonplace.md"
.&&. complement "content/colophon.md") $ do
route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/page.html" pageCtx
>>= loadAndApplyTemplate "templates/default.html" pageCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Essays — flat (content/essays/foo.md → essays/foo.html) and
-- directory-based (content/essays/slug/index.md → essays/slug/index.html).
-- In dev mode, drafts under content/drafts/essays/ route to
-- drafts/essays/foo.html (flat) or drafts/essays/slug/index.html (dir).
-- ---------------------------------------------------------------------------
match allEssays $ do
route $ customRoute $ \ident ->
let fp = toFilePath ident
fname = takeFileName fp
isIndex = fname == "index.md"
isDraft = "content/drafts/essays/" `isPrefixOf` fp
in case (isDraft, isIndex) of
-- content/drafts/essays/slug/index.md → drafts/essays/slug/index.html
(True, True) -> replaceExtension (drop 8 fp) "html"
-- content/drafts/essays/foo.md → drafts/essays/foo.html
(True, False) -> "drafts/essays/" ++ replaceExtension fname "html"
-- content/essays/slug/index.md → essays/slug/index.html
(False, True) -> replaceExtension (drop 8 fp) "html"
-- content/essays/foo.md → essays/foo.html
(False, False) -> "essays/" ++ replaceExtension fname "html"
compile $ essayCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- Static assets co-located with directory-based essays (figures, data, PDFs, …)
match ("content/essays/**"
.&&. complement "content/essays/*.md"
.&&. complement "content/essays/*/index.md") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Static assets co-located with draft essays (dev-only).
when isDev $ match ("content/drafts/essays/**"
.&&. complement "content/drafts/essays/*.md"
.&&. complement "content/drafts/essays/*/index.md") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- ---------------------------------------------------------------------------
-- 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
-- ---------------------------------------------------------------------------
-- Flat poems (e.g. content/poetry/sonnet-60.md)
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
-- Collection poems (e.g. content/poetry/shakespeare-sonnets/sonnet-1.md)
match collectionPoems $ 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
-- Collection index pages (e.g. content/poetry/shakespeare-sonnets/index.md)
match "content/poetry/*/index.md" $ do
route $ gsubRoute "content/poetry/" (const "poetry/")
`composeRoutes` setExtension "html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/default.html" pageCtx
>>= 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 (allEssays .&&. 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
-- ---------------------------------------------------------------------------
-- New page — all content sorted by creation date, newest first
-- ---------------------------------------------------------------------------
create ["new.html"] $ do
route idRoute
compile $ do
let allContent = ( allEssays
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. allPoetry
.||. "content/music/*/index.md"
) .&&. hasNoVersion
items <- recentFirst =<< loadAll allContent
let itemCtx = contentKindField
<> dateField "date-iso" "%Y-%m-%d"
<> essayCtx
ctx = listField "recent-items" itemCtx (return items)
<> constField "title" "New"
<> constField "new-page" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/new.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Library — comprehensive portal-grouped index of all content
-- ---------------------------------------------------------------------------
create ["library.html"] $ do
route idRoute
compile $ do
-- 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
itemCtx = dateField "date-iso" "%Y-%m-%d" <> essayCtx
buildPortal allItems portal = do
let slug = T.unpack (Config.portalSlug portal)
filtered <- filterM (hasPortal slug) allItems
sorted <- recentFirst filtered
return $ Item (fromFilePath ("portal-" ++ slug)) (portal, sorted)
portalCtx =
field "portal-slug" (return . T.unpack . Config.portalSlug . fst . itemBody)
<> field "portal-name" (return . T.unpack . Config.portalName . fst . itemBody)
<> listFieldWith "entries" itemCtx (return . snd . itemBody)
let portalsField = listField "portals" portalCtx $ do
essays <- loadAll (allEssays .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion)
let allItems = essays ++ posts ++ fiction ++ poetry
mapM (buildPortal allItems) (Config.portals Config.siteConfig)
ctx = portalsField
<> 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 (allEssays .&&. 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 $ LBS.unpack (Aeson.encode urls)
-- ---------------------------------------------------------------------------
-- Atom feed — all content sorted by date
-- ---------------------------------------------------------------------------
create ["feed.xml"] $ do
route idRoute
compile $ do
posts <- fmap (take 30) . recentFirst
=<< loadAllSnapshots
( ( allEssays
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. allPoetry
.||. "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

215
build/Stability.hs Normal file
View File

@ -0,0 +1,215 @@
{-# 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 qualified Data.Text.IO as TIO
import System.Exit (ExitCode (..))
import System.IO (hPutStrLn, stderr)
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.
--
-- Uses strict text IO so the file handle is released immediately rather
-- than left dangling on the lazy spine of 'readFile'.
readIgnore :: IO [FilePath]
readIgnore =
(filter (not . null) . map T.unpack . T.lines <$> TIO.readFile "IGNORE.txt")
`catch` \(_ :: IOException) -> return []
-- ---------------------------------------------------------------------------
-- Git helpers
-- ---------------------------------------------------------------------------
-- | Return commit dates (ISO "YYYY-MM-DD", newest-first) for @fp@.
--
-- Logs git's stderr to the build's stderr when present so the author
-- isn't left in the dark when a file isn't tracked yet (the warning
-- otherwise vanishes silently).
gitDates :: FilePath -> IO [String]
gitDates fp = do
(ec, out, err) <- readProcessWithExitCode
"git" ["log", "--follow", "--format=%ad", "--date=short", "--", fp] ""
case ec of
ExitFailure _ -> do
let msg = if null err then "git log failed" else err
hPutStrLn stderr $ "[Stability] " ++ fp ++ ": " ++ msg
return []
ExitSuccess -> do
case err of
"" -> return ()
_ -> hPutStrLn stderr $ "[Stability] " ++ fp ++ ": " ++ err
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).
--
-- Thresholds (commit count + age in days since first commit):
--
-- * @volatile@ — solo commit OR less than two weeks old.
-- * @revising@ — under six commits AND under three months old.
-- * @fairly stable@ — under sixteen commits OR under one year old.
-- * @stable@ — under thirty-one commits OR under two years old.
-- * @established@ — anything beyond.
--
-- These cliffs are deliberately conservative: a fast burst of commits
-- early in a piece's life looks volatile until enough time has passed
-- to demonstrate it has settled.
stabilityFromDates :: [String] -> String
stabilityFromDates [] = "volatile"
stabilityFromDates dates@(newest : _) =
let oldest = case reverse dates of
(x : _) -> x
[] -> newest -- unreachable; matched above
in classify (length dates) (daySpan oldest newest)
where
classify n age
| n <= 1 || age < volatileAge = "volatile"
| n <= 5 && age < revisingAge = "revising"
| n <= 15 || age < fairlyStableAge = "fairly stable"
| n <= 30 || age < stableAge = "stable"
| otherwise = "established"
volatileAge, revisingAge, fairlyStableAge, stableAge :: Int
volatileAge = 14
revisingAge = 90
fairlyStableAge = 365
stableAge = 730
-- | 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)

962
build/Stats.hs Normal file
View File

@ -0,0 +1,962 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Build telemetry page (/build/): corpus statistics, word-length
-- distribution, tag frequencies, link analysis, epistemic coverage,
-- output metrics, repository overview, and build timing.
-- Rendered as a full essay (3-column layout, TOC, metadata block).
module Stats (statsRules) where
import Control.Exception (IOException, catch)
import Control.Monad (forM)
import Data.Char (isSpace, toLower)
import Data.List (find, isPrefixOf, isSuffixOf, sort, sortBy)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, fromMaybe, isJust, listToMaybe)
import Data.Ord (comparing, Down (..))
import qualified Data.Set as Set
import Data.String (fromString)
import Data.Time (getCurrentTime, formatTime, defaultTimeLocale,
Day, parseTimeM, utctDay, addDays, diffDays)
import Data.Time.Calendar (toGregorian, dayOfWeek)
import System.Directory (doesDirectoryExist, getFileSize, listDirectory,
pathIsSymbolicLink)
import System.Exit (ExitCode (..))
import System.FilePath (takeExtension, (</>))
import System.Process (readProcessWithExitCode)
import Text.Read (readMaybe)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as AK
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Data.Text.Encoding as TE
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Text.Blaze.Html.Renderer.String (renderHtml)
import qualified Text.Blaze.Internal as BI
import Hakyll
import Contexts (siteCtx, authorLinksField)
import qualified Patterns as P
import Utils (readingTime)
-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------
data TypeRow = TypeRow
{ trLabel :: String
, trCount :: Int
, trWords :: Int
}
data PageInfo = PageInfo
{ piTitle :: String
, piUrl :: String
, piWC :: Int
}
-- ---------------------------------------------------------------------------
-- Hakyll helpers
-- ---------------------------------------------------------------------------
loadWC :: Item String -> Compiler Int
loadWC item = do
snap <- loadSnapshot (itemIdentifier item) "word-count"
return $ fromMaybe 0 (readMaybe (itemBody snap))
loadPI :: Item String -> Compiler (Maybe PageInfo)
loadPI item = do
meta <- getMetadata (itemIdentifier item)
mRoute <- getRoute (itemIdentifier item)
wc <- loadWC item
return $ fmap (\r -> PageInfo
{ piTitle = fromMaybe "(untitled)" (lookupString "title" meta)
, piUrl = "/" ++ r
, piWC = wc
}) mRoute
-- ---------------------------------------------------------------------------
-- Formatting helpers
-- ---------------------------------------------------------------------------
commaInt :: Int -> String
commaInt n
| n < 1000 = show n
| otherwise = commaInt (n `div` 1000) ++ "," ++ pad3 (n `mod` 1000)
where
pad3 x
| x < 10 = "00" ++ show x
| x < 100 = "0" ++ show x
| otherwise = show x
formatBytes :: Integer -> String
formatBytes b
| b < 1024 = show b ++ " B"
| b < 1024*1024 = showD (b * 10 `div` 1024) ++ " KB"
| otherwise = showD (b * 10 `div` (1024*1024)) ++ " MB"
where showD n = show (n `div` 10) ++ "." ++ show (n `mod` 10)
rtStr :: Int -> String
rtStr totalWords
| mins < 60 = show mins ++ " min"
| otherwise = show (mins `div` 60) ++ "h " ++ show (mins `mod` 60) ++ "m"
where mins = totalWords `div` 200
pctStr :: Int -> Int -> String
pctStr _ 0 = ""
pctStr n total = show (n * 100 `div` total) ++ "%"
-- | Strip HTML tags for plain-text word counting.
--
-- Handles:
-- * Tag bodies, including @>@ inside double-quoted attribute values
-- (so @\<img alt=\"a > b\"\>@ doesn't slice the surrounding text).
-- * HTML comments @\<!-- ... --\>@ as a unit.
-- * @\<![CDATA[ ... ]]\>@ sections.
--
-- This is still a heuristic — it does not validate the HTML — but it
-- closes the most common ways for "tag stripping" to leak content.
stripHtmlTags :: String -> String
stripHtmlTags = go
where
go [] = []
go ('<':'!':'-':'-':rest) = go (dropComment rest)
go ('<':'!':'[':'C':'D':'A':'T':'A':'[':rest)
= go (dropCdata rest)
go ('<':rest) = go (dropTag rest)
go (c:rest) = c : go rest
-- Drop everything up to and including "-->".
dropComment ('-':'-':'>':rs) = rs
dropComment (_:rs) = dropComment rs
dropComment [] = []
-- Drop everything up to and including "]]>".
dropCdata (']':']':'>':rs) = rs
dropCdata (_:rs) = dropCdata rs
dropCdata [] = []
-- Drop a tag body, respecting double-quoted attribute values.
dropTag ('"':rs) = dropTag (skipQuoted rs)
dropTag ('\'':rs) = dropTag (skipApos rs)
dropTag ('>':rs) = rs
dropTag (_:rs) = dropTag rs
dropTag [] = []
skipQuoted ('"':rs) = rs
skipQuoted (_:rs) = skipQuoted rs
skipQuoted [] = []
skipApos ('\'':rs) = rs
skipApos (_:rs) = skipApos rs
skipApos [] = []
-- | Normalise a page URL for backlink map lookup (strip trailing .html).
normUrl :: String -> String
normUrl u
| ".html" `isSuffixOf` u = take (length u - 5) u
| otherwise = u
pad2 :: (Show a, Integral a) => a -> String
pad2 n = if n < 10 then "0" ++ show n else show n
-- | Median of a non-empty list; returns 0 for empty. Uses 'drop' +
-- pattern match instead of @(!!)@ so the function is total in its
-- own implementation, not just by external invariant.
median :: [Int] -> Int
median [] = 0
median xs =
case drop (length xs `div` 2) (sort xs) of
(m : _) -> m
[] -> 0 -- unreachable: length xs >= 1 above
-- ---------------------------------------------------------------------------
-- Date helpers (for /stats/ page)
-- ---------------------------------------------------------------------------
parseDay :: String -> Maybe Day
parseDay = parseTimeM True defaultTimeLocale "%Y-%m-%d"
-- | First Monday on or before 'day' (start of its ISO week).
weekStart :: Day -> Day
weekStart day = addDays (fromIntegral (negate (fromEnum (dayOfWeek day)))) day
-- | Intensity class for the heatmap (hm0 … hm4).
heatClass :: Int -> String
heatClass 0 = "hm0"
heatClass n | n < 500 = "hm1"
heatClass n | n < 2000 = "hm2"
heatClass n | n < 5000 = "hm3"
heatClass _ = "hm4"
shortMonth :: Int -> String
shortMonth m = case m of
1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr"
5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug"
9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec"
_ -> ""
-- ---------------------------------------------------------------------------
-- URL sanitization and core HTML combinators
-- ---------------------------------------------------------------------------
-- | Defense-in-depth URL allowlist: reject anything that isn't an internal
-- path, a fragment, or an explicit safe scheme. Case-insensitive and
-- whitespace-tolerant to block @JavaScript:@, @\tjavascript:@, @data:@, etc.
-- @http://@ is intentionally excluded to avoid mixed-content warnings.
--
-- Protocol-relative URLs (@//evil.com@) are rejected because the leading
-- slash would otherwise admit them through the @\"\/\"@ prefix check.
isSafeUrl :: String -> Bool
isSafeUrl u =
let norm = map toLower (dropWhile isSpace u)
in not ("//" `isPrefixOf` norm)
&& any (`isPrefixOf` norm) ["/", "https://", "mailto:", "#"]
safeHref :: String -> H.AttributeValue
safeHref u
| isSafeUrl u = H.stringValue u
| otherwise = H.stringValue "#"
-- | Shorthand for 'H.toHtml' over a 'String'.
txt :: String -> H.Html
txt = H.toHtml
-- | Anchor element with escaped title text and URL sanitized via 'safeHref'.
-- Use for trusted plain-text labels such as tag slugs.
link :: String -> String -> H.Html
link url title = H.a H.! A.href (safeHref url) $ H.toHtml title
-- | Anchor for a content page, where the title comes from frontmatter and
-- may contain author-authored inline HTML (e.g. @<em>Book Title</em>@).
-- The URL is still sanitized via 'safeHref'; the title is emitted
-- pre-escaped, matching site convention that metadata titles are
-- author-controlled trusted HTML.
pageLink :: String -> String -> H.Html
pageLink url title = H.a H.! A.href (safeHref url) $ H.preEscapedToHtml title
-- | Typed section header followed by its body content.
section :: String -> String -> H.Html -> H.Html
section id_ title body = do
H.h2 H.! A.id (H.stringValue id_) $ H.toHtml title
body
-- | Build-telemetry table with header row, body rows, and an optional total
-- row. Cell contents are pre-rendered 'H.Html' so callers may embed links or
-- emphasis inside cells without risking double-escaping.
table :: [String] -> [[H.Html]] -> Maybe [H.Html] -> H.Html
table headers rows mFoot =
H.table H.! A.class_ "build-table" $ do
H.thead $ H.tr $ mapM_ (H.th . H.toHtml) headers
H.tbody $ mapM_ renderRow rows
maybe (return ()) renderFoot mFoot
where
renderRow cells = H.tr $ mapM_ H.td cells
renderFoot cells = H.tfoot $
H.tr H.! A.class_ "build-total" $ mapM_ H.td cells
-- | Two-column metadata block: each pair becomes a @<dt>/<dd>@ entry. Values
-- are 'H.Html' to allow mixing links and plain text.
dl :: [(String, H.Html)] -> H.Html
dl pairs = H.dl H.! A.class_ "build-meta" $
mapM_ (\(k, v) -> do H.dt (H.toHtml k); H.dd v) pairs
-- ---------------------------------------------------------------------------
-- SVG / custom element helpers (no blaze-svg dependency)
-- ---------------------------------------------------------------------------
svgTag, rectTag, textTag, titleTag :: H.Html -> H.Html
svgTag = BI.customParent "svg"
rectTag = BI.customParent "rect"
textTag = BI.customParent "text"
titleTag = BI.customParent "title"
-- | Attach an attribute that isn't in 'Text.Blaze.Html5.Attributes' (e.g.
-- SVG @viewBox@, @x@, @y@, or @data-target@).
customAttr :: String -> String -> H.Attribute
customAttr name val = BI.customAttribute (fromString name) (fromString val)
-- ---------------------------------------------------------------------------
-- Heatmap SVG
-- ---------------------------------------------------------------------------
-- | 52-week writing activity heatmap. Styled via @.heatmap-svg@ rules in
-- static/css/build.css (no inline @<style>@).
renderHeatmap :: Map.Map Day Int -> Day -> H.Html
renderHeatmap wordsByDay today =
let cellSz = 10 :: Int
gap = 2 :: Int
step = cellSz + gap
hdrH = 22 :: Int -- vertical space for month labels
nWeeks = 52
-- First Monday of the 52-week window
startDay = addDays (fromIntegral (-(nWeeks - 1)) * 7) (weekStart today)
nDays = diffDays today startDay + 1
allDays = [addDays i startDay | i <- [0 .. nDays - 1]]
weekOf d = fromIntegral (diffDays d startDay `div` 7) :: Int
dowOf d = fromEnum (dayOfWeek d) -- Mon=0..Sun=6
svgW = (nWeeks - 1) * step + cellSz
svgH = 6 * step + cellSz + hdrH
monthLabel d =
let (_, mo, da) = toGregorian d
in if da == 1
then textTag H.! A.class_ "hm-lbl"
H.! customAttr "x" (show (weekOf d * step))
H.! customAttr "y" "14"
$ txt (shortMonth mo)
else mempty
dayCell d =
let wc = fromMaybe 0 (Map.lookup d wordsByDay)
(yr, mo, da) = toGregorian d
x = weekOf d * step
y = dowOf d * step + hdrH
tip = show yr ++ "-" ++ pad2 mo ++ "-" ++ pad2 da
++ if wc > 0 then ": " ++ commaInt wc ++ " words" else ""
in rectTag H.! A.class_ (H.stringValue (heatClass wc))
H.! customAttr "x" (show x)
H.! customAttr "y" (show y)
H.! customAttr "width" (show cellSz)
H.! customAttr "height" (show cellSz)
H.! customAttr "rx" "2"
$ titleTag (txt tip)
legendW = 5 * step - gap
legendCell i =
rectTag H.! A.class_ (H.stringValue ("hm" ++ show i))
H.! customAttr "x" (show (i * step))
H.! customAttr "y" "0"
H.! customAttr "width" (show cellSz)
H.! customAttr "height" (show cellSz)
H.! customAttr "rx" "2"
$ mempty
legendSvg =
svgTag H.! customAttr "width" (show legendW)
H.! customAttr "height" (show cellSz)
H.! customAttr "viewBox" ("0 0 " ++ show legendW ++ " " ++ show cellSz)
H.! customAttr "style" "display:inline;vertical-align:middle"
$ mapM_ legendCell [0 .. 4 :: Int]
in H.figure H.! A.class_ "stats-heatmap" $ do
svgTag H.! customAttr "width" (show svgW)
H.! customAttr "height" (show svgH)
H.! customAttr "viewBox" ("0 0 " ++ show svgW ++ " " ++ show svgH)
H.! A.class_ "heatmap-svg"
H.! customAttr "role" "img"
H.! customAttr "aria-label" "52-week writing activity heatmap"
$ do
mapM_ monthLabel allDays
mapM_ dayCell allDays
H.figcaption H.! A.class_ "heatmap-legend" $ do
"Less\xA0"
legendSvg
"\xA0More"
-- ---------------------------------------------------------------------------
-- Stats page sections
-- ---------------------------------------------------------------------------
renderMonthlyVolume :: Map.Map Day Int -> H.Html
renderMonthlyVolume wordsByDay =
section "volume" "Monthly volume" $
let byMonth = Map.fromListWith (+)
[ ((y, m), wc)
| (day, wc) <- Map.toList wordsByDay
, let (y, m, _) = toGregorian day
]
in if Map.null byMonth
then H.p (H.em "No dated content yet.")
else
let maxWC = max 1 $ maximum $ Map.elems byMonth
bar (y, m) =
let wc = fromMaybe 0 (Map.lookup (y, m) byMonth)
pct = if wc == 0 then 0 else max 2 (wc * 100 `div` maxWC)
lbl = shortMonth m ++ " \x2019" ++ drop 2 (show y)
in H.div H.! A.class_ "build-bar-row" $ do
H.span H.! A.class_ "build-bar-label" $ txt lbl
H.span H.! A.class_ "build-bar-wrap" $
H.span H.! A.class_ "build-bar"
H.! A.style (H.stringValue ("width:" ++ show pct ++ "%"))
$ mempty
H.span H.! A.class_ "build-bar-count" $
if wc > 0 then txt (commaInt wc) else mempty
in H.div H.! A.class_ "build-bars" $
mapM_ bar (Map.keys byMonth)
renderCorpus :: [TypeRow] -> [PageInfo] -> H.Html
renderCorpus typeRows allPIs =
section "corpus" "Corpus" $ do
dl [ ("Total words", txt (commaInt totalWords))
, ("Total pages", txt (commaInt (length allPIs)))
, ("Total reading time", txt (rtStr totalWords))
, ("Average length", txt (commaInt avgWC ++ " words"))
, ("Median length", txt (commaInt medWC ++ " words"))
]
table ["Type", "Pages", "Words", "Reading time"]
(map row typeRows)
(Just [ "Total"
, txt (commaInt (sum (map trCount typeRows)))
, txt (commaInt totalWords)
, txt (rtStr totalWords)
])
where
hasSomeWC = filter (\p -> piWC p > 0) allPIs
totalWords = sum (map trWords typeRows)
avgWC = if null hasSomeWC then 0 else totalWords `div` length hasSomeWC
medWC = median (map piWC hasSomeWC)
row r = [ txt (trLabel r)
, txt (commaInt (trCount r))
, txt (commaInt (trWords r))
, txt (rtStr (trWords r))
]
renderNotable :: [PageInfo] -> H.Html
renderNotable allPIs =
section "notable" "Notable" $ do
H.p (H.strong "Longest")
pageList (take 5 (sortBy (comparing (Down . piWC)) hasSomeWC))
H.p (H.strong "Shortest")
pageList (take 5 (sortBy (comparing piWC) hasSomeWC))
where
hasSomeWC = filter (\p -> piWC p > 50) allPIs
pageList ps = H.ol H.! A.class_ "build-page-list" $
mapM_ (\p -> H.li $ do
pageLink (piUrl p) (piTitle p)
txt (" \x2014 " ++ commaInt (piWC p) ++ " words")
) ps
-- | Renamed/aliased to 'renderTagsSection' below — kept as a name for
-- legacy call sites until they are migrated. Defining it as the same
-- function (instead of an independent copy) prevents the two from
-- drifting silently.
renderStatsTags :: [(String, Int)] -> Int -> H.Html
renderStatsTags = renderTagsSection
statsTOC :: H.Html
statsTOC = H.ol $ mapM_ item entries
where
item (i, t) =
H.li $ H.a H.! A.href (H.stringValue ("#" ++ i))
H.! customAttr "data-target" i
$ txt t
entries = [ ("activity", "Writing activity")
, ("volume", "Monthly volume")
, ("corpus", "Corpus")
, ("notable", "Notable")
, ("tags", "Tags")
]
-- ---------------------------------------------------------------------------
-- IO: output directory walk
-- ---------------------------------------------------------------------------
-- | Recursively walk a directory, returning @(file, size)@ tuples for every
-- regular file beneath it.
--
-- Symlinks (both files and directories) are skipped, so a stray
-- @_site\/a -> _site@ doesn't trigger an infinite loop.
walkDir :: FilePath -> IO [(FilePath, Integer)]
walkDir dir = do
entries <- listDirectory dir `catch` (\(_ :: IOException) -> return [])
fmap concat $ forM entries $ \e -> do
let path = dir </> e
isLink <- pathIsSymbolicLink path
`catch` (\(_ :: IOException) -> return False)
if isLink
then return []
else do
isDir <- doesDirectoryExist path
if isDir
then walkDir path
else do
sz <- getFileSize path
`catch` (\(_ :: IOException) -> return 0)
return [(path, sz)]
displayExt :: FilePath -> String
displayExt path = case takeExtension path of
".html" -> ".html"
".css" -> ".css"
".js" -> ".js"
".woff2" -> ".woff2"
".svg" -> ".svg"
".mp3" -> ".mp3"
".pdf" -> ".pdf"
".json" -> ".json"
".xml" -> ".xml"
".ico" -> ".ico"
".png" -> "image"
".jpg" -> "image"
".jpeg" -> "image"
".webp" -> "image"
_ -> "other"
getOutputStats :: IO (Map.Map String (Int, Integer), Int, Integer)
getOutputStats = do
files <- walkDir "_site"
let grouped = foldr (\(path, sz) acc ->
Map.insertWith (\(c1,s1) (c2,s2) -> (c1+c2, s1+s2))
(displayExt path)
(1, sz) acc)
Map.empty files
return (grouped, length files, sum (map snd files))
-- ---------------------------------------------------------------------------
-- IO: lines of code
-- ---------------------------------------------------------------------------
countLinesDir :: FilePath -> String -> (FilePath -> Bool) -> IO (Int, Int)
countLinesDir dir ext skipPred = do
entries <- listDirectory dir `catch` (\(_ :: IOException) -> return [])
let files = filter (\e -> takeExtension e == ext && not (skipPred e)) entries
-- Use strict text IO so the file handle is released as soon as the
-- contents are read; the prior 'readFile' chained lazy IO under
-- 'forM', leaving every handle open until the loop forced 'lines'.
ls <- fmap sum $ forM files $ \e -> do
content <- TIO.readFile (dir </> e)
`catch` (\(_ :: IOException) -> return T.empty)
return (length (T.lines content))
return (length files, ls)
getLocStats :: IO (Int, Int, Int, Int, Int, Int)
-- (hsFiles, hsLines, cssFiles, cssLines, jsFiles, jsLines)
getLocStats = do
(hf, hl) <- countLinesDir "build" ".hs" (const False)
(cf, cl) <- countLinesDir "static/css" ".css" (const False)
(jf, jl) <- countLinesDir "static/js" ".js" (".min.js" `isSuffixOf`)
return (hf, hl, cf, cl, jf, jl)
-- ---------------------------------------------------------------------------
-- IO: git stats
-- ---------------------------------------------------------------------------
gitRun :: [String] -> IO String
gitRun args = do
(ec, out, _) <- readProcessWithExitCode "git" args ""
return $ if ec == ExitSuccess then out else ""
getGitStats :: IO (Int, String)
getGitStats = do
countOut <- gitRun ["rev-list", "--count", "HEAD"]
firstOut <- gitRun ["log", "--format=%ad", "--date=short", "--reverse"]
let commits = fromMaybe 0 (readMaybe (filter (/= '\n') countOut) :: Maybe Int)
firstDate = case lines firstOut of { (d:_) -> d; _ -> "\x2014" }
return (commits, firstDate)
-- ---------------------------------------------------------------------------
-- HTML rendering: build page sections
-- ---------------------------------------------------------------------------
renderContent :: [TypeRow] -> H.Html
renderContent rows =
section "content" "Content" $
table ["Type", "Count", "Words", "Reading time"]
(map row rows)
(Just [ "Total"
, txt (commaInt totalCount)
, txt (commaInt totalWords)
, txt (rtStr totalWords)
])
where
totalCount = sum (map trCount rows)
totalWords = sum (map trWords rows)
row r = [ txt (trLabel r)
, txt (commaInt (trCount r))
, txt (commaInt (trWords r))
, txt (rtStr (trWords r))
]
renderPages :: [PageInfo]
-> Maybe (String, String, String)
-> Maybe (String, String, String)
-> H.Html
renderPages allPIs mOldest mNewest =
section "pages" "Pages" $ do
dl $
[ ("Total pages", txt (commaInt (length allPIs)))
, ("Average length", txt (commaInt avgWC ++ " words"))
] ++
maybe [] (\(d,t,u) -> [("Oldest content", datedLink d t u)]) mOldest ++
maybe [] (\(d,t,u) -> [("Newest content", datedLink d t u)]) mNewest
H.p (H.strong "Longest")
pageList (take 3 (sortBy (comparing (Down . piWC)) hasSomeWC))
H.p (H.strong "Shortest")
pageList (take 3 (sortBy (comparing piWC) hasSomeWC))
where
hasSomeWC = filter (\p -> piWC p > 0) allPIs
avgWC = if null hasSomeWC then 0
else sum (map piWC hasSomeWC) `div` length hasSomeWC
datedLink d t u = do
txt (d ++ " \x2014 ")
pageLink u t
pageList ps = H.ol H.! A.class_ "build-page-list" $
mapM_ (\p -> H.li $ do
pageLink (piUrl p) (piTitle p)
txt (" \x2014 " ++ commaInt (piWC p) ++ " words")
) ps
renderDistribution :: [Int] -> H.Html
renderDistribution wcs =
section "distribution" "Word-length distribution" $
H.div H.! A.class_ "build-bars" $ mapM_ bar buckets
where
bucketOf w
| w < 500 = 0
| w < 1000 = 1
| w < 2000 = 2
| w < 5000 = 3
| otherwise = 4
labels :: [H.Html]
labels = [ "< 500"
, "500 \x2013 1k"
, "1k \x2013 2k"
, "2k \x2013 5k"
, "\x2265 5k"
]
counts = foldr (\w acc -> Map.insertWith (+) (bucketOf w) (1 :: Int) acc)
(Map.fromList [(i, 0 :: Int) | i <- [0 .. 4]]) wcs
buckets = [(labels !! i, fromMaybe 0 (Map.lookup i counts)) | i <- [0 .. 4]]
maxCount = max 1 (maximum (map snd buckets))
bar (lbl, n) =
let pct = n * 100 `div` maxCount
in H.div H.! A.class_ "build-bar-row" $ do
H.span H.! A.class_ "build-bar-label" $ lbl
H.span H.! A.class_ "build-bar-wrap" $
H.span H.! A.class_ "build-bar"
H.! A.style (H.stringValue ("width:" ++ show pct ++ "%"))
$ mempty
H.span H.! A.class_ "build-bar-count" $ txt (show n)
renderTagsSection :: [(String, Int)] -> Int -> H.Html
renderTagsSection topTags uniqueCount =
section "tags" "Tags" $ do
dl [("Unique tags", txt (commaInt uniqueCount))]
table ["Tag", "Items"] (map row topTags) Nothing
where
row (t, n) = [link ("/" ++ t ++ "/") t, txt (show n)]
renderLinks :: Maybe (String, Int, String) -> Int -> Int -> H.Html
renderLinks mMostLinked orphanCount total =
section "links" "Links" $
dl
[ case mMostLinked of
Nothing -> ("Most-linked page", "\x2014")
Just (u, n, t) ->
( "Most-linked page"
, do pageLink u t
txt (" (" ++ show n ++ " inbound links)")
)
, ( "Orphan pages"
, txt (commaInt orphanCount
++ " of " ++ commaInt total
++ " (" ++ pctStr orphanCount total ++ ")")
)
]
renderEpistemic :: Int -> Int -> Int -> Int -> Int -> H.Html
renderEpistemic total ws wc wi we =
section "epistemic" "Epistemic coverage" $
table
["Field", "Set", "Coverage"]
[ row "Status" ws
, row "Confidence" wc
, row "Importance" wi
, row "Evidence" we
]
Nothing
where
row label n = [ txt label
, txt (show n ++ " / " ++ show total)
, txt (pctStr n total)
]
renderOutput :: Map.Map String (Int, Integer) -> Int -> Integer -> H.Html
renderOutput grouped totalFiles totalSize =
section "output" "Output" $
table
["Type", "Files", "Size"]
(map row (sortBy (comparing (Down . snd . snd)) (Map.toList grouped)))
(Just [ "Total"
, txt (commaInt totalFiles)
, txt (formatBytes totalSize)
])
where
row (ext, (n, sz)) = [txt ext, txt (commaInt n), txt (formatBytes sz)]
renderRepository :: Int -> Int -> Int -> Int -> Int -> Int -> Int -> String -> H.Html
renderRepository hf hl cf cl jf jl commits firstDate =
section "repository" "Repository" $
dl
[ ("Haskell", txt (commaInt hl ++ " lines across " ++ show hf ++ " files"))
, ("CSS", txt (commaInt cl ++ " lines across " ++ show cf ++ " files"))
, ("JavaScript", txt (commaInt jl ++ " lines across " ++ show jf ++ " files (excl. minified)"))
, ("Total git commits", txt (commaInt commits))
, ("Repository started", txt firstDate)
]
renderBuild :: String -> String -> H.Html
renderBuild ts dur =
section "build" "Build" $
dl
[ ("Generated", txt ts)
, ("Last build duration", txt dur)
]
-- ---------------------------------------------------------------------------
-- Static TOC (matches the nine h2 sections above)
-- ---------------------------------------------------------------------------
pageTOC :: H.Html
pageTOC = H.ol $ mapM_ item sections
where
item (id_, title) =
H.li $ H.a H.! A.href (H.stringValue ("#" ++ id_))
H.! customAttr "data-target" id_
$ txt title
sections =
[ ("content", "Content")
, ("pages", "Pages")
, ("distribution", "Word-length distribution")
, ("tags", "Tags")
, ("links", "Links")
, ("epistemic", "Epistemic coverage")
, ("output", "Output")
, ("repository", "Repository")
, ("build", "Build")
]
-- ---------------------------------------------------------------------------
-- Rules
-- ---------------------------------------------------------------------------
statsRules :: Tags -> Rules ()
statsRules tags = do
-- -------------------------------------------------------------------------
-- Build telemetry page (/build/)
-- -------------------------------------------------------------------------
create ["build/index.html"] $ do
route idRoute
compile $ do
-- ----------------------------------------------------------------
-- Load all content items
-- ----------------------------------------------------------------
essays <- loadAll (P.essayPattern .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
poems <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
comps <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
-- ----------------------------------------------------------------
-- Word counts
-- ----------------------------------------------------------------
essayWCs <- mapM loadWC essays
postWCs <- mapM loadWC posts
poemWCs <- mapM loadWC poems
fictionWCs <- mapM loadWC fiction
compWCs <- mapM loadWC comps
let allWCs = essayWCs ++ postWCs ++ poemWCs ++ fictionWCs ++ compWCs
rows =
[ TypeRow "Essays" (length essays) (sum essayWCs)
, TypeRow "Blog posts" (length posts) (sum postWCs)
, TypeRow "Poems" (length poems) (sum poemWCs)
, TypeRow "Fiction" (length fiction) (sum fictionWCs)
, TypeRow "Compositions" (length comps) (sum compWCs)
]
-- ----------------------------------------------------------------
-- Per-page info (title + URL + word count)
-- ----------------------------------------------------------------
allItems <- return (essays ++ posts ++ poems ++ fiction ++ comps)
allPIs <- catMaybes <$> mapM loadPI allItems
-- ----------------------------------------------------------------
-- Dates (essays + posts only)
-- ----------------------------------------------------------------
let getDateMeta item = do
meta <- getMetadata (itemIdentifier item)
mRoute <- getRoute (itemIdentifier item)
let d = fromMaybe "" (lookupString "date" meta)
t = fromMaybe "(untitled)" (lookupString "title" meta)
u = maybe "#" (\r -> "/" ++ r) mRoute
return (d, t, u)
essayDates <- mapM getDateMeta essays
postDates <- mapM getDateMeta posts
let allDates = filter (\(d,_,_) -> not (null d)) (essayDates ++ postDates)
sortedDates = sortBy (comparing (\(d,_,_) -> d)) allDates
oldestDate = listToMaybe sortedDates
newestDate = listToMaybe (reverse sortedDates)
-- ----------------------------------------------------------------
-- Tags
-- ----------------------------------------------------------------
let tagFreqs = map (\(t, ids) -> (t, length ids)) (tagsMap tags)
topTags = take 15 (sortBy (comparing (Down . snd)) tagFreqs)
uniqueTags = length tagFreqs
-- ----------------------------------------------------------------
-- Backlinks: most-linked page + orphan count
-- ----------------------------------------------------------------
blItem <- load (fromFilePath "data/backlinks.json") :: Compiler (Item String)
let rawBL = itemBody blItem
mBLVal = Aeson.decodeStrict (TE.encodeUtf8 (T.pack rawBL)) :: Maybe Aeson.Value
blPairs = case mBLVal of
Just (Aeson.Object km) ->
[ (T.unpack (AK.toText k),
case v of Aeson.Array arr -> V.length arr; _ -> 0)
| (k, v) <- KM.toList km ]
_ -> []
blSet = Set.fromList (map fst blPairs)
orphanCount = length
[ p | p <- allPIs
, not (Set.member (normUrl (piUrl p)) blSet) ]
mostLinked = listToMaybe (sortBy (comparing (Down . snd)) blPairs)
mostLinkedInfo = mostLinked >>= \(url, ct) ->
let mTitle = piTitle <$> find (\p -> normUrl (piUrl p) == url) allPIs
in Just (url, ct, fromMaybe url mTitle)
-- ----------------------------------------------------------------
-- Epistemic coverage (essays + posts)
-- ----------------------------------------------------------------
essayMetas <- mapM (getMetadata . itemIdentifier) essays
postMetas <- mapM (getMetadata . itemIdentifier) posts
let epMetas = essayMetas ++ postMetas
epTotal = length epMetas
ep f = length (filter (isJust . f) epMetas)
withStatus = ep (lookupString "status")
withConf = ep (lookupString "confidence")
withImp = ep (lookupString "importance")
withEv = ep (lookupString "evidence")
-- ----------------------------------------------------------------
-- Output directory stats
-- ----------------------------------------------------------------
(outputGrouped, totalFiles, totalSize) <-
unsafeCompiler getOutputStats
-- ----------------------------------------------------------------
-- Lines of code + git stats
-- ----------------------------------------------------------------
(hf, hl, cf, cl, jf, jl) <- unsafeCompiler getLocStats
(commits, firstDate) <- unsafeCompiler getGitStats
-- ----------------------------------------------------------------
-- Build timestamp + last build duration
-- ----------------------------------------------------------------
buildTimestamp <- unsafeCompiler $
formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC" <$> getCurrentTime
lastBuildDur <- unsafeCompiler $
(readFile "data/last-build-seconds.txt" >>= \s ->
let secs = fromMaybe 0 (readMaybe (filter (/= '\n') s) :: Maybe Int)
in return (show secs ++ "s"))
`catch` (\(_ :: IOException) -> return "\x2014")
-- ----------------------------------------------------------------
-- Assemble page
-- ----------------------------------------------------------------
let htmlContent :: H.Html
htmlContent = do
renderContent rows
renderPages allPIs oldestDate newestDate
renderDistribution allWCs
renderTagsSection topTags uniqueTags
renderLinks mostLinkedInfo orphanCount (length allPIs)
renderEpistemic epTotal withStatus withConf withImp withEv
renderOutput outputGrouped totalFiles totalSize
renderRepository hf hl cf cl jf jl commits firstDate
renderBuild buildTimestamp lastBuildDur
contentString = renderHtml htmlContent
plainText = stripHtmlTags contentString
wc = length (words plainText)
rt = readingTime plainText
ctx = constField "toc" (renderHtml pageTOC)
<> constField "word-count" (show wc)
<> constField "reading-time" (show rt)
<> constField "title" "Build Telemetry"
<> constField "abstract" "Per-build corpus statistics, tag distribution, \
\link analysis, epistemic coverage, output metrics, \
\repository overview, and build timing."
<> constField "build" "true"
<> authorLinksField
<> siteCtx
makeItem contentString
>>= loadAndApplyTemplate "templates/essay.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- -------------------------------------------------------------------------
-- Writing statistics page (/stats/)
-- -------------------------------------------------------------------------
create ["stats/index.html"] $ do
route idRoute
compile $ do
essays <- loadAll (P.essayPattern .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
poems <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
comps <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
essayWCs <- mapM loadWC essays
postWCs <- mapM loadWC posts
poemWCs <- mapM loadWC poems
fictionWCs <- mapM loadWC fiction
compWCs <- mapM loadWC comps
let allItems = essays ++ posts ++ poems ++ fiction ++ comps
typeRows =
[ TypeRow "Essays" (length essays) (sum essayWCs)
, TypeRow "Blog posts" (length posts) (sum postWCs)
, TypeRow "Poems" (length poems) (sum poemWCs)
, TypeRow "Fiction" (length fiction) (sum fictionWCs)
, TypeRow "Compositions" (length comps) (sum compWCs)
]
allPIs <- catMaybes <$> mapM loadPI allItems
-- Build wordsByDay: for each item with a parseable `date`, map that
-- day to the item's word count (summing if multiple items share a date).
datePairs <- fmap catMaybes $ forM allItems $ \item -> do
meta <- getMetadata (itemIdentifier item)
wc <- loadWC item
return $ case lookupString "date" meta >>= parseDay of
Nothing -> Nothing
Just d -> Just (d, wc)
let wordsByDay = Map.fromListWith (+) datePairs
let tagFreqs = map (\(t, ids) -> (t, length ids)) (tagsMap tags)
topTags = take 15 (sortBy (comparing (Down . snd)) tagFreqs)
uniqueTags = length tagFreqs
today <- unsafeCompiler (utctDay <$> getCurrentTime)
let htmlContent :: H.Html
htmlContent = do
section "activity" "Writing activity" (renderHeatmap wordsByDay today)
renderMonthlyVolume wordsByDay
renderCorpus typeRows allPIs
renderNotable allPIs
renderStatsTags topTags uniqueTags
contentString = renderHtml htmlContent
plainText = stripHtmlTags contentString
wc = length (words plainText)
rt = readingTime plainText
ctx = constField "toc" (renderHtml statsTOC)
<> constField "word-count" (show wc)
<> constField "reading-time" (show rt)
<> constField "title" "Writing Statistics"
<> constField "abstract" "Writing activity, corpus breakdown, \
\and tag distribution computed at build time."
<> constField "build" "true"
<> authorLinksField
<> siteCtx
makeItem contentString
>>= loadAndApplyTemplate "templates/essay.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls

108
build/Tags.hs Normal file
View File

@ -0,0 +1,108 @@
{-# 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
) where
import Data.List (intercalate, nub)
import Hakyll
import Pagination (sortAndGroup)
import Patterns (tagIndexable)
import Contexts (abstractField, tagLinksField)
-- ---------------------------------------------------------------------------
-- 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 tagIndexable tagIdentifier
-- ---------------------------------------------------------------------------
-- Tag index page rules
-- ---------------------------------------------------------------------------
tagItemCtx :: Context String
tagItemCtx =
dateField "date" "%-d %B %Y"
<> tagLinksField "item-tags"
<> abstractField
<> 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

78
build/Utils.hs Normal file
View File

@ -0,0 +1,78 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Shared utilities used across the build system.
--
-- The HTML escapers (one for 'String', one for 'Text') live here so that
-- every filter, context, and renderer goes through the same definition.
-- The expansion order matters: @&@ MUST be replaced first, otherwise the
-- @&amp;@ injected by other rules gets re-escaped to @&amp;amp;@. The
-- pure-character-by-character implementation used here avoids that hazard
-- entirely (each character is mapped exactly once).
module Utils
( wordCount
, readingTime
, escapeHtml
, escapeHtmlText
, trim
, authorSlugify
, authorNameOf
) where
import Data.Char (isAlphaNum, isSpace, toLower)
import qualified Data.Text as T
-- | 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: @&@, @<@, @>@, @\"@, @\'@.
--
-- Safe for use in attribute values and text content. The order of the
-- @case@ branches is irrelevant — each input character maps to exactly
-- one output sequence.
escapeHtml :: String -> String
escapeHtml = concatMap escChar
where
escChar '&' = "&amp;"
escChar '<' = "&lt;"
escChar '>' = "&gt;"
escChar '"' = "&quot;"
escChar '\'' = "&#39;"
escChar c = [c]
-- | 'Text' counterpart of 'escapeHtml'.
escapeHtmlText :: T.Text -> T.Text
escapeHtmlText = T.concatMap escChar
where
escChar '&' = "&amp;"
escChar '<' = "&lt;"
escChar '>' = "&gt;"
escChar '"' = "&quot;"
escChar '\'' = "&#39;"
escChar c = T.singleton c
-- | Strip leading and trailing whitespace.
trim :: String -> String
trim = dropWhile isSpace . reverse . dropWhile isSpace . reverse
-- | Lowercase a string, drop everything that isn't alphanumeric or
-- space, then replace runs of spaces with single hyphens.
--
-- Used for author URL slugs (e.g. @"Jane Doe" → "jane-doe"@).
-- Centralised here so 'Authors' and 'Contexts' cannot drift on Unicode
-- edge cases.
authorSlugify :: String -> String
authorSlugify = map (\c -> if c == ' ' then '-' else c)
. filter (\c -> isAlphaNum c || c == ' ')
. map toLower
-- | Extract the author name from a "Name | url" frontmatter entry.
-- The URL portion is dropped (it's no longer used by the author system,
-- which routes everything through @/authors/{slug}/@).
authorNameOf :: String -> String
authorNameOf s = trim (takeWhile (/= '|') s)

8
cabal.project Normal file
View File

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

235
cabal.project.freeze Normal file
View File

@ -0,0 +1,235 @@
active-repositories: hackage.haskell.org:merge
constraints: any.Glob ==0.10.2,
any.HUnit ==1.6.2.0,
any.JuicyPixels ==3.3.9,
any.OneTuple ==0.4.2.1,
any.Only ==0.1,
any.QuickCheck ==2.15.0.1,
any.StateVar ==1.2.2,
any.aeson ==2.2.1.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.6,
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.3,
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.6,
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.8.0,
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.1,
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.5,
any.optparse-applicative ==0.18.1.0,
any.ordered-containers ==0.2.4,
any.os-string ==2.0.10,
any.pandoc ==3.6,
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.3,
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.1,
any.semialign ==1.3.1,
any.semigroupoids ==6.0.2,
any.serialise ==0.2.6.1,
any.simple-sendfile ==0.2.32,
any.skylighting ==0.14.4,
any.skylighting-core ==0.14.4,
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.12,
any.text ==2.0.2,
any.text-conversions ==0.3.1.1,
any.text-icu ==0.8.0.5,
any.text-iso8601 ==0.1.1,
any.text-short ==0.1.6.1,
any.th-abstraction ==0.6.0.0,
any.th-compat ==0.1.7,
any.th-expand-syns ==0.4.12.0,
any.th-lift ==0.8.6,
any.th-lift-instances ==0.1.20,
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.9,
any.time-locale-compat ==0.1.1.5,
any.time-manager ==0.0.1,
any.tls ==2.0.6,
any.toml-parser ==2.0.2.0,
any.transformers ==0.6.1.0,
any.transformers-base ==0.4.6.1,
any.transformers-compat ==0.7.2,
any.typed-process ==0.2.13.0,
any.typst ==0.6.1,
any.typst-symbols ==0.1.7,
any.unicode-collation ==0.1.3.6,
any.unicode-data ==0.6.0,
any.unicode-transforms ==0.4.0.1,
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.6,
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-04-02T12:38:26Z

5
content/about.md Normal file
View File

@ -0,0 +1,5 @@
---
title: About
---
This is the About page. Replace this stub in `content/about.md` with information about yourself.

View File

@ -0,0 +1,10 @@
---
title: Hello, world
date: 2026-04-12
abstract: First demo blog post.
tags: [writing]
---
Welcome to the blog. This demo post exists so the paginated blog index has at least one entry. Replace it with your first real post.
Add Markdown files under `content/blog/` and they appear in reverse chronological order at `/blog/`.

17
content/colophon.md Normal file
View File

@ -0,0 +1,17 @@
---
title: Colophon
abstract: How this site is built.
---
This site is built with Ozymandias, a static site framework using Hakyll, Pandoc, and a custom pipeline of Haskell filters and JavaScript components.
## Stack
- **Build engine:** Haskell + [Hakyll](https://jaspervdj.be/hakyll/).
- **Markdown processing:** [Pandoc](https://pandoc.org/) with custom AST filters for sidenotes, dropcaps, smallcaps, wikilinks, math, code highlighting, and more.
- **Typography:** Spectral (body), Fira Sans (headings and UI), JetBrains Mono (code).
- **Search:** [Pagefind](https://pagefind.app/) for client-side full-text search.
- **Citations:** Pandoc citeproc with the Chicago Notes style.
- **Score reader:** Inline SVG score rendering for music compositions.
Configuration lives in `site.yaml` at the project root.

View File

@ -0,0 +1,91 @@
---
title: A Feature Tour
date: 2026-04-12
abstract: A brief tour of the typography, structural, and analytic features available in an Ozymandias site.
tags: [writing, notes]
status: Stable
confidence: 80
importance: 3
evidence: 4
scope: average
novelty: low
practicality: high
confidence-history: [70, 80]
history:
- date: "2026-04-12"
note: Initial demo essay
---
This essay exists to give you something to look at on a fresh build and to demonstrate the major features of the pipeline at a glance. Feel free to delete it once you have written your own.
## Typography
The body typeface is Spectral, a screen-first serif with old-style figures (2026, 1984), genuine italic *like this*, and **bold weights**. Standard ligatures are active: *first*, *fifty*, *ffle*. Headings are set in Fira Sans Semibold; code is JetBrains Mono.
Inline code looks like `make build`. Common abbreviations --- HTML, CSS, JSON, NASA, MIT --- are automatically set in smallcaps by the typography filter.
## Sidenotes
Footnotes in your Markdown become sidebar notes on wide screens.[^margin] On narrow screens they fall back to numbered footnotes at the bottom. No special syntax is required beyond standard Pandoc footnotes.
[^margin]: This is a sidenote. It lives in the margin on wide displays and folds into the flow on mobile.
A second sidenote for demonstration.[^second] The numbering and positioning are handled automatically.
[^second]: Sidenotes can contain *inline formatting*, `code`, and even math like $x^2$.
## Mathematics
Inline math like $e^{i\pi} + 1 = 0$ uses KaTeX. Display equations render centered:
$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$
The quadratic formula solves $ax^2 + bx + c = 0$:
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
## Code
Fenced code blocks with language annotations get syntax highlighting:
```haskell
greet :: String -> String
greet name = "Hello, " ++ name ++ "!"
```
```python
def greet(name: str) -> str:
return f"Hello, {name}!"
```
## Wikilinks
Link between pages by title using double brackets: [[About]] resolves to the about page. Use pipe syntax for custom link text: [[Colophon|the colophon page]].
## Citations
Citations use Pandoc's `[@key]` syntax and resolve against `data/bibliography.bib`. For instance, Knuth's classic text on typesetting[@knuth1984] or Shannon's foundational paper on information theory[@shannon1948]. The bibliography appears at the bottom of the essay.
## Epistemic profile
The frontmatter of this essay declares `status`, `confidence`, `importance`, `evidence`, and related fields. These render as a compact metadata block at the top of the page, giving readers a credibility signal before they commit to reading.
## Tables
| Feature | Status |
|:---------------|:---------|
| Typography | complete |
| Sidenotes | complete |
| Math (KaTeX) | complete |
| Code | complete |
| Citations | complete |
| Wikilinks | complete |
| Backlinks | complete |
| Score reader | complete |
## Further features
- **Backlinks:** if other essays link to this page, they appear in a Backlinks section at the bottom.
- **Similar links:** enable the embedding pipeline (`uv sync && make build`) and semantically similar essays appear in Further Reading.
- **Dark mode:** use the settings toggle in the nav bar.
- **Score reader:** add SVG score pages to a composition directory; see `content/music/demo-piece/` for an example.

View File

@ -0,0 +1,13 @@
---
title: The Crow and the Pitcher
authors:
- "Aesop"
date: 1894-01-01
tags: [writing]
---
A Crow, half-dead with thirst, came upon a Pitcher which had once been full of water; but when the Crow put its beak into the mouth of the Pitcher he found that only very little water was left in it, and that he could not reach far enough down to get at it. He tried, and he tried, but at last had to give up in despair.
Then a thought came to him, and he took a pebble and dropped it into the Pitcher. Then he took another pebble and dropped it into the Pitcher. Then he took another pebble and dropped it into the Pitcher. At last, at last, he saw the water mount up near him, and after casting in a few more pebbles he was able to quench his thirst and save his life.
*Little by little does the trick.*

14
content/index.md Normal file
View File

@ -0,0 +1,14 @@
---
title: My Site
home: true
---
Welcome to your new Ozymandias site. This is the homepage --- replace this text in `content/index.md` with whatever you want visitors to see first.
Some places to start:
- The [feature tour](/essays/feature-tour.html) demonstrates typography, sidenotes, math, code, citations, and the epistemic profile.
- The [library](/library.html) groups all content by portal.
- Browse [poetry](/poetry/), [fiction](/fiction/), [blog posts](/blog/), or [compositions](/music/).
Edit `site.yaml` to set your name, URL, navigation links, and portal taxonomy.

View File

@ -0,0 +1,28 @@
---
title: Demo Sonata
authors:
- "Demo Composer"
date: 2026-04-12
tags: [notes]
instrumentation: Solo piano
duration: "5'30\""
abstract: A placeholder composition demonstrating the score reader.
score-pages:
- score-001.svg
- score-002.svg
- score-003.svg
movements:
- name: "Allegro"
page: 1
duration: "2'00\""
- name: "Adagio"
page: 2
duration: "2'00\""
- name: "Presto"
page: 3
duration: "1'30\""
---
This is a placeholder composition. Replace the contents of `content/music/demo-piece/` with your own work --- each composition lives in its own directory with SVG score pages alongside the `index.md`.
Click the score reader link above to view the pages in the dedicated reader.

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 595 842">
<rect x="20" y="20" width="555" height="802" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="297.5" y="60" font-family="serif" font-size="24" text-anchor="middle" fill="currentColor">Demo Sonata</text>
<text x="297.5" y="90" font-family="serif" font-size="16" text-anchor="middle" fill="currentColor">I. Allegro</text>
<text x="297.5" y="421" font-family="serif" font-size="36" text-anchor="middle" fill="currentColor" opacity="0.3">Page 1</text>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 595 842">
<rect x="20" y="20" width="555" height="802" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="297.5" y="60" font-family="serif" font-size="24" text-anchor="middle" fill="currentColor">Demo Sonata</text>
<text x="297.5" y="90" font-family="serif" font-size="16" text-anchor="middle" fill="currentColor">II. Adagio</text>
<text x="297.5" y="421" font-family="serif" font-size="36" text-anchor="middle" fill="currentColor" opacity="0.3">Page 2</text>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 595 842">
<rect x="20" y="20" width="555" height="802" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="297.5" y="60" font-family="serif" font-size="24" text-anchor="middle" fill="currentColor">Demo Sonata</text>
<text x="297.5" y="90" font-family="serif" font-size="16" text-anchor="middle" fill="currentColor">III. Presto</text>
<text x="297.5" y="421" font-family="serif" font-size="36" text-anchor="middle" fill="currentColor" opacity="0.3">Page 3</text>
</svg>

After

Width:  |  Height:  |  Size: 542 B

6
content/music/index.md Normal file
View File

@ -0,0 +1,6 @@
---
title: Music
catalog: true
---
Add composition pages under `content/music/<slug>/index.md` and they will appear here automatically.

View File

@ -0,0 +1,22 @@
---
title: Ozymandias
authors:
- "Percy Bysshe Shelley"
date: 1818-01-11
tags: [writing]
---
I met a traveller from an antique land,
Who said---"Two vast and trunkless legs of stone
Stand in the desert. . . . Near them, on the sand,
Half sunk a shattered visage lies, whose frown,
And wrinkled lip, and sneer of cold command,
Tell that its sculptor well those passions read
Which yet survive, stamped on these lifeless things,
The hand that mocked them, and the heart that fed;
And on the pedestal, these words appear:
My name is Ozymandias, King of Kings;
Look on my Works, ye Mighty, and despair!
Nothing beside remains. Round the decay
Of that colossal Wreck, boundless and bare
The lone and level sands stretch far away."

6
content/search.md Normal file
View File

@ -0,0 +1,6 @@
---
title: Search
search: true
---
<div id="search"></div>

1
data/annotations.json Normal file
View File

@ -0,0 +1 @@
{}

17
data/bibliography.bib Normal file
View File

@ -0,0 +1,17 @@
@book{knuth1984,
author = {Donald E. Knuth},
title = {The {{\TeX}}book},
publisher = {Addison-Wesley},
year = {1984},
address = {Reading, MA}
}
@article{shannon1948,
author = {Claude E. Shannon},
title = {A Mathematical Theory of Communication},
journal = {Bell System Technical Journal},
volume = {27},
number = {3},
pages = {379--423},
year = {1948}
}

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

@ -0,0 +1,248 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0"
demote-non-dropping-particle="sort-only"
default-locale="en-US">
<info>
<title>Chicago Notes Bibliography</title>
<id>chicago-notes-ozymandias</id>
<link href="http://www.chicagomanualofstyle.org/tools_citationguide.html" rel="documentation"/>
<author><name>Ozymandias</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>

67
ozymandias.cabal Normal file
View File

@ -0,0 +1,67 @@
cabal-version: 3.0
name: ozymandias
version: 0.1.0.0
synopsis: Static site builder for the Ozymandias template
license: MIT
license-file: LICENSE
author: Your Name
maintainer: you@example.com
build-type: Simple
executable site
main-is: Main.hs
hs-source-dirs: build
other-modules:
Site
Authors
Catalog
Commonplace
Backlinks
SimilarLinks
Compilers
Config
Contexts
Patterns
Stats
Stability
Tags
Pagination
Citations
Filters
Filters.Typography
Filters.Sidenotes
Filters.Dropcaps
Filters.Smallcaps
Filters.Wikilinks
Filters.Transclusion
Filters.EmbedPdf
Filters.Links
Filters.Math
Filters.Code
Filters.Images
Filters.Score
Filters.Viz
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,
blaze-html >= 0.9 && < 0.10,
blaze-markup >= 0.8 && < 0.9
default-language: GHC2021
ghc-options:
-threaded
-Wall

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[project]
name = "ozymandias-tools"
version = "0.1.0"
description = "Build-time tooling for the Ozymandias static site template"
requires-python = ">=3.12"
dependencies = [
# Visualization
"matplotlib>=3.9,<4",
"altair>=5.4,<6",
# Embedding pipeline
# Upper bounds are intentionally generous (next major) but always
# present so that an unrelated `uv sync` upgrade can't silently pull
# an API-breaking 4.x release. Bump deliberately when validating.
"sentence-transformers>=3.4,<4",
"faiss-cpu>=1.9,<2",
"numpy>=2.0,<3",
"beautifulsoup4>=4.12,<5",
# CPU-only torch — avoids pulling ~3 GB of CUDA libraries
"torch>=2.5,<3",
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
torch = [{ index = "pytorch-cpu" }]
[tool.uv]

81
site.yaml Normal file
View File

@ -0,0 +1,81 @@
# site.yaml — Ozymandias site configuration
#
# Single source of truth for site identity, navigation, and the library
# portal taxonomy. Edit this file before your first build.
# ──────────────────────────────────────────────────────────────────────────────
# Site identity
# ──────────────────────────────────────────────────────────────────────────────
site-name: "My Site"
site-url: "https://example.com"
site-description: "A personal site built with Ozymandias"
site-language: "en"
# ──────────────────────────────────────────────────────────────────────────────
# Author identity
#
# `author-name` is the default author for any content with no `authors`
# frontmatter key. `author-email` appears in the Atom feed.
# ──────────────────────────────────────────────────────────────────────────────
author-name: "Your Name"
author-email: "you@example.com"
# ──────────────────────────────────────────────────────────────────────────────
# Atom feed
# ──────────────────────────────────────────────────────────────────────────────
feed-title: "My Site"
feed-description: "Essays, notes, and creative work"
# ──────────────────────────────────────────────────────────────────────────────
# Footer
#
# `license` is the content license shown in the footer. (Ozymandias itself
# is MIT-licensed — see LICENSE.) `source-url` is an optional link to your
# source repository; leave empty to omit the link.
# ──────────────────────────────────────────────────────────────────────────────
license: "CC BY-SA 4.0"
source-url: ""
# ──────────────────────────────────────────────────────────────────────────────
# GPG signing (optional)
#
# If `gpg-fingerprint` is set, the footer shows a "sig" link next to each
# page pointing to its detached signature (`<page>.sig`). Use
# `tools/sign-site.sh` during deployment to actually sign pages. Leave
# `gpg-fingerprint` empty to hide the sig link entirely.
#
# `gpg-pubkey-url` is the public path to your armored pubkey; the default
# assumes you've placed it at `static/gpg/pubkey.asc`.
# ──────────────────────────────────────────────────────────────────────────────
gpg-fingerprint: ""
gpg-pubkey-url: "/gpg/pubkey.asc"
# ──────────────────────────────────────────────────────────────────────────────
# Navigation — primary header links
#
# The order here is the order shown in the header.
# ──────────────────────────────────────────────────────────────────────────────
nav:
- { href: "/", label: "Home" }
- { href: "/library.html", label: "Library" }
- { href: "/new.html", label: "New" }
- { href: "/search.html", label: "Search" }
# ──────────────────────────────────────────────────────────────────────────────
# Library portal taxonomy
#
# Each portal becomes a section on /library.html. Posts whose tags include
# the portal's `slug` (or any tag starting with `<slug>/`) appear in that
# section. The `name` is the human-readable label.
# ──────────────────────────────────────────────────────────────────────────────
portals:
- { slug: "writing", name: "Writing" }
- { slug: "code", name: "Code" }
- { slug: "notes", name: "Notes" }

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

@ -0,0 +1,215 @@
/* 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); }
/* System dark mode fallback (for prefers-color-scheme without explicit data-theme) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); }
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); }
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); }
:root:not([data-theme="light"]) 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; }
/* ============================================================
ANNOTATE PICKER
Appears above the selection when "Annotate" is clicked.
Inverted colors (dark bg, light text) same as the tooltip.
============================================================ */
.ann-picker {
position: absolute;
z-index: 760;
background: var(--text);
color: var(--bg);
border-radius: 5px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
padding: 0.55rem 0.6rem;
font-family: var(--font-sans);
font-size: 0.75rem;
width: 13rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s ease;
pointer-events: none;
}
.ann-picker.is-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.ann-picker-swatches {
display: flex;
gap: 0.45rem;
margin-bottom: 0.5rem;
align-items: center;
}
.ann-picker-swatch {
width: 1.05rem;
height: 1.05rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
outline: none;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.ann-picker-swatch--amber { background: rgba(245, 158, 11, 0.9); }
.ann-picker-swatch--sage { background: rgba(107, 158, 120, 0.9); }
.ann-picker-swatch--steel { background: rgba(112, 150, 184, 0.9); }
.ann-picker-swatch--rose { background: rgba(200, 116, 116, 0.9); }
.ann-picker-swatch.is-selected,
.ann-picker-swatch:focus-visible {
border-color: var(--bg);
transform: scale(1.2);
}
.ann-picker-note {
display: block;
width: 100%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
color: inherit;
font-family: var(--font-sans);
font-size: 0.72rem;
padding: 0.28rem 0.4rem;
margin-bottom: 0.45rem;
outline: none;
}
.ann-picker-note::placeholder { opacity: 0.5; }
.ann-picker-note:focus {
border-color: rgba(255, 255, 255, 0.45);
}
.ann-picker-actions {
display: flex;
justify-content: flex-end;
}
.ann-picker-submit {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
color: inherit;
font-family: var(--font-sans);
font-size: 0.72rem;
padding: 0.22rem 0.55rem;
cursor: pointer;
line-height: 1.4;
transition: background 0.1s ease;
}
.ann-picker-submit:hover {
background: rgba(255, 255, 255, 0.28);
}
/* ============================================================
REDUCE MOTION
============================================================ */
[data-reduce-motion] mark.user-annotation { transition: none; }
[data-reduce-motion] .ann-tooltip { transition: none; }
[data-reduce-motion] .ann-picker { transition: none; }

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

@ -0,0 +1,305 @@
/* 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;
--transition-medium: 0.28s ease;
--transition-slow: 0.5s ease;
/* Writing activity heatmap (light mode) */
--hm-0: #e8e8e4; /* empty cell */
--hm-1: #b4b4b0; /* < 500 words */
--hm-2: #787874; /* 5001999 words */
--hm-3: #424240; /* 20004999 words */
--hm-4: #1a1a1a; /* 5000+ words */
/* Aliases (introduced for build.css, components.css, and the
annotation system, all of which referenced custom properties that
were never defined). Browsers silently fall back to the property's
initial value when var(--undefined) is used, so without these
aliases the build/annotation pages would degrade to default
greys/serif fonts.
Defining them here keeps a single source of truth change the
primitive token (--border-muted, --font-sans, --bg-offset) and
every consumer follows. */
--rule: var(--border-muted);
--font-ui: var(--font-sans);
--bg-subtle: var(--bg-offset);
/* Layout breakpoints referenced from JS via getComputedStyle and
documented here for grep. CSS @media queries cannot use custom
properties, so the @media values throughout components.css and
layout.css must be kept in lockstep with these. */
--bp-phone: 540px;
--bp-tablet: 680px;
--bp-desktop: 900px;
--bp-wide: 1500px;
}
/* ============================================================
DARK MODE (Refined to Charcoal & Ink)
============================================================ */
/* Explicit dark mode */
[data-theme="dark"] {
--bg: #121212;
--bg-nav: #181818;
--bg-offset: #1a1a1a;
/* --text-faint was previously #6a6660, which yields ~2.8:1 contrast
on the #121212 background and fails WCAG AA. Bumped to #8b8680 for
a contrast of ~3.5:1, the minimum for non-text UI elements. */
--text: #d4d0c8;
--text-muted: #8c8881;
--text-faint: #8b8680;
--border: #333333;
--border-muted: #444444;
--link: #d4d0c8;
--link-underline: #8b8680;
--link-hover: #ffffff;
--link-hover-underline: #ffffff;
--link-visited: #a39f98;
--selection-bg: #d4d0c8;
--selection-text: #121212;
/* Aliases — kept in sync with the light-mode definitions above. */
--bg-subtle: var(--bg-offset);
/* Writing activity heatmap (dark mode) */
--hm-0: #252524;
--hm-1: #484844;
--hm-2: #6e6e6a;
--hm-3: #9e9e9a;
--hm-4: #d4d0c8;
}
/* 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: #8b8680;
--border: #333333;
--border-muted: #444444;
--link: #d4d0c8;
--link-underline: #8b8680;
--link-hover: #ffffff;
--link-hover-underline: #ffffff;
--link-visited: #a39f98;
--selection-bg: #d4d0c8;
--selection-text: #121212;
--bg-subtle: var(--bg-offset);
--hm-0: #252524;
--hm-1: #484844;
--hm-2: #6e6e6a;
--hm-3: #9e9e9a;
--hm-4: #d4d0c8;
}
}
/* ============================================================
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;
/* clip (not hidden) prevents horizontal scroll at the viewport level
without creating a scroll container, so position:sticky still works. */
overflow-x: clip;
}
body {
margin: 0;
padding: 0;
overflow-x: clip;
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);
}
/* Global keyboard-focus indicator. Applies only when the user navigates
with the keyboard (`:focus-visible`), not on mouse click, so it does
not interfere with normal click feedback. Buttons, anchors, summaries
and form controls all share a single 2px outline so the focus path is
visible regardless of the surrounding component styling.
Individual components may override this with a tighter or differently
positioned ring, but the default is always present. */
:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
border-radius: 2px;
}
button:focus,
a:focus,
summary:focus,
[role="button"]:focus {
outline: none; /* fall back to :focus-visible above */
}
button:focus-visible,
a:focus-visible,
summary:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
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 */
}

165
static/css/build.css Normal file
View File

@ -0,0 +1,165 @@
/* ============================================================
Build telemetry page (/build/)
============================================================ */
.build-section {
margin: 2.5rem 0;
}
/* Summary + tag tables */
.build-table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 0.95em;
}
.build-table th,
.build-table td {
padding: 0.35rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--rule);
}
.build-table th {
font-family: var(--font-ui);
font-size: 0.8em;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 2px solid var(--rule);
}
.build-table td:not(:first-child) {
font-variant-numeric: tabular-nums;
}
.build-total td {
font-weight: 600;
border-top: 2px solid var(--rule);
border-bottom: none;
}
/* Word-length distribution bars */
.build-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 480px;
}
.build-bar-row {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9em;
}
.build-bar-label {
width: 6.5rem;
flex-shrink: 0;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: 0.85em;
text-align: right;
}
.build-bar-wrap {
flex: 1;
background: var(--rule);
height: 1rem;
border-radius: 2px;
overflow: hidden;
}
.build-bar {
display: block;
height: 100%;
background: var(--text);
border-radius: 2px;
transition: width 0.2s ease;
min-width: 2px;
}
.build-bar-count {
width: 2.5rem;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-size: 0.85em;
color: var(--text-muted);
}
/* Build info dl */
.build-meta {
display: grid;
grid-template-columns: 12rem 1fr;
gap: 0.25rem 1rem;
font-size: 0.95em;
}
.build-meta dt {
color: var(--text-muted);
font-family: var(--font-ui);
font-size: 0.85em;
}
.build-meta dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
/* ============================================================
Writing statistics page (/stats/)
============================================================ */
/* Heatmap figure */
.stats-heatmap {
margin: 1.5rem 0 1rem;
overflow-x: auto;
}
.heatmap-svg {
display: block;
max-width: 100%;
}
/* Heatmap intensity classes (driven by CSS vars in base.css) */
.heatmap-svg .hm0,
.heatmap-legend .hm0 { fill: var(--hm-0); }
.heatmap-svg .hm1,
.heatmap-legend .hm1 { fill: var(--hm-1); }
.heatmap-svg .hm2,
.heatmap-legend .hm2 { fill: var(--hm-2); }
.heatmap-svg .hm3,
.heatmap-legend .hm3 { fill: var(--hm-3); }
.heatmap-svg .hm4,
.heatmap-legend .hm4 { fill: var(--hm-4); }
.heatmap-svg .hm-lbl {
font-size: 9px;
fill: var(--text-faint);
font-family: sans-serif;
}
/* Legend row below heatmap */
.heatmap-legend {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
font-size: 0.75em;
font-family: var(--font-ui, var(--font-sans));
color: var(--text-faint);
}
/* Ordered lists on the Notable section */
.build-page-list {
font-size: 0.95em;
padding-left: 1.5rem;
margin: 0.25rem 0 1rem;
}
.build-page-list li {
margin: 0.3rem 0;
font-variant-numeric: tabular-nums;
}

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

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

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

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

1451
static/css/components.css Normal file

File diff suppressed because it is too large Load Diff

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

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

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

@ -0,0 +1,106 @@
/* home.css — Homepage-specific styles (loaded only on index.html) */
/* ============================================================
UTILITY ROWS
Professional row and curiosities row: compact Fira Sans
link strips separated by middots. Two rows, visually equal.
============================================================ */
.hp-pro-row,
.hp-curiosity-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-family: var(--font-sans);
font-size: var(--text-size-small);
margin-top: 0.75rem;
}
/* First row: extra top margin since the hr is gone breathing
room between the intro block and the utility rows. */
.hp-pro-row {
margin-top: 2rem;
}
.hp-pro-row a,
.hp-curiosity-row a {
color: var(--text-muted);
text-decoration: none;
transition: color var(--transition-fast);
padding: 0.1rem 0.15rem;
}
.hp-pro-row a:hover,
.hp-curiosity-row a:hover {
color: var(--text);
}
.hp-sep {
color: var(--text-faint);
padding: 0 0.2em;
user-select: none;
pointer-events: none;
}
/* ============================================================
LATIN BENEDICTION
Right-aligned closing salutation, set in lang="la" for
correct hyphenation and screen-reader pronunciation.
============================================================ */
.hp-latin {
text-align: right;
margin-top: 1.5rem;
}
.hp-latin p {
margin: 0;
color: var(--text-muted);
}
/* ============================================================
PORTAL LIST
Annotated portal directory, eight items, one per line.
Name link em dash short description in faint text.
============================================================ */
.hp-portals {
margin-top: 2.5rem;
font-family: var(--font-sans);
}
.hp-portal-item {
display: flex;
align-items: baseline;
gap: 0.4em;
padding: 0.2rem 0;
}
.hp-portal-name {
color: var(--text);
text-decoration: underline;
text-decoration-color: var(--border-muted);
text-decoration-thickness: 0.15em;
text-underline-offset: 0.2em;
text-decoration-skip-ink: auto;
transition: text-decoration-color var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.hp-portal-name:hover {
text-decoration-color: var(--link-hover-underline);
}
.hp-portal-dash {
color: var(--text-faint);
padding: 0 0.15em;
user-select: none;
flex-shrink: 0;
}
.hp-portal-desc {
font-size: var(--text-size-small);
color: var(--text-faint);
line-height: 1.4;
}

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

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

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

@ -0,0 +1,242 @@
/* 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;
flex: 1;
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-right {
flex: 1;
text-align: right;
}
.footer-build-link {
font-size: 0.72rem;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.footer-build-link:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* ============================================================
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%;
}
/* Footer: stack vertically so three sections don't fight for width. */
body > footer {
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.9rem 1rem;
text-align: center;
}
.footer-left {
justify-content: center;
}
.footer-right {
text-align: center;
}
}
/* Below ~900px: body spans full width (TOC hidden, no phantom column). */
@media (max-width: 900px) and (min-width: 681px) {
#markdownBody {
grid-column: 1 / -1;
}
}

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

@ -0,0 +1,133 @@
/* 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: 1.25rem;
}
/* ============================================================
SORT CONTROLS
============================================================ */
.library-controls {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 2.5rem;
}
.library-controls-label {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-faint);
}
.library-controls-options {
display: flex;
gap: 0.3rem;
}
.library-sort-btn {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.55em;
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.library-sort-btn:hover {
border-color: var(--border-muted);
color: var(--text);
}
.library-sort-btn.is-active {
border-color: var(--text-muted);
color: var(--text);
font-weight: 600;
}
/* ============================================================
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;
}

149
static/css/new.css Normal file
View File

@ -0,0 +1,149 @@
/* new.css — Recently published content page */
.new-intro {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin-bottom: 2rem;
}
/* ============================================================
COUNT CONTROL
============================================================ */
.new-controls {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.new-controls-label {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-faint);
}
.new-controls-options {
display: flex;
gap: 0.3rem;
}
.new-count-btn {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.55em;
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.new-count-btn:hover {
border-color: var(--border-muted);
color: var(--text);
}
.new-count-btn.is-active {
border-color: var(--text-muted);
color: var(--text);
font-weight: 600;
}
/* ============================================================
ENTRY LIST
============================================================ */
.new-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.new-entry {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.new-entry:first-child {
border-top: 1px solid var(--border);
}
/* ============================================================
KIND BADGE
============================================================ */
.new-entry-kind {
font-family: var(--font-sans);
font-size: 0.63rem;
font-variant: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
background: var(--bg-offset);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.5em;
flex-shrink: 0;
margin-top: 0.25em;
min-width: 5.5rem;
text-align: center;
line-height: 1.6;
}
/* ============================================================
ENTRY CONTENT
============================================================ */
.new-entry-main {
flex: 1;
min-width: 0;
}
.new-entry-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.new-entry-title {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text);
text-decoration: none;
line-height: 1.35;
}
.new-entry-title:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.new-entry-date {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.new-entry-abstract {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin: 0.3rem 0 0;
line-height: 1.55;
/* Clamp to two lines */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

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

@ -0,0 +1,216 @@
/* 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 {
display: flex;
align-items: center;
gap: 0.3em;
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;
}
/* Icon preceding the source label — same mask-image technique as inline link icons */
.popup-source[data-popup-source]::before {
content: '';
display: inline-block;
flex-shrink: 0;
width: 0.85em;
height: 0.85em;
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.7;
}
.popup-source[data-popup-source="wikipedia"]::before {
mask-image: url('/images/link-icons/wikipedia.svg');
-webkit-mask-image: url('/images/link-icons/wikipedia.svg');
}
.popup-source[data-popup-source="arxiv"]::before {
mask-image: url('/images/link-icons/arxiv.svg');
-webkit-mask-image: url('/images/link-icons/arxiv.svg');
}
.popup-source[data-popup-source="doi"]::before {
mask-image: url('/images/link-icons/doi.svg');
-webkit-mask-image: url('/images/link-icons/doi.svg');
}
.popup-source[data-popup-source="github"]::before {
mask-image: url('/images/link-icons/github.svg');
-webkit-mask-image: url('/images/link-icons/github.svg');
}
.popup-source[data-popup-source="youtube"]::before {
mask-image: url('/images/link-icons/youtube.svg');
-webkit-mask-image: url('/images/link-icons/youtube.svg');
}
.popup-source[data-popup-source="internet-archive"]::before {
mask-image: url('/images/link-icons/internet-archive.svg');
-webkit-mask-image: url('/images/link-icons/internet-archive.svg');
}
.popup-source[data-popup-source="biorxiv"]::before,
.popup-source[data-popup-source="medrxiv"]::before {
mask-image: url('/images/link-icons/arxiv.svg');
-webkit-mask-image: url('/images/link-icons/arxiv.svg');
}
.popup-source[data-popup-source="openlibrary"]::before {
mask-image: url('/images/link-icons/worldcat.svg');
-webkit-mask-image: url('/images/link-icons/worldcat.svg');
}
.popup-source[data-popup-source="pubmed"]::before {
mask-image: url('/images/link-icons/orcid.svg');
-webkit-mask-image: url('/images/link-icons/orcid.svg');
}
/* 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-entry {
display: block;
}
.popup-citation-entry + .popup-citation-entry {
margin-top: 0.55rem;
padding-top: 0.55rem;
border-top: 1px solid var(--border);
}
.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;
}
.popup-citation .ref-num {
font-weight: 600;
color: var(--text-faint);
margin-right: 0.35em;
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.popup-citation .ref-num:hover {
color: var(--text);
}
/* Epistemic preview popup — content cloned from #epistemic; ep-* styles inherited */
.popup-epistemic {
min-width: 230px;
}
/* PDF thumbnail popup — first-page image generated by pdftoppm at build time */
.link-popup:has(.popup-pdf) {
padding: 0;
overflow: hidden;
}
.popup-pdf {
line-height: 0; /* collapse whitespace gap below <img> */
}
.popup-pdf-thumb {
display: block;
width: 320px;
max-width: 100%;
height: auto;
max-height: 420px;
object-fit: contain;
object-position: top left;
}
/* PGP signature popup */
.popup-sig pre {
margin: 0;
font-family: var(--font-mono);
font-size: 0.68rem;
line-height: 1.45;
white-space: pre;
overflow-x: auto;
color: var(--text-muted);
}

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

@ -0,0 +1,149 @@
/* 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. The custom-property overrides drive the
rest of the cascade use them consistently below instead of
reaching for hardcoded #fff/#000 again.
---------------------------------------------------------------- */
:root,
[data-theme="dark"] {
--bg: #ffffff;
--bg-offset: #f5f5f5;
--bg-subtle: #f9f9f9;
--text: #000000;
--text-muted: #333333;
--text-faint: #555555;
--border: #cccccc;
--border-muted: #aaaaaa;
--rule: #cccccc;
}
/* ----------------------------------------------------------------
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: var(--bg);
color: var(--text);
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 var(--border);
font-size: 9pt;
color: var(--text-faint);
}
/* ----------------------------------------------------------------
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: var(--text-faint);
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: var(--bg-subtle) !important;
border: 1px solid var(--border-muted) !important;
box-shadow: none !important;
}
/* ----------------------------------------------------------------
Bibliography / footer keep but compact
---------------------------------------------------------------- */
.page-meta-footer {
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid var(--border);
}
.meta-footer-full,
.meta-footer-grid {
width: 100% !important;
max-width: 100% !important;
}
}

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

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

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

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

View File

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

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

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

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

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

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

@ -0,0 +1,715 @@
/* 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 — excludes user annotation marks */
#markdownBody mark:not(.user-annotation) {
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:not(.user-annotation) {
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%
);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) #markdownBody mark:not(.user-annotation) {
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 {
position: relative;
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 */
}
/* Image figures: size the box to the image and constrain the caption to the
same width. `display: table` + `caption-side: bottom` makes the figure's
intrinsic width depend only on its table-cell content (the image), so long
captions wrap to the image width instead of stretching the figure off-screen. */
#markdownBody figure:has(> img) {
display: table;
}
#markdownBody figure:has(> img) > figcaption {
display: table-caption;
caption-side: bottom;
}
#markdownBody figcaption {
font-family: var(--font-sans);
font-size: 0.92em;
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');
}
a[data-link-icon="worldcat"]::after {
mask-image: url('/images/link-icons/worldcat.svg');
-webkit-mask-image: url('/images/link-icons/worldcat.svg');
}
a[data-link-icon="orcid"]::after {
mask-image: url('/images/link-icons/orcid.svg');
-webkit-mask-image: url('/images/link-icons/orcid.svg');
}
a[data-link-icon="internet-archive"]::after {
mask-image: url('/images/link-icons/internet-archive.svg');
-webkit-mask-image: url('/images/link-icons/internet-archive.svg');
}
a[data-link-icon="tensorflow"]::after {
mask-image: url('/images/link-icons/tensorflow.svg');
-webkit-mask-image: url('/images/link-icons/tensorflow.svg');
}
a[data-link-icon="anthropic"]::after {
mask-image: url('/images/link-icons/anthropic.svg');
-webkit-mask-image: url('/images/link-icons/anthropic.svg');
}
a[data-link-icon="openai"]::after {
mask-image: url('/images/link-icons/openai.svg');
-webkit-mask-image: url('/images/link-icons/openai.svg');
}
a[data-link-icon="twitter"]::after {
mask-image: url('/images/link-icons/twitter.svg');
-webkit-mask-image: url('/images/link-icons/twitter.svg');
}
a[data-link-icon="reddit"]::after {
mask-image: url('/images/link-icons/reddit.svg');
-webkit-mask-image: url('/images/link-icons/reddit.svg');
}
a[data-link-icon="youtube"]::after {
mask-image: url('/images/link-icons/youtube.svg');
-webkit-mask-image: url('/images/link-icons/youtube.svg');
}
a[data-link-icon="tiktok"]::after {
mask-image: url('/images/link-icons/tiktok.svg');
-webkit-mask-image: url('/images/link-icons/tiktok.svg');
}
a[data-link-icon="substack"]::after {
mask-image: url('/images/link-icons/substack.svg');
-webkit-mask-image: url('/images/link-icons/substack.svg');
}
a[data-link-icon="hacker-news"]::after {
mask-image: url('/images/link-icons/hacker-news.svg');
-webkit-mask-image: url('/images/link-icons/hacker-news.svg');
}
a[data-link-icon="new-york-times"]::after {
mask-image: url('/images/link-icons/new-york-times.svg');
-webkit-mask-image: url('/images/link-icons/new-york-times.svg');
}
a[data-link-icon="nasa"]::after {
mask-image: url('/images/link-icons/nasa.svg');
-webkit-mask-image: url('/images/link-icons/nasa.svg');
}
a[data-link-icon="apple"]::after {
mask-image: url('/images/link-icons/apple.svg');
-webkit-mask-image: url('/images/link-icons/apple.svg');
}

86
static/css/viz.css Normal file
View File

@ -0,0 +1,86 @@
/* viz.css — Styles for inline data visualizations (.viz-figure, .viz-interactive) */
/* ============================================================
Static figures (Matplotlib SVG)
============================================================ */
.viz-figure {
margin: 2rem 0;
break-inside: avoid;
}
.viz-figure svg {
width: 100%;
height: auto;
display: block;
}
/* Force labels and text glyphs to use currentColor instead of default/hardcoded black */
.viz-figure svg g[id^="text_"] path,
.viz-figure svg g[id^="text_"] use {
fill: currentColor !important;
}
/* Ensure axis lines and ticks also follow currentColor */
.viz-figure svg g[id^="axes_"] path,
.viz-figure svg g[id^="xtick_"] line,
.viz-figure svg g[id^="ytick_"] line,
.viz-figure svg g[id^="xtick_"] use,
.viz-figure svg g[id^="ytick_"] use {
stroke: currentColor !important;
}
/* Catch explicit styles on axes paths */
.viz-figure svg g[id^="patch_"] path[style*="stroke: #000000"] {
stroke: currentColor !important;
}
/* ============================================================
Interactive figures (Vega-Lite via vega-embed)
============================================================ */
.viz-interactive {
margin: 2rem 0;
break-inside: avoid;
}
.vega-container {
width: 100%;
overflow: hidden;
}
/* vega-embed injects a <div> containing a <canvas> or <svg> */
.vega-container > div {
width: 100% !important;
}
.vega-container canvas,
.vega-container svg {
max-width: 100%;
display: block;
}
/* ============================================================
Captions (shared)
============================================================ */
.viz-caption {
font-size: var(--text-size-small);
color: var(--text-muted);
text-align: center;
margin-top: 0.5rem;
font-style: italic;
}
/* ============================================================
Error display (build-time script failures)
============================================================ */
.viz-error {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--text-size-small);
margin: 1.5rem 0;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="23 35 205 180"><path d="M47.1 124.4l-30.1 76c0 .3 7.6.6 16.9.6h16.9l3.6-9.2 6-15.8 2.4-6.5 31.5-.3 31.5-.2 3 7.7 6.2 16 3.2 8.3h16.9c9.3 0 16.9-.2 16.9-.4s-8.5-21.7-18.9-47.8L123 77.2 111.8 49H94.5 77.2l-30.1 75.4zm57.3-10.4l9.6 25.2c0 .5-8.8.8-19.5.8s-19.5-.3-19.5-.8c0-.4 3.4-9.5 7.6-20.2l9.6-24.8c1.1-2.9 2.1-5.2 2.3-5s4.7 11.3 9.9 24.8zM140 50.2c0 .7 13.4 34.8 29.8 75.8l29.7 74.5 16.9.3c9.9.1 16.6-.1 16.4-.7-.1-.5-13.8-34.7-30.2-76l-30-75.1h-16.3c-12.4 0-16.3.3-16.3 1.2z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 814 1000"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>

After

Width:  |  Height:  |  Size: 632 B

View File

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

After

Width:  |  Height:  |  Size: 664 B

View File

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

After

Width:  |  Height:  |  Size: 274 B

View File

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

After

Width:  |  Height:  |  Size: 476 B

View File

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

After

Width:  |  Height:  |  Size: 386 B

View File

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

After

Width:  |  Height:  |  Size: 493 B

View File

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

After

Width:  |  Height:  |  Size: 724 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 30 448 448"><path d="M 30,30 V 478 H 478 V 30 Z"/><path fill="#fff" stroke="#fff" stroke-width="6" d="M 269.2,281.1 V 382 H 237.8 V 279.3 L 158,126 h 37.3 c 52.5,98.3 49.2,101.2 59.3,125.6 12.3,-27 5.8,-24.4 60.6,-125.6 H 350 Z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13.1 15"><path d="M.1 1.5c.08.12.12.3.23.4H12.6l.3-.4L6.5 0 .1 1.5z m.3 1h12.2v1.1H.4z M1 4.2h1l.2.1.2 3.7-.1 4.5H1l-.3-.1L.5 8c0-1.5.2-3.7.2-3.7l.3-.1z m10 0h1.1l.2.1.2 3.7-.1 4.5-.3.1h-1l-.3-.1-.2-4.4c0-1.5.2-3.7.2-3.7l.2-.2z m-6.7 0h1l.2.1.2 3.7-.1 4.5-.3.1h-.9l-.3-.1-.2-4.4c0-1.5.2-3.7.2-3.7l.2-.2z m3.4 0H9l.2 3.7-.1 4.5-.3.1h-1l-.3-.1L7.3 8c0-1.5.2-3.7.2-3.7l.2-.1z M.4 13.1h12.3v.6H.4z M0 14.3h13.1v.7H0z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

View File

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

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 284.86 299.23" stroke="#000" stroke-width="4"><path d="M93.991 106.699c1.576 5.961 4.119 8.266 8.613 8.266 4.659 0 7.102-2.799 7.102-8.266V5.2h29.184v101.499c0 14.307-1.856 20.506-9.11 27.762-5.228 5.229-14.871 9.271-27.047 9.271-9.837 0-19.25-3.256-25.253-9.27-5.263-5.273-8.154-10.689-12.672-27.764L46.9 39.033c-1.577-5.961-4.119-8.265-8.613-8.265-4.66 0-7.103 2.798-7.103 8.265v101.5H2v-101.5c0-14.306 1.857-20.506 9.111-27.762C16.337 6.044 25.981 2 38.158 2c9.837 0 19.25 3.257 25.253 9.27 5.263 5.273 8.154 10.689 12.672 27.764zM251.552 297.23l-33.704-105.437c-.372-1.164-.723-2.152-1.263-2.811-.926-1.127-2.207-1.719-3.931-1.719s-3.004.592-3.931 1.719c-.539.658-.891 1.646-1.262 2.811L173.758 297.23h-30.167l36.815-115.177c1.918-6 4.66-11.094 8.139-14.488 5.971-5.821 13.007-8.868 24.11-8.868s18.14 3.047 24.109 8.867c3.479 3.395 6.221 8.488 8.14 14.488l36.814 115.177zm-165.931 0c22.529 0 33.518-4.062 42.2-11.389 9.607-8.105 14.202-16.973 14.202-30.213 0-11.699-5.047-22.535-12.731-29.019-10.046-8.479-22.525-11.151-42.872-11.151l-28.5-.001c-10.89 0-15.23-1.117-18.663-3.98-2.358-1.964-3.463-4.885-3.463-8.328 0-3.559 1.01-7.074 3.892-9.475 2.558-2.131 6.045-3.109 12.745-3.109H134.8v-28.668H58.723c-22.529 0-33.517 4.063-42.2 11.389-9.606 8.105-14.202 16.972-14.202 30.212 0 11.701 5.047 22.536 12.731 29.019 10.048 8.479 22.525 11.152 42.872 11.152l28.501.002c10.89 0 15.23 1.115 18.663 3.979 2.358 1.965 3.463 4.885 3.463 8.328 0 3.559-1.01 7.074-3.891 9.475-2.559 2.131-6.046 3.109-12.746 3.109H2.25l-.15 28.537 83.521.13zm166.334-153.266L218.251 38.527c-.372-1.164-.723-2.152-1.263-2.811-.926-1.127-2.207-1.719-3.931-1.719s-3.004.592-3.931 1.719c-.539.658-.891 1.646-1.262 2.811l-33.703 105.437h-30.167l36.815-115.177c1.918-6 4.66-11.094 8.139-14.488 5.971-5.821 13.007-8.868 24.11-8.868s18.14 3.047 24.109 8.867c3.479 3.395 6.221 8.488 8.14 14.488l36.814 115.177z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 0.75 13.5 14.75"><path d="M13.994 2.824c0-1.565-1.7-1.956-3.025-1.956v.235c.8 0 1.423.235 1.423.783 0 .313-.267.783-1.067.783-.623 0-1.957-.313-2.936-.626-1.156-.39-2.224-.704-3.113-.704-1.78 0-3.025 1.174-3.025 2.504 0 1.174.98 1.565 1.334 1.722l.09-.157c-.178-.157-.445-.313-.445-.783 0-.313.356-.86 1.245-.86.8 0 1.868.313 3.29.704 1.245.313 2.58.548 3.29.626V7.52L9.724 8.537v.078l1.334 1.017v3.365c-.712.39-1.512.47-2.224.47-1.334 0-2.49-.313-3.47-1.252l3.647-1.565v-5.4L4.565 6.972C4.92 5.955 5.9 5.25 6.878 4.78l-.09-.235c-2.67.626-5.07 2.817-5.07 5.478 0 3.13 2.936 5.478 6.227 5.478 3.558 0 5.87-2.504 5.87-5.087h-.178c-.534 1.017-1.334 1.956-2.313 2.426v-3.21l1.423-1.017v-.078L11.325 7.52V5.094c1.334 0 2.67-.783 2.67-2.27zm-7.74 8.608l-1.067.47c-.623-.704-.98-1.643-.98-2.974 0-.548 0-1.174.178-1.643l1.868-.704zm8.273 2.582c-8.54 4.07-4.27 2.035 0 0z"/></svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 14 14"><path d="M6.563 7.142l1.445-.85 1.445.85v1.7l-1.445.85-1.445-.85zm-.595 1.36l-1.2-.68v-3.4c.085-1.445 1.7-2.55 3.145-2.125.255.085 1.275.425.765.595L6.053 4.337c-.255.34-.085.765-.17 1.2v2.9zm7.14-2.38l-2.9-1.7c-.425 0-.68.34-1.105.425l-2.55 1.445v-1.36l2.975-1.7c1.36-.68 3.145.255 3.485 1.615.085.34.085.765.085 1.105zm-1.105 1.7l-3.315-1.87c.425-.17.765-.5 1.2-.68l3.06 1.785a2.55 2.55 0 0 1 .34 3.655c-.17.17-.935.935-1.02.5V8.077c0-.085-.085-.17-.255-.255zM2.567 5.697c.255-.425 1.02-1.2 1.36-1.02v3.23c.17.34.595.425.935.68l2.55 1.445c-.425.255-.765.5-1.2.68l-2.975-1.7c-1.02-.765-1.36-2.295-.68-3.315zm.68 5.865c-.255-.425-.5-1.36-.255-1.615l2.805 1.615c.425 0 .68-.34 1.105-.425l2.55-1.445v1.36l-3.06 1.7c-1.105.425-2.55 0-3.145-1.105zm5.525 2.295c-.5 0-1.445-.255-1.53-.595l2.805-1.615c.255-.34.085-.765.17-1.2V7.482l1.2.68v3.4c-.085 1.275-1.275 2.295-2.55 2.295zm5.1-7.14c.595-1.7-.425-3.74-2.125-4.25-.765-.425-1.785.17-2.295-.5-1.445-1.2-3.74-.935-4.845.595-.425.5-.5 1.2-1.275 1.275-1.615.68-2.465 2.72-1.785 4.335.255.595.85 1.02.5 1.7-.255 1.785 1.2 3.485 2.9 3.74.68.085 1.36-.17 1.785.425 1.445 1.105 3.655.765 4.76-.68.425-.5.5-1.275 1.275-1.275 1.615-.68 2.465-2.72 1.785-4.335a3.4 3.4 0 0 0-.68-1.02z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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