From c877d8c9c6f7b3a6c3979ff33e4390e092062965 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 20 Apr 2026 21:19:36 -0400 Subject: [PATCH] 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 '\$-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) --- build/Site.hs | 142 ++++++++++++++++++++++++++++++++++++--------- content/library.md | 6 ++ 2 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 content/library.md diff --git a/build/Site.hs b/build/Site.hs index e1ce356..fdc6320 100644 --- a/build/Site.hs +++ b/build/Site.hs @@ -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, @$-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 - 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
    ). - if null sorted - then noResult ("no items in portal " ++ p) - else return sorted + -- 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 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 @-entries@ + -- listField (or absent via noResult when the shelf is + -- empty) plus an optional @-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 diff --git a/content/library.md b/content/library.md new file mode 100644 index 0000000..2646b39 --- /dev/null +++ b/content/library.md @@ -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