levineuwirth.org/build/Site.hs

404 lines
18 KiB
Haskell

{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
module Site (rules) where
import Control.Monad (filterM)
import Data.List (intercalate, isPrefixOf)
import Data.Maybe (fromMaybe)
import System.FilePath (takeDirectory, takeFileName)
import Hakyll
import Authors (buildAllAuthors, applyAuthorRules)
import Backlinks (backlinkRules)
import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler,
compositionCompiler)
import Catalog (musicCatalogCtx)
import Commonplace (commonplaceCtx)
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx)
import Tags (buildAllTags, applyTagRules)
import Pagination (blogPaginateRules)
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"
, feedDescription = "Essays, notes, and creative work by Levi Neuwirth"
, feedAuthorName = "Levi Neuwirth"
, feedAuthorEmail = "levi@levineuwirth.org"
, feedRoot = "https://levineuwirth.org"
}
musicFeedConfig :: FeedConfiguration
musicFeedConfig = FeedConfiguration
{ feedTitle = "Levi Neuwirth — Music"
, feedDescription = "New compositions by Levi Neuwirth"
, feedAuthorName = "Levi Neuwirth"
, feedAuthorEmail = "levi@levineuwirth.org"
, feedRoot = "https://levineuwirth.org"
}
rules :: Rules ()
rules = do
-- ---------------------------------------------------------------------------
-- Backlinks (pass 1: link extraction; pass 2: JSON generation)
-- Must run before content rules so dependencies resolve correctly.
-- ---------------------------------------------------------------------------
backlinkRules
-- ---------------------------------------------------------------------------
-- Author index pages
-- ---------------------------------------------------------------------------
authors <- buildAllAuthors
applyAuthorRules authors siteCtx
-- ---------------------------------------------------------------------------
-- Tag index pages
-- ---------------------------------------------------------------------------
tags <- buildAllTags
applyTagRules tags siteCtx
statsRules tags
-- Per-page JS files — authored alongside content in content/**/*.js
match "content/**/*.js" $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- CSS — must be matched before the broad static/** rule to avoid
-- double-matching (compressCssCompiler vs. copyFileCompiler).
match "static/css/*" $ do
route $ gsubRoute "static/" (const "")
compile compressCssCompiler
-- All other static files (fonts, JS, images, …)
match ("static/**" .&&. complement "static/css/*") $ do
route $ gsubRoute "static/" (const "")
compile copyFileCompiler
-- Templates
match "templates/**" $ compile templateBodyCompiler
-- Link annotations — author-defined previews for any URL
match "data/annotations.json" $ do
route idRoute
compile copyFileCompiler
-- 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
-- ---------------------------------------------------------------------------
-- Standalone pages (me/, colophon.md, …)
-- ---------------------------------------------------------------------------
-- me/index.md — compiled as a full essay (TOC, metadata block, sidenotes).
-- Lives in its own directory so co-located SVG score fragments resolve
-- correctly: the Score filter reads paths relative to the source file's
-- directory (content/me/), not the content root.
match "content/me/index.md" $ do
route $ constRoute "me.html"
compile $ essayCompiler
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- SVG score fragments co-located with me/index.md.
match "content/me/scores/*.svg" $ do
route $ gsubRoute "content/me/" (const "")
compile copyFileCompiler
-- memento-mori/index.md — lives in its own directory so co-located SVG
-- score fragments resolve correctly (same pattern as me/index.md).
match "content/memento-mori/index.md" $ do
route $ constRoute "memento-mori.html"
compile $ essayCompiler
>>= loadAndApplyTemplate "templates/essay.html"
(constField "memento-mori" "true" <> essayCtx)
>>= loadAndApplyTemplate "templates/default.html"
(constField "memento-mori" "true" <> essayCtx)
>>= relativizeUrls
-- SVG score fragments co-located with memento-mori/index.md.
match "content/memento-mori/scores/*.svg" $ do
route $ gsubRoute "content/memento-mori/" (const "")
compile copyFileCompiler
-- ---------------------------------------------------------------------------
-- Commonplace book
-- ---------------------------------------------------------------------------
match "content/commonplace.md" $ do
route $ constRoute "commonplace.html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/commonplace.html" commonplaceCtx
>>= loadAndApplyTemplate "templates/default.html" commonplaceCtx
>>= relativizeUrls
match "content/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
-- ---------------------------------------------------------------------------
match "content/essays/*.md" $ do
route $ gsubRoute "content/essays/" (const "essays/")
`composeRoutes` setExtension "html"
compile $ essayCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Blog posts
-- ---------------------------------------------------------------------------
match "content/blog/*.md" $ do
route $ gsubRoute "content/blog/" (const "blog/")
`composeRoutes` setExtension "html"
compile $ postCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/blog-post.html" postCtx
>>= loadAndApplyTemplate "templates/default.html" postCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Poetry
-- ---------------------------------------------------------------------------
-- 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 ("content/essays/*.md" .&&. hasNoVersion)
let ctx =
listField "essays" essayCtx (return essays)
<> constField "title" "Essays"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/essay-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Library — comprehensive portal-grouped index of all content
-- ---------------------------------------------------------------------------
create ["library.html"] $ do
route idRoute
compile $ do
-- Helper: filter all content to items whose tags include a given portal.
-- A tag matches portal P if it equals "P" or starts with "P/".
let hasPortal p item = do
meta <- getMetadata (itemIdentifier item)
let ts = fromMaybe [] (lookupStringList "tags" meta)
return $ any (\t -> t == p || (p ++ "/") `isPrefixOf` t) ts
portalList name p = listField name essayCtx $ do
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion)
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
recentFirst filtered
let ctx = portalList "ai-entries" "ai"
<> portalList "fiction-entries" "fiction"
<> portalList "miscellany-entries" "miscellany"
<> portalList "music-entries" "music"
<> portalList "nonfiction-entries" "nonfiction"
<> portalList "poetry-entries" "poetry"
<> portalList "research-entries" "research"
<> portalList "tech-entries" "tech"
<> constField "title" "Library"
<> constField "library" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/library.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Random page manifest — essays + blog posts only (no pagination/index pages)
-- ---------------------------------------------------------------------------
create ["random-pages.json"] $ do
route idRoute
compile $ do
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion) :: Compiler [Item String]
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) :: Compiler [Item String]
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) :: Compiler [Item String]
poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion) :: Compiler [Item String]
routes <- mapM (getRoute . itemIdentifier) (essays ++ posts ++ fiction ++ poetry)
let urls = [ "/" ++ r | Just r <- routes ]
makeItem $ "[" ++ intercalate "," (map show urls) ++ "]"
-- ---------------------------------------------------------------------------
-- Atom feed — all content sorted by date
-- ---------------------------------------------------------------------------
create ["feed.xml"] $ do
route idRoute
compile $ do
posts <- fmap (take 30) . recentFirst
=<< loadAllSnapshots
( ( "content/essays/*.md"
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. 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