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:
parent
908136b646
commit
c877d8c9c6
132
build/Site.hs
132
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, @$<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue