369 lines
17 KiB
Haskell
369 lines
17 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)
|
|
|
|
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/*.md"
|
|
.&&. complement "content/index.md"
|
|
.&&. complement "content/commonplace.md") $ do
|
|
route $ gsubRoute "content/" (const "")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ pageCompiler
|
|
>>= loadAndApplyTemplate "templates/page.html" pageCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Essays
|
|
-- ---------------------------------------------------------------------------
|
|
match "content/essays/*.md" $ do
|
|
route $ gsubRoute "content/essays/" (const "essays/")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ essayCompiler
|
|
>>= saveSnapshot "content"
|
|
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" essayCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Blog posts
|
|
-- ---------------------------------------------------------------------------
|
|
match "content/blog/*.md" $ do
|
|
route $ gsubRoute "content/blog/" (const "blog/")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ postCompiler
|
|
>>= saveSnapshot "content"
|
|
>>= loadAndApplyTemplate "templates/blog-post.html" postCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" postCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Poetry
|
|
-- ---------------------------------------------------------------------------
|
|
match "content/poetry/*.md" $ do
|
|
route $ gsubRoute "content/poetry/" (const "poetry/")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ poetryCompiler
|
|
>>= saveSnapshot "content"
|
|
>>= loadAndApplyTemplate "templates/reading.html" poetryCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" poetryCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Fiction
|
|
-- ---------------------------------------------------------------------------
|
|
match "content/fiction/*.md" $ do
|
|
route $ gsubRoute "content/fiction/" (const "fiction/")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ fictionCompiler
|
|
>>= saveSnapshot "content"
|
|
>>= loadAndApplyTemplate "templates/reading.html" fictionCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" fictionCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Music — catalog index
|
|
-- ---------------------------------------------------------------------------
|
|
match "content/music/index.md" $ do
|
|
route $ constRoute "music/index.html"
|
|
compile $ pageCompiler
|
|
>>= loadAndApplyTemplate "templates/music-catalog.html" musicCatalogCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" musicCatalogCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Music — composition landing pages + score reader
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Static assets (SVG score pages, audio, PDF) served unchanged.
|
|
match "content/music/**/*.svg" $ do
|
|
route $ gsubRoute "content/" (const "")
|
|
compile copyFileCompiler
|
|
|
|
match "content/music/**/*.mp3" $ do
|
|
route $ gsubRoute "content/" (const "")
|
|
compile copyFileCompiler
|
|
|
|
match "content/music/**/*.pdf" $ do
|
|
route $ gsubRoute "content/" (const "")
|
|
compile copyFileCompiler
|
|
|
|
-- Landing page — full essay pipeline.
|
|
match "content/music/*/index.md" $ do
|
|
route $ gsubRoute "content/" (const "")
|
|
`composeRoutes` setExtension "html"
|
|
compile $ compositionCompiler
|
|
>>= saveSnapshot "content"
|
|
>>= loadAndApplyTemplate "templates/composition.html" compositionCtx
|
|
>>= loadAndApplyTemplate "templates/default.html" compositionCtx
|
|
>>= relativizeUrls
|
|
|
|
-- Score reader — separate URL, minimal chrome.
|
|
-- Compiled from the same source with version "score-reader".
|
|
match "content/music/*/index.md" $ version "score-reader" $ do
|
|
route $ customRoute $ \ident ->
|
|
let slug = takeFileName . takeDirectory . toFilePath $ ident
|
|
in "music/" ++ slug ++ "/score/index.html"
|
|
compile $ do
|
|
makeItem ""
|
|
>>= loadAndApplyTemplate "templates/score-reader.html"
|
|
compositionCtx
|
|
>>= loadAndApplyTemplate "templates/score-reader-default.html"
|
|
compositionCtx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Blog index (paginated)
|
|
-- ---------------------------------------------------------------------------
|
|
blogPaginateRules postCtx siteCtx
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Essay index
|
|
-- ---------------------------------------------------------------------------
|
|
create ["essays/index.html"] $ do
|
|
route idRoute
|
|
compile $ do
|
|
essays <- recentFirst =<< loadAll ("content/essays/*.md" .&&. hasNoVersion)
|
|
let ctx =
|
|
listField "essays" essayCtx (return essays)
|
|
<> constField "title" "Essays"
|
|
<> siteCtx
|
|
makeItem ""
|
|
>>= loadAndApplyTemplate "templates/essay-index.html" ctx
|
|
>>= loadAndApplyTemplate "templates/default.html" ctx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Library — comprehensive portal-grouped index of all content
|
|
-- ---------------------------------------------------------------------------
|
|
create ["library.html"] $ do
|
|
route idRoute
|
|
compile $ do
|
|
-- Helper: filter all content to items whose tags include a given portal.
|
|
-- A tag matches portal P if it equals "P" or starts with "P/".
|
|
let hasPortal p item = do
|
|
meta <- getMetadata (itemIdentifier item)
|
|
let ts = fromMaybe [] (lookupStringList "tags" meta)
|
|
return $ any (\t -> t == p || (p ++ "/") `isPrefixOf` t) ts
|
|
|
|
portalList name p = listField name essayCtx $ do
|
|
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion)
|
|
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
|
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
|
poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
|
|
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
|
|
recentFirst filtered
|
|
|
|
let ctx = portalList "ai-entries" "ai"
|
|
<> portalList "fiction-entries" "fiction"
|
|
<> portalList "miscellany-entries" "miscellany"
|
|
<> portalList "music-entries" "music"
|
|
<> portalList "nonfiction-entries" "nonfiction"
|
|
<> portalList "poetry-entries" "poetry"
|
|
<> portalList "research-entries" "research"
|
|
<> portalList "tech-entries" "tech"
|
|
<> constField "title" "Library"
|
|
<> constField "library" "true"
|
|
<> siteCtx
|
|
|
|
makeItem ""
|
|
>>= loadAndApplyTemplate "templates/library.html" ctx
|
|
>>= loadAndApplyTemplate "templates/default.html" ctx
|
|
>>= relativizeUrls
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Random page manifest — essays + blog posts only (no pagination/index pages)
|
|
-- ---------------------------------------------------------------------------
|
|
create ["random-pages.json"] $ do
|
|
route idRoute
|
|
compile $ do
|
|
essays <- loadAll ("content/essays/*.md" .&&. hasNoVersion) :: Compiler [Item String]
|
|
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) :: Compiler [Item String]
|
|
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) :: Compiler [Item String]
|
|
poetry <- loadAll ("content/poetry/*.md" .&&. hasNoVersion) :: Compiler [Item String]
|
|
routes <- mapM (getRoute . itemIdentifier) (essays ++ posts ++ fiction ++ poetry)
|
|
let urls = [ "/" ++ r | Just r <- routes ]
|
|
makeItem $ "[" ++ intercalate "," (map show urls) ++ "]"
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Atom feed — all content sorted by date
|
|
-- ---------------------------------------------------------------------------
|
|
create ["feed.xml"] $ do
|
|
route idRoute
|
|
compile $ do
|
|
posts <- fmap (take 30) . recentFirst
|
|
=<< loadAllSnapshots
|
|
( ( "content/essays/*.md"
|
|
.||. "content/blog/*.md"
|
|
.||. "content/fiction/*.md"
|
|
.||. "content/poetry/*.md"
|
|
.||. "content/music/*/index.md"
|
|
)
|
|
.&&. hasNoVersion
|
|
)
|
|
"content"
|
|
let feedCtx =
|
|
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
|
|
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
|
|
<> bodyField "description"
|
|
<> defaultContext
|
|
renderAtom feedConfig feedCtx posts
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Music feed — compositions only
|
|
-- ---------------------------------------------------------------------------
|
|
create ["music/feed.xml"] $ do
|
|
route idRoute
|
|
compile $ do
|
|
compositions <- recentFirst
|
|
=<< loadAllSnapshots
|
|
("content/music/*/index.md" .&&. hasNoVersion)
|
|
"content"
|
|
let feedCtx =
|
|
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
|
|
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
|
|
<> bodyField "description"
|
|
<> defaultContext
|
|
renderAtom musicFeedConfig feedCtx compositions
|