library: sidecar-driven curation plumbing

Adds the library infrastructure without visible change to the rendered
page: a 'featured:' list in each portal's tag-meta sidecar drives shelf
curation (up to 5, default cap 4, recency fills the rest), a content/
library.md snapshot feeds a '\$library-intro\$' slot for a leading
blockquote, and '\$<slug>-has-more\$' gates expose whether the unfiltered
portal overflows the shelf. Items are now loaded once and partitioned
by primary portal rather than scanned per-section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-04-20 21:19:36 -04:00
parent 908136b646
commit c877d8c9c6
2 changed files with 119 additions and 29 deletions

View File

@ -2,8 +2,8 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
module Site (rules) where module Site (rules) where
import Control.Monad (filterM, forM, forM_, when) import Control.Monad (forM, forM_, when)
import Data.Char (toUpper) import Data.Char (isSpace, toUpper)
import Data.List (groupBy, isPrefixOf, sort, sortBy) import Data.List (groupBy, isPrefixOf, sort, sortBy)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
import Data.Maybe (catMaybes, fromMaybe, listToMaybe) import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
@ -55,6 +55,20 @@ homePortals =
, ("Miscellany", "miscellany") , ("Miscellany", "miscellany")
] ]
-- | Default number of cards shown per library shelf. The sidecar
-- 'featured:' list may push this up to 'libraryShelfMax'.
libraryShelfCap :: Int
libraryShelfCap = 4
-- | Hard ceiling on cards per shelf, regardless of sidecar length.
libraryShelfMax :: Int
libraryShelfMax = 5
-- | Optional prose intro lifted into @$library-intro$@ on the library
-- page. Matched but not routed; consumed via the @"body"@ snapshot.
libraryIntroId :: Identifier
libraryIntroId = fromFilePath "content/library.md"
-- Poems inside collection subdirectories, excluding their index pages. -- Poems inside collection subdirectories, excluding their index pages.
collectionPoems :: Pattern collectionPoems :: Pattern
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md" collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
@ -257,7 +271,8 @@ rules = do
match ("content/*.md" match ("content/*.md"
.&&. complement "content/index.md" .&&. complement "content/index.md"
.&&. complement "content/commonplace.md" .&&. complement "content/commonplace.md"
.&&. complement "content/colophon.md") $ do .&&. complement "content/colophon.md"
.&&. complement "content/library.md") $ do
route $ gsubRoute "content/" (const "") route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html" `composeRoutes` setExtension "html"
compile $ pageCompiler compile $ pageCompiler
@ -457,6 +472,13 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" ctx >>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls >>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Library intro — optional prose block (typically a blockquote) lifted
-- into @$library-intro$@ at the top of /library.html. Matched but not
-- routed; the body snapshot is consumed by the library rule below.
-- ---------------------------------------------------------------------------
match "content/library.md" $ compile sidecarCompiler
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Library — portal-grouped view over the /new.html dataset, deduplicated -- Library — portal-grouped view over the /new.html dataset, deduplicated
-- by primary portal. An item's primary portal is the top segment of the -- by primary portal. An item's primary portal is the top segment of the
@ -465,14 +487,27 @@ rules = do
-- silently dropped from the library (they remain on /new.html and on any -- silently dropped from the library (they remain on /new.html and on any
-- tag pages their frontmatter produces). -- tag pages their frontmatter produces).
-- --
-- Each shelf is capped at 'libraryShelfCap' items by default. A portal's
-- tag-meta sidecar may carry a 'featured:' list of content-rooted paths
-- (e.g. @content/essays/foo.md@); featured items are placed first, in
-- listed order, and the remainder is filled by recency up to a hard
-- ceiling of 'libraryShelfMax'. Featured paths that don't resolve to an
-- item in the portal (wrong primary portal, or typo) are silently
-- dropped. When the unfiltered portal has more items than the shelf
-- shows, @$<slug>-has-more$@ is exposed so the template can render a
-- "More on this shelf →" affordance linking to the portal's tag page.
--
-- Each card uses the shared item-card partial, with cross-portal filings -- Each card uses the shared item-card partial, with cross-portal filings
-- rendered in the card's tag footer via 'tagLinksFieldExcludingScope', -- rendered in the card's tag footer via 'tagLinksFieldExcludingTopSegment',
-- scoped to the section's portal so the portal's own tag is suppressed. -- scoped to the section's portal so the portal's own tag is suppressed.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
create ["library.html"] $ do create ["library.html"] $ do
route idRoute route idRoute
compile $ do compile $ do
let knownPortals = map snd homePortals sidecarIds <- getMatches ("content/tag-meta/*.md"
.||. "content/tag-meta/**/*.md")
let sidecarSet = Set.fromList sidecarIds
knownPortals = map snd homePortals
-- Top segment of the first tag that names a known portal. -- Top segment of the first tag that names a known portal.
-- Nothing when no tag matches — item is excluded from library. -- Nothing when no tag matches — item is excluded from library.
@ -499,31 +534,80 @@ rules = do
<> constField "full-abstract" "true" <> constField "full-abstract" "true"
<> essayCtx <> essayCtx
portalList name p = listField name (portalItemCtx p) $ do -- Load every content item once, then partition by primary portal
essays <- loadAll (allEssays .&&. hasNoVersion) -- so each shelf draws from a pre-filtered list rather than
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion) -- re-scanning the whole corpus eight times.
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion) essays <- loadAll (allEssays .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion) posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion) fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
let allItems = essays ++ posts ++ fiction ++ poetry ++ music poetry <- loadAll (allPoetry .&&. hasNoVersion)
filtered <- filterM (\i -> (== Just p) <$> primaryPortalOf i) allItems music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
sorted <- recentFirstByDisplay filtered let allContent = essays ++ posts ++ fiction ++ poetry ++ music
-- noResult here makes the field absent, so the template's :: [Item String]
-- $if(p-entries)$ gate evaluates false and the section is tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent
-- omitted entirely (rather than rendering an empty <ul>). let itemsByPortal :: Map.Map String [Item String]
if null sorted itemsByPortal =
then noResult ("no items in portal " ++ p) Map.fromListWith (++) [(p, [i]) | (Just p, i) <- tagged]
else return sorted
-- Section order matches homePortals — single ordering authority. -- Eager snapshot load registers the library-intro dependency
let ctx = portalList "research-entries" "research" -- unconditionally, so a first-populate of content/library.md
<> portalList "nonfiction-entries" "nonfiction" -- re-renders the library page even when the gate was previously
<> portalList "fiction-entries" "fiction" -- false (see 'sidecarContext' in Tags.hs for the same pattern).
<> portalList "poetry-entries" "poetry" _ <- loadSnapshot libraryIntroId "body" :: Compiler (Item String)
<> portalList "music-entries" "music" let libraryIntroFld = field "library-intro" $ \_ -> do
<> portalList "ai-entries" "ai" html <- itemBody <$> loadSnapshot libraryIntroId "body"
<> portalList "tech-entries" "tech" if all isSpace html
<> portalList "miscellany-entries" "miscellany" then noResult "empty library intro"
else return html
-- One shelf's context contribution: the @<slug>-entries@
-- listField (or absent via noResult when the shelf is
-- empty) plus an optional @<slug>-has-more@ gate.
portalSection p = do
let portalItems = fromMaybe [] (Map.lookup p itemsByPortal)
sorted <- recentFirstByDisplay portalItems
featuredPaths <-
if sidecarIdentifier p `Set.member` sidecarSet
then do
meta <- getMetadata (sidecarIdentifier p)
return (fromMaybe [] (lookupStringList "featured" meta))
else return []
let portalIdSet =
Set.fromList (map itemIdentifier portalItems)
featuredItems =
[ i
| path <- featuredPaths
, let ident = fromFilePath path
, ident `Set.member` portalIdSet
, Just i <- [listToMaybe
(filter ((== ident) . itemIdentifier) portalItems)]
]
cap = min libraryShelfMax
(max libraryShelfCap (length featuredItems))
featuredIds =
Set.fromList (map itemIdentifier featuredItems)
rest = filter (\i -> not (itemIdentifier i `Set.member` featuredIds)) sorted
merged = take cap (featuredItems ++ rest)
let entriesFld =
listField (p ++ "-entries") (portalItemCtx p)
(if null merged
then noResult ("no items in portal " ++ p)
else return merged)
hasMoreFld
| length portalItems > length merged =
constField (p ++ "-has-more") "true"
| otherwise = mempty
return (entriesFld <> hasMoreFld)
-- Section order follows homePortals — single ordering authority.
sections <- mapM portalSection knownPortals
let ctx = mconcat sections
<> libraryIntroFld
<> constField "title" "Library" <> constField "title" "Library"
<> constField "library" "true" <> constField "library" "true"
<> siteCtx <> siteCtx

6
content/library.md Normal file
View File

@ -0,0 +1,6 @@
> *El universo (que otros llaman la Biblioteca) se compone de un número indefinido, y tal vez infinito, de galerías hexagonales, con vastos pozos de ventilación en el medio, cercados por barandas bajísimas.*
>
> [*Ficciones*](https://es.wikipedia.org/wiki/Ficciones) — Jorge Luis Borges (1941)
# TODO: add language flagging within markdown source, needs to propagate to Ozymandias