@ wrapping. A single-paragraph abstract becomes a -- bare @Plain@ so the rendered HTML is unwrapped inlines. A multi-paragraph -- abstract (author used a blank line in the YAML literal block) is flattened -- to a single @Plain@ with @LineBreak@ separators between what were -- originally paragraph boundaries — the visual break is preserved without -- emitting stray @
@ tags inside the metadata block. Mixed block content
-- (e.g. an abstract containing a blockquote) falls through unchanged.
abstractField :: Context String
abstractField = field "abstract" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "abstract" meta of
Nothing -> fail "no abstract"
Just src -> do
let pandocResult = runPure $ do
doc <- readMarkdown defaultHakyllReaderOptions (T.pack src)
let doc' = case doc of
Pandoc m [Para ils] -> Pandoc m [Plain ils]
Pandoc m blocks
| all isPara blocks && not (null blocks) ->
let joined = intercalate [LineBreak]
[ils | Para ils <- blocks]
in Pandoc m [Plain joined]
_ -> doc
let wOpts = defaultHakyllWriterOptions { writerHTMLMathMethod = MathML }
writeHtml5String wOpts doc'
case pandocResult of
Left err -> fail $ "Pandoc error rendering abstract: " ++ show err
Right html -> return (T.unpack html)
where
isPara (Para _) = True
isPara _ = False
siteCtx :: Context String
siteCtx =
constField "site-title" (T.unpack (Config.siteName Config.siteConfig))
<> constField "site-url" (T.unpack (Config.siteUrl Config.siteConfig))
<> constField "site-description" (T.unpack (Config.siteDescription Config.siteConfig))
<> constField "site-language" (T.unpack (Config.siteLanguage Config.siteConfig))
<> constField "author-name" (T.unpack (Config.authorName Config.siteConfig))
<> constField "author-email" (T.unpack (Config.authorEmail Config.siteConfig))
<> constField "license" (T.unpack (Config.license Config.siteConfig))
<> optionalConstField "source-url" (T.unpack (Config.sourceUrl Config.siteConfig))
<> optionalConstField "gpg-fingerprint" (T.unpack (Config.gpgFingerprint Config.siteConfig))
<> optionalConstField "gpg-pubkey-url" (T.unpack (Config.gpgPubkeyUrl Config.siteConfig))
<> navLinksField
<> portalsField
<> buildTimeField
<> pageScriptsField
<> abstractField
<> defaultContext
where
optionalConstField name value
| null value = field name (\_ -> fail (name ++ " is empty"))
| otherwise = constField name value
navLinksField = listField "nav-links" navCtx (return navItems)
navItems = zipWith
(\i nl -> Item (fromFilePath ("nav-" ++ show (i :: Int))) nl)
[0 :: Int ..]
(Config.navLinks Config.siteConfig)
navCtx = field "href" (return . T.unpack . Config.navHref . itemBody)
<> field "label" (return . T.unpack . Config.navLabel . itemBody)
portalsField = listField "portals" portalCtx (return portalItems)
portalItems = zipWith
(\i p -> Item (fromFilePath ("portal-" ++ show (i :: Int))) p)
[0 :: Int ..]
(Config.portals Config.siteConfig)
portalCtx = field "portal-slug" (return . T.unpack . Config.portalSlug . itemBody)
<> field "portal-name" (return . T.unpack . Config.portalName . itemBody)
-- ---------------------------------------------------------------------------
-- 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.
--
-- The arrow flips when the absolute change crosses 'trendThreshold'
-- (currently 5 percentage points). Smaller swings count as flat.
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 -> case lastTwo xs of
Nothing -> fail "no confidence history"
Just (prevS, curS) ->
let prev = readMaybe prevS :: Maybe Int
cur = readMaybe curS :: Maybe Int
in case (prev, cur) of
(Just p, Just c)
| c - p > trendThreshold -> return "\x2191" -- ↑
| p - c > trendThreshold -> return "\x2193" -- ↓
| otherwise -> return "\x2192" -- →
_ -> return "\x2192"
where
trendThreshold :: Int
trendThreshold = 5
-- Total replacement for @(xs !! (length xs - 2), last xs)@: returns
-- the last two elements of a list, in order, or 'Nothing' when the
-- list has fewer than two entries.
lastTwo :: [a] -> Maybe (a, a)
lastTwo [] = Nothing
lastTwo [_] = Nothing
lastTwo [a, b] = Just (a, b)
lastTwo (_ : rest) = lastTwo rest
-- | @$overall-score$@: weighted composite of confidence (60 %) and
-- evidence quality (40 %), expressed as an integer on a 0–100 scale.
--
-- Importance is intentionally excluded from the score: it answers
-- "should you read this?", not "should you trust it?", and folding
-- the two together inflated the number and muddied its meaning.
-- It still appears in the footer as an independent orientation
-- signal — just not as a credibility input.
--
-- The 1–5 evidence scale is rescaled as @(ev − 1) / 4@ rather than
-- plain @ev / 5@. The naive form left a hidden +6 floor (since
-- @1/5 = 0.2@) and a midpoint of 0.6 instead of 0.5; the rescale
-- makes evidence=1 contribute zero and evidence=3 contribute exactly
-- half, so a "true midpoint" entry (conf=50, ev=3) lands on 50.
--
-- Returns @noResult@ when confidence or evidence is absent, so
-- @$if(overall-score)$@ guards the template safely.
--
-- Formula: raw = conf/100 · 0.6 + (ev − 1)/4 · 0.4 (0–1)
-- score = clamp₀₋₁₀₀(round(raw · 100))
overallScoreField :: Context String
overallScoreField = field "overall-score" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let readInt s = readMaybe s :: Maybe Int
case ( readInt =<< lookupString "confidence" meta
, readInt =<< lookupString "evidence" meta
) of
(Just conf, Just ev) ->
let raw :: Double
raw = fromIntegral conf / 100.0 * 0.6
+ fromIntegral (ev - 1) / 4.0 * 0.4
score = max 0 (min 100 (round (raw * 100.0) :: Int))
in return (show score)
_ -> fail "overall-score: confidence or evidence not set"
-- | All epistemic context fields composed.
epistemicCtx :: Context String
epistemicCtx =
dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence"
<> overallScoreField
<> confidenceTrendField
<> stabilityField
<> lastReviewedField
-- ---------------------------------------------------------------------------
-- Essay context
-- ---------------------------------------------------------------------------
essayCtx :: Context String
essayCtx =
authorLinksField
<> affiliationField
<> snapshotField "toc" "toc"
<> snapshotField "word-count" "word-count"
<> snapshotField "reading-time" "reading-time"
<> bibliographyField
<> furtherReadingField
<> backlinksField
<> similarLinksField
<> 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
<> affiliationField
<> backlinksField
<> similarLinksField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> constField "math" "true"
<> siteCtx
-- ---------------------------------------------------------------------------
-- Page context
-- ---------------------------------------------------------------------------
pageCtx :: Context String
pageCtx = authorLinksField <> affiliationField <> 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
}
-- | Parse the @movements@ frontmatter key. Returns parsed movements and a
-- list of human-readable warnings for any entries that failed to parse.
-- Callers can surface the warnings via 'unsafeCompiler' so silent typos
-- don't strip movements without diagnostic.
parseMovementsWithWarnings :: Metadata -> ([Movement], [String])
parseMovementsWithWarnings meta =
case KM.lookup "movements" meta of
Just (Array v) ->
let results = zipWith parseIndexed [1 :: Int ..] (V.toList v)
in ( [m | Right m <- results]
, [w | Left w <- results]
)
_ -> ([], [])
where
parseIndexed i value =
case parseOne value of
Just m -> Right m
Nothing -> Left $
"movement #" ++ show i ++ " is missing a required field "
++ "(name, page, or duration) — entry skipped"
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
parseMovements :: Metadata -> [Movement]
parseMovements = fst . parseMovementsWithWarnings
-- | 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, warnings) = parseMovementsWithWarnings meta
ident = toFilePath (itemIdentifier item)
unsafeCompiler $ mapM_
(\w -> putStrLn $ "[Movements] " ++ ident ++ ": " ++ w)
warnings
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)))
-- ---------------------------------------------------------------------------
-- Photography context
-- ---------------------------------------------------------------------------
-- | Extract the photo entry's slug from its identifier.
--
-- * Flat single @content/photography/. Read from the EXIF sidecar produced by extract-exif.py;
-- frontmatter wins if the author wants to override (e.g., to
-- declare a different rendered size).
<> exifBackedField "width"
<> exifBackedField "height"
<> capturedDisplayField
<> capturedIsoField
<> paletteSwatchesField
<> licenseUrlField
<> photoLinksField
<> tagLinksField "photography-tags"
<> authorLinksField
<> affiliationField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> siteCtx
where
slugField :: Context String
slugField = field "slug" (return . photoSlug)
-- Build @/photography/
@ on the original-format src. Browsers do NOT
-- fall back from a 404'd @
@; the
-- file-existence check at build time is load-bearing.
photoWebpUrlField :: Context String
photoWebpUrlField = field "photo-webp-url" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) | not (null photo) -> do
let entryDir = takeDirectory fp
webpDisk = entryDir > photoToWebp photo
exists <- unsafeCompiler (doesFileExist webpDisk)
if exists
then return $ "/photography/" ++ photoSlug item
++ "/" ++ photoToWebp photo
else noResult "no webp companion on disk"
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
where
photoToWebp :: String -> String
photoToWebp p =
let dotIdx = lastDotIndex p
in case dotIdx of
Just i -> take i p ++ ".webp"
Nothing -> p ++ ".webp"
lastDotIndex :: String -> Maybe Int
lastDotIndex s = go (length s - 1)
where
go i
| i < 0 = Nothing
| s !! i == '/' = Nothing -- crossed a path boundary
| s !! i == '.' = Just i
| otherwise = go (i - 1)
-- Resolve the @captured:@ ISO date with frontmatter > sidecar
-- precedence. Centralised so the display and ISO fields stay in
-- agreement on which source they read from.
resolveCapturedIso :: Item a -> Compiler (Maybe String)
resolveCapturedIso item = do
meta <- getMetadata (itemIdentifier item)
case lookupString "captured" meta of
Just v | not (null (trim v)) -> return (Just (trim v))
_ -> do
obj <- readPhotoSidecar ".exif.yaml" item
return (sidecarLookupString "captured" obj)
-- @captured:@ as "15 March 2026". Reads frontmatter, falls back to
-- the EXIF sidecar's @captured:@ key. Returns @noResult@ when
-- absent so @$if(captured-display)$@ gates the metadata row.
capturedDisplayField :: Context String
capturedDisplayField = field "captured-display" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%-d %B %Y" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- ISO form passed through unchanged (after a parse-validate round-trip
-- so a malformed value in either source doesn't reach the template).
capturedIsoField :: Context String
capturedIsoField = field "captured-iso" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%Y-%m-%d" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- @palette:@ list field. Frontmatter wins; otherwise pull the
-- list from @{photo}.palette.yaml@ (the @palette:@ key, an array
-- of hex strings produced by @tools/extract-palette.py@). Each
-- swatch exposes @$swatch$@.
paletteSwatchesField :: Context String
paletteSwatchesField = listFieldWith "palette-swatches" swCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fmEntries = fromMaybe [] (lookupStringList "palette" meta)
fmVisible = filter (not . null . trim) fmEntries
swatches <- if null fmVisible
then do
obj <- readPhotoSidecar ".palette.yaml" item
case KM.lookup "palette" obj of
Just (Array vec) ->
return [ trim s
| val <- V.toList vec
, Just s <- [yamlAsString val]
, not (null (trim s)) ]
_ -> return []
else return fmVisible
if null swatches
then noResult "no palette swatches in frontmatter or palette sidecar"
else return $ zipWith
(\i s -> Item (fromFilePath ("palette-" ++ show i)) s)
([0 ..] :: [Int])
swatches
where
swCtx = field "swatch" (return . itemBody)
-- @$license-url-resolved$@: an explicit @license-url:@ frontmatter
-- value when present, otherwise a canonical URL looked up from the
-- @license:@ string for known licenses (CC variants, CC0, public
-- domain). Returns @noResult@ when neither is set, so
-- @$if(license-url-resolved)$@ gates the link wrapper.
--
-- Frontmatter @license:@ itself flows through @defaultContext@ as
-- @$license$@; the template renders the license name as link text
-- and uses @$license-url-resolved$@ as @href@.
licenseUrlField :: Context String
licenseUrlField = field "license-url-resolved" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "license-url" meta of
Just u | not (null (trim u)) -> return (trim u)
_ -> case lookupString "license" meta of
Nothing -> noResult "no license"
Just l -> case canonicalLicenseUrl l of
Just u -> return u
Nothing -> noResult "license not in canonical lookup"
-- @links:@ frontmatter — outbound links to other surfaces where
-- the photograph appears or can be acquired (Wikimedia Commons,
-- Flickr, exhibition catalog, print-sale page, etc.). Each entry
-- uses the same @"Name | URL"@ pipe syntax as @authors:@ /
-- @affiliation:@ — the existing site convention.
--
-- Each item exposes @$link-name$@ and @$link-url$@. Entries
-- without a URL are dropped (no point linking to nothing). Returns
-- @noResult@ on empty so @$if(photo-links)$@ guards the wrapper.
photoLinksField :: Context String
photoLinksField = listFieldWith "photo-links" lkCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let entries = fromMaybe [] (lookupStringList "links" meta)
parsed = filter (not . null . snd) (map parseEntry entries)
if null parsed
then noResult "no outbound links"
else return $ map (Item (fromFilePath "")) parsed
where
lkCtx = field "link-name" (return . fst . itemBody)
<> field "link-url" (return . snd . itemBody)
parseEntry s = case break (== '|') s of
(name, '|' : url) -> (trim name, trim url)
(name, _) -> (trim name, "")