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 #-}
|
{-# 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
|
||||||
|
-- so each shelf draws from a pre-filtered list rather than
|
||||||
|
-- re-scanning the whole corpus eight times.
|
||||||
essays <- loadAll (allEssays .&&. hasNoVersion)
|
essays <- loadAll (allEssays .&&. hasNoVersion)
|
||||||
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
||||||
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
||||||
poetry <- loadAll (allPoetry .&&. hasNoVersion)
|
poetry <- loadAll (allPoetry .&&. hasNoVersion)
|
||||||
music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
|
music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
|
||||||
let allItems = essays ++ posts ++ fiction ++ poetry ++ music
|
let allContent = essays ++ posts ++ fiction ++ poetry ++ music
|
||||||
filtered <- filterM (\i -> (== Just p) <$> primaryPortalOf i) allItems
|
:: [Item String]
|
||||||
sorted <- recentFirstByDisplay filtered
|
tagged <- mapM (\i -> (,i) <$> primaryPortalOf i) allContent
|
||||||
-- noResult here makes the field absent, so the template's
|
let itemsByPortal :: Map.Map String [Item String]
|
||||||
-- $if(p-entries)$ gate evaluates false and the section is
|
itemsByPortal =
|
||||||
-- omitted entirely (rather than rendering an empty <ul>).
|
Map.fromListWith (++) [(p, [i]) | (Just p, i) <- tagged]
|
||||||
if null sorted
|
|
||||||
then noResult ("no items in portal " ++ p)
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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