diff --git a/build/BibExtras.hs b/build/BibExtras.hs
new file mode 100644
index 0000000..77c3120
--- /dev/null
+++ b/build/BibExtras.hs
@@ -0,0 +1,173 @@
+{-# LANGUAGE GHC2021 #-}
+-- | Parser for custom fields on BibLaTeX entries that citeproc doesn't
+-- surface on its own: @file:@ (path to a hosted PDF) and @keywords:@
+-- (comma-separated list, shared vocabulary with essay-frontmatter
+-- @keywords:@ for bibliography-page cross-linking). Also captures
+-- @author:@ and @year:@ used for bibliography-page sorting.
+--
+-- Character-based scanner with brace-balance tracking, so fields
+-- whose values span multiple lines parse correctly — e.g.:
+--
+-- @
+-- \@inproceedings{kyber2018,
+-- author = {Bos, Joppe W. and Ducas, Léo and ...
+-- and Stehlé, Damien},
+-- title = {{CRYSTALS -- Kyber}},
+-- year = {2018}
+-- }
+-- @
+--
+-- Field values enclosed in @{...}@ (balanced) or @"..."@ are both
+-- recognized. Unknown fields are ignored.
+module BibExtras
+ ( BibExtra (..)
+ , emptyBibExtra
+ , parseBibExtras
+ , firstAuthorSurname
+ ) where
+
+import Data.Char (isAlphaNum, isSpace, toLower)
+import Data.List (dropWhileEnd)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+
+
+-- | Custom fields we extract per citekey. Fields absent from the
+-- entry normalize to @Nothing@ / @[]@.
+data BibExtra = BibExtra
+ { bibFile :: Maybe FilePath -- ^ @file:@ — URL path to a hosted PDF.
+ , bibKeywords :: [String] -- ^ @keywords:@ — comma-split, trimmed.
+ , bibAuthor :: Maybe String -- ^ @author:@ — raw value, sort key only.
+ , bibYear :: Maybe String -- ^ @year:@ — raw value, sort key only.
+ } deriving (Show)
+
+-- | Neutral default for a citekey with no custom fields.
+emptyBibExtra :: BibExtra
+emptyBibExtra = BibExtra Nothing [] Nothing Nothing
+
+-- | First-author surname for alphabetic sort. Conservative extraction:
+-- take everything up to the first comma of the first author entry.
+-- BibLaTeX author format separates authors with " and ", so
+-- "Nietzsche, Friedrich and Holub, Robert C." → "Nietzsche".
+-- Corporate authors like "{National Institute of ...}" strip the
+-- outer braces (the parser drops them) and sort by the full name.
+-- Entries without an author sort under the empty string.
+firstAuthorSurname :: BibExtra -> String
+firstAuthorSurname extra = case bibAuthor extra of
+ Just s -> trim (takeWhile (/= ',') (stripOuterBraces s))
+ Nothing -> ""
+ where
+ stripOuterBraces ('{':rest) = dropWhileEnd (== '}') rest
+ stripOuterBraces s = s
+
+
+-- | Parse a @.bib@ file; returns a map @citekey -> 'BibExtra'@.
+parseBibExtras :: FilePath -> IO (Map String BibExtra)
+parseBibExtras path = Map.fromList . parseBib <$> readFile path
+
+
+-- ---------------------------------------------------------------------------
+-- Character-based scanner
+-- ---------------------------------------------------------------------------
+
+-- | Enumerate all entries in a .bib file as (citekey, extra) pairs.
+parseBib :: String -> [(String, BibExtra)]
+parseBib input = go (dropTo '@' input)
+ where
+ -- Advance past any non-entry prefix to the first '@'.
+ dropTo c = dropWhile (/= c)
+
+ go [] = []
+ go ('@':rest) =
+ let -- Entry type, then '{', then citekey, then ',', then fields, then '}'.
+ r1 = dropWhile isAlphaNum rest -- skip type name
+ r2 = dropWhile isSpace r1
+ in case r2 of
+ '{':r3 ->
+ let (citekey, r4) = span (\c -> c /= ',' && not (isSpace c)) r3
+ r5 = dropWhile (\c -> c /= ',' && c /= '}') r4
+ in case r5 of
+ ',':r6 ->
+ let (flds, r7) = parseFields r6
+ in (trim citekey, toExtra flds) : go (dropTo '@' r7)
+ -- Fieldless entries: walk past and carry on.
+ '}':r6 -> (trim citekey, emptyBibExtra) : go (dropTo '@' r6)
+ _ -> []
+ _ -> go (dropTo '@' r2)
+ go (_:rest) = go (dropTo '@' rest)
+
+-- | Parse fields until the closing '}' of the enclosing entry.
+-- Accepts @name = {value}@, @name = "value"@, or trailing commas.
+parseFields :: String -> ([(String, String)], String)
+parseFields = go
+ where
+ go s =
+ let s' = dropWhile isSkippable s
+ in case s' of
+ [] -> ([], [])
+ '}':rest -> ([], rest)
+ _ -> case parseField s' of
+ Nothing -> ([], s') -- malformed; stop collecting
+ Just (nv, rest) ->
+ let (more, rest') = go rest
+ in (nv : more, rest')
+
+ isSkippable c = isSpace c || c == ','
+
+-- | Parse a single @name = value@ field.
+parseField :: String -> Maybe ((String, String), String)
+parseField s =
+ let (name, r1) = span (\c -> isAlphaNum c || c == '_') (dropWhile isSpace s)
+ r2 = dropWhile isSpace r1
+ in case r2 of
+ '=':r3 -> do
+ let r4 = dropWhile isSpace r3
+ (value, r5) <- readFieldValue r4
+ return ((map toLower (trim name), value), r5)
+ _ -> Nothing
+
+-- | Read a field's value, honoring nested braces and quoted forms.
+readFieldValue :: String -> Maybe (String, String)
+readFieldValue ('{':rest) = Just (readBraces 1 "" rest)
+readFieldValue ('"':rest) = Just (readQuote "" rest)
+readFieldValue _ = Nothing
+
+-- | Read characters up to the matching @}@ that closes the outermost
+-- @{@; preserves interior @{@ / @}@ pairs as part of the value.
+readBraces :: Int -> String -> String -> (String, String)
+readBraces 0 acc r = (reverse acc, r)
+readBraces _ acc [] = (reverse acc, [])
+readBraces 1 acc ('}':r) = (reverse acc, r) -- outer close
+readBraces n acc ('{':r) = readBraces (n + 1) ('{' : acc) r
+readBraces n acc ('}':r) = readBraces (n - 1) ('}' : acc) r
+readBraces n acc (c:r) = readBraces n (c : acc) r
+
+-- | Read characters up to the closing @"@.
+readQuote :: String -> String -> (String, String)
+readQuote acc ('"':r) = (reverse acc, r)
+readQuote acc [] = (reverse acc, [])
+readQuote acc (c:r) = readQuote (c : acc) r
+
+-- | Build a 'BibExtra' from the parsed fields list.
+toExtra :: [(String, String)] -> BibExtra
+toExtra flds = BibExtra
+ { bibFile = lookup "file" flds
+ , bibKeywords = case lookup "keywords" flds of
+ Nothing -> []
+ Just s -> filter (not . null) (map trim (splitOn ',' s))
+ , bibAuthor = lookup "author" flds
+ , bibYear = lookup "year" flds
+ }
+
+
+-- ---------------------------------------------------------------------------
+-- Utilities
+-- ---------------------------------------------------------------------------
+
+trim :: String -> String
+trim = dropWhile isSpace . dropWhileEnd isSpace
+
+splitOn :: Eq a => a -> [a] -> [[a]]
+splitOn c xs = case break (== c) xs of
+ (before, []) -> [before]
+ (before, _ : rest) -> before : splitOn c rest
diff --git a/build/Citations.hs b/build/Citations.hs
index f7fbc8e..3850936 100644
--- a/build/Citations.hs
+++ b/build/Citations.hs
@@ -1,4 +1,5 @@
{-# LANGUAGE GHC2021 #-}
+{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Citation processing pipeline.
--
@@ -25,9 +26,13 @@
-- caller (read from Hakyll's own metadata via lookupStringList).
--
-- NOTE: Does not import Contexts to avoid cycles.
-module Citations (applyCitations) where
+module Citations
+ ( applyCitations
+ -- * For synthetic bibliography pages (Phase 6b)
+ , renderBibliographyHtml
+ ) where
-import Data.List (intercalate, nub, partition, sortBy)
+import Data.List (intercalate, intersperse, nub, partition, sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, mapMaybe)
@@ -38,6 +43,8 @@ import Text.Pandoc
import Text.Pandoc.Citeproc (processCitations)
import Text.Pandoc.Walk
+import BibExtras (BibExtra (..), emptyBibExtra, parseBibExtras)
+
-- ---------------------------------------------------------------------------
-- Public API
@@ -54,11 +61,81 @@ applyCitations :: [Text] -> Text -> Pandoc -> IO (Pandoc, Text, Text)
applyCitations frKeys bibPath doc
| not (hasCitations frKeys doc) = return (doc, "", "")
| otherwise = do
+ -- Read custom fields (@file:@, @keywords:@) from the .bib file
+ -- in parallel with citeproc. These don't affect citation
+ -- resolution — they enhance the rendered bibliography entries.
+ extras <- parseBibExtras (T.unpack bibPath)
let doc1 = injectMeta frKeys bibPath doc
processed <- runIOorExplode $ processCitations doc1
- let (body, citedHtml, furtherHtml) = transformAndExtract frKeys processed
+ let (body, citedHtml, furtherHtml) = transformAndExtract extras frKeys processed
return (body, citedHtml, furtherHtml)
+-- | Render a standalone bibliography section from a list of citekeys and
+-- a set of @.bib@ file paths. Used by the synthetic @\/bibliography\/@
+-- pages (Phase 6b) to produce CSL-formatted entries outside of any
+-- essay's citation context.
+--
+-- Given citekeys are passed to citeproc via a synthesized @nocite@
+-- metadata entry on an otherwise empty document; citeproc emits a
+-- @refs@ Div whose children are the rendered entries. We then reorder
+-- the children to match the caller-supplied @keys@ list (citeproc's
+-- own ordering is overridden so callers control sort), enhance each
+-- entry with the Phase 6a PDF-link and keyword-strip hooks, and
+-- render to HTML wrapped in @\
@.
+--
+-- @extras@ is the combined 'BibExtra' map for the same @.bib@ files;
+-- passed in so that 'enhanceEntry' can consult @file:@ and
+-- @keywords:@ without each entry re-parsing the files.
+renderBibliographyHtml :: [FilePath] -- ^ .bib paths
+ -> Map String BibExtra -- ^ enhancement map
+ -> [String] -- ^ citekeys, in desired order
+ -> IO Text
+renderBibliographyHtml _ _ [] = return ""
+renderBibliographyHtml bibPaths extras keys = do
+ let doc = synthesizeNociteDoc bibPaths keys
+ processed <- runIOorExplode $ processCitations doc
+ let refsDivs = concatMap unwrapRefs (pandocBlocks processed)
+ ordered = reorderByKeys keys refsDivs
+ enhanced = map (enhanceEntry extras) ordered
+ return (renderEntries "csl-bib-body" enhanced)
+ where
+ pandocBlocks (Pandoc _ bs) = bs
+ unwrapRefs (Div ("refs", _, _) children) = children
+ unwrapRefs _ = []
+
+-- | Build a Pandoc doc whose only citation-relevant content is a
+-- @nocite@ metadata entry listing every supplied citekey. Runs
+-- through 'processCitations' to emit a fully-formatted @refs@ Div
+-- containing every entry.
+synthesizeNociteDoc :: [FilePath] -> [String] -> Pandoc
+synthesizeNociteDoc bibPaths keys =
+ let meta = Meta $ Map.fromList
+ [ ("bibliography", bibPathMeta bibPaths)
+ , ("csl", MetaString "data/chicago-notes.csl")
+ , ("nocite", nociteVal (map T.pack keys))
+ ]
+ in Pandoc meta []
+ where
+ bibPathMeta [p] = MetaString (T.pack p)
+ bibPathMeta ps = MetaList (map (MetaString . T.pack) ps)
+
+ nociteVal ks = MetaInlines (intercalate [Space] (map mkCite ks))
+ mkCite k = [Cite [Citation k [] [] AuthorInText 1 0] [Str ("@" <> k)]]
+
+-- | Reorder a list of @csl-entry@ Divs to match a requested key order.
+-- Divs not in the key list (shouldn't happen in practice, but safe
+-- by construction) drop to the end in their original order.
+reorderByKeys :: [String] -> [Block] -> [Block]
+reorderByKeys keys divs =
+ let divMap = Map.fromList [ (T.unpack (stripRefPrefix d), blk)
+ | blk@(Div (d, _, _) _) <- divs ]
+ found = mapMaybe (`Map.lookup` divMap) keys
+ leftovers = filter (\blk -> case blk of
+ Div (d, _, _) _ ->
+ T.unpack (stripRefPrefix d) `notElem` keys
+ _ -> True) divs
+ in found ++ leftovers
+
-- ---------------------------------------------------------------------------
-- Detection
@@ -108,14 +185,14 @@ insertMeta k v (Meta m) = Meta (Map.insert k v m)
-- ---------------------------------------------------------------------------
-- | Number citation Cite nodes and extract the bibliography div.
-transformAndExtract :: [Text] -> Pandoc -> (Pandoc, Text, Text)
-transformAndExtract frKeys doc@(Pandoc meta _) =
+transformAndExtract :: Map String BibExtra -> [Text] -> Pandoc -> (Pandoc, Text, Text)
+transformAndExtract extras frKeys doc@(Pandoc meta _) =
let citeOrder = collectCiteOrder doc -- keys, first-appearance order
keyNums = Map.fromList (zip citeOrder [1 :: Int ..])
-- Replace Cite nodes with numbered superscript markers
doc' = walk (transformInline keyNums) doc
-- Pull bibliography div out of body and render to HTML
- (bodyBlocks, citedHtml, furtherHtml) = extractBibliography citeOrder frKeys
+ (bodyBlocks, citedHtml, furtherHtml) = extractBibliography extras citeOrder frKeys
(pandocBlocks doc')
in (Pandoc meta bodyBlocks, citedHtml, furtherHtml)
where
@@ -164,12 +241,13 @@ markerHtml keys firstKey firstNum nums =
-- | Separate the @refs@ div from body blocks and render it to HTML.
-- Returns @(bodyBlocks, citedHtml, furtherHtml)@.
-extractBibliography :: [Text] -> [Text] -> [Block] -> ([Block], Text, Text)
-extractBibliography citeOrder frKeys blocks =
+extractBibliography :: Map String BibExtra -> [Text] -> [Text] -> [Block]
+ -> ([Block], Text, Text)
+extractBibliography extras citeOrder frKeys blocks =
let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks
(citedHtml, furtherHtml) = case refDivs of
[] -> ("", "")
- (d:_) -> renderBibDiv citeOrder frKeys d
+ (d:_) -> renderBibDiv extras citeOrder frKeys d
in (bodyBlocks, citedHtml, furtherHtml)
where
isRefsDiv (Div ("refs", _, _) _) = True
@@ -178,11 +256,17 @@ extractBibliography citeOrder frKeys blocks =
-- | Render the citeproc @refs@ Div into two HTML strings:
-- @(citedHtml, furtherHtml)@ — each is empty when there are no entries
-- in that section. Headings are rendered in the template, not here.
-renderBibDiv :: [Text] -> [Text] -> Block -> (Text, Text)
-renderBibDiv citeOrder _frKeys (Div _ children) =
- let keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
+--
+-- Entry bodies are enhanced before numbering: title-wrapped as a
+-- @.pdf-link[data-pdf-src]@ when the .bib @file:@ field is set (so
+-- popups.js's PDF hover preview fires), and a trailing
+-- @\
@ appended when @keywords:@ is set.
+renderBibDiv :: Map String BibExtra -> [Text] -> [Text] -> Block -> (Text, Text)
+renderBibDiv extras citeOrder _frKeys (Div _ children) =
+ let enhanced = map (enhanceEntry extras) children
+ keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
(citedEntries, furtherEntries) =
- partition (isCited keyIndex) children
+ partition (isCited keyIndex) enhanced
sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries
numbered = zipWith addNumber [1..] sorted
citedHtml = renderEntries "csl-bib-body cite-refs" numbered
@@ -190,7 +274,81 @@ renderBibDiv citeOrder _frKeys (Div _ children) =
| null furtherEntries = ""
| otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries
in (citedHtml, furtherHtml)
-renderBibDiv _ _ _ = ("", "")
+renderBibDiv _ _ _ _ = ("", "")
+
+
+-- ---------------------------------------------------------------------------
+-- Bib entry enhancement (Phase 6a)
+-- ---------------------------------------------------------------------------
+
+-- | Augment a single @csl-entry@ Div with the custom fields we parsed
+-- from the .bib file. Other Blocks pass through unchanged.
+enhanceEntry :: Map String BibExtra -> Block -> Block
+enhanceEntry extras b@(Div attrs@(divId, _, _) blocks) =
+ let key = T.unpack (stripRefPrefix divId)
+ extra = fromMaybe emptyBibExtra (Map.lookup key extras)
+ withLink = case bibFile extra of
+ Nothing -> blocks
+ Just fp -> map (wrapFirstTitleBlock (T.pack fp)) blocks
+ withKw = withLink ++ keywordsBlocks (bibKeywords extra)
+ in case (bibFile extra, bibKeywords extra) of
+ (Nothing, []) -> b
+ _ -> Div attrs withKw
+enhanceEntry _ b = b
+
+-- | In one block of an entry, wrap the first title-bearing inline
+-- with a @.pdf-link@ anchor. Pandoc's CSL-formatted references
+-- render the title as either a @Quoted@ (article titles in
+-- Chicago-notes: "Paper Title") or an @Emph@ (book titles:
+-- /Book Title/), and those are the first such inline in each
+-- entry. We wrap at the block level and fall back to passing the
+-- block through if no matching inline appears.
+wrapFirstTitleBlock :: Text -> Block -> Block
+wrapFirstTitleBlock href = \case
+ Para ils -> Para (wrapFirstTitle href ils)
+ Plain ils -> Plain (wrapFirstTitle href ils)
+ other -> other
+
+-- | Left-to-right scan: wrap the first title-bearing inline in a link
+-- pointing at the PDF. Pandoc's CSL renderer emits article titles as
+-- @Span@ nodes (whose rendered HTML wraps quotation marks around the
+-- title text) and book titles as @Emph@; @Quoted@ appears in some
+-- other CSL styles. First match of any of these is treated as the
+-- title; subsequent ones pass through — journal names are also
+-- @Emph@ on @\@article@ entries but come after the @Span@ title, so
+-- the article case picks the right target.
+wrapFirstTitle :: Text -> [Inline] -> [Inline]
+wrapFirstTitle href inls = reverse . fst $ foldl step ([], False) inls
+ where
+ step (acc, True) inl = (inl:acc, True)
+ step (acc, False) inl = case inl of
+ Span _ _ -> (asPdfLink href [inl] : acc, True)
+ Quoted _ _ -> (asPdfLink href [inl] : acc, True)
+ Emph _ -> (asPdfLink href [inl] : acc, True)
+ _ -> (inl:acc, False)
+
+-- | Build the @.pdf-link[data-pdf-src]@ anchor that popups.js binds to.
+-- See @static/js/popups.js:112@ for the matching selector.
+asPdfLink :: Text -> [Inline] -> Inline
+asPdfLink href content =
+ Link ("", ["pdf-link"], [("data-pdf-src", href)])
+ content
+ (href, "")
+
+-- | Trailing keyword strip, linking each keyword to the future
+-- @/bibliography/\/@ page. Returns @[]@ when the keyword
+-- list is empty so the entry gets no extra block at all.
+keywordsBlocks :: [String] -> [Block]
+keywordsBlocks [] = []
+keywordsBlocks ks =
+ [ Div ("", ["bib-keywords"], [])
+ [Plain (intersperse (Str ", ") (map keywordLink ks))]
+ ]
+ where
+ keywordLink k =
+ Link ("", ["bib-keyword"], [])
+ [Str (T.pack k)]
+ (T.pack ("/bibliography/" ++ k ++ "/"), "")
isCited :: Map Text Int -> Block -> Bool
isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex
diff --git a/build/Compilers.hs b/build/Compilers.hs
index dfd3640..72da06c 100644
--- a/build/Compilers.hs
+++ b/build/Compilers.hs
@@ -7,6 +7,7 @@ module Compilers
, poetryCompiler
, fictionCompiler
, compositionCompiler
+ , sidecarCompiler
, readerOpts
, writerOpts
) where
@@ -200,6 +201,28 @@ fictionCompiler = essayCompiler
compositionCompiler :: Compiler (Item String)
compositionCompiler = essayCompiler
+-- | Reduced pipeline for tag-meta sidecar markdown files. Applies
+-- source-level preprocessors and AST filters (wikilinks, sidenotes,
+-- smallcaps, links, etc.) so sidecar prose can use the same rich
+-- markdown features as essays, then saves the rendered HTML under
+-- the @"body"@ snapshot. Skips TOC, word count, reading time, and
+-- citations — none of those belong in a portal intro. The item
+-- itself is not routed; the body is consumed only via snapshot
+-- loads by the tag-index rule and the home-page grid.
+sidecarCompiler :: Compiler (Item String)
+sidecarCompiler = do
+ body <- getResourceBody
+ let src = itemBody body
+ body' = itemSetBody (preprocessSource src) body
+ filePath <- getResourceFilePath
+ let srcDir = takeDirectory filePath
+ pandocItem <- readPandocWith readerOpts body'
+ pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
+ let pandocItem' = itemSetBody pandocFiltered pandocItem
+ let htmlItem = writePandocWith writerOpts pandocItem'
+ _ <- saveSnapshot "body" htmlItem
+ return htmlItem
+
-- | Compiler for simple pages: filters applied, no TOC snapshot.
pageCompiler :: Compiler (Item String)
pageCompiler = do
diff --git a/build/Contexts.hs b/build/Contexts.hs
index ee3a833..10a9cf3 100644
--- a/build/Contexts.hs
+++ b/build/Contexts.hs
@@ -11,17 +11,26 @@ module Contexts
, contentKindField
, abstractField
, tagLinksField
+ , tagLinksFieldExcludingScope
+ , tagLinksFieldExcludingTopSegment
+ , keywordLinksField
, authorLinksField
+ , dateDisplayField
+ , revisionDateFields
+ , recentFirstByDisplay
+ , Revision (..)
+ , getRevisions
) where
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
-import Data.List (intercalate, isPrefixOf)
-import Data.Maybe (fromMaybe)
+import Data.List (intercalate, isPrefixOf, sortBy)
+import Data.Maybe (fromMaybe, mapMaybe)
+import Data.Ord (comparing)
import Data.Time.Calendar (toGregorian)
-import Data.Time.Clock (getCurrentTime, utctDay)
-import Data.Time.Format (formatTime, defaultTimeLocale)
+import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
+import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
import System.FilePath (takeDirectory, takeFileName)
import Text.Read (readMaybe)
import qualified Data.Text as T
@@ -152,6 +161,129 @@ tagLinksField fieldName = listFieldWith fieldName ctx $ \item ->
ctx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
+-- | Variant of 'tagLinksField' that suppresses tags equal to or ancestral
+-- to the given scope. Used on tag index pages to hide the redundant
+-- filing ribbon entry for the current page's own scope.
+--
+-- Suppression is equality-based on the scope plus its prefix-ancestors:
+-- on @\/nonfiction\/@ (scope = @"nonfiction"@) only the literal
+-- @"nonfiction"@ tag is hidden; @"nonfiction/philosophy"@ still renders.
+-- On @\/nonfiction\/philosophy\/@ both @"nonfiction"@ and
+-- @"nonfiction/philosophy"@ are hidden; sibling and cross-filed tags
+-- remain.
+--
+-- When every tag is suppressed, the field fails with 'noResult' so
+-- @$if(...)$@ is false and the tag-ribbon wrapper is omitted entirely
+-- instead of rendering as an empty @
@.
+tagLinksFieldExcludingScope :: String -> String -> Context a
+tagLinksFieldExcludingScope fieldName scope =
+ listFieldWith fieldName ctx $ \item -> do
+ ts <- getTags (itemIdentifier item)
+ let visible = filter (not . isScopeOrAncestor) ts
+ if null visible
+ then noResult "no visible tags after scope suppression"
+ else return (map toItem visible)
+ where
+ toItem t = Item (fromFilePath (t ++ "/index.html")) t
+ ctx = field "tag-name" (return . itemBody)
+ <> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
+ -- Hide tag t when t == scope, or when t is a strict prefix-ancestor
+ -- of scope (i.e., scope starts with t ++ "/"). Descendants of scope
+ -- (e.g., "nonfiction/philosophy" when scope="nonfiction") are kept.
+ isScopeOrAncestor t = t == scope || (t ++ "/") `isPrefixOf` scope
+
+-- | Variant of 'tagLinksField' that suppresses any tag whose top
+-- (slash-separated) segment equals the given scope. Used by the
+-- Library page: an item rendered under the "Research" section
+-- should not re-list its own @research\/*@ filings in the tag
+-- footer (the section heading makes those structurally implied),
+-- but should still list @tech\/*@ cross-filings.
+--
+-- This is distinct from 'tagLinksFieldExcludingScope', which
+-- suppresses only exact-match and strict ancestors. Library's
+-- redundancy goal is broader: hide the whole subtree rooted at
+-- the section's portal, not just the portal tag itself.
+--
+-- @
+-- scope = "research"
+-- t = "research" → hide (top = "research" == scope)
+-- t = "research/cryptography" → hide (top = "research" == scope)
+-- t = "tech" → show (top = "tech" /= scope)
+-- t = "tech/hpc" → show (top = "tech" /= scope)
+-- @
+--
+-- 'noResult' fires when every tag is suppressed so
+-- @$if(item-tags)$@ gates off an empty footer wrapper, same
+-- discipline as 'tagLinksFieldExcludingScope'.
+tagLinksFieldExcludingTopSegment :: String -> String -> Context a
+tagLinksFieldExcludingTopSegment fieldName scope =
+ listFieldWith fieldName ctx $ \item -> do
+ ts <- getTags (itemIdentifier item)
+ let visible = filter (not . matchesTopSegment) ts
+ if null visible
+ then noResult "no cross-portal tags after top-segment suppression"
+ else return (map toItem visible)
+ where
+ toItem t = Item (fromFilePath (t ++ "/index.html")) t
+ ctx = field "tag-name" (return . itemBody)
+ <> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
+ matchesTopSegment t = takeWhile (/= '/') t == scope
+
+-- ---------------------------------------------------------------------------
+-- Keyword links field (bibliography-scoped vocabulary, Phase 6a)
+-- ---------------------------------------------------------------------------
+
+-- | List context field exposing an item's @keywords:@ frontmatter as
+-- @$kw-name$@ / @$kw-url$@ pairs. URL targets @/bibliography/\/@,
+-- the per-keyword bibliography pages (built by Phase 6b; links will
+-- 404 until then, deliberately — the mechanism has to be in place
+-- before the pages can be populated).
+--
+-- Shared vocabulary with bib-entry @keywords:@ fields parsed by
+-- 'BibExtras.parseBibExtras'. An essay tagged with the same keyword
+-- as a bib entry will appear alongside that entry on the keyword
+-- page.
+--
+-- Accepts both YAML list and comma-separated scalar forms:
+--
+-- @
+-- keywords: [crypto, lattices]
+-- keywords:
+-- - crypto
+-- - lattices
+-- keywords: "crypto, lattices"
+-- @
+--
+-- Returns @noResult@ when absent or empty so the template's
+-- @$if(essay-keywords)$@ gate suppresses the meta row.
+--
+-- Usage in metadata.html:
+--
+-- @
+-- $for(essay-keywords)$\$kw-name$\$endfor$
+-- @
+keywordLinksField :: String -> Context a
+keywordLinksField fieldName = listFieldWith fieldName ctx $ \item -> do
+ meta <- getMetadata (itemIdentifier item)
+ let kws = case lookupStringList "keywords" meta of
+ Just xs -> xs
+ Nothing -> case lookupString "keywords" meta of
+ Just s -> filter (not . null) (map trim (splitOn ',' s))
+ Nothing -> []
+ visible = filter (not . null . trim) kws
+ if null visible
+ then noResult "no keywords"
+ else return (map toItem visible)
+ where
+ toItem k = Item (fromFilePath (k ++ "/index.html")) k
+ ctx = field "kw-name" (return . itemBody)
+ <> field "kw-url" (\i -> return $ "/bibliography/" ++ itemBody i ++ "/")
+
+ splitOn :: Char -> String -> [String]
+ splitOn c s = case break (== c) s of
+ (before, []) -> [before]
+ (before, _ : rest) -> before : splitOn c rest
+
-- ---------------------------------------------------------------------------
-- Author links field
-- ---------------------------------------------------------------------------
@@ -385,6 +517,144 @@ epistemicCtx =
-- Essay context
-- ---------------------------------------------------------------------------
+-- ---------------------------------------------------------------------------
+-- Display date (revision-aware)
+-- ---------------------------------------------------------------------------
+
+-- | Resolve an item's display date as a 'UTCTime': the most-recent
+-- 'revisionDateISO' if the item has a 'revised:' entry, else the
+-- creation date via 'getItemUTC'. Falls back to the creation date
+-- when a revision's ISO string fails to parse.
+--
+-- Shared by every revision-aware field below and by
+-- 'recentFirstByDisplay', so they always agree on what the item's
+-- display date is.
+itemDisplayUTC :: Item a -> Compiler UTCTime
+itemDisplayUTC item = do
+ meta <- getMetadata (itemIdentifier item)
+ case getRevisions meta of
+ (r:_) -> case parseTimeM True defaultTimeLocale "%Y-%m-%d"
+ (revisionDateISO r) :: Maybe UTCTime of
+ Just utc -> return utc
+ Nothing -> getItemUTC defaultTimeLocale (itemIdentifier item)
+ [] -> getItemUTC defaultTimeLocale (itemIdentifier item)
+
+-- | @$date-display$@ — the date shown next to an item in list renderings.
+-- Most-recent revision date if the item has a 'revised:' entry, else
+-- its creation date. Formatted "17 April 2026".
+dateDisplayField :: Context String
+dateDisplayField = field "date-display" $ \item ->
+ formatTime defaultTimeLocale "%-d %B %Y" <$> itemDisplayUTC item
+
+-- | @$date-iso$@ — ISO-8601 form of the display date, for
+-- @