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 #-}
module Site (rules) where
import Control.Monad (filterM, forM, forM_, when)
import Data.Char (toUpper)
import Control.Monad (forM, forM_, when)
import Data.Char (isSpace, toUpper)
import Data.List (groupBy, isPrefixOf, sort, sortBy)
import Data.Map.Strict (Map)
import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
@ -55,6 +55,20 @@ homePortals =
, ("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.
collectionPoems :: Pattern
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
@ -257,7 +271,8 @@ rules = do
match ("content/*.md"
.&&. complement "content/index.md"
.&&. complement "content/commonplace.md"
.&&. complement "content/colophon.md") $ do
.&&. complement "content/colophon.md"
.&&. complement "content/library.md") $ do
route $ gsubRoute "content/" (const "")
`composeRoutes` setExtension "html"
compile $ pageCompiler
@ -457,6 +472,13 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= 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
-- 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
-- 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
-- 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.
-- ---------------------------------------------------------------------------
create ["library.html"] $ do
route idRoute
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.
-- Nothing when no tag matches — item is excluded from library.
@ -499,31 +534,80 @@ rules = do
<> constField "full-abstract" "true"
<> essayCtx
portalList name p = listField name (portalItemCtx p) $ do
-- Load every content item once, then partition by primary portal
-- so each shelf draws from a pre-filtered list rather than
-- re-scanning the whole corpus eight times.
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 <ul>).
if null sorted
then noResult ("no items in portal " ++ p)
else return sorted
let allContent = essays ++ posts ++ fiction ++ poetry ++ music
:: [Item String]
tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent
let itemsByPortal :: Map.Map String [Item String]
itemsByPortal =
Map.fromListWith (++) [(p, [i]) | (Just p, i) <- tagged]
-- Section order matches homePortals — single ordering authority.
let ctx = portalList "research-entries" "research"
<> portalList "nonfiction-entries" "nonfiction"
<> portalList "fiction-entries" "fiction"
<> portalList "poetry-entries" "poetry"
<> portalList "music-entries" "music"
<> portalList "ai-entries" "ai"
<> portalList "tech-entries" "tech"
<> portalList "miscellany-entries" "miscellany"
-- Eager snapshot load registers the library-intro dependency
-- unconditionally, so a first-populate of content/library.md
-- re-renders the library page even when the gate was previously
-- false (see 'sidecarContext' in Tags.hs for the same pattern).
_ <- loadSnapshot libraryIntroId "body" :: Compiler (Item String)
let libraryIntroFld = field "library-intro" $ \_ -> do
html <- itemBody <$> loadSnapshot libraryIntroId "body"
if all isSpace html
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 "library" "true"
<> 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