{-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} module Site (rules) where import Control.Monad (filterM, forM, forM_, when) import Data.Char (toUpper) import Data.List (groupBy, isPrefixOf, sort, sortBy) import Data.Map.Strict (Map) import Data.Maybe (catMaybes, fromMaybe, listToMaybe) import Data.Ord (Down (..), comparing) import Data.Set (Set) import qualified Data.Set as Set import qualified Data.Text as T import System.Directory (listDirectory) import System.Environment (lookupEnv) import System.FilePath (takeDirectory, takeFileName, takeExtension, replaceExtension, ()) import Text.Read (readMaybe) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.Map.Strict as Map import Hakyll import Authors (buildAllAuthors, applyAuthorRules) import Backlinks (backlinkRules) import BibExtras (BibExtra (..), emptyBibExtra, firstAuthorSurname, parseBibExtras) import Citations (renderBibliographyHtml) import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler, compositionCompiler, sidecarCompiler) import Catalog (musicCatalogCtx) import Commonplace (commonplaceCtx) import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx, contentKindField, recentFirstByDisplay, tagLinksFieldExcludingTopSegment) import qualified Patterns as P import Tags (buildAllTags, applyTagRules, sidecarIdentifier, portalIntroField, portalTooltipField) import Pagination (blogPaginateRules) import Stats (statsRules) -- | Home-page portal grid order. Canonical ordering authority for every -- rendering of the eight portals (currently: the home page; future -- consumers follow this list). Each entry is (display name, tag name); -- the tag name is the key to everything else — URL (@/\/@), -- sidecar path (@content\/tag-meta\/\.md@), and the Tags.hs -- machinery that already keys off it. Edit this list to change order; -- do not introduce an @order:@ frontmatter field on sidecars. homePortals :: [(String, String)] homePortals = [ ("Research", "research") , ("Nonfiction", "nonfiction") , ("Fiction", "fiction") , ("Poetry", "poetry") , ("Music", "music") , ("AI", "ai") , ("Tech", "tech") , ("Miscellany", "miscellany") ] -- 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" } -- | Context for the home page. Extends 'pageCtx' with a @portals@ -- listField iterating 'homePortals' in order. Each item exposes -- @$portal-name$@, @$portal-url$@, and (if the sidecar's tooltip is -- populated) @$portal-tooltip$@, consumed by @templates/home.html@. -- Tooltip lookup uses 'portalTooltipField' — the same function -- 'Tags.applyTagRules' uses on per-tag pages — so the two surfaces -- stay in lockstep on suppression and missing-file semantics. homeCtx :: Context String homeCtx = listField "portals" portalItemCtx portalItems <> pageCtx where portalItems :: Compiler [Item (String, String)] portalItems = return (map (Item (fromFilePath "")) homePortals) portalItemCtx :: Context (String, String) portalItemCtx = field "portal-name" (return . fst . itemBody) <> field "portal-url" (\i -> return $ "/" ++ snd (itemBody i) ++ "/") <> portalTooltipField (sidecarIdentifier . snd . itemBody) 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-meta sidecars — optional prose intros + tooltips for tag index -- pages and the home-page portal grid. Matched but not routed: the -- rendered body is exposed only via the @"body"@ snapshot and the -- @tooltip:@ frontmatter key is read through 'getMetadata' by the -- consumers (Tags.hs, home-page rule). Registered before tag rules so -- snapshot loads during tag-page compilation find a compiled target. -- -- Two-pattern union: Hakyll's @**/*@ glob requires at least one -- subdirectory level, so flat sidecars (@content/tag-meta/nonfiction.md@) -- and nested sidecars (@content/tag-meta/nonfiction/philosophy.md@) must -- each be named by their own level-specific pattern. -- --------------------------------------------------------------------------- match ("content/tag-meta/*.md" .||. "content/tag-meta/**/*.md") $ compile sidecarCompiler -- --------------------------------------------------------------------------- -- Tag index pages -- --------------------------------------------------------------------------- tags <- buildAllTags applyTagRules tags homePortals 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" homeCtx >>= loadAndApplyTemplate "templates/default.html" homeCtx >>= 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 — 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 <- recentFirstByDisplay =<< loadAll allContent let itemCtx = contentKindField <> essayCtx ctx = listField "recent-items" itemCtx (return items) <> constField "title" "New" <> constField "list-page" "true" <> siteCtx makeItem "" >>= loadAndApplyTemplate "templates/new.html" ctx >>= loadAndApplyTemplate "templates/default.html" ctx >>= relativizeUrls -- --------------------------------------------------------------------------- -- Library — portal-grouped view over the /new.html dataset, deduplicated -- by primary portal. An item's primary portal is the top segment of the -- first tag in its frontmatter 'tags:' list whose top segment matches a -- known portal (the eight in 'homePortals'). Items with no such tag are -- silently dropped from the library (they remain on /new.html and on any -- tag pages their frontmatter produces). -- -- Each card uses the shared item-card partial, with cross-portal filings -- rendered in the card's tag footer via 'tagLinksFieldExcludingScope', -- scoped to the section's portal so the portal's own tag is suppressed. -- --------------------------------------------------------------------------- create ["library.html"] $ do route idRoute compile $ do let knownPortals = map snd homePortals -- Top segment of the first tag that names a known portal. -- Nothing when no tag matches — item is excluded from library. primaryPortalOf item = do meta <- getMetadata (itemIdentifier item) let ts = fromMaybe [] (lookupStringList "tags" meta) return $ listToMaybe [ p | t <- ts , let p = takeWhile (/= '/') t , p `elem` knownPortals ] -- Per-section item context: kind badge, ISO date for datetime -- attr, human-readable display date via essayCtx's dateDisplayField, -- abstract via siteCtx's abstractField, and cross-portal filings -- in the footer. Suppression is top-segment-based (hide every -- tag under the section's portal, not just the exact match) so -- a Research-section card doesn't re-list its research/* filings -- alongside the section heading. @full-abstract@ unclamps the -- card's 2-line abstract truncation — Library is the canonical -- browsing surface and shows full abstracts. portalItemCtx p = contentKindField <> tagLinksFieldExcludingTopSegment "item-tags" p <> constField "full-abstract" "true" <> essayCtx portalList name p = listField name (portalItemCtx p) $ do essays <- loadAll (allEssays .&&. hasNoVersion) posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) poetry <- loadAll (allPoetry .&&. hasNoVersion) music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion) let allItems = essays ++ posts ++ fiction ++ poetry ++ music filtered <- filterM (\i -> (== Just p) <$> primaryPortalOf i) allItems sorted <- recentFirstByDisplay filtered -- noResult here makes the field absent, so the template's -- $if(p-entries)$ gate evaluates false and the section is -- omitted entirely (rather than rendering an empty