diff --git a/.gitignore b/.gitignore
index 5175c27..f038a4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,17 @@ 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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/HOMEPAGE.md b/HOMEPAGE.md
new file mode 100644
index 0000000..7eca335
--- /dev/null
+++ b/HOMEPAGE.md
@@ -0,0 +1,129 @@
+I think this is exactly right. Let me lay out the concrete plan.
+
+The insight driving your approach is: **the personality is the filter, and that's intentional.** The kind of collaborators, advisors, and readers you want are precisely the ones who encounter a homepage that opens with intellectual warmth and curiosity and think *yes, this is someone I want to talk to*. The professional information is there for those who need it, but it's not the first thing — it's the second or third, placed where someone who's already interested can find it instantly.
+
+Here's how I'd structure the page, top to bottom:
+
+---
+
+### The Plan
+
+**1. Your name.** Large, in Spectral. Not centered — left-aligned, consistent with how essay titles appear on the rest of the site. No subtitle, no description line. Just the name. The site header and nav sit above it as usual.
+
+**2. The introduction.** Two to four paragraphs of prose. This is the heart of the homepage and the thing that does the most work. It should be written the way you'd introduce yourself to someone interesting at a dinner party — not "I am a computer scientist" but something closer to what's already there, except tighter and more specific. The current introduction is good but it has two problems: the first sentence ("You have reached the website of...") is a web convention from 2003 that undersells you, and the middle veers into site-navigation explanation ("This website is organized broadly by Portals and Tags...") which is functional information masquerading as prose. Strip the navigational explanation entirely — readers will figure out portals and tags by using them, and if they don't, the search works. Replace it with something that communicates what you actually care about, what you're working on right now, what this place *is*. Your Me page demonstrates that you write about yourself with genuine flair. Channel that here, compressed.
+
+The Latin welcome at the end of the current introduction (*Te accipio, hospes benignus*) is a lovely touch and should stay — it's exactly the kind of earned ornament that signals personality. But it should be the capstone of the introduction, not buried after a navigation guide.
+
+**3. The professional row.** A single horizontal line of compact links, visually quiet, in Fira Sans at a slightly smaller size than body text. Something like:
+
+> Biography · CV · Email · GitHub · ORCID · GPG
+
+No labels explaining what these are. Anyone who needs your CV knows what "CV" means. This row is for the professor who just reviewed your paper and wants to know more, the potential collaborator who met you at a conference, the PhD committee member checking your background. They get what they need in a glance, without the homepage making a big deal about it.
+
+Styled as a single line with middot separators, perhaps in smallcaps or with slightly muted color. It should be clearly present but visually subordinate to the prose above and the portals below. Think of it as a utility strip.
+
+**4. The curiosities row.** A second horizontal line, same visual treatment as the professional row, linking to the unusual features of the site — the things that make levineuwirth.org distinctive and that signal to a certain kind of visitor that they're in the right place:
+
+> Memento Mori · Build · Commonplace · Colophon · Library · Random
+
+These are the easter eggs promoted to the front door. Someone who sees "Memento Mori" and "Build Telemetry" on a personal homepage and clicks them is exactly the kind of person you want reading your site. These links serve as a personality signal as much as a navigation element — they say *this site has layers, and the author thinks about infrastructure, mortality, and curation*.
+
+This row should be visually parallel to the professional row — same size, same treatment — so that together they read as two lines of a compact directory. The professional row is "here's how to reach me in the conventional world." The curiosities row is "here's what makes this place unusual."
+
+**5. Portal links.** Below both rows, after some breathing room, the portals. Not as cards. As a simple vertical list or a wrapped horizontal line, each portal as a plain text link. If you want brief annotations (and I think you should, because several portal names are ambiguous to a first-time visitor), they should be very short — five to ten words, in muted text:
+
+> Research — formal inquiry and open problems
+> Nonfiction — essays, criticism, living documents
+> Fiction — stories and a novel in progress
+> Poetry — verse, formal and free
+> Music — compositions, scores, and recordings
+> AI — on intelligence, artificial and otherwise
+> Tech — systems, tools, and craft
+> Miscellany — everything that defies category
+
+Each annotation is just enough to tell a new visitor what they'll find behind the door, without turning the portal list into a card grid. The annotations should be in your voice — "everything that defies category" is better than "blog posts and other content."
+
+**6. A "Recently" section (optional, add when corpus supports it).** Below the portals, 3–5 most recently published or substantially revised items. Title, date, portal tag. Auto-populated by Hakyll `recentFirst`. This is Direction B from your HOMEPAGE.md — the heartbeat that rewards returning visitors. I'd defer this until you have enough content that the list changes meaningfully between visits (probably 8–10 published pieces). When it's ready, it goes here.
+
+**7. Footer.** As it currently is — the build timestamp, the license, the sig. No change needed.
+
+---
+
+### What's removed
+
+- The card grid. Gone entirely.
+- The "How to navigate this site" collapsible (already removed, stays removed).
+- The contact row as a separate visual element at the bottom. The contact links are absorbed into the professional row (item 3).
+- The "Random Page" card. Random moves to the curiosities row, where it fits perfectly.
+- The "About" card. The Me page is linked from the nav and from the Biography link in the professional row.
+
+### What's new
+
+- The professional row (compact, one line, visually quiet).
+- The curiosities row (compact, one line, same treatment).
+- Portal annotations (very short, in your voice).
+- The introduction is rewritten to be tighter and more personal.
+
+### What's unchanged
+
+- Your name at the top.
+- The nav bar and portal dropdown.
+- The footer.
+- The overall monochrome palette and typographic system.
+
+---
+
+### Visual rhythm of the page
+
+```
+[nav bar with portals dropdown and settings]
+
+Levi Neuwirth ← large, Spectral
+
+Two to four paragraphs of introduction ← body text, Spectral
+in your voice. What you care about,
+what you're working on, what this place
+is. Te accipio, hospes benignus.
+
+Biography · CV · Email · GitHub · ← compact row, Fira Sans, muted
+ORCID · GPG
+
+Memento Mori · Build · Commonplace · ← compact row, same treatment
+Colophon · Library · Random
+
+ ← breathing room
+
+Research — formal inquiry ← portal list, Fira Sans
+Nonfiction — essays and living documents
+Fiction — stories and a novel
+Poetry — verse, formal and free
+Music — compositions and scores
+AI — on intelligence
+Tech — systems and craft
+Miscellany — everything else
+
+ ← (future: Recently section)
+
+[footer: license, build, sig]
+```
+
+The entire page fits on one screen at desktop width (assuming 3–4 paragraphs of introduction). There is nothing to scroll past, nothing to decode, no interface to learn. A reader arrives, encounters your voice, sees where to find professional information, notices some intriguing links, and chooses a portal. The page takes about 45 seconds to read, which is exactly right for a homepage.
+
+---
+
+### On the transition toward G
+
+This design degrades gracefully into Direction G over time. As your reputation grows and the corpus deepens, you can:
+
+1. Shorten the introduction to one paragraph, then to one sentence, then to nothing.
+2. Drop the portal annotations as readers learn what each portal contains.
+3. Eventually remove the professional row (it moves to the Me page permanently).
+4. What remains: your name, the curiosities row (which becomes a signature element of the site), and the portal list.
+
+Each step is a subtraction, and each subtraction signals growing confidence. The infrastructure supports all of these states without any engineering changes — it's just editing `index.md` and the homepage template.
+
+---
+
+### The one thing I'd encourage you to write first
+
+The introduction. Not a draft — the real thing. Sit down, write it as if you're explaining to a curious stranger at a dinner party what this website is and why it exists, and what's on your mind right now. Don't worry about whether it's "good enough for a homepage." The Me page proves you can write this kind of thing with warmth and depth. The introduction is the same skill, compressed. Once that prose exists, the rest of the homepage design falls into place around it — it's just CSS and template work.
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 039b587..8f08b6b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build deploy sign watch clean dev
+.PHONY: build deploy sign download-model convert-images 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).
@@ -9,6 +9,7 @@ build:
@git add content/
@git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@date +%s > data/build-start.txt
+ @./tools/convert-images.sh
cabal run site -- build
pagefind --site _site
@if [ -d .venv ]; then \
@@ -24,6 +25,16 @@ build:
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
+
deploy: build sign
@if [ -z "$(GITHUB_TOKEN)" ] || [ -z "$(GITHUB_REPO)" ]; then \
echo "Skipping GitHub push: set GITHUB_TOKEN and GITHUB_REPO in .env"; \
diff --git a/WRITING.md b/WRITING.md
index d04c4bf..66ef277 100644
--- a/WRITING.md
+++ b/WRITING.md
@@ -47,6 +47,9 @@ authors: # optional; overrides the default "Levi Neuwirth" link
- "Levi Neuwirth | /me.html"
- "Collaborator | https://their.site"
- "Plain Name" # URL optional; omit for plain-text credit
+affiliation: # optional; shown below author in metadata block
+ - "Brown University | https://cs.brown.edu"
+ - "Some Research Lab" # URL optional; scalar string also accepted
further-reading: # optional; see Citations section
- someKey
- anotherKey
@@ -271,9 +274,14 @@ not derivable from the page title.
## Math
-KaTeX renders client-side from raw LaTeX. CSS and JS are loaded conditionally
-on pages that have `math: true` set in their context (all essays and posts have
-this by default).
+Pandoc parses LaTeX math and wraps it in `class="math inline"` / `class="math display"`
+spans. KaTeX CSS is loaded conditionally on pages that contain math — this styles the
+pre-rendered output. Client-side KaTeX JS rendering is not yet loaded; complex math
+will appear as LaTeX source. Build-time server-side rendering is planned but not yet
+implemented. Simple math currently renders through Pandoc's built-in KaTeX span output.
+
+`math: true` is auto-set for all essays and blog posts. Standalone pages that use
+math must set it explicitly in frontmatter to load the KaTeX CSS.
| Syntax | Usage |
|--------|-------|
@@ -671,19 +679,19 @@ The [CPU]{.smallcaps} handles this.
Wrap any paragraph in a `::: dropcap` fenced div to get a drop cap regardless
of its position in the document. The first line is automatically rendered in
-small caps.
+small caps via `::first-line { font-variant-caps: small-caps }`.
```markdown
::: dropcap
-COMPOSITION [IS]{.smallcaps} PERHAPS MORE THAN ANYTHING ELSE THE PRACTICE OF MY
-LIFE. I say these strong words because I feel strongly about this process.
+A personal website is not a publication. It is a position — something you
+inhabit, argue from, and occasionally revise in public.
:::
```
-The opening word (or words, before a space) should be written in ALL CAPS in
-source — they will render as small caps via `::first-line`. The `[IS]{.smallcaps}`
-span is not strictly necessary but can force specific words into the smallcaps
-run if needed.
+Write in normal mixed case. The CSS applies `font-variant-caps: small-caps` to
+the entire first rendered line, converting lowercase letters to small-cap glyphs.
+Use `[WORD]{.smallcaps}` spans to force specific words into small-caps anywhere
+in the paragraph.
A paragraph that immediately follows a `::: dropcap` block will be indented
correctly (`text-indent: 1.5em`), matching the paragraph-after-paragraph rule.
diff --git a/build/Contexts.hs b/build/Contexts.hs
index f78ad9f..bc2fc5b 100644
--- a/build/Contexts.hs
+++ b/build/Contexts.hs
@@ -28,6 +28,38 @@ import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, versionHistoryField)
import Tags (tagLinksField)
+-- ---------------------------------------------------------------------------
+-- 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)$$affiliation-name$
+-- $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, "")
+ trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')
+
-- ---------------------------------------------------------------------------
-- Build time field
-- ---------------------------------------------------------------------------
@@ -162,6 +194,7 @@ epistemicCtx =
essayCtx :: Context String
essayCtx =
authorLinksField
+ <> affiliationField
<> snapshotField "toc" "toc"
<> snapshotField "word-count" "word-count"
<> snapshotField "reading-time" "reading-time"
@@ -184,6 +217,7 @@ essayCtx =
postCtx :: Context String
postCtx =
authorLinksField
+ <> affiliationField
<> backlinksField
<> similarLinksField
<> dateField "date" "%-d %B %Y"
@@ -196,7 +230,7 @@ postCtx =
-- ---------------------------------------------------------------------------
pageCtx :: Context String
-pageCtx = authorLinksField <> siteCtx
+pageCtx = authorLinksField <> affiliationField <> siteCtx
-- ---------------------------------------------------------------------------
-- Reading contexts (fiction + poetry)
diff --git a/build/Filters.hs b/build/Filters.hs
index 9b3a547..010424d 100644
--- a/build/Filters.hs
+++ b/build/Filters.hs
@@ -14,7 +14,8 @@ 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.Wikilinks as Wikilinks
+import qualified Filters.Transclusion as Transclusion
import qualified Filters.Code as Code
import qualified Filters.Images as Images
@@ -34,4 +35,4 @@ applyAll
-- | Apply source-level preprocessors to the raw Markdown string.
-- Run before 'readPandocWith'.
preprocessSource :: String -> String
-preprocessSource = Wikilinks.preprocess
+preprocessSource = Transclusion.preprocess . Wikilinks.preprocess
diff --git a/build/Filters/Images.hs b/build/Filters/Images.hs
index 978cd7c..d75ba16 100644
--- a/build/Filters/Images.hs
+++ b/build/Filters/Images.hs
@@ -1,23 +1,23 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
--- | Image attribute filter.
+-- | Image filter: lazy loading, lightbox markers, and WebP wrappers.
--
--- Walks all @Image@ inlines and:
--- * Adds @loading="lazy"@ to every image.
--- * Adds @data-lightbox="true"@ to images that are NOT already wrapped in
--- a @Link@ inline (i.e. the image is not itself a hyperlink).
+-- For local raster images (JPG, JPEG, PNG, GIF), emits a @@ element
+-- with a WebP @@ and the original format as the @@ fallback.
+-- tools/convert-images.sh produces the companion .webp files at build time.
--
--- The wrapping-link check is done by walking the document with two passes:
--- a block-level walk that handles the common @Link [Image …] …@ pattern,
--- and a plain image walk that stamps @loading="lazy"@ on everything else.
+-- 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.Text (Text)
import qualified Data.Text as T
+import System.FilePath (replaceExtension)
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk)
--- | Apply image attribute injection to the entire document.
+-- | Apply image attribute injection and WebP wrapping to the entire document.
apply :: Pandoc -> Pandoc
apply = walk transformInline
@@ -25,32 +25,118 @@ apply = walk transformInline
-- Core transformation
-- ---------------------------------------------------------------------------
--- | Process a single inline node.
---
--- * @Link … [Image …] …@ — image inside a link: add only @loading="lazy"@.
--- * @Image …@ — standalone image: add both @loading="lazy"@ and
--- @data-lightbox="true"@.
--- * Anything else — pass through unchanged.
transformInline :: Inline -> Inline
transformInline (Link lAttr ils lTarget) =
- -- Recurse into link contents, but mark any images inside as linked
- -- (so they receive lazy loading only, no lightbox marker).
- Link lAttr (map (addLazyOnly) ils) lTarget
+ -- Recurse into link contents; images inside a link get no lightbox marker.
+ Link lAttr (map wrapLinkedImg ils) lTarget
where
- addLazyOnly (Image iAttr alt iTarget) =
- Image (addAttr "loading" "lazy" iAttr) alt iTarget
- addLazyOnly x = x
+ wrapLinkedImg (Image iAttr alt iTarget) = renderImg iAttr alt iTarget False
+ wrapLinkedImg x = x
transformInline (Image attr alt target) =
- Image (addAttr "data-lightbox" "true" (addAttr "loading" "lazy" attr)) alt target
+ renderImg attr alt target True
transformInline x = x
+-- | Dispatch on image type:
+-- * Local raster → @@ with WebP @@
+-- * Everything else → plain @@ with loading/lightbox attrs
+renderImg :: Attr -> [Inline] -> Target -> Bool -> Inline
+renderImg attr alt target@(src, _) lightbox
+ | isLocalRaster (T.unpack src) =
+ RawInline (Format "html") (renderPicture attr alt target lightbox)
+ | otherwise =
+ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target
+ where
+ addLightbox True a = addAttr "data-lightbox" "true" a
+ addLightbox False a = a
+
-- ---------------------------------------------------------------------------
--- Attribute helpers
+-- rendering
-- ---------------------------------------------------------------------------
--- | Prepend a key=value pair to an @Attr@'s key-value list (if not already
--- present, to avoid duplicating attributes that come from Markdown).
+-- | Emit a @@ element with a WebP @@ and an @@ fallback.
+renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text
+renderPicture (ident, classes, kvs) alt (src, title) lightbox =
+ T.concat
+ [ ""
+ , ""
+ , ""
+ , ""
+ ]
+ where
+ webpSrc = replaceExtension (T.unpack src) ".webp"
+ -- Strip attrs we handle explicitly so they don't appear twice.
+ passedKvs = filter (\(k, _) -> k `notElem` ["loading", "data-lightbox"]) 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:"]
+ where isPrefixOf pfx str = take (length pfx) str == pfx
+
+-- | Extension of a path, lowercased (e.g. ".JPG" → ".jpg").
+lowerExt :: FilePath -> String
+lowerExt = map toLower . reverse . ('.' :) . takeWhile (/= '.') . tail . dropWhile (/= '.') . reverse
+
+-- | Prepend a key=value pair if not already present.
addAttr :: Text -> Text -> Attr -> Attr
-addAttr k v (ident, classes, kvs)
- | any ((== k) . fst) kvs = (ident, classes, kvs)
- | otherwise = (ident, classes, (k, v) : kvs)
+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 (Code _ t) = t
+ go (Link _ ils _) = stringify ils
+ go (Image _ ils _) = stringify ils
+ go (Span _ ils) = stringify ils
+ go _ = ""
+
+-- | HTML-escape a text value for use in attribute values.
+esc :: Text -> Text
+esc = T.concatMap escChar
+ where
+ escChar '&' = "&"
+ escChar '<' = "<"
+ escChar '>' = ">"
+ escChar '"' = """
+ escChar c = T.singleton c
diff --git a/build/Filters/Transclusion.hs b/build/Filters/Transclusion.hs
new file mode 100644
index 0000000..ddc0822
--- /dev/null
+++ b/build/Filters/Transclusion.hs
@@ -0,0 +1,60 @@
+{-# 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)
+
+-- | Apply transclusion substitution to the raw Markdown source string.
+preprocess :: String -> String
+preprocess = unlines . map processLine . lines
+
+processLine :: String -> String
+processLine line =
+ case parseDirective (trim line) of
+ Nothing -> line
+ Just (url, secAttr) ->
+ ""
+
+-- | Parse a {{slug}} or {{slug#section}} directive.
+-- Returns (absolute-url, section-attribute-string) or Nothing.
+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=\"" ++ sec ++ "\"")
+ _ -> Nothing
+
+-- | Convert a slug (possibly with leading slash, possibly with path segments)
+-- to a root-relative .html URL.
+slugToUrl :: String -> String
+slugToUrl slug
+ | "/" `isPrefixOf` slug = slug ++ ".html"
+ | otherwise = "/" ++ slug ++ ".html"
+
+-- | 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
+
+-- | Strip leading and trailing spaces.
+trim :: String -> String
+trim = f . f
+ where f = reverse . dropWhile (== ' ')
diff --git a/build/Site.hs b/build/Site.hs
index 4f2e09f..3131a4f 100644
--- a/build/Site.hs
+++ b/build/Site.hs
@@ -18,6 +18,14 @@ 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 = "Levi Neuwirth"
@@ -144,9 +152,17 @@ rules = do
>>= 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") $ do
+ .&&. complement "content/commonplace.md"
+ .&&. complement "content/colophon.md") $ do
route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html"
compile $ pageCompiler
@@ -181,6 +197,7 @@ rules = do
-- ---------------------------------------------------------------------------
-- Poetry
-- ---------------------------------------------------------------------------
+ -- Flat poems (e.g. content/poetry/sonnet-60.md)
match "content/poetry/*.md" $ do
route $ gsubRoute "content/poetry/" (const "poetry/")
`composeRoutes` setExtension "html"
@@ -190,6 +207,24 @@ rules = do
>>= 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
-- ---------------------------------------------------------------------------
@@ -291,7 +326,7 @@ rules = do
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
- poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
+ poetry <- loadAll (allPoetry .&&. hasNoVersion)
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
recentFirst filtered
@@ -337,7 +372,7 @@ rules = do
( ( "content/essays/*.md"
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
- .||. "content/poetry/*.md"
+ .||. allPoetry
.||. "content/music/*/index.md"
)
.&&. hasNoVersion
diff --git a/build/Stats.hs b/build/Stats.hs
index 62c9ee1..b0b4f50 100644
--- a/build/Stats.hs
+++ b/build/Stats.hs
@@ -8,12 +8,14 @@ module Stats (statsRules) where
import Control.Exception (IOException, catch)
import Control.Monad (forM)
-import Data.List (find, isSuffixOf, sortBy)
+import Data.List (find, 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.Time (getCurrentTime, formatTime, defaultTimeLocale)
+import Data.Time (getCurrentTime, formatTime, defaultTimeLocale,
+ Day, parseTimeM, utctDay, addDays, diffDays)
+import Data.Time.Calendar (toGregorian, dayOfWeek)
import System.Directory (doesDirectoryExist, getFileSize, listDirectory)
import System.Exit (ExitCode (..))
import System.FilePath (takeExtension, (>))
@@ -109,6 +111,197 @@ 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.
+median :: [Int] -> Int
+median [] = 0
+median xs = let s = sort xs in s !! (length s `div` 2)
+
+-- ---------------------------------------------------------------------------
+-- 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"
+ _ -> ""
+
+-- ---------------------------------------------------------------------------
+-- Heatmap SVG
+-- ---------------------------------------------------------------------------
+
+-- | 52-week writing activity heatmap (inline SVG, CSS-variable colors).
+renderHeatmap :: Map.Map Day Int -> Day -> String
+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
+
+ -- Month labels: one per first-of-month day
+ monthLbls = concatMap (\d ->
+ let (_, mo, da) = toGregorian d
+ in if da == 1
+ then "" ++ shortMonth mo ++ ""
+ else "") allDays
+
+ -- One rect per day
+ cells = concatMap (\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 "" ++ tip ++ "") allDays
+
+ -- Inline legend (five sample rects)
+ legendW = 5 * step - gap
+ legendSvg =
+ ""
+
+ in ""
+ ++ ""
+ ++ ""
+ ++ "Less\xA0" ++ legendSvg ++ "\xA0More"
+ ++ ""
+ ++ ""
+
+-- ---------------------------------------------------------------------------
+-- Stats page sections
+-- ---------------------------------------------------------------------------
+
+renderMonthlyVolume :: Map.Map Day Int -> String
+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 "
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 "