318 lines
13 KiB
Haskell
318 lines
13 KiB
Haskell
{-# LANGUAGE GHC2021 #-}
|
||
{-# LANGUAGE OverloadedStrings #-}
|
||
module Contexts
|
||
( siteCtx
|
||
, essayCtx
|
||
, postCtx
|
||
, pageCtx
|
||
, poetryCtx
|
||
, fictionCtx
|
||
, compositionCtx
|
||
) where
|
||
|
||
import Data.Aeson (Value (..))
|
||
import qualified Data.Aeson.KeyMap as KM
|
||
import qualified Data.Vector as V
|
||
import Data.Maybe (catMaybes, 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 Hakyll
|
||
import Hakyll.Core.Metadata (lookupStringList)
|
||
import Authors (authorLinksField)
|
||
import Backlinks (backlinksField)
|
||
import Stability (stabilityField, lastReviewedField, versionHistoryField)
|
||
import Tags (tagLinksField)
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- 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"
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- 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.
|
||
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)
|
||
return $ map (\s -> Item (fromFilePath s) s) scripts
|
||
where
|
||
ctx = field "script-src" (return . itemBody)
|
||
|
||
siteCtx :: Context String
|
||
siteCtx =
|
||
constField "site-title" "Levi Neuwirth"
|
||
<> constField "site-url" "https://levineuwirth.org"
|
||
<> buildTimeField
|
||
<> pageScriptsField
|
||
<> defaultContext
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- 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.
|
||
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 | length xs < 2 -> fail "no confidence history"
|
||
Just xs ->
|
||
let prev = readMaybe (xs !! (length xs - 2)) :: Maybe Int
|
||
cur = readMaybe (last xs) :: Maybe Int
|
||
in case (prev, cur) of
|
||
(Just p, Just c)
|
||
| c - p > 5 -> return "\x2191" -- ↑
|
||
| p - c > 5 -> return "\x2193" -- ↓
|
||
| otherwise -> return "\x2192" -- →
|
||
_ -> return "\x2192"
|
||
|
||
-- | All epistemic context fields composed.
|
||
epistemicCtx :: Context String
|
||
epistemicCtx =
|
||
dotsField "importance-dots" "importance"
|
||
<> dotsField "evidence-dots" "evidence"
|
||
<> confidenceTrendField
|
||
<> stabilityField
|
||
<> lastReviewedField
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- Essay context
|
||
-- ---------------------------------------------------------------------------
|
||
|
||
essayCtx :: Context String
|
||
essayCtx =
|
||
authorLinksField
|
||
<> snapshotField "toc" "toc"
|
||
<> snapshotField "word-count" "word-count"
|
||
<> snapshotField "reading-time" "reading-time"
|
||
<> bibliographyField
|
||
<> furtherReadingField
|
||
<> backlinksField
|
||
<> 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
|
||
<> backlinksField
|
||
<> dateField "date" "%-d %B %Y"
|
||
<> dateField "date-iso" "%Y-%m-%d"
|
||
<> constField "math" "true"
|
||
<> siteCtx
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- Page context
|
||
-- ---------------------------------------------------------------------------
|
||
|
||
pageCtx :: Context String
|
||
pageCtx = authorLinksField <> 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
|
||
}
|
||
|
||
parseMovements :: Metadata -> [Movement]
|
||
parseMovements meta =
|
||
case KM.lookup "movements" meta of
|
||
Just (Array v) -> catMaybes $ map parseOne (V.toList v)
|
||
_ -> []
|
||
where
|
||
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
|
||
|
||
-- | 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 = parseMovements meta
|
||
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)))
|