Initial commit for ozymandias
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 '&' = "&"
|
||||
esc '<' = "<"
|
||||
esc '>' = ">"
|
||||
esc '"' = """
|
||||
esc '\'' = "'"
|
||||
esc c = [c]
|
||||
|
||||
escText :: String -> String
|
||||
escText = concatMap esc
|
||||
where
|
||||
esc '&' = "&"
|
||||
esc '<' = "<"
|
||||
esc '>' = ">"
|
||||
esc c = [c]
|
||||
|
||||
renderIndicators :: CatalogEntry -> String
|
||||
renderIndicators e = concatMap render
|
||||
[ (ceHasScore e, "<span class=\"catalog-ind\" title=\"Score available\">◼</span>")
|
||||
, (ceHasRecording e, "<span class=\"catalog-ind\" title=\"Recording available\">♪</span>")
|
||||
]
|
||||
where
|
||||
render (True, s) = s
|
||||
render (False, _) = ""
|
||||
|
||||
renderEntry :: CatalogEntry -> String
|
||||
renderEntry e = concat
|
||||
[ "<li class=\"catalog-entry\">"
|
||||
, "<div class=\"catalog-entry-main\">"
|
||||
, "<a class=\"catalog-title\" href=\"", 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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Commonplace book: loads data/commonplace.yaml and renders
|
||||
-- themed and chronological HTML views for /commonplace.
|
||||
module Commonplace
|
||||
( commonplaceCtx
|
||||
) where
|
||||
|
||||
import Data.Aeson (FromJSON (..), withObject, (.:), (.:?), (.!=))
|
||||
import Data.List (nub, sortBy)
|
||||
import Data.Ord (comparing, Down (..))
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.Yaml as Y
|
||||
import Hakyll hiding (escapeHtml, renderTags)
|
||||
import Contexts (siteCtx)
|
||||
import Utils (escapeHtml)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Entry type
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
data CPEntry = CPEntry
|
||||
{ cpText :: String
|
||||
, cpAttribution :: String
|
||||
, cpSource :: Maybe String
|
||||
, cpSourceUrl :: Maybe String
|
||||
, cpTags :: [String]
|
||||
, cpCommentary :: Maybe String
|
||||
, cpDateAdded :: String
|
||||
}
|
||||
|
||||
instance FromJSON CPEntry where
|
||||
parseJSON = withObject "CPEntry" $ \o -> CPEntry
|
||||
<$> o .: "text"
|
||||
<*> o .: "attribution"
|
||||
<*> o .:? "source"
|
||||
<*> o .:? "source-url"
|
||||
<*> o .:? "tags" .!= []
|
||||
<*> o .:? "commentary"
|
||||
<*> o .:? "date-added" .!= ""
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HTML rendering
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Escape HTML, then replace newlines with <br> for multi-line verse.
|
||||
renderText :: String -> String
|
||||
renderText = concatMap tr . escapeHtml . stripTrailingNL
|
||||
where
|
||||
tr '\n' = "<br>\n"
|
||||
tr c = [c]
|
||||
stripTrailingNL = reverse . dropWhile (== '\n') . reverse
|
||||
|
||||
renderAttribution :: CPEntry -> String
|
||||
renderAttribution e =
|
||||
"<p class=\"cp-attribution\">\x2014\x202f"
|
||||
++ escapeHtml (cpAttribution e)
|
||||
++ maybe "" renderSource (cpSource e)
|
||||
++ "</p>"
|
||||
where
|
||||
renderSource src = case cpSourceUrl e of
|
||||
Just url -> ", <a href=\"" ++ escapeHtml url ++ "\">"
|
||||
++ escapeHtml src ++ "</a>"
|
||||
Nothing -> ", " ++ escapeHtml src
|
||||
|
||||
renderTags :: [String] -> String
|
||||
renderTags [] = ""
|
||||
renderTags ts =
|
||||
"<div class=\"cp-tags\">"
|
||||
++ concatMap (\t -> "<span class=\"cp-tag\">" ++ escapeHtml t ++ "</span>") ts
|
||||
++ "</div>"
|
||||
|
||||
renderEntry :: CPEntry -> String
|
||||
renderEntry e = concat
|
||||
[ "<article class=\"cp-entry\">"
|
||||
, "<blockquote class=\"cp-quote\"><p>"
|
||||
, renderText (cpText e)
|
||||
, "</p></blockquote>"
|
||||
, renderAttribution e
|
||||
, maybe "" renderCommentary (cpCommentary e)
|
||||
, renderTags (cpTags e)
|
||||
, "</article>"
|
||||
]
|
||||
where
|
||||
renderCommentary c =
|
||||
"<p class=\"cp-commentary\">" ++ escapeHtml c ++ "</p>"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Themed view
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | All distinct tags in first-occurrence order (preserves YAML ordering).
|
||||
allTags :: [CPEntry] -> [String]
|
||||
allTags = nub . concatMap cpTags
|
||||
|
||||
renderTagSection :: String -> [CPEntry] -> String
|
||||
renderTagSection tag entries = concat
|
||||
[ "<section class=\"cp-theme-section\">"
|
||||
, "<h2 class=\"cp-theme-heading\">" ++ escapeHtml tag ++ "</h2>"
|
||||
, concatMap renderEntry entries
|
||||
, "</section>"
|
||||
]
|
||||
|
||||
renderThemedView :: [CPEntry] -> String
|
||||
renderThemedView [] =
|
||||
"<div class=\"cp-themed\" id=\"cp-themed\">"
|
||||
++ "<p class=\"cp-empty\">No entries yet.</p>"
|
||||
++ "</div>"
|
||||
renderThemedView entries =
|
||||
"<div class=\"cp-themed\" id=\"cp-themed\">"
|
||||
++ concatMap renderSection (allTags entries)
|
||||
++ (if null untagged then ""
|
||||
else renderTagSection "miscellany" untagged)
|
||||
++ "</div>"
|
||||
where
|
||||
renderSection t =
|
||||
let es = filter (elem t . cpTags) entries
|
||||
in if null es then "" else renderTagSection t es
|
||||
untagged = filter (null . cpTags) entries
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Chronological view
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
renderChronoView :: [CPEntry] -> String
|
||||
renderChronoView entries =
|
||||
"<div class=\"cp-chrono\" id=\"cp-chrono\" hidden>"
|
||||
++ (if null sorted
|
||||
then "<p class=\"cp-empty\">No entries yet.</p>"
|
||||
else concatMap renderEntry sorted)
|
||||
++ "</div>"
|
||||
where
|
||||
sorted = sortBy (comparing (Down . cpDateAdded)) entries
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load entries from data/commonplace.yaml
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
loadCommonplace :: Compiler [CPEntry]
|
||||
loadCommonplace = do
|
||||
rawItem <- load (fromFilePath "data/commonplace.yaml") :: Compiler (Item String)
|
||||
let raw = itemBody rawItem
|
||||
case Y.decodeEither' (BS.pack raw) of
|
||||
Left err -> fail ("commonplace.yaml: " ++ show err)
|
||||
Right entries -> return entries
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Context
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
commonplaceCtx :: Context String
|
||||
commonplaceCtx =
|
||||
constField "commonplace" "true"
|
||||
<> themedField
|
||||
<> chronoField
|
||||
<> siteCtx
|
||||
where
|
||||
themedField = field "cp-themed-html" $ \_ ->
|
||||
renderThemedView <$> loadCommonplace
|
||||
chronoField = field "cp-chrono-html" $ \_ ->
|
||||
renderChronoView <$> loadCommonplace
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 1–5 frontmatter key as filled/empty dot chars.
|
||||
-- Returns @noResult@ when the key is absent or unparseable.
|
||||
dotsField :: String -> String -> Context String
|
||||
dotsField ctxKey metaKey = field ctxKey $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
case lookupString metaKey meta >>= readMaybe of
|
||||
Nothing -> fail (ctxKey ++ ": not set")
|
||||
Just (n :: Int) ->
|
||||
let v = max 0 (min 5 n)
|
||||
in return (replicate v '\x25CF' ++ replicate (5 - v) '\x25CB')
|
||||
|
||||
-- | @$confidence-trend$@: ↑, ↓, or → derived from the last two entries
|
||||
-- in the @confidence-history@ frontmatter list. Returns @noResult@ when
|
||||
-- there is no history or only a single entry.
|
||||
--
|
||||
-- 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 0–100 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 1–5 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 (0–1)
|
||||
-- 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)))
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Prepend "language-" to fenced-code-block class names so that
|
||||
-- Prism.js can find and highlight them.
|
||||
--
|
||||
-- Pandoc (with writerHighlightStyle = Nothing) outputs
|
||||
-- <pre class="python"><code>
|
||||
-- Prism.js requires
|
||||
-- <pre class="language-python"><code class="language-python">
|
||||
--
|
||||
-- We transform the AST before writing rather than post-processing HTML,
|
||||
-- so the class appears on both <pre> and <code> via Pandoc's normal output.
|
||||
module Filters.Code (apply) where
|
||||
|
||||
import qualified Data.Text as T
|
||||
import Text.Pandoc.Definition
|
||||
import Text.Pandoc.Walk (walk)
|
||||
|
||||
apply :: Pandoc -> Pandoc
|
||||
apply = walk addLangPrefix
|
||||
|
||||
addLangPrefix :: Block -> Block
|
||||
addLangPrefix (CodeBlock (ident, classes, kvs) code) =
|
||||
CodeBlock (ident, map prefix classes, kvs) code
|
||||
where
|
||||
prefix c
|
||||
| "language-" `T.isPrefixOf` c = c
|
||||
| otherwise = "language-" <> c
|
||||
addLangPrefix x = x
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
-- | Dropcap support.
|
||||
--
|
||||
-- The dropcap on the opening paragraph is implemented entirely in CSS
|
||||
-- via @#markdownBody > p:first-of-type::first-letter@, so no AST
|
||||
-- transformation is required. This module is a placeholder for future
|
||||
-- work (e.g. adding a @.lead-paragraph@ class when the first block is
|
||||
-- not a Para, or decorative initial-capital images).
|
||||
module Filters.Dropcaps (apply) where
|
||||
|
||||
import Text.Pandoc.Definition (Pandoc)
|
||||
|
||||
-- | Identity — dropcaps are handled by CSS.
|
||||
apply :: Pandoc -> Pandoc
|
||||
apply = id
|
||||
|
|
@ -0,0 +1,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]
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
-- | Math filter placeholder.
|
||||
--
|
||||
-- The spec calls for converting simple LaTeX to Unicode at build time.
|
||||
-- For now, all math (inline and display) is handled client-side by KaTeX,
|
||||
-- which is loaded conditionally on pages that contain math. Server-side
|
||||
-- KaTeX rendering is a Phase 3 task.
|
||||
module Filters.Math (apply) where
|
||||
|
||||
import Text.Pandoc.Definition (Pandoc)
|
||||
|
||||
-- | Identity — math rendering is handled by KaTeX.
|
||||
apply :: Pandoc -> Pandoc
|
||||
apply = id
|
||||
|
|
@ -0,0 +1,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. 1–8" score-caption="The opening gesture."}
|
||||
-- > 
|
||||
-- > :::
|
||||
--
|
||||
-- The filter reads the referenced SVG from disk (path resolved relative to
|
||||
-- the source file's directory), replaces hardcoded black fills/strokes with
|
||||
-- @currentColor@ for dark-mode compatibility, and emits a @\<figure\>@ with
|
||||
-- the appropriate exhibit attributes for gallery.js TOC integration.
|
||||
module Filters.Score (inlineScores) where
|
||||
|
||||
import 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 '&' = "&"
|
||||
esc '<' = "<"
|
||||
esc '>' = ">"
|
||||
esc '"' = """
|
||||
esc '\'' = "'"
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Pagination helpers.
|
||||
--
|
||||
-- NOTE: This module must not import Contexts or Tags to avoid cycles.
|
||||
-- Callers (Site.hs) pass contexts in as parameters.
|
||||
module Pagination
|
||||
( pageSize
|
||||
, sortAndGroup
|
||||
, blogPaginateRules
|
||||
) where
|
||||
|
||||
import Hakyll
|
||||
|
||||
|
||||
-- | Items per page across all paginated lists.
|
||||
pageSize :: Int
|
||||
pageSize = 20
|
||||
|
||||
-- | Sort identifiers by date (most recent first) and split into pages.
|
||||
sortAndGroup :: (MonadMetadata m, MonadFail m) => [Identifier] -> m [[Identifier]]
|
||||
sortAndGroup ids = paginateEvery pageSize <$> sortRecentFirst ids
|
||||
|
||||
-- | Page identifier for the blog index.
|
||||
-- Page 1 → blog/index.html
|
||||
-- Page N → blog/page/N/index.html
|
||||
blogPageId :: PageNumber -> Identifier
|
||||
blogPageId 1 = fromFilePath "blog/index.html"
|
||||
blogPageId n = fromFilePath $ "blog/page/" ++ show n ++ "/index.html"
|
||||
|
||||
-- | Build and rule-ify a paginated blog index.
|
||||
-- @itemCtx@: context for individual posts (postCtx).
|
||||
-- @baseCtx@: site-level context (siteCtx).
|
||||
blogPaginateRules :: Context String -> Context String -> Rules ()
|
||||
blogPaginateRules itemCtx baseCtx = do
|
||||
paginate <- buildPaginateWith sortAndGroup ("content/blog/*.md" .&&. hasNoVersion) blogPageId
|
||||
paginateRules paginate $ \pageNum pat -> do
|
||||
route idRoute
|
||||
compile $ do
|
||||
posts <- recentFirst =<< loadAll (pat .&&. hasNoVersion)
|
||||
let ctx = listField "posts" itemCtx (return posts)
|
||||
<> paginateContext paginate pageNum
|
||||
<> constField "title" "Blog"
|
||||
<> baseCtx
|
||||
makeItem ""
|
||||
>>= loadAndApplyTemplate "templates/blog-index.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
-- @&@ injected by other rules gets re-escaped to @&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 '&' = "&"
|
||||
escChar '<' = "<"
|
||||
escChar '>' = ">"
|
||||
escChar '"' = """
|
||||
escChar '\'' = "'"
|
||||
escChar c = [c]
|
||||
|
||||
-- | 'Text' counterpart of 'escapeHtml'.
|
||||
escapeHtmlText :: T.Text -> T.Text
|
||||
escapeHtmlText = T.concatMap escChar
|
||||
where
|
||||
escChar '&' = "&"
|
||||
escChar '<' = "<"
|
||||
escChar '>' = ">"
|
||||
escChar '"' = """
|
||||
escChar '\'' = "'"
|
||||
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)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
packages: .
|
||||
|
||||
with-compiler: ghc-9.6.6
|
||||
|
||||
-- Optimise the build program itself. -O1 is sufficient and much faster
|
||||
-- to compile than -O2.
|
||||
program-options
|
||||
ghc-options: -O1
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
active-repositories: hackage.haskell.org:merge
|
||||
constraints: any.Glob ==0.10.2,
|
||||
any.HUnit ==1.6.2.0,
|
||||
any.JuicyPixels ==3.3.9,
|
||||
any.OneTuple ==0.4.2.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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: About
|
||||
---
|
||||
|
||||
This is the About page. Replace this stub in `content/about.md` with information about yourself.
|
||||
|
|
@ -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/`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.*
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Music
|
||||
catalog: true
|
||||
---
|
||||
|
||||
Add composition pages under `content/music/<slug>/index.md` and they will appear here automatically.
|
||||
|
|
@ -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."
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Search
|
||||
search: true
|
||||
---
|
||||
|
||||
<div id="search"></div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -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}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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" }
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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; /* 500–1999 words */
|
||||
--hm-3: #424240; /* 2000–4999 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 */
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/* ── Music Catalog ────────────────────────────────────────────────────────── */
|
||||
|
||||
.catalog-abstract {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.catalog-prose {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
/* ── Featured section ─────────────────────────────────────────────────────── */
|
||||
|
||||
.catalog-featured {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.catalog-featured-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Per-category sections ────────────────────────────────────────────────── */
|
||||
|
||||
.catalog-page {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.catalog-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.catalog-section-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.35rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Entry list ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.catalog-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.catalog-entry {
|
||||
padding: 0.55rem 0;
|
||||
border-bottom: 1px solid var(--border-subtle, color-mix(in srgb, var(--border) 50%, transparent));
|
||||
}
|
||||
|
||||
.catalog-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.catalog-entry-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.catalog-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.catalog-title:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.catalog-year,
|
||||
.catalog-duration {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalog-year::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalog-duration::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalog-ind {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: -0.05em;
|
||||
}
|
||||
|
||||
.catalog-instrumentation {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.catalog-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/* ── Commonplace ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Intro ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-intro {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── View toggle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-view-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.cp-toggle-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.73rem;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.cp-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cp-toggle-btn.is-active {
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Theme sections ───────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-theme-heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.73rem;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin: 2.5rem 0 1.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cp-theme-section:first-child .cp-theme-heading {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ── Entry ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-entry {
|
||||
margin: 0 0 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.cp-entry:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Quote block — overrides default #markdownBody blockquote ─────────────── */
|
||||
|
||||
#markdownBody .cp-quote {
|
||||
background-image: none;
|
||||
padding-left: 0;
|
||||
color: var(--text);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
#markdownBody .cp-quote p {
|
||||
margin: 0;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── Attribution ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-attribution {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cp-attribution a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.cp-attribution a:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Commentary ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-commentary {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin: 0.7rem 0 0;
|
||||
}
|
||||
|
||||
/* ── Tags ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.cp-tag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.67rem;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.cp-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
/* gallery.css — Exhibit system (always-visible inline blocks with overlay
|
||||
expansion) and annotation system (static or collapsible callout boxes). */
|
||||
|
||||
|
||||
/* ============================================================
|
||||
EXHIBIT WRAPPER
|
||||
Base styles shared by all exhibit types.
|
||||
============================================================ */
|
||||
|
||||
.exhibit {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 1.65rem 0;
|
||||
padding: 0; /* reset <figure> default margin */
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
MATH FOCUSABLE
|
||||
Wrapper injected around every .katex-display by gallery.js.
|
||||
Clicking anywhere on the wrapper opens the overlay.
|
||||
============================================================ */
|
||||
|
||||
.math-focusable {
|
||||
position: relative;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Expand glyph — decorative affordance, not interactive */
|
||||
.exhibit-expand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.4rem;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--text-faint);
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: 2px;
|
||||
pointer-events: none; /* click handled by wrapper */
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.math-focusable:hover .exhibit-expand {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Caption tooltip — appears above the math on hover, like alt text */
|
||||
.math-focusable[data-caption]::after {
|
||||
content: attr(data-caption);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
/* Explicit width prevents the box from collapsing to a single-word column */
|
||||
width: min(440px, 70vw);
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.math-focusable[data-caption]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PROOF EXHIBIT
|
||||
Always-visible inline block. Header is a non-interactive label.
|
||||
============================================================ */
|
||||
|
||||
.exhibit--proof .exhibit-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.45rem;
|
||||
margin-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.exhibit--proof .exhibit-header-label {
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exhibit--proof .exhibit-header-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ANNOTATION
|
||||
Editorial callout boxes: definitions, notes, remarks, warnings.
|
||||
Two variants: static (always visible) and collapsible.
|
||||
============================================================ */
|
||||
|
||||
.annotation {
|
||||
position: relative;
|
||||
margin: 1.65rem 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.annotation-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-offset);
|
||||
}
|
||||
|
||||
.annotation-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.annotation-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.annotation-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-faint);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.annotation-toggle:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.annotation-body {
|
||||
padding: 0.825rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Collapsible variant: body hidden until toggled.
|
||||
Only max-height transitions — padding applies instantly so that
|
||||
scrollHeight reads the correct full height before the animation starts. */
|
||||
.annotation--collapsible .annotation-body {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
transition: max-height 0.35s ease;
|
||||
}
|
||||
|
||||
.annotation--collapsible.is-open .annotation-body {
|
||||
padding-top: 0.825rem;
|
||||
padding-bottom: 0.825rem;
|
||||
}
|
||||
|
||||
/* Static variant: always open, no toggle */
|
||||
.annotation--static .annotation-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
OVERLAY
|
||||
Full-screen dark stage. Content floats inside — no panel box.
|
||||
Modeled on Gwern's image-focus: the overlay IS the backdrop,
|
||||
content is centered directly within it.
|
||||
============================================================ */
|
||||
|
||||
#gallery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
cursor: zoom-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#gallery-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Content stage — large light area centered in the dark screen.
|
||||
Fixed width (not max-width) so JS can detect overflow for font fitting. */
|
||||
#gallery-overlay-body {
|
||||
background: var(--bg);
|
||||
padding: 3.5rem 4.5rem;
|
||||
width: 88vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden; /* JS shrinks font until content fits; auto as last resort */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 80px rgba(0, 0, 0, 0.5);
|
||||
cursor: default; /* undo zoom-out from parent */
|
||||
}
|
||||
|
||||
#gallery-overlay-body .katex-display {
|
||||
/* font-size is set entirely by JS after measuring — no CSS value here */
|
||||
overflow-x: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Close — top right of screen */
|
||||
#gallery-overlay-close {
|
||||
position: absolute;
|
||||
top: 1.1rem;
|
||||
right: 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 0.3rem 0.5rem;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
#gallery-overlay-close:hover {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Group name — top center of screen */
|
||||
#gallery-overlay-name {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.09em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Caption — bottom center of screen, above counter */
|
||||
#gallery-overlay-caption {
|
||||
position: absolute;
|
||||
bottom: 3.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-align: center;
|
||||
max-width: 60vw;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#gallery-overlay-caption:empty,
|
||||
#gallery-overlay-caption[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Counter — bottom center of screen */
|
||||
#gallery-overlay-counter {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Nav buttons — floating at the screen edges, vertically centered */
|
||||
.gallery-nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 2.25rem;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
padding: 0.75rem 1rem;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.gallery-nav-btn:hover:not(:disabled) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.gallery-nav-btn:disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#gallery-overlay-prev { left: 1rem; }
|
||||
#gallery-overlay-next { right: 1rem; }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SCORE FRAGMENT
|
||||
Inline SVG music notation. Integrates with the gallery focusable
|
||||
and named-exhibit systems. No header — caption only.
|
||||
============================================================ */
|
||||
|
||||
.score-fragment {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 2rem 0;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Reset #markdownBody figure box styles — must use ID+class to beat
|
||||
the specificity of `#markdownBody figure` (0,1,1 vs 0,1,0). */
|
||||
#markdownBody figure.score-fragment {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100%; /* override fit-content so SVG fills the column */
|
||||
margin: 2rem 0; /* override the base 3.3rem auto */
|
||||
}
|
||||
|
||||
.score-fragment-inner {
|
||||
display: block;
|
||||
line-height: 0; /* collapse inline gap below the SVG block */
|
||||
}
|
||||
|
||||
/* Make the LilyPond SVG responsive and theme-aware.
|
||||
CSS width/height override the SVG's own width/height presentation
|
||||
attributes. color: var(--text) is inherited by currentColor fills/strokes
|
||||
(set by Filters.Score at build time). */
|
||||
.score-fragment-inner svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Remove LilyPond's white background rectangle */
|
||||
.score-fragment-inner svg > rect:first-child {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Expand glyph: top-right corner, not vertically centred */
|
||||
.score-fragment .exhibit-expand {
|
||||
top: 0.5rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.score-fragment:hover .exhibit-expand {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.score-caption {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 0.65rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
OVERLAY — SCORE MODE
|
||||
Tighter horizontal padding; SVG fills available width.
|
||||
============================================================ */
|
||||
|
||||
#gallery-overlay-body.is-score {
|
||||
padding: 2rem 1.5rem;
|
||||
/* Caption sits at bottom: 3.5rem in the viewport. The body is flex-centered,
|
||||
so its bottom edge = (100vh + body-height) / 2. To guarantee clearance,
|
||||
body-height must be ≤ 100vh − 2 × caption-offset − some margin.
|
||||
calc(100vh − 12rem) keeps the bottom edge ≥ 6rem above the viewport bottom
|
||||
regardless of screen size. */
|
||||
max-height: calc(100vh - 12rem);
|
||||
}
|
||||
|
||||
#gallery-overlay-body.is-score svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 16rem); /* body height minus 2rem padding each side */
|
||||
display: block;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#gallery-overlay-body.is-score svg > rect:first-child {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
TOC EXHIBIT INTEGRATION
|
||||
============================================================ */
|
||||
|
||||
/* Compact inline exhibit links under a heading entry */
|
||||
.toc-exhibits-inline {
|
||||
font-size: 0.69rem;
|
||||
color: var(--text-faint);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.1rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.toc-exhibits-inline a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0;
|
||||
font-size: 0.74rem;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.toc-exhibits-inline a:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Contained Herein — collapsible global exhibit index */
|
||||
.toc-contained {
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toc-contained-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 600;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
padding: 0;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.toc-contained-toggle:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-contained-arrow {
|
||||
font-size: 0.55rem;
|
||||
display: inline-block;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-contained.is-open .toc-contained-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.toc-contained-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.35rem 0 0 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toc-contained.is-open .toc-contained-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toc-contained-list li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.toc-contained-list a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.toc-contained-list a:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-exhibit-type-badge {
|
||||
font-size: 0.6rem;
|
||||
font-variant-caps: all-small-caps;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/* images.css — Figure layout, captions, and lightbox overlay */
|
||||
|
||||
/* ============================================================
|
||||
FIGURE BASE (supplements typography.css figure rules)
|
||||
============================================================ */
|
||||
|
||||
figure {
|
||||
margin: 2rem 0;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* Zoom cursor for lightbox-enabled images */
|
||||
img[data-lightbox] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
LIGHTBOX OVERLAY
|
||||
============================================================ */
|
||||
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 800;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox-overlay.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-align: center;
|
||||
max-width: 60ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2em;
|
||||
padding: 0.25em 0.75em;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
REDUCED MOTION
|
||||
============================================================ */
|
||||
|
||||
[data-reduce-motion] .lightbox-overlay {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lightbox-overlay {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
/* new.css — Recently published content page */
|
||||
|
||||
.new-intro {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COUNT CONTROL
|
||||
============================================================ */
|
||||
|
||||
.new-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.new-controls-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.new-controls-options {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.new-count-btn {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0.15em 0.55em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.new-count-btn:hover {
|
||||
border-color: var(--border-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.new-count-btn.is-active {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ENTRY LIST
|
||||
============================================================ */
|
||||
|
||||
.new-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.new-entry {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.new-entry:first-child {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
KIND BADGE
|
||||
============================================================ */
|
||||
|
||||
.new-entry-kind {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.63rem;
|
||||
font-variant: all-small-caps;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-faint);
|
||||
background: var(--bg-offset);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0.15em 0.5em;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25em;
|
||||
min-width: 5.5rem;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ENTRY CONTENT
|
||||
============================================================ */
|
||||
|
||||
.new-entry-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-entry-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.new-entry-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.new-entry-title:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.new-entry-date {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.new-entry-abstract {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
color: var(--text-muted);
|
||||
margin: 0.3rem 0 0;
|
||||
line-height: 1.55;
|
||||
/* Clamp to two lines */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/* reading.css — Codex layout for fiction and poetry.
|
||||
Loaded only on pages where the "reading" context field is set. */
|
||||
|
||||
/* ============================================================
|
||||
SCROLL PROGRESS BAR
|
||||
A 2px warm-gray line fixed at the very top of the viewport.
|
||||
z-index 200 sits above the sticky nav (z-index 100).
|
||||
============================================================ */
|
||||
|
||||
#reading-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
background-color: var(--text-faint);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
transition: width 0.08s linear;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
READING MODE — BASE
|
||||
Slightly warmer background; narrower, book-like measure.
|
||||
The class is applied to <body> by default.html.
|
||||
============================================================ */
|
||||
|
||||
body.reading-mode {
|
||||
--bg: #fdf9f1;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] body.reading-mode {
|
||||
--bg: #1c1917;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) body.reading-mode {
|
||||
--bg: #1c1917;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reading body: narrower than the essay default (800px → ~62ch).
|
||||
Since reading.html emits body > #markdownBody (no #content grid),
|
||||
the centering is handled by the existing layout.css rule. */
|
||||
body.reading-mode > #markdownBody {
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
POETRY
|
||||
============================================================ */
|
||||
|
||||
/* Slightly narrower measure for verse */
|
||||
body.reading-mode.poetry > #markdownBody {
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
/* Generous line height and stanza spacing */
|
||||
body.reading-mode.poetry > #markdownBody > p {
|
||||
line-height: 1.85;
|
||||
margin-bottom: 1.6rem;
|
||||
/* Ragged right — no hyphenation, no justification */
|
||||
hyphens: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Suppress the prose dropcap — poetry opens without a floated initial */
|
||||
body.reading-mode.poetry > #markdownBody > p:first-of-type::first-letter {
|
||||
float: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Suppress the smallcaps lead-in on the first line */
|
||||
body.reading-mode.poetry > #markdownBody > p:first-of-type::first-line {
|
||||
font-variant-caps: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FICTION
|
||||
============================================================ */
|
||||
|
||||
/* Chapter headings: Fira Sans smallcaps, centered, spaced */
|
||||
body.reading-mode.fiction > #markdownBody h2 {
|
||||
text-align: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-variant: small-caps;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: lowercase;
|
||||
color: var(--text-muted);
|
||||
margin: 3.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
/* Drop cap at the start of each chapter (h2 → next paragraph) */
|
||||
body.reading-mode.fiction > #markdownBody h2 + p::first-letter {
|
||||
float: left;
|
||||
font-size: 3.8em;
|
||||
line-height: 0.8;
|
||||
padding-top: 0.05em;
|
||||
padding-right: 0.1em;
|
||||
color: var(--text);
|
||||
font-variant-caps: normal;
|
||||
}
|
||||
|
||||
/* Smallcaps lead-in for chapter-opening lines */
|
||||
body.reading-mode.fiction > #markdownBody h2 + p::first-line {
|
||||
font-variant-caps: small-caps;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Scene breaks (***) — reuse the asterism but give it more breathing room */
|
||||
body.reading-mode.fiction > #markdownBody hr {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/* score-reader.css — Full-page score reader layout.
|
||||
Used only on /music/{slug}/score/ pages via score-reader-default.html. */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Top bar
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.score-reader-bar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 2.75rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.score-reader-bar-left {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.score-reader-movements {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.score-reader-movements::-webkit-scrollbar { display: none; }
|
||||
|
||||
.score-reader-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.score-reader-back {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.score-reader-back:hover { color: var(--text); }
|
||||
|
||||
.score-reader-mvt {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.score-reader-mvt:hover,
|
||||
.score-reader-mvt.is-active {
|
||||
background: var(--bg-code);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.score-reader-counter {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.score-reader-pdf {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.score-reader-pdf:hover { color: var(--text); }
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Score stage
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.score-reader-stage {
|
||||
padding-top: 2.75rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.score-reader-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.score-page {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dark-mode inversion — appropriate for pure B&W notation. */
|
||||
[data-theme="dark"] .score-page {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Prev / next buttons
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.score-reader-prev,
|
||||
.score-reader-next {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.score-reader-prev:hover,
|
||||
.score-reader-next:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.score-reader-prev:disabled,
|
||||
.score-reader-next:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Narrow screens: score scrolls horizontally; arrow buttons hidden. */
|
||||
@media (max-width: 640px) {
|
||||
.score-reader-viewport {
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.score-page {
|
||||
min-width: 600px;
|
||||
}
|
||||
.score-reader-prev,
|
||||
.score-reader-next {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Composition landing page additions (metadata block)
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.composition-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 1.5rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.comp-detail { white-space: nowrap; }
|
||||
|
||||
.composition-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comp-btn {
|
||||
display: inline-block;
|
||||
padding: 0.35em 0.9em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.825rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.comp-btn:hover {
|
||||
background: var(--bg-code);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.comp-btn--secondary { color: var(--text-muted); }
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Movement list
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.composition-movements {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.comp-movement-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.comp-movement-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.comp-movement-duration {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.comp-movement-score {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.comp-movement-score:hover { color: var(--text); }
|
||||
|
||||
.movement-audio {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
accent-color: var(--text-muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/* selection-popup.css — Custom text-selection toolbar */
|
||||
|
||||
/* ============================================================
|
||||
CONTAINER
|
||||
Inverted: --text as background, --bg as foreground.
|
||||
This gives a dark pill in light mode and a light pill in dark
|
||||
mode — same contrast relationship as the page selection colour.
|
||||
============================================================ */
|
||||
|
||||
.selection-popup {
|
||||
position: absolute;
|
||||
z-index: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
gap: 0;
|
||||
|
||||
background: var(--text);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.22);
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(5px);
|
||||
transition: opacity 0.13s ease, visibility 0.13s ease, transform 0.13s ease;
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.selection-popup.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Caret pointing down (popup is above selection) */
|
||||
.selection-popup::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: calc(var(--caret-left, 50%) - 5px);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Flip: caret pointing up (popup is below selection) */
|
||||
.selection-popup.is-below::after {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
BUTTONS
|
||||
============================================================ */
|
||||
|
||||
.selection-popup-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
padding: 0.38rem 0.6rem;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.selection-popup-btn:hover,
|
||||
.selection-popup-btn:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder buttons — visually present but muted, not interactive */
|
||||
.selection-popup-btn--placeholder {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SEPARATOR
|
||||
============================================================ */
|
||||
|
||||
.selection-popup-sep {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: var(--bg);
|
||||
opacity: 0.2;
|
||||
margin: 0 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ANNOTATION FORM MODE
|
||||
Replaces the button row with a compact form: colour swatches,
|
||||
optional note textarea, Save / Cancel.
|
||||
============================================================ */
|
||||
|
||||
.selection-popup.is-form-mode {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
min-width: 13rem;
|
||||
}
|
||||
|
||||
.selection-popup-ann-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Colour swatches ── */
|
||||
|
||||
.ann-swatches {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.ann-swatch {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.ann-swatch:hover { transform: scale(1.15); }
|
||||
.ann-swatch.is-selected { border-color: var(--bg); }
|
||||
|
||||
.ann-swatch--amber { background: #f59e0b; }
|
||||
.ann-swatch--sage { background: #6b9e78; }
|
||||
.ann-swatch--steel { background: #7096b8; }
|
||||
.ann-swatch--rose { background: #c87474; }
|
||||
|
||||
/* ── Note textarea ── */
|
||||
|
||||
.ann-note-input {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
color: var(--bg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
padding: 0.3rem 0.45rem;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ann-note-input::placeholder { opacity: 0.45; }
|
||||
.ann-note-input:focus { border-color: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
/* ── Save / Cancel row ── */
|
||||
|
||||
.ann-form-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Reduce motion */
|
||||
[data-reduce-motion] .ann-swatch { transition: none; }
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/* sidenotes.css — Inline sidenote layout.
|
||||
The Haskell Sidenotes filter converts Pandoc footnotes to:
|
||||
<sup class="sidenote-ref"><a href="#sn-N">N</a></sup>
|
||||
<span class="sidenote" id="sn-N"><sup class="sidenote-num">N</sup> …</span>
|
||||
|
||||
Layout strategy
|
||||
───────────────
|
||||
On wide viewports (≥ 1500px) the sidenote <span> is positioned
|
||||
absolutely relative to #markdownBody (which is position: relative).
|
||||
We do NOT use float because float with negative margin is unreliable
|
||||
across browsers when the float's margin box is effectively zero-width;
|
||||
it tends to wrap below the paragraph rather than escaping to the right.
|
||||
|
||||
position: absolute with no explicit top/bottom uses the "hypothetical
|
||||
static position" — the spot the element would occupy if position: static.
|
||||
For an inline <span> inside a <p>, this is roughly the line containing
|
||||
the sidenote reference, giving correct vertical alignment without JS.
|
||||
|
||||
On narrow viewports the <span> is hidden and the Pandoc-generated
|
||||
<section class="footnotes"> at document end is shown instead.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
SIDENOTE REFERENCE (in-text superscript)
|
||||
============================================================ */
|
||||
|
||||
.sidenote-ref {
|
||||
font-size: 0.7em;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
top: -0.4em;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: normal;
|
||||
}
|
||||
|
||||
.sidenote-ref a {
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
padding: 0 0.1em;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidenote-ref a:hover,
|
||||
.sidenote-ref.is-active a {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Highlight the sidenote when its ref is hovered (CSS: adjacent sibling). */
|
||||
.sidenote-ref:hover + .sidenote,
|
||||
.sidenote.is-active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SIDENOTE SPAN
|
||||
position: absolute anchors to #markdownBody (position: relative).
|
||||
left: 100% + gap puts the left edge just past the right side of
|
||||
the body column. No top/bottom → hypothetical static position.
|
||||
============================================================ */
|
||||
|
||||
.sidenote {
|
||||
position: absolute;
|
||||
left: calc(100% + 1.5rem); /* 1.5rem gap from body right edge */
|
||||
width: clamp(200px, calc(50vw - var(--body-max-width) / 2 - var(--page-padding) - 1.5rem), 320px);
|
||||
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
|
||||
text-indent: 0;
|
||||
font-feature-settings: normal;
|
||||
hyphens: none;
|
||||
hanging-punctuation: none;
|
||||
}
|
||||
|
||||
/* Number badge inside the sidenote — inline box, not a superscript */
|
||||
.sidenote-num {
|
||||
display: inline-block;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.65em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
vertical-align: baseline;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: 2px;
|
||||
padding: 0 0.3em;
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
/* Paragraphs injected by blocksToHtml (rendered as inline-block spans
|
||||
to keep them valid inside the outer <span class="sidenote">) */
|
||||
.sidenote-para {
|
||||
display: block;
|
||||
margin: 0;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.sidenote-para + .sidenote-para {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.sidenote a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
─────────────────────────────────────────────────────────────
|
||||
Side columns are 1fr (fluid). Sidenotes need at least
|
||||
body(800px) + gap(24px) + sidenote(200px) + padding(48px) ≈ 1072px,
|
||||
but a comfortable threshold is kept at 1500px so sidenotes
|
||||
have enough room not to feel cramped.
|
||||
============================================================ */
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
section.footnotes {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1499px) {
|
||||
.sidenote {
|
||||
display: none;
|
||||
}
|
||||
|
||||
section.footnotes {
|
||||
display: block;
|
||||
margin-top: 3.3rem;
|
||||
padding-top: 1.65rem;
|
||||
border-top: 1px solid var(--border-muted);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
FOOTNOTE REFERENCES — shown on narrow viewports alongside
|
||||
section.footnotes
|
||||
============================================================ */
|
||||
|
||||
a.footnote-ref {
|
||||
text-decoration: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
top: -0.4em;
|
||||
font-family: var(--font-sans);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a.footnote-ref:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/* syntax.css — Monochrome Prism.js syntax highlighting.
|
||||
No toggle — always active on language-annotated code blocks.
|
||||
No hue: only bold, italic, and opacity variations matching site palette.
|
||||
Dark mode is automatic via CSS custom properties. */
|
||||
|
||||
|
||||
/* Comments: faint + italic — least prominent */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata,
|
||||
.token.shebang {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Keywords, control flow: bold — most prominent */
|
||||
.token.keyword,
|
||||
.token.rule,
|
||||
.token.important,
|
||||
.token.atrule,
|
||||
.token.builtin,
|
||||
.token.deleted {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Strings: muted */
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.regex,
|
||||
.token.template-string,
|
||||
.token.inserted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Numbers and booleans: full text color (explicit for completeness) */
|
||||
.token.number,
|
||||
.token.boolean,
|
||||
.token.constant {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Punctuation and operators: faint — structural noise, recede */
|
||||
.token.punctuation,
|
||||
.token.operator {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Functions and class names: semibold */
|
||||
.token.function,
|
||||
.token.function-definition,
|
||||
.token.class-name,
|
||||
.token.maybe-class-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tags (HTML/XML): semibold */
|
||||
.token.tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Attribute names: muted */
|
||||
.token.attr-name,
|
||||
.token.property {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Selectors (CSS): semibold */
|
||||
.token.selector {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Type annotations and namespaces: italic + muted */
|
||||
.token.namespace,
|
||||
.token.type-annotation,
|
||||
.token.type {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Variables and parameters: italic */
|
||||
.token.variable,
|
||||
.token.parameter {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* URLs: muted underline */
|
||||
.token.url {
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
}
|
||||
|
|
@ -0,0 +1,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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<!--
|
||||
arXiv chi (χ): two thick diagonal bars.
|
||||
The bottom-left→top-right diagonal is continuous.
|
||||
The top-right→bottom-left diagonal is broken at center (chi, not X).
|
||||
All shapes are filled so mask-image works reliably.
|
||||
-->
|
||||
<!-- Bar 1: top-left → bottom-right (continuous) -->
|
||||
<polygon points="2,2.5 3.5,2.5 14,13.5 12.5,13.5" fill="black"/>
|
||||
<!-- Bar 2 upper: top-right → center -->
|
||||
<polygon points="14,2.5 12.5,2.5 8.8,7.6 10.3,7.6" fill="black"/>
|
||||
<!-- Bar 2 lower: center → bottom-left -->
|
||||
<polygon points="5.7,8.4 7.2,8.4 3.5,13.5 2,13.5" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 664 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="black" d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0H4zm0 1h5v4h4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zm5.5.25L13 4.5h-3.5V1.25zM5 8h6v1H5V8zm0 2.5h6v1H5v-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 274 B |
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<!--
|
||||
DOI: two chain-link rings side by side.
|
||||
Stroked paths with no fill — stroke pixels are opaque and work with mask-image.
|
||||
-->
|
||||
<!-- Left ring -->
|
||||
<rect x="1" y="5.5" width="7.5" height="5" rx="2.5"
|
||||
stroke="black" stroke-width="1.5" fill="none"/>
|
||||
<!-- Right ring -->
|
||||
<rect x="7.5" y="5.5" width="7.5" height="5" rx="2.5"
|
||||
stroke="black" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 476 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="black" d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2zm13 2.383-4.708 2.825L15 11.105V5.383zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741zM1 11.105l4.708-2.897L1 5.383v5.722z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 386 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<!--
|
||||
Standard external-link icon: arrow emerging from a box.
|
||||
Fill-based paths for reliable mask-image rendering.
|
||||
-->
|
||||
<!-- Box (open top-right corner) -->
|
||||
<path fill="black" d="
|
||||
M6.5 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9.5H12.5V12.5H3.5V3.5H6.5V2Z
|
||||
"/>
|
||||
<!-- Arrow shaft + head pointing up-right -->
|
||||
<path fill="black" d="
|
||||
M8 2h6v6h-1.5V4.6L7.06 10.06 5.94 8.94 11.4 3.5H8V2Z
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||
<!-- GitHub Mark — official GitHub logo path -->
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
|
|
@ -0,0 +1 @@
|
|||
<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 |
|
|
@ -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 |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="black" d="M0 8a4 4 0 0 1 7.465-2H14L15 7l1 1-1 1-1 1-1-1-1 1-1-1-1 1H7.465A4 4 0 0 1 0 8zm4 0a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 208 B |
|
|
@ -0,0 +1 @@
|
|||
<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 |
|
|
@ -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 |
|
|
@ -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 |