Navigation refactor

This commit is contained in:
Levi Neuwirth 2026-04-19 14:35:41 -04:00
parent 237380c4be
commit 908136b646
31 changed files with 1726 additions and 760 deletions

173
build/BibExtras.hs Normal file
View File

@ -0,0 +1,173 @@
{-# LANGUAGE GHC2021 #-}
-- | Parser for custom fields on BibLaTeX entries that citeproc doesn't
-- surface on its own: @file:@ (path to a hosted PDF) and @keywords:@
-- (comma-separated list, shared vocabulary with essay-frontmatter
-- @keywords:@ for bibliography-page cross-linking). Also captures
-- @author:@ and @year:@ used for bibliography-page sorting.
--
-- Character-based scanner with brace-balance tracking, so fields
-- whose values span multiple lines parse correctly — e.g.:
--
-- @
-- \@inproceedings{kyber2018,
-- author = {Bos, Joppe W. and Ducas, Léo and ...
-- and Stehlé, Damien},
-- title = {{CRYSTALS -- Kyber}},
-- year = {2018}
-- }
-- @
--
-- Field values enclosed in @{...}@ (balanced) or @"..."@ are both
-- recognized. Unknown fields are ignored.
module BibExtras
( BibExtra (..)
, emptyBibExtra
, parseBibExtras
, firstAuthorSurname
) where
import Data.Char (isAlphaNum, isSpace, toLower)
import Data.List (dropWhileEnd)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- | Custom fields we extract per citekey. Fields absent from the
-- entry normalize to @Nothing@ / @[]@.
data BibExtra = BibExtra
{ bibFile :: Maybe FilePath -- ^ @file:@ — URL path to a hosted PDF.
, bibKeywords :: [String] -- ^ @keywords:@ — comma-split, trimmed.
, bibAuthor :: Maybe String -- ^ @author:@ — raw value, sort key only.
, bibYear :: Maybe String -- ^ @year:@ — raw value, sort key only.
} deriving (Show)
-- | Neutral default for a citekey with no custom fields.
emptyBibExtra :: BibExtra
emptyBibExtra = BibExtra Nothing [] Nothing Nothing
-- | First-author surname for alphabetic sort. Conservative extraction:
-- take everything up to the first comma of the first author entry.
-- BibLaTeX author format separates authors with " and ", so
-- "Nietzsche, Friedrich and Holub, Robert C." → "Nietzsche".
-- Corporate authors like "{National Institute of ...}" strip the
-- outer braces (the parser drops them) and sort by the full name.
-- Entries without an author sort under the empty string.
firstAuthorSurname :: BibExtra -> String
firstAuthorSurname extra = case bibAuthor extra of
Just s -> trim (takeWhile (/= ',') (stripOuterBraces s))
Nothing -> ""
where
stripOuterBraces ('{':rest) = dropWhileEnd (== '}') rest
stripOuterBraces s = s
-- | Parse a @.bib@ file; returns a map @citekey -> 'BibExtra'@.
parseBibExtras :: FilePath -> IO (Map String BibExtra)
parseBibExtras path = Map.fromList . parseBib <$> readFile path
-- ---------------------------------------------------------------------------
-- Character-based scanner
-- ---------------------------------------------------------------------------
-- | Enumerate all entries in a .bib file as (citekey, extra) pairs.
parseBib :: String -> [(String, BibExtra)]
parseBib input = go (dropTo '@' input)
where
-- Advance past any non-entry prefix to the first '@'.
dropTo c = dropWhile (/= c)
go [] = []
go ('@':rest) =
let -- Entry type, then '{', then citekey, then ',', then fields, then '}'.
r1 = dropWhile isAlphaNum rest -- skip type name
r2 = dropWhile isSpace r1
in case r2 of
'{':r3 ->
let (citekey, r4) = span (\c -> c /= ',' && not (isSpace c)) r3
r5 = dropWhile (\c -> c /= ',' && c /= '}') r4
in case r5 of
',':r6 ->
let (flds, r7) = parseFields r6
in (trim citekey, toExtra flds) : go (dropTo '@' r7)
-- Fieldless entries: walk past and carry on.
'}':r6 -> (trim citekey, emptyBibExtra) : go (dropTo '@' r6)
_ -> []
_ -> go (dropTo '@' r2)
go (_:rest) = go (dropTo '@' rest)
-- | Parse fields until the closing '}' of the enclosing entry.
-- Accepts @name = {value}@, @name = "value"@, or trailing commas.
parseFields :: String -> ([(String, String)], String)
parseFields = go
where
go s =
let s' = dropWhile isSkippable s
in case s' of
[] -> ([], [])
'}':rest -> ([], rest)
_ -> case parseField s' of
Nothing -> ([], s') -- malformed; stop collecting
Just (nv, rest) ->
let (more, rest') = go rest
in (nv : more, rest')
isSkippable c = isSpace c || c == ','
-- | Parse a single @name = value@ field.
parseField :: String -> Maybe ((String, String), String)
parseField s =
let (name, r1) = span (\c -> isAlphaNum c || c == '_') (dropWhile isSpace s)
r2 = dropWhile isSpace r1
in case r2 of
'=':r3 -> do
let r4 = dropWhile isSpace r3
(value, r5) <- readFieldValue r4
return ((map toLower (trim name), value), r5)
_ -> Nothing
-- | Read a field's value, honoring nested braces and quoted forms.
readFieldValue :: String -> Maybe (String, String)
readFieldValue ('{':rest) = Just (readBraces 1 "" rest)
readFieldValue ('"':rest) = Just (readQuote "" rest)
readFieldValue _ = Nothing
-- | Read characters up to the matching @}@ that closes the outermost
-- @{@; preserves interior @{@ / @}@ pairs as part of the value.
readBraces :: Int -> String -> String -> (String, String)
readBraces 0 acc r = (reverse acc, r)
readBraces _ acc [] = (reverse acc, [])
readBraces 1 acc ('}':r) = (reverse acc, r) -- outer close
readBraces n acc ('{':r) = readBraces (n + 1) ('{' : acc) r
readBraces n acc ('}':r) = readBraces (n - 1) ('}' : acc) r
readBraces n acc (c:r) = readBraces n (c : acc) r
-- | Read characters up to the closing @"@.
readQuote :: String -> String -> (String, String)
readQuote acc ('"':r) = (reverse acc, r)
readQuote acc [] = (reverse acc, [])
readQuote acc (c:r) = readQuote (c : acc) r
-- | Build a 'BibExtra' from the parsed fields list.
toExtra :: [(String, String)] -> BibExtra
toExtra flds = BibExtra
{ bibFile = lookup "file" flds
, bibKeywords = case lookup "keywords" flds of
Nothing -> []
Just s -> filter (not . null) (map trim (splitOn ',' s))
, bibAuthor = lookup "author" flds
, bibYear = lookup "year" flds
}
-- ---------------------------------------------------------------------------
-- Utilities
-- ---------------------------------------------------------------------------
trim :: String -> String
trim = dropWhile isSpace . dropWhileEnd isSpace
splitOn :: Eq a => a -> [a] -> [[a]]
splitOn c xs = case break (== c) xs of
(before, []) -> [before]
(before, _ : rest) -> before : splitOn c rest

View File

@ -1,4 +1,5 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Citation processing pipeline.
--
@ -25,9 +26,13 @@
-- caller (read from Hakyll's own metadata via lookupStringList).
--
-- NOTE: Does not import Contexts to avoid cycles.
module Citations (applyCitations) where
module Citations
( applyCitations
-- * For synthetic bibliography pages (Phase 6b)
, renderBibliographyHtml
) where
import Data.List (intercalate, nub, partition, sortBy)
import Data.List (intercalate, intersperse, nub, partition, sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, mapMaybe)
@ -38,6 +43,8 @@ import Text.Pandoc
import Text.Pandoc.Citeproc (processCitations)
import Text.Pandoc.Walk
import BibExtras (BibExtra (..), emptyBibExtra, parseBibExtras)
-- ---------------------------------------------------------------------------
-- Public API
@ -54,11 +61,81 @@ applyCitations :: [Text] -> Text -> Pandoc -> IO (Pandoc, Text, Text)
applyCitations frKeys bibPath doc
| not (hasCitations frKeys doc) = return (doc, "", "")
| otherwise = do
-- Read custom fields (@file:@, @keywords:@) from the .bib file
-- in parallel with citeproc. These don't affect citation
-- resolution — they enhance the rendered bibliography entries.
extras <- parseBibExtras (T.unpack bibPath)
let doc1 = injectMeta frKeys bibPath doc
processed <- runIOorExplode $ processCitations doc1
let (body, citedHtml, furtherHtml) = transformAndExtract frKeys processed
let (body, citedHtml, furtherHtml) = transformAndExtract extras frKeys processed
return (body, citedHtml, furtherHtml)
-- | Render a standalone bibliography section from a list of citekeys and
-- a set of @.bib@ file paths. Used by the synthetic @\/bibliography\/@
-- pages (Phase 6b) to produce CSL-formatted entries outside of any
-- essay's citation context.
--
-- Given citekeys are passed to citeproc via a synthesized @nocite@
-- metadata entry on an otherwise empty document; citeproc emits a
-- @refs@ Div whose children are the rendered entries. We then reorder
-- the children to match the caller-supplied @keys@ list (citeproc's
-- own ordering is overridden so callers control sort), enhance each
-- entry with the Phase 6a PDF-link and keyword-strip hooks, and
-- render to HTML wrapped in @\<div class="csl-bib-body"\>@.
--
-- @extras@ is the combined 'BibExtra' map for the same @.bib@ files;
-- passed in so that 'enhanceEntry' can consult @file:@ and
-- @keywords:@ without each entry re-parsing the files.
renderBibliographyHtml :: [FilePath] -- ^ .bib paths
-> Map String BibExtra -- ^ enhancement map
-> [String] -- ^ citekeys, in desired order
-> IO Text
renderBibliographyHtml _ _ [] = return ""
renderBibliographyHtml bibPaths extras keys = do
let doc = synthesizeNociteDoc bibPaths keys
processed <- runIOorExplode $ processCitations doc
let refsDivs = concatMap unwrapRefs (pandocBlocks processed)
ordered = reorderByKeys keys refsDivs
enhanced = map (enhanceEntry extras) ordered
return (renderEntries "csl-bib-body" enhanced)
where
pandocBlocks (Pandoc _ bs) = bs
unwrapRefs (Div ("refs", _, _) children) = children
unwrapRefs _ = []
-- | Build a Pandoc doc whose only citation-relevant content is a
-- @nocite@ metadata entry listing every supplied citekey. Runs
-- through 'processCitations' to emit a fully-formatted @refs@ Div
-- containing every entry.
synthesizeNociteDoc :: [FilePath] -> [String] -> Pandoc
synthesizeNociteDoc bibPaths keys =
let meta = Meta $ Map.fromList
[ ("bibliography", bibPathMeta bibPaths)
, ("csl", MetaString "data/chicago-notes.csl")
, ("nocite", nociteVal (map T.pack keys))
]
in Pandoc meta []
where
bibPathMeta [p] = MetaString (T.pack p)
bibPathMeta ps = MetaList (map (MetaString . T.pack) ps)
nociteVal ks = MetaInlines (intercalate [Space] (map mkCite ks))
mkCite k = [Cite [Citation k [] [] AuthorInText 1 0] [Str ("@" <> k)]]
-- | Reorder a list of @csl-entry@ Divs to match a requested key order.
-- Divs not in the key list (shouldn't happen in practice, but safe
-- by construction) drop to the end in their original order.
reorderByKeys :: [String] -> [Block] -> [Block]
reorderByKeys keys divs =
let divMap = Map.fromList [ (T.unpack (stripRefPrefix d), blk)
| blk@(Div (d, _, _) _) <- divs ]
found = mapMaybe (`Map.lookup` divMap) keys
leftovers = filter (\blk -> case blk of
Div (d, _, _) _ ->
T.unpack (stripRefPrefix d) `notElem` keys
_ -> True) divs
in found ++ leftovers
-- ---------------------------------------------------------------------------
-- Detection
@ -108,14 +185,14 @@ insertMeta k v (Meta m) = Meta (Map.insert k v m)
-- ---------------------------------------------------------------------------
-- | Number citation Cite nodes and extract the bibliography div.
transformAndExtract :: [Text] -> Pandoc -> (Pandoc, Text, Text)
transformAndExtract frKeys doc@(Pandoc meta _) =
transformAndExtract :: Map String BibExtra -> [Text] -> Pandoc -> (Pandoc, Text, Text)
transformAndExtract extras frKeys doc@(Pandoc meta _) =
let citeOrder = collectCiteOrder doc -- keys, first-appearance order
keyNums = Map.fromList (zip citeOrder [1 :: Int ..])
-- Replace Cite nodes with numbered superscript markers
doc' = walk (transformInline keyNums) doc
-- Pull bibliography div out of body and render to HTML
(bodyBlocks, citedHtml, furtherHtml) = extractBibliography citeOrder frKeys
(bodyBlocks, citedHtml, furtherHtml) = extractBibliography extras citeOrder frKeys
(pandocBlocks doc')
in (Pandoc meta bodyBlocks, citedHtml, furtherHtml)
where
@ -164,12 +241,13 @@ markerHtml keys firstKey firstNum nums =
-- | Separate the @refs@ div from body blocks and render it to HTML.
-- Returns @(bodyBlocks, citedHtml, furtherHtml)@.
extractBibliography :: [Text] -> [Text] -> [Block] -> ([Block], Text, Text)
extractBibliography citeOrder frKeys blocks =
extractBibliography :: Map String BibExtra -> [Text] -> [Text] -> [Block]
-> ([Block], Text, Text)
extractBibliography extras citeOrder frKeys blocks =
let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks
(citedHtml, furtherHtml) = case refDivs of
[] -> ("", "")
(d:_) -> renderBibDiv citeOrder frKeys d
(d:_) -> renderBibDiv extras citeOrder frKeys d
in (bodyBlocks, citedHtml, furtherHtml)
where
isRefsDiv (Div ("refs", _, _) _) = True
@ -178,11 +256,17 @@ extractBibliography citeOrder frKeys blocks =
-- | Render the citeproc @refs@ Div into two HTML strings:
-- @(citedHtml, furtherHtml)@ — each is empty when there are no entries
-- in that section. Headings are rendered in the template, not here.
renderBibDiv :: [Text] -> [Text] -> Block -> (Text, Text)
renderBibDiv citeOrder _frKeys (Div _ children) =
let keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
--
-- Entry bodies are enhanced before numbering: title-wrapped as a
-- @.pdf-link[data-pdf-src]@ when the .bib @file:@ field is set (so
-- popups.js's PDF hover preview fires), and a trailing
-- @\<div class="bib-keywords"\>@ appended when @keywords:@ is set.
renderBibDiv :: Map String BibExtra -> [Text] -> [Text] -> Block -> (Text, Text)
renderBibDiv extras citeOrder _frKeys (Div _ children) =
let enhanced = map (enhanceEntry extras) children
keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
(citedEntries, furtherEntries) =
partition (isCited keyIndex) children
partition (isCited keyIndex) enhanced
sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries
numbered = zipWith addNumber [1..] sorted
citedHtml = renderEntries "csl-bib-body cite-refs" numbered
@ -190,7 +274,81 @@ renderBibDiv citeOrder _frKeys (Div _ children) =
| null furtherEntries = ""
| otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries
in (citedHtml, furtherHtml)
renderBibDiv _ _ _ = ("", "")
renderBibDiv _ _ _ _ = ("", "")
-- ---------------------------------------------------------------------------
-- Bib entry enhancement (Phase 6a)
-- ---------------------------------------------------------------------------
-- | Augment a single @csl-entry@ Div with the custom fields we parsed
-- from the .bib file. Other Blocks pass through unchanged.
enhanceEntry :: Map String BibExtra -> Block -> Block
enhanceEntry extras b@(Div attrs@(divId, _, _) blocks) =
let key = T.unpack (stripRefPrefix divId)
extra = fromMaybe emptyBibExtra (Map.lookup key extras)
withLink = case bibFile extra of
Nothing -> blocks
Just fp -> map (wrapFirstTitleBlock (T.pack fp)) blocks
withKw = withLink ++ keywordsBlocks (bibKeywords extra)
in case (bibFile extra, bibKeywords extra) of
(Nothing, []) -> b
_ -> Div attrs withKw
enhanceEntry _ b = b
-- | In one block of an entry, wrap the first title-bearing inline
-- with a @.pdf-link@ anchor. Pandoc's CSL-formatted references
-- render the title as either a @Quoted@ (article titles in
-- Chicago-notes: "Paper Title") or an @Emph@ (book titles:
-- /Book Title/), and those are the first such inline in each
-- entry. We wrap at the block level and fall back to passing the
-- block through if no matching inline appears.
wrapFirstTitleBlock :: Text -> Block -> Block
wrapFirstTitleBlock href = \case
Para ils -> Para (wrapFirstTitle href ils)
Plain ils -> Plain (wrapFirstTitle href ils)
other -> other
-- | Left-to-right scan: wrap the first title-bearing inline in a link
-- pointing at the PDF. Pandoc's CSL renderer emits article titles as
-- @Span@ nodes (whose rendered HTML wraps quotation marks around the
-- title text) and book titles as @Emph@; @Quoted@ appears in some
-- other CSL styles. First match of any of these is treated as the
-- title; subsequent ones pass through — journal names are also
-- @Emph@ on @\@article@ entries but come after the @Span@ title, so
-- the article case picks the right target.
wrapFirstTitle :: Text -> [Inline] -> [Inline]
wrapFirstTitle href inls = reverse . fst $ foldl step ([], False) inls
where
step (acc, True) inl = (inl:acc, True)
step (acc, False) inl = case inl of
Span _ _ -> (asPdfLink href [inl] : acc, True)
Quoted _ _ -> (asPdfLink href [inl] : acc, True)
Emph _ -> (asPdfLink href [inl] : acc, True)
_ -> (inl:acc, False)
-- | Build the @.pdf-link[data-pdf-src]@ anchor that popups.js binds to.
-- See @static/js/popups.js:112@ for the matching selector.
asPdfLink :: Text -> [Inline] -> Inline
asPdfLink href content =
Link ("", ["pdf-link"], [("data-pdf-src", href)])
content
(href, "")
-- | Trailing keyword strip, linking each keyword to the future
-- @/bibliography/\<keyword\>/@ page. Returns @[]@ when the keyword
-- list is empty so the entry gets no extra block at all.
keywordsBlocks :: [String] -> [Block]
keywordsBlocks [] = []
keywordsBlocks ks =
[ Div ("", ["bib-keywords"], [])
[Plain (intersperse (Str ", ") (map keywordLink ks))]
]
where
keywordLink k =
Link ("", ["bib-keyword"], [])
[Str (T.pack k)]
(T.pack ("/bibliography/" ++ k ++ "/"), "")
isCited :: Map Text Int -> Block -> Bool
isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex

View File

@ -7,6 +7,7 @@ module Compilers
, poetryCompiler
, fictionCompiler
, compositionCompiler
, sidecarCompiler
, readerOpts
, writerOpts
) where
@ -200,6 +201,28 @@ fictionCompiler = essayCompiler
compositionCompiler :: Compiler (Item String)
compositionCompiler = essayCompiler
-- | Reduced pipeline for tag-meta sidecar markdown files. Applies
-- source-level preprocessors and AST filters (wikilinks, sidenotes,
-- smallcaps, links, etc.) so sidecar prose can use the same rich
-- markdown features as essays, then saves the rendered HTML under
-- the @"body"@ snapshot. Skips TOC, word count, reading time, and
-- citations — none of those belong in a portal intro. The item
-- itself is not routed; the body is consumed only via snapshot
-- loads by the tag-index rule and the home-page grid.
sidecarCompiler :: Compiler (Item String)
sidecarCompiler = do
body <- getResourceBody
let src = itemBody body
body' = itemSetBody (preprocessSource src) body
filePath <- getResourceFilePath
let srcDir = takeDirectory filePath
pandocItem <- readPandocWith readerOpts body'
pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
let pandocItem' = itemSetBody pandocFiltered pandocItem
let htmlItem = writePandocWith writerOpts pandocItem'
_ <- saveSnapshot "body" htmlItem
return htmlItem
-- | Compiler for simple pages: filters applied, no TOC snapshot.
pageCompiler :: Compiler (Item String)
pageCompiler = do

View File

@ -11,17 +11,26 @@ module Contexts
, contentKindField
, abstractField
, tagLinksField
, tagLinksFieldExcludingScope
, tagLinksFieldExcludingTopSegment
, keywordLinksField
, authorLinksField
, dateDisplayField
, revisionDateFields
, recentFirstByDisplay
, Revision (..)
, getRevisions
) where
import Data.Aeson (Value (..))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.List (intercalate, isPrefixOf)
import Data.Maybe (fromMaybe)
import Data.List (intercalate, isPrefixOf, sortBy)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Ord (comparing)
import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale)
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
import System.FilePath (takeDirectory, takeFileName)
import Text.Read (readMaybe)
import qualified Data.Text as T
@ -152,6 +161,129 @@ tagLinksField fieldName = listFieldWith fieldName ctx $ \item ->
ctx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
-- | Variant of 'tagLinksField' that suppresses tags equal to or ancestral
-- to the given scope. Used on tag index pages to hide the redundant
-- filing ribbon entry for the current page's own scope.
--
-- Suppression is equality-based on the scope plus its prefix-ancestors:
-- on @\/nonfiction\/@ (scope = @"nonfiction"@) only the literal
-- @"nonfiction"@ tag is hidden; @"nonfiction/philosophy"@ still renders.
-- On @\/nonfiction\/philosophy\/@ both @"nonfiction"@ and
-- @"nonfiction/philosophy"@ are hidden; sibling and cross-filed tags
-- remain.
--
-- When every tag is suppressed, the field fails with 'noResult' so
-- @$if(...)$@ is false and the tag-ribbon wrapper is omitted entirely
-- instead of rendering as an empty @<div>@.
tagLinksFieldExcludingScope :: String -> String -> Context a
tagLinksFieldExcludingScope fieldName scope =
listFieldWith fieldName ctx $ \item -> do
ts <- getTags (itemIdentifier item)
let visible = filter (not . isScopeOrAncestor) ts
if null visible
then noResult "no visible tags after scope suppression"
else return (map toItem visible)
where
toItem t = Item (fromFilePath (t ++ "/index.html")) t
ctx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
-- Hide tag t when t == scope, or when t is a strict prefix-ancestor
-- of scope (i.e., scope starts with t ++ "/"). Descendants of scope
-- (e.g., "nonfiction/philosophy" when scope="nonfiction") are kept.
isScopeOrAncestor t = t == scope || (t ++ "/") `isPrefixOf` scope
-- | Variant of 'tagLinksField' that suppresses any tag whose top
-- (slash-separated) segment equals the given scope. Used by the
-- Library page: an item rendered under the "Research" section
-- should not re-list its own @research\/*@ filings in the tag
-- footer (the section heading makes those structurally implied),
-- but should still list @tech\/*@ cross-filings.
--
-- This is distinct from 'tagLinksFieldExcludingScope', which
-- suppresses only exact-match and strict ancestors. Library's
-- redundancy goal is broader: hide the whole subtree rooted at
-- the section's portal, not just the portal tag itself.
--
-- @
-- scope = "research"
-- t = "research" → hide (top = "research" == scope)
-- t = "research/cryptography" → hide (top = "research" == scope)
-- t = "tech" → show (top = "tech" /= scope)
-- t = "tech/hpc" → show (top = "tech" /= scope)
-- @
--
-- 'noResult' fires when every tag is suppressed so
-- @$if(item-tags)$@ gates off an empty footer wrapper, same
-- discipline as 'tagLinksFieldExcludingScope'.
tagLinksFieldExcludingTopSegment :: String -> String -> Context a
tagLinksFieldExcludingTopSegment fieldName scope =
listFieldWith fieldName ctx $ \item -> do
ts <- getTags (itemIdentifier item)
let visible = filter (not . matchesTopSegment) ts
if null visible
then noResult "no cross-portal tags after top-segment suppression"
else return (map toItem visible)
where
toItem t = Item (fromFilePath (t ++ "/index.html")) t
ctx = field "tag-name" (return . itemBody)
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
matchesTopSegment t = takeWhile (/= '/') t == scope
-- ---------------------------------------------------------------------------
-- Keyword links field (bibliography-scoped vocabulary, Phase 6a)
-- ---------------------------------------------------------------------------
-- | List context field exposing an item's @keywords:@ frontmatter as
-- @$kw-name$@ / @$kw-url$@ pairs. URL targets @/bibliography/\<kw\>/@,
-- the per-keyword bibliography pages (built by Phase 6b; links will
-- 404 until then, deliberately — the mechanism has to be in place
-- before the pages can be populated).
--
-- Shared vocabulary with bib-entry @keywords:@ fields parsed by
-- 'BibExtras.parseBibExtras'. An essay tagged with the same keyword
-- as a bib entry will appear alongside that entry on the keyword
-- page.
--
-- Accepts both YAML list and comma-separated scalar forms:
--
-- @
-- keywords: [crypto, lattices]
-- keywords:
-- - crypto
-- - lattices
-- keywords: "crypto, lattices"
-- @
--
-- Returns @noResult@ when absent or empty so the template's
-- @$if(essay-keywords)$@ gate suppresses the meta row.
--
-- Usage in metadata.html:
--
-- @
-- $for(essay-keywords)$\<a class="meta-keyword" href="$kw-url$"\>$kw-name$\</a\>$endfor$
-- @
keywordLinksField :: String -> Context a
keywordLinksField fieldName = listFieldWith fieldName ctx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let kws = case lookupStringList "keywords" meta of
Just xs -> xs
Nothing -> case lookupString "keywords" meta of
Just s -> filter (not . null) (map trim (splitOn ',' s))
Nothing -> []
visible = filter (not . null . trim) kws
if null visible
then noResult "no keywords"
else return (map toItem visible)
where
toItem k = Item (fromFilePath (k ++ "/index.html")) k
ctx = field "kw-name" (return . itemBody)
<> field "kw-url" (\i -> return $ "/bibliography/" ++ itemBody i ++ "/")
splitOn :: Char -> String -> [String]
splitOn c s = case break (== c) s of
(before, []) -> [before]
(before, _ : rest) -> before : splitOn c rest
-- ---------------------------------------------------------------------------
-- Author links field
-- ---------------------------------------------------------------------------
@ -385,6 +517,144 @@ epistemicCtx =
-- Essay context
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- Display date (revision-aware)
-- ---------------------------------------------------------------------------
-- | Resolve an item's display date as a 'UTCTime': the most-recent
-- 'revisionDateISO' if the item has a 'revised:' entry, else the
-- creation date via 'getItemUTC'. Falls back to the creation date
-- when a revision's ISO string fails to parse.
--
-- Shared by every revision-aware field below and by
-- 'recentFirstByDisplay', so they always agree on what the item's
-- display date is.
itemDisplayUTC :: Item a -> Compiler UTCTime
itemDisplayUTC item = do
meta <- getMetadata (itemIdentifier item)
case getRevisions meta of
(r:_) -> case parseTimeM True defaultTimeLocale "%Y-%m-%d"
(revisionDateISO r) :: Maybe UTCTime of
Just utc -> return utc
Nothing -> getItemUTC defaultTimeLocale (itemIdentifier item)
[] -> getItemUTC defaultTimeLocale (itemIdentifier item)
-- | @$date-display$@ — the date shown next to an item in list renderings.
-- Most-recent revision date if the item has a 'revised:' entry, else
-- its creation date. Formatted "17 April 2026".
dateDisplayField :: Context String
dateDisplayField = field "date-display" $ \item ->
formatTime defaultTimeLocale "%-d %B %Y" <$> itemDisplayUTC item
-- | @$date-iso$@ — ISO-8601 form of the display date, for
-- @<time datetime="...">@ attributes. Same revision-aware
-- semantics as 'dateDisplayField'.
dateDisplayIsoField :: Context String
dateDisplayIsoField = field "date-iso" $ \item ->
formatTime defaultTimeLocale "%Y-%m-%d" <$> itemDisplayUTC item
-- | @$date-original$@ — the item's creation date, present in the
-- context only when the most-recent revision date differs from it.
-- Consumed by the card partial's "· revised from …" annotation.
-- 'noResult' otherwise (so the annotation is simply absent for
-- never-revised items).
dateOriginalField :: Context String
dateOriginalField = field "date-original" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case getRevisions meta of
[] -> noResult "no revisions"
(r:_) -> do
created <- getItemUTC defaultTimeLocale (itemIdentifier item)
let createdIso = formatTime defaultTimeLocale "%Y-%m-%d" created
if revisionDateISO r == createdIso
then noResult "revision date equals creation date"
else return (formatTime defaultTimeLocale "%-d %B %Y" created)
-- | @$revision-note$@ — prose note attached to the most-recent
-- 'revised:' entry, if any. Rendered as an italicized line under
-- the abstract on the item card. 'noResult' when there's no
-- revision, or when the most-recent revision has no note.
revisionNoteField :: Context String
revisionNoteField = field "revision-note" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case getRevisions meta of
(r:_) | Just note <- revisionNote r, not (null (trim note)) -> return note
_ -> noResult "no revision note"
-- | Bundle of revision-aware fields consumed by the item-card partial:
-- @$date-display$@, @$date-iso$@, @$date-original$@, @$revision-note$@.
-- Compose once on any surface that renders item cards.
revisionDateFields :: Context String
revisionDateFields =
dateDisplayField
<> dateDisplayIsoField
<> dateOriginalField
<> revisionNoteField
-- | Sort items most-recent-first by 'itemDisplayUTC' — same ordering
-- the card shows in its date gutter, so items with recent revisions
-- move to the top without divorcing the sort key from the visible
-- date. Callers: the @/new.html@ rule, 'Tags.applyTagRules', and
-- the library rule.
recentFirstByDisplay :: [Item a] -> Compiler [Item a]
recentFirstByDisplay items = do
keyed <- mapM (\i -> (,) <$> itemDisplayUTC i <*> pure i) items
return $ map snd $ sortBy (flip (comparing fst)) keyed
-- ---------------------------------------------------------------------------
-- Revised: frontmatter schema
-- ---------------------------------------------------------------------------
-- | A single entry from a @revised:@ frontmatter list. Exposed so
-- downstream Phase-5 consumers (the 'dateDisplayField' implementation
-- and the revision-annotation fields on the item card) can all read
-- the same canonical form.
data Revision = Revision
{ revisionDateISO :: String -- ^ ISO-8601 date, e.g. "2026-04-10"
, revisionNote :: Maybe String -- ^ optional prose note for the entry
}
-- | Parse and normalize the @revised:@ frontmatter field into a list
-- of 'Revision' entries, sorted most-recent-first (ISO @YYYY-MM-DD@
-- strings sort lexicographically in chronological order, so
-- reverse-sorting them yields most-recent-first).
--
-- Accepted frontmatter shapes:
--
-- @
-- -- Scalar shorthand (normalized to one entry with no note)
-- revised: "2026-04-10"
--
-- -- Canonical list of objects
-- revised:
-- - date: "2026-04-10"
-- note: "expanded §3 on Shestov"
-- - date: "2025-12-03" -- note optional per-entry
-- @
--
-- The two shapes normalize to the same list-of-'Revision' form here.
-- No other site code should branch on the frontmatter shape —
-- everything downstream reads this function's output.
--
-- Entries that fail to parse (missing @date:@, non-string values,
-- unexpected types) are silently dropped rather than erroring the
-- whole build; the site still compiles with a malformed @revised:@.
getRevisions :: Metadata -> [Revision]
getRevisions meta =
sortBy (flip (comparing revisionDateISO)) $
case KM.lookup "revised" meta of
Just (String t) -> [Revision (T.unpack t) Nothing]
Just (Array v) -> mapMaybe parseEntry (V.toList v)
_ -> []
where
parseEntry (Object o) = do
d <- getString =<< KM.lookup "date" o
return (Revision d (getString =<< KM.lookup "note" o))
parseEntry _ = Nothing
getString (String t) = Just (T.unpack t)
getString _ = Nothing
essayCtx :: Context String
essayCtx =
authorLinksField
@ -406,8 +676,10 @@ essayCtx =
<> versionHistoryCommitsField
<> dateField "date-created" "%-d %B %Y"
<> dateField "date-modified" "%-d %B %Y"
<> revisionDateFields
<> constField "math" "true"
<> tagLinksField "essay-tags"
<> keywordLinksField "essay-keywords"
<> siteCtx
-- ---------------------------------------------------------------------------

View File

@ -7,19 +7,25 @@
module Pagination
( pageSize
, sortAndGroup
, sortAndGroupAt
, blogPaginateRules
) where
import Hakyll
-- | Items per page across all paginated lists.
-- | Items per page across most paginated lists (e.g. the blog).
pageSize :: Int
pageSize = 20
-- | Sort identifiers by date (most recent first) and split into pages.
sortAndGroup :: (MonadMetadata m, MonadFail m) => [Identifier] -> m [[Identifier]]
sortAndGroup ids = paginateEvery pageSize <$> sortRecentFirst ids
sortAndGroup = sortAndGroupAt pageSize
-- | Like 'sortAndGroup' but with a caller-supplied page size. Used by
-- listings that want a different density than the blog default.
sortAndGroupAt :: (MonadMetadata m, MonadFail m) => Int -> [Identifier] -> m [[Identifier]]
sortAndGroupAt n ids = paginateEvery n <$> sortRecentFirst ids
-- | Page identifier for the blog index.
-- Page 1 → blog/index.html

View File

@ -2,11 +2,18 @@
{-# LANGUAGE OverloadedStrings #-}
module Site (rules) where
import Control.Monad (filterM, when)
import Data.List (isPrefixOf)
import Data.Maybe (catMaybes, fromMaybe)
import Control.Monad (filterM, forM, forM_, when)
import Data.Char (toUpper)
import Data.List (groupBy, isPrefixOf, sort, sortBy)
import Data.Map.Strict (Map)
import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
import Data.Ord (Down (..), comparing)
import Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as T
import System.Directory (listDirectory)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
import System.FilePath (takeDirectory, takeFileName, takeExtension, replaceExtension, (</>))
import Text.Read (readMaybe)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as LBS
@ -14,17 +21,40 @@ import qualified Data.Map.Strict as Map
import Hakyll
import Authors (buildAllAuthors, applyAuthorRules)
import Backlinks (backlinkRules)
import BibExtras (BibExtra (..), emptyBibExtra, firstAuthorSurname, parseBibExtras)
import Citations (renderBibliographyHtml)
import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler,
compositionCompiler)
compositionCompiler, sidecarCompiler)
import Catalog (musicCatalogCtx)
import Commonplace (commonplaceCtx)
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
contentKindField)
contentKindField, recentFirstByDisplay,
tagLinksFieldExcludingTopSegment)
import qualified Patterns as P
import Tags (buildAllTags, applyTagRules)
import Tags (buildAllTags, applyTagRules, sidecarIdentifier,
portalIntroField, portalTooltipField)
import Pagination (blogPaginateRules)
import Stats (statsRules)
-- | Home-page portal grid order. Canonical ordering authority for every
-- rendering of the eight portals (currently: the home page; future
-- consumers follow this list). Each entry is (display name, tag name);
-- the tag name is the key to everything else — URL (@/\<tag\>/@),
-- sidecar path (@content\/tag-meta\/\<tag\>.md@), and the Tags.hs
-- machinery that already keys off it. Edit this list to change order;
-- do not introduce an @order:@ frontmatter field on sidecars.
homePortals :: [(String, String)]
homePortals =
[ ("Research", "research")
, ("Nonfiction", "nonfiction")
, ("Fiction", "fiction")
, ("Poetry", "poetry")
, ("Music", "music")
, ("AI", "ai")
, ("Tech", "tech")
, ("Miscellany", "miscellany")
]
-- Poems inside collection subdirectories, excluding their index pages.
collectionPoems :: Pattern
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
@ -51,6 +81,25 @@ musicFeedConfig = FeedConfiguration
, feedRoot = "https://levineuwirth.org"
}
-- | Context for the home page. Extends 'pageCtx' with a @portals@
-- listField iterating 'homePortals' in order. Each item exposes
-- @$portal-name$@, @$portal-url$@, and (if the sidecar's tooltip is
-- populated) @$portal-tooltip$@, consumed by @templates/home.html@.
-- Tooltip lookup uses 'portalTooltipField' — the same function
-- 'Tags.applyTagRules' uses on per-tag pages — so the two surfaces
-- stay in lockstep on suppression and missing-file semantics.
homeCtx :: Context String
homeCtx = listField "portals" portalItemCtx portalItems <> pageCtx
where
portalItems :: Compiler [Item (String, String)]
portalItems = return (map (Item (fromFilePath "")) homePortals)
portalItemCtx :: Context (String, String)
portalItemCtx =
field "portal-name" (return . fst . itemBody)
<> field "portal-url" (\i -> return $ "/" ++ snd (itemBody i) ++ "/")
<> portalTooltipField (sidecarIdentifier . snd . itemBody)
rules :: Rules ()
rules = do
-- ---------------------------------------------------------------------------
@ -75,11 +124,27 @@ rules = do
authors <- buildAllAuthors
applyAuthorRules authors siteCtx
-- ---------------------------------------------------------------------------
-- Tag-meta sidecars — optional prose intros + tooltips for tag index
-- pages and the home-page portal grid. Matched but not routed: the
-- rendered body is exposed only via the @"body"@ snapshot and the
-- @tooltip:@ frontmatter key is read through 'getMetadata' by the
-- consumers (Tags.hs, home-page rule). Registered before tag rules so
-- snapshot loads during tag-page compilation find a compiled target.
--
-- Two-pattern union: Hakyll's @**/*@ glob requires at least one
-- subdirectory level, so flat sidecars (@content/tag-meta/nonfiction.md@)
-- and nested sidecars (@content/tag-meta/nonfiction/philosophy.md@) must
-- each be named by their own level-specific pattern.
-- ---------------------------------------------------------------------------
match ("content/tag-meta/*.md" .||. "content/tag-meta/**/*.md") $
compile sidecarCompiler
-- ---------------------------------------------------------------------------
-- Tag index pages
-- ---------------------------------------------------------------------------
tags <- buildAllTags
applyTagRules tags siteCtx
applyTagRules tags homePortals siteCtx
statsRules tags
-- Per-page JS files — authored alongside content in content/**/*.js.
@ -133,8 +198,8 @@ rules = do
match "content/index.md" $ do
route $ constRoute "index.html"
compile $ pageCompiler
>>= loadAndApplyTemplate "templates/home.html" pageCtx
>>= loadAndApplyTemplate "templates/default.html" pageCtx
>>= loadAndApplyTemplate "templates/home.html" homeCtx
>>= loadAndApplyTemplate "templates/default.html" homeCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
@ -380,13 +445,12 @@ rules = do
.||. allPoetry
.||. "content/music/*/index.md"
) .&&. hasNoVersion
items <- recentFirst =<< loadAll allContent
items <- recentFirstByDisplay =<< loadAll allContent
let itemCtx = contentKindField
<> dateField "date-iso" "%Y-%m-%d"
<> essayCtx
ctx = listField "recent-items" itemCtx (return items)
<> constField "title" "New"
<> constField "new-page" "true"
<> constField "list-page" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/new.html" ctx
@ -394,36 +458,72 @@ rules = do
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Library — comprehensive portal-grouped index of all content
-- Library — portal-grouped view over the /new.html dataset, deduplicated
-- by primary portal. An item's primary portal is the top segment of the
-- first tag in its frontmatter 'tags:' list whose top segment matches a
-- known portal (the eight in 'homePortals'). Items with no such tag are
-- silently dropped from the library (they remain on /new.html and on any
-- tag pages their frontmatter produces).
--
-- Each card uses the shared item-card partial, with cross-portal filings
-- rendered in the card's tag footer via 'tagLinksFieldExcludingScope',
-- scoped to the section's portal so the portal's own tag is suppressed.
-- ---------------------------------------------------------------------------
create ["library.html"] $ do
route idRoute
compile $ do
-- Helper: filter all content to items whose tags include a given portal.
-- A tag matches portal P if it equals "P" or starts with "P/".
let hasPortal p item = do
let 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.
primaryPortalOf item = do
meta <- getMetadata (itemIdentifier item)
let ts = fromMaybe [] (lookupStringList "tags" meta)
return $ any (\t -> t == p || (p ++ "/") `isPrefixOf` t) ts
return $ listToMaybe
[ p | t <- ts
, let p = takeWhile (/= '/') t
, p `elem` knownPortals ]
itemCtx = dateField "date-iso" "%Y-%m-%d" <> essayCtx
-- Per-section item context: kind badge, ISO date for datetime
-- attr, human-readable display date via essayCtx's dateDisplayField,
-- abstract via siteCtx's abstractField, and cross-portal filings
-- in the footer. Suppression is top-segment-based (hide every
-- tag under the section's portal, not just the exact match) so
-- a Research-section card doesn't re-list its research/* filings
-- alongside the section heading. @full-abstract@ unclamps the
-- card's 2-line abstract truncation — Library is the canonical
-- browsing surface and shows full abstracts.
portalItemCtx p =
contentKindField
<> tagLinksFieldExcludingTopSegment "item-tags" p
<> constField "full-abstract" "true"
<> essayCtx
portalList name p = listField name itemCtx $ do
essays <- loadAll (allEssays .&&. hasNoVersion)
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion)
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
recentFirst filtered
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 <ul>).
if null sorted
then noResult ("no items in portal " ++ p)
else return sorted
let ctx = portalList "ai-entries" "ai"
<> portalList "fiction-entries" "fiction"
<> portalList "miscellany-entries" "miscellany"
<> portalList "music-entries" "music"
-- 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 "research-entries" "research"
<> portalList "music-entries" "music"
<> portalList "ai-entries" "ai"
<> portalList "tech-entries" "tech"
<> portalList "miscellany-entries" "miscellany"
<> constField "title" "Library"
<> constField "library" "true"
<> siteCtx
@ -433,6 +533,130 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Bibliography — synthetic index + per-keyword pages (Phase 6b).
-- ---------------------------------------------------------------------------
-- Bibliography-meta sidecars: same shape as tag-meta, used by the
-- per-keyword pages for the prose intro and (future) tooltips. No
-- route; body snapshot consumed by the keyword rule.
match ("content/bibliography-meta/*.md" .||. "content/bibliography-meta/**/*.md") $
compile sidecarCompiler
-- Collect the universe of keywords at rule-gen time. Two sources:
-- * @keywords:@ fields across all @data/*.bib@ entries
-- * @keywords:@ frontmatter across all essays + blog + poetry +
-- fiction + music-composition pages
-- The union drives which @/bibliography/\<kw\>/@ pages get generated;
-- keywords with no referents anywhere are not synthesized into pages.
bibFilePaths <- preprocess $ do
files <- listDirectory "data"
return $ sort [ "data" </> f | f <- files, takeExtension f == ".bib" ]
bibExtrasAll <- preprocess $
Map.unions <$> mapM parseBibExtras bibFilePaths
let bibKwMap :: Map String [String]
bibKwMap = invertKeywordsBib bibExtrasAll
writingIds <- getMatches $ (P.essayPattern
.||. "content/blog/*.md"
.||. "content/fiction/*.md"
.||. P.poetryPattern
.||. "content/music/*/index.md")
.&&. hasNoVersion
writingKwPairs <- forM writingIds $ \ident -> do
meta <- getMetadata ident
let kws = readKeywords meta
return (ident, kws)
let writingKwMap :: Map String [Identifier]
writingKwMap = invertKeywordsWritings writingKwPairs
-- Keywords with at least one referent (writing OR bib entry).
allKeywords :: Set String
allKeywords = Set.union (Map.keysSet bibKwMap) (Map.keysSet writingKwMap)
-- Identifiers of bibliography-meta sidecars that exist on disk,
-- used to optionally inject $portal-intro$ + $portal-tooltip$ on
-- keyword pages when the author populates a sidecar.
bibMetaIds <- getMatches ("content/bibliography-meta/*.md"
.||. "content/bibliography-meta/**/*.md")
let bibMetaSet = Set.fromList bibMetaIds
-- /bibliography/index.html — every entry across every .bib file.
-- Sort: ascending by first-author surname, year-descending within
-- author (scholarly convention).
create ["bibliography/index.html"] $ do
route idRoute
compile $ do
let sortedKeys = bibliographyIndexOrder bibExtrasAll
grouped = groupByLetter bibExtrasAll sortedKeys
present = map fst grouped
html <- unsafeCompiler $ do
parts <- forM grouped $ \(letter, keys) -> do
body <- renderBibliographyHtml bibFilePaths bibExtrasAll keys
return (renderLetterHeader letter <> body)
return (renderBibliographyAlphabet present <> T.concat parts)
let ctx = constField "title" "Bibliography"
<> constField "bibliography-index" "true"
<> constField "bibliography-entries" (T.unpack html)
<> constField "library" "true" -- reuse flag to load library.css + item-card.css
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/bibliography-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- /bibliography/<keyword>/index.html for each keyword in the union.
forM_ (Set.toList allKeywords) $ \kw ->
create [fromFilePath ("bibliography/" ++ kw ++ "/index.html")] $ do
route idRoute
compile $ do
-- Writings section
let wIds = fromMaybe [] (Map.lookup kw writingKwMap)
writingItems <- case wIds of
[] -> return []
_ -> recentFirstByDisplay
=<< mapM (\i -> load i :: Compiler (Item String)) wIds
let writingsCtx
| null writingItems = mempty
| otherwise = listField "writings" (portalWritingCtx kw)
(return writingItems)
<> constField "has-writings" "true"
-- References section
let refKeys = keywordReferencesOrder bibExtrasAll kw
refsHtml <- unsafeCompiler $
renderBibliographyHtml bibFilePaths bibExtrasAll refKeys
let referencesCtx
| null refKeys = mempty
| otherwise =
constField "references" (T.unpack refsHtml)
-- Sidecar (tooltip + optional prose intro)
let sidecarId = bibliographyMetaIdentifier kw
hasSidecar = sidecarId `Set.member` bibMetaSet
scCtx <- if hasSidecar
then do
_ <- loadSnapshot sidecarId "body" :: Compiler (Item String)
return (portalIntroField (const sidecarId)
<> portalTooltipField (const sidecarId))
else return mempty
let ctx = constField "title" kw
<> constField "keyword" kw
<> constField "bibliography-keyword" "true"
<> constField "library" "true" -- reuse flag to load library.css + item-card.css
<> writingsCtx
<> referencesCtx
<> scCtx
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/bibliography-keyword.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Random page manifest — essays + blog posts only (no pagination/index pages)
-- ---------------------------------------------------------------------------
@ -550,3 +774,124 @@ epistemicEntry item = do
grab name meta = case lookupString name meta of
Just v -> Just (name, v)
Nothing -> Nothing
-- ---------------------------------------------------------------------------
-- Bibliography helpers (Phase 6b)
-- ---------------------------------------------------------------------------
-- | Invert a @citekey -> BibExtra@ map into @keyword -> [citekey]@
-- using each entry's 'bibKeywords' list as the inversion source.
invertKeywordsBib :: Map String BibExtra -> Map String [String]
invertKeywordsBib =
Map.fromListWith (++) . concatMap flatten . Map.toList
where
flatten (k, e) = [ (kw, [k]) | kw <- bibKeywords e ]
-- | Read a @keywords:@ frontmatter field, accepting YAML list and
-- comma-separated scalar forms. Matches 'Contexts.keywordLinksField'.
readKeywords :: Metadata -> [String]
readKeywords meta = filter (not . null) . map trimSpaces $
case lookupStringList "keywords" meta of
Just xs -> xs
Nothing -> case lookupString "keywords" meta of
Just s -> splitComma s
Nothing -> []
where
trimSpaces = dropWhile (== ' ') . reverse . dropWhile (== ' ') . reverse
splitComma s = case break (== ',') s of
(before, []) -> [before]
(before, _ : rest) -> before : splitComma rest
-- | Invert a @[(Identifier, [keyword])]@ association into
-- @keyword -> [Identifier]@. Identifiers can appear under multiple
-- keywords (multi-keyword items).
invertKeywordsWritings :: [(Identifier, [String])] -> Map String [Identifier]
invertKeywordsWritings pairs =
Map.fromListWith (++)
[ (kw, [ident]) | (ident, kws) <- pairs, kw <- kws ]
-- | Sort citekeys for the /bibliography/ index: ascending first-author
-- surname, year-descending within author.
bibliographyIndexOrder :: Map String BibExtra -> [String]
bibliographyIndexOrder extras =
map fst $ sortBy (comparing sortKey) (Map.toList extras)
where
sortKey (_, e) = (firstAuthorSurname e, Down (bibYear e))
-- | Sort citekeys for a /bibliography/<kw>/ References section: year
-- descending, then alphabetical by first-author surname within the
-- year. Filtered to only entries whose 'bibKeywords' includes @kw@.
keywordReferencesOrder :: Map String BibExtra -> String -> [String]
keywordReferencesOrder extras kw =
map fst $ sortBy (comparing sortKey)
[ (k, e) | (k, e) <- Map.toList extras, kw `elem` bibKeywords e ]
where
sortKey (_, e) = (Down (bibYear e), firstAuthorSurname e)
-- | Identifier of a bibliography-meta sidecar for a given keyword.
-- Parallels 'Tags.sidecarIdentifier' but under
-- @content/bibliography-meta/@ rather than @content/tag-meta/@.
bibliographyMetaIdentifier :: String -> Identifier
bibliographyMetaIdentifier kw =
fromFilePath ("content/bibliography-meta/" ++ kw ++ ".md")
-- | Group an alphabetically-sorted list of citekeys into letter buckets
-- keyed by the uppercase first letter of each entry's first-author
-- surname (falling back to the citekey's first letter when no author
-- was parsed — edge case, shouldn't occur in current content).
--
-- Because @sortedKeys@ is already alphabetical, 'Data.List.groupBy'
-- produces contiguous same-letter runs in one pass.
groupByLetter :: Map String BibExtra -> [String] -> [(Char, [String])]
groupByLetter extras sortedKeys =
let withLetters = [ (k, letterOf k) | k <- sortedKeys ]
grouped = groupBy (\(_, a) (_, b) -> a == b) withLetters
in [ (letter, map fst grp) | grp <- grouped
, (_, letter) : _ <- [grp] ]
where
letterOf k =
let e = fromMaybe emptyBibExtra (Map.lookup k extras)
in case firstAuthorSurname e of
(c:_) -> toUpper c
_ -> case k of
(c:_) -> toUpper c
_ -> '?'
-- | The AZ jump strip above the entry list. Present letters render as
-- anchor links to their section heading; absent letters render as
-- muted, non-linked spans so the alphabet reads as a complete strip
-- regardless of content gaps.
renderBibliographyAlphabet :: [Char] -> T.Text
renderBibliographyAlphabet presentList =
let present = Set.fromList presentList
cell c
| c `Set.member` present =
"<a href=\"#" <> T.singleton c
<> "\" class=\"alpha\">" <> T.singleton c <> "</a>"
| otherwise =
"<span class=\"alpha alpha-empty\" aria-hidden=\"true\">"
<> T.singleton c <> "</span>"
in "<nav class=\"bibliography-alphabet\" aria-label=\"Jump to letter\">"
<> T.concat (map cell ['A' .. 'Z'])
<> "</nav>\n"
-- | Letter-group heading inserted between entry groups on the
-- bibliography index. The @id@ is the anchor target for
-- 'renderBibliographyAlphabet' jump-links.
renderLetterHeader :: Char -> T.Text
renderLetterHeader c =
"<h2 id=\"" <> T.singleton c
<> "\" class=\"bibliography-letter\">"
<> T.singleton c <> "</h2>\n"
-- | Item-level context for Writings-section cards on a keyword page.
-- Same fields as the library's 'portalItemCtx' but with tag-footer
-- suppression tuned to the keyword context rather than a portal
-- (nothing to suppress here — writings keep their full tag list so
-- readers can see the item's own portal filings).
portalWritingCtx :: String -> Context String
portalWritingCtx _kw =
contentKindField
<> constField "full-abstract" "true"
<> essayCtx

View File

@ -12,16 +12,52 @@
-- research → /research/
-- research/mathematics → /research/mathematics/
-- typography → /typography/
--
-- Optional sidecar files at @content/tag-meta/<tag-path>.md@ supply
-- a per-tag @tooltip:@ (frontmatter) and prose intro (body). When
-- a sidecar exists, the tag page exposes @$portal-tooltip$@ and
-- @$portal-intro$@; when it is absent, both fields are noResult
-- and the corresponding @$if$@ blocks render nothing.
module Tags
( buildAllTags
, applyTagRules
, tagPaginationThreshold
, tagPageSize
, sidecarIdentifier
, portalIntroField
, portalTooltipField
, seeAlsoContext
) where
import Data.List (intercalate, nub)
import Data.Char (isSpace)
import Data.List (intercalate, isPrefixOf, nub, sort)
import Data.Maybe (fromMaybe, isNothing, maybeToList)
import Data.Set (Set)
import qualified Data.Set as Set
import Hakyll
import Pagination (sortAndGroup)
import Patterns (tagIndexable)
import Contexts (abstractField, tagLinksField)
import Pagination (sortAndGroupAt)
import Patterns (tagIndexable)
import Contexts (abstractField, contentKindField,
recentFirstByDisplay, revisionDateFields,
tagLinksFieldExcludingScope)
-- ---------------------------------------------------------------------------
-- Pagination policy
-- ---------------------------------------------------------------------------
-- | Maximum number of items for which a tag index ships its full list and
-- relies purely on the client-side 25/50/100/All toggle (matching
-- @\/new.html@). Tags above this threshold fall back to server-side
-- pagination at 'tagPageSize' per page; the count toggle then operates
-- within the current page only.
tagPaginationThreshold :: Int
tagPaginationThreshold = 150
-- | Page size used for server-side pagination on tag pages that exceed
-- 'tagPaginationThreshold'.
tagPageSize :: Int
tagPageSize = 100
-- ---------------------------------------------------------------------------
@ -57,6 +93,12 @@ tagFilePath tag = tag ++ "/index.html"
tagIdentifier :: String -> Identifier
tagIdentifier = fromFilePath . tagFilePath
-- | Identifier of the optional sidecar for a given tag.
-- "nonfiction" → content/tag-meta/nonfiction.md
-- "nonfiction/philosophy" → content/tag-meta/nonfiction/philosophy.md
sidecarIdentifier :: String -> Identifier
sidecarIdentifier tag = fromFilePath ("content/tag-meta/" ++ tag ++ ".md")
-- ---------------------------------------------------------------------------
-- Building the Tags index
@ -68,14 +110,173 @@ buildAllTags =
buildTagsWith getExpandedTags tagIndexable tagIdentifier
-- ---------------------------------------------------------------------------
-- Sidecar fields
-- ---------------------------------------------------------------------------
-- | Field exposing a sidecar's rendered HTML body as @$portal-intro$@.
-- Fails with 'noResult' when the sidecar body is empty or whitespace-only,
-- so @$if(portal-intro)$@ is false and the render site emits nothing.
--
-- Takes a function that yields the sidecar identifier from the current
-- item — this lets tag pages bind the sidecar statically at rule time
-- (@const sidecarId@) while the home-page portal listField derives it
-- per-item from the item body.
portalIntroField :: (Item a -> Identifier) -> Context a
portalIntroField getSidecarId = field "portal-intro" $ \item -> do
let sidecarId = getSidecarId item
html <- itemBody <$> loadSnapshot sidecarId "body"
if all isSpace html
then noResult "sidecar body is empty"
else return html
-- | Field exposing a sidecar's @tooltip:@ frontmatter value as
-- @$portal-tooltip$@. Fails with 'noResult' when the key is absent
-- or the value is empty / whitespace-only. Accepts a per-item
-- identifier resolver for the same reason as 'portalIntroField'.
portalTooltipField :: (Item a -> Identifier) -> Context a
portalTooltipField getSidecarId = field "portal-tooltip" $ \item -> do
let sidecarId = getSidecarId item
meta <- getMetadata sidecarId
case fmap trim (lookupString "tooltip" meta) of
Just t | not (null t) -> return t
_ -> noResult "no tooltip"
-- ---------------------------------------------------------------------------
-- See Also: parent / sibling / child computation
-- ---------------------------------------------------------------------------
-- | Direct parent of a tag path. @Nothing@ for top-level tags (portals).
-- "nonfiction/philosophy" → Just "nonfiction"
-- "nonfiction" → Nothing
-- "a/b/c" → Just "a/b"
parentOf :: String -> Maybe String
parentOf t =
let segs = wordsBy (== '/') t
in if length segs > 1
then Just (intercalate "/" (init segs))
else Nothing
-- | Number of @/@ characters in a tag path (i.e., depth - 1).
slashCount :: String -> Int
slashCount = length . filter (== '/')
-- | Parent / siblings / children of a scope tag, each filtered to tags that
-- appear in 'tagsMap' (i.e., have at least one item). Parent is returned
-- unconditionally — if it isn't in @tagsMap@ the See Also still links it,
-- since a parent with no direct items but some descendant items is still
-- a navigable aggregation page.
--
-- Sibling portals (scope is top-level) render in @portalOrder@. Sibling
-- subcategories (scope has a parent) render alphabetically. Children
-- always render alphabetically.
seeAlsoGroups :: [String] -- ^ canonical portal tag order
-> Tags -- ^ all tags for has-items filter
-> String -- ^ current scope
-> (Maybe String, [String], [String])
seeAlsoGroups portalOrder tags scope =
let tKeys = map fst (tagsMap tags)
mParent = parentOf scope
sibs = case mParent of
Nothing ->
-- Scope is a portal. Siblings: other portals in tagsMap,
-- emitted in portalOrder.
[ p | p <- portalOrder, p /= scope, p `elem` tKeys ]
Just parent ->
-- Scope is a subcategory. Siblings: other direct children
-- of the parent, alphabetical.
sort [ s | s <- tKeys
, parentOf s == Just parent
, s /= scope
]
kids = sort
[ c | c <- tKeys
, (scope ++ "/") `isPrefixOf` c
, slashCount c == slashCount scope + 1
]
in (mParent, sibs, kids)
-- ---------------------------------------------------------------------------
-- See Also: rendering into a Context
-- ---------------------------------------------------------------------------
-- | Display name for a tag path. Portals get their capitalized form from
-- @portalPairs@ (e.g., "Research"); subcategories display their raw tag
-- path (e.g., "nonfiction/philosophy").
displayNameFor :: [(String, String)] -> String -> String
displayNameFor portalPairs t =
fromMaybe t (lookup t (map (\(d, tg) -> (tg, d)) portalPairs))
-- | Item-level context for See Also entries. Body is a tag path string.
seeAlsoItemCtx :: [(String, String)] -> Context String
seeAlsoItemCtx portalPairs =
field "see-also-name" (\i -> return (displayNameFor portalPairs (itemBody i)))
<> field "see-also-url" (\i -> return $ "/" ++ itemBody i ++ "/")
<> portalTooltipField (sidecarIdentifier . itemBody)
-- | Full See Also context contribution for a scope: three listFields
-- (@see-also-parent@ at most one entry, @see-also-siblings@,
-- @see-also-children@) and a @has-see-also@ gate that fails when all
-- three are empty so the template's @$if(has-see-also)$@ suppresses
-- the entire @<nav>@ wrapper.
seeAlsoContext :: [(String, String)] -> Tags -> String -> Context String
seeAlsoContext portalPairs tags scope =
listField "see-also-parent" itemCtx (return (toItems (maybeToList mParent)))
<> listField "see-also-siblings" itemCtx (return (toItems sibs))
<> listField "see-also-children" itemCtx (return (toItems kids))
<> field "has-see-also" (\_ ->
if isNothing mParent && null sibs && null kids
then noResult "no see-also entries"
else return "true")
where
(mParent, sibs, kids) = seeAlsoGroups (map snd portalPairs) tags scope
itemCtx = seeAlsoItemCtx portalPairs
toItems = map (Item (fromFilePath ""))
-- | Context contribution for the current tag's sidecar, if one exists,
-- and eager registration of the snapshot dependency.
--
-- When a sidecar exists, the body snapshot is loaded unconditionally
-- (and discarded) so Hakyll's dependency tracker sees the edge
-- /tag page → sidecar body/ on every compile — even when the
-- rendered @$if(portal-intro)$@ gate is false because the body is
-- empty. Without this, the first build after populating a previously
-- empty sidecar would not re-render the tag page (the lazy field
-- load inside 'portalIntroField' never fires while the gate is
-- false, so the dep is never established).
--
-- Tags with no sidecar take the @mempty@ branch and register no
-- dependency, which is correct — there is nothing to depend on.
sidecarContext :: Set Identifier -> String -> Compiler (Context String)
sidecarContext sidecarSet tag
| sidecarId `Set.member` sidecarSet = do
_ <- loadSnapshot sidecarId "body" :: Compiler (Item String)
return ( portalIntroField (const sidecarId)
<> portalTooltipField (const sidecarId))
| otherwise = return mempty
where
sidecarId = sidecarIdentifier tag
-- ---------------------------------------------------------------------------
-- Tag index page rules
-- ---------------------------------------------------------------------------
tagItemCtx :: Context String
tagItemCtx =
dateField "date" "%-d %B %Y"
<> tagLinksField "item-tags"
-- | Item-level context used inside @$for(items)$@ on tag index pages.
-- Provides the fields consumed by @templates/partials/item-card.html@
-- (@$item-kind$@, @$date-iso$@, @$date-created$@, @$abstract$@,
-- @$item-tags$@) with tag-ribbon suppression scoped to the current tag.
tagItemCtx :: String -> Context String
tagItemCtx scope =
contentKindField
<> dateField "date-created" "%-d %B %Y"
<> dateField "date" "%-d %B %Y"
<> revisionDateFields
<> tagLinksFieldExcludingScope "item-tags" scope
<> abstractField
<> defaultContext
@ -86,23 +287,80 @@ tagPageId :: String -> PageNumber -> Identifier
tagPageId tag 1 = fromFilePath $ tag ++ "/index.html"
tagPageId tag n = fromFilePath $ tag ++ "/page/" ++ show n ++ "/index.html"
-- | Generate paginated index pages for every tag.
-- | Generate index pages for every tag. Tags with at most
-- 'tagPaginationThreshold' items render a single page with the full
-- list and rely on the client-side count toggle; larger tags fall
-- back to server-side pagination at 'tagPageSize' per page.
--
-- Each tag's context is augmented with its sidecar, if present, so
-- the template can render a @$portal-intro$@ section and (later)
-- a See Also block keyed on @$portal-tooltip$@.
--
-- @baseCtx@ should be @siteCtx@ (passed in to avoid a circular import).
applyTagRules :: Tags -> Context String -> Rules ()
applyTagRules tags baseCtx = tagsRules tags $ \tag pat -> do
paginate <- buildPaginateWith sortAndGroup pat (tagPageId tag)
applyTagRules :: Tags -> [(String, String)] -> Context String -> Rules ()
applyTagRules tags portalPairs baseCtx = do
-- Hakyll's @**/*@ glob needs a subdirectory level, so the flat and
-- nested sidecar paths each need their own pattern. Keep this list
-- in sync with the matching rule in Site.rules.
sidecarIds <- getMatches ("content/tag-meta/*.md" .||. "content/tag-meta/**/*.md")
let sidecarSet = Set.fromList sidecarIds
tagsRules tags $ \tag pat -> do
let itemCount = length (fromMaybe [] (lookup tag (tagsMap tags)))
saCtx = seeAlsoContext portalPairs tags tag
if itemCount <= tagPaginationThreshold
then clientPaginatedRule tag pat sidecarSet saCtx baseCtx
else serverPaginatedRule tag pat sidecarSet saCtx baseCtx
-- | Single-page tag index: the count toggle runs client-side against
-- the full list. No server-side pagination, no @page/N@ URLs.
clientPaginatedRule :: String
-> Pattern
-> Set Identifier
-> Context String -- ^ See Also contribution
-> Context String -- ^ base (siteCtx)
-> Rules ()
clientPaginatedRule tag pat sidecarSet saCtx baseCtx = do
route idRoute
compile $ do
scCtx <- sidecarContext sidecarSet tag
items <- recentFirstByDisplay =<< loadAll (pat .&&. hasNoVersion)
let ctx = listField "items" (tagItemCtx tag) (return items)
<> constField "tag" tag
<> constField "title" tag
<> constField "list-page" "true"
<> saCtx
<> scCtx
<> baseCtx
makeItem ""
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- | Server-side pagination at 'tagPageSize' per page. Previous/next
-- navigation renders via @templates/partials/paginate-nav.html@;
-- the count toggle operates within the current page only.
serverPaginatedRule :: String
-> Pattern
-> Set Identifier
-> Context String -- ^ See Also contribution
-> Context String -- ^ base (siteCtx)
-> Rules ()
serverPaginatedRule tag pat sidecarSet saCtx baseCtx = do
paginate <- buildPaginateWith (sortAndGroupAt tagPageSize) pat (tagPageId tag)
paginateRules paginate $ \pageNum pat' -> do
route idRoute
compile $ do
items <- recentFirst =<< loadAll (pat' .&&. hasNoVersion)
let ctx = listField "items" tagItemCtx (return items)
scCtx <- sidecarContext sidecarSet tag
items <- recentFirstByDisplay =<< loadAll (pat' .&&. hasNoVersion)
let ctx = listField "items" (tagItemCtx tag) (return items)
<> paginateContext paginate pageNum
<> constField "tag" tag
<> constField "title" tag
<> constField "tag" tag
<> constField "title" tag
<> constField "list-page" "true"
<> saCtx
<> scCtx
<> baseCtx
makeItem ""
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls

View File

@ -8,6 +8,10 @@ tags:
- nonfiction/philosophy
authors:
- "Levi Neuwirth | /me.html"
revised:
- date: "2026-04-17"
note: "expanded section on Shestov's divergence from Nietzsche"
- date: "2025-12-03"
---
*Notes from Underground* is widely admired as a cornerstone of literature, culture, and philosophy.[@Frank] Dostoevsky masterfully combines the narrative of the now-iconic Underground Man with implicit philosophical undercurrents. The result is a piece which is simultaneously a masterwork of fiction and a deeply influential philosophical treatise. Intriguingly, Dostoevsky never explicitly defines what the philosophical positions that he is advocating for or against are in his work, instead exclusively expressing them implicitly through storytelling. This paper will develop the argument that the primary philosophical undercurrent in *Notes from Underground* is a rejection of logic and science as end-alls in modern life through comparison of Dostoevsky's implicit revelation to the more explicitly articulated philosophical works of his contemporaries.

View File

@ -21,38 +21,3 @@ This website is *not* an academic homepage, nor a blog, nor a portfolio — thou
<a href="/memento-mori.html">Memento Mori</a><span class="hp-sep" aria-hidden="true">·</span><a href="/commonplace.html">Commonplace</a><span class="hp-sep" aria-hidden="true">·</span><a href="/colophon.html">Colophon</a><span class="hp-sep" aria-hidden="true">·</span><a href="/build/">Build</a><span class="hp-sep" aria-hidden="true">·</span><a href="#" data-random>Random</a>
</div>
<nav class="hp-portals">
<div class="hp-portal-item">
<a class="hp-portal-name" href="/research/">Research</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">formal and less formal inquiry</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/nonfiction/">Nonfiction</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">living documents and essays</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/fiction/">Fiction</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">stories and a novel in progress</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/poetry/">Poetry</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">verse, free and rigid</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/music/">Music</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">compositions, scores, and recordings</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/ai/">AI</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">on intelligence, artificial and otherwise</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/tech/">Tech</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">systems, tools, and craft</span>
</div>
<div class="hp-portal-item">
<a class="hp-portal-name" href="/miscellany/">Miscellany</a><span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">everything that defies category</span>
</div>
</nav>

3
content/tag-meta/ai.md Normal file
View File

@ -0,0 +1,3 @@
---
tooltip: "on intelligence, artificial and otherwise"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "stories and a novel in progress"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "everything that defies category"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "compositions, scores, and recordings"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "living documents and essays"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "verse, free and rigid"
---

View File

@ -0,0 +1,3 @@
---
tooltip: "formal and less formal inquiry"
---

3
content/tag-meta/tech.md Normal file
View File

@ -0,0 +1,3 @@
---
tooltip: "systems, tools, and craft"
---

View File

@ -27,6 +27,7 @@ executable site
Tags
Pagination
Citations
BibExtras
Filters
Filters.Typography
Filters.Sidenotes

View File

@ -1,35 +1,99 @@
/* new.css — Recently published content page */
/* item-card.css — shared list-page components (item cards, count toggle, See Also) */
.new-intro {
/* ============================================================
SEE ALSO
Supplementary navigation block at the top of a tag page
parent, siblings, and direct children of the current scope.
Quieter than the home portal grid; reads as a subordinate
wayfinding aid rather than primary content. Content flows
inline (name, em-dash, description) so long descriptions
wrap naturally on narrow screens instead of overflowing.
============================================================ */
.see-also {
margin: 0.15rem 0 1.5rem;
font-family: var(--font-sans);
font-size: var(--text-size-small);
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 2rem;
line-height: 1.45;
}
.see-also-list {
list-style: none;
margin: 0;
padding: 0;
}
.see-also-item {
padding: 0.03rem 0;
}
.see-also-name {
color: var(--text-muted);
text-decoration: underline;
text-decoration-color: var(--border);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.2em;
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
}
.see-also-name:hover {
color: var(--text);
text-decoration-color: var(--border-muted);
}
.see-also-dash {
color: var(--text-faint);
padding: 0 0.2em;
user-select: none;
}
.see-also-desc {
color: var(--text-faint);
}
/* Hierarchy markers parent up, children right-and-down.
Subtle enough that it reads at a glance without calling attention. */
.see-also-parent .see-also-name::before {
content: "↑ ";
color: var(--text-faint);
}
.see-also-child {
padding-left: 0.9rem;
}
.see-also-child .see-also-name::before {
content: "↳ ";
color: var(--text-faint);
padding-right: 0.1em;
}
/* ============================================================
COUNT CONTROL
============================================================ */
.new-controls {
.list-controls {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.new-controls-label {
.list-controls-label {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-faint);
}
.new-controls-options {
.list-controls-options {
display: flex;
gap: 0.3rem;
}
.new-count-btn {
.list-count-btn {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
@ -41,12 +105,12 @@
transition: border-color 0.1s, color 0.1s;
}
.new-count-btn:hover {
.list-count-btn:hover {
border-color: var(--border-muted);
color: var(--text);
}
.new-count-btn.is-active {
.list-count-btn.is-active {
border-color: var(--text-muted);
color: var(--text);
font-weight: 600;
@ -56,7 +120,7 @@
ENTRY LIST
============================================================ */
.new-list {
.item-card-list {
list-style: none;
margin: 0;
padding: 0;
@ -64,7 +128,7 @@
flex-direction: column;
}
.new-entry {
.item-card {
display: flex;
gap: 0.9rem;
align-items: flex-start;
@ -72,7 +136,7 @@
border-bottom: 1px solid var(--border);
}
.new-entry:first-child {
.item-card:first-child {
border-top: 1px solid var(--border);
}
@ -80,20 +144,16 @@
KIND BADGE
============================================================ */
.new-entry-kind {
.item-card-kind {
font-family: var(--font-sans);
font-size: 0.63rem;
font-size: 0.68rem;
font-variant: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
background: var(--bg-offset);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.5em;
flex-shrink: 0;
margin-top: 0.25em;
margin-top: 0.35em;
min-width: 5.5rem;
text-align: center;
text-align: left;
line-height: 1.6;
}
@ -101,19 +161,19 @@
ENTRY CONTENT
============================================================ */
.new-entry-main {
.item-card-main {
flex: 1;
min-width: 0;
}
.new-entry-header {
.item-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.new-entry-title {
.item-card-title {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text);
@ -121,12 +181,12 @@
line-height: 1.35;
}
.new-entry-title:hover {
.item-card-title:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.new-entry-date {
.item-card-date {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
@ -135,7 +195,7 @@
font-variant-numeric: tabular-nums;
}
.new-entry-abstract {
.item-card-abstract {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
@ -148,38 +208,56 @@
overflow: hidden;
}
/* Library (and any other surface that sets $full-abstract$) renders the
abstract unclamped. Resetting display drops the -webkit-box flex that
drives the clamp; overflow reset keeps long abstracts visible. */
.item-card-abstract.is-full {
display: block;
overflow: visible;
}
/* Revision note italicized line under the abstract carrying the
most-recent 'revised:' entry's note. Muted color, smaller than
body, matches the card's secondary-text register. */
.item-card-revision-note {
font-family: var(--font-sans);
font-size: 0.78rem;
color: var(--text-faint);
margin: 0.2rem 0 0;
line-height: 1.4;
}
/* ============================================================
MOBILE (540px)
The desktop layout packs [badge | title ... date] in a
single flex row. On narrow phones the date's nowrap width +
title min-content + gap overflows the viewport, pushing
titles off the right edge. Stack title-over-date and shrink
the badge so .new-entry-main gets real room.
the badge so .item-card-main gets real room.
============================================================ */
@media (max-width: 540px) {
.new-entry {
.item-card {
gap: 0.65rem;
}
.new-entry-kind {
.item-card-kind {
min-width: 0;
padding: 0.15em 0.4em;
font-size: 0.58rem;
font-size: 0.62rem;
}
.new-entry-header {
.item-card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
.new-entry-title {
.item-card-title {
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.new-entry-date {
.item-card-date {
font-size: 0.68rem;
}
}

View File

@ -1,63 +1,15 @@
/* library.css — Comprehensive site index page */
.library-intro {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
margin-top: 0.25rem;
margin-bottom: 1.25rem;
}
/* library.css Library + Bibliography page components, plus the
epistemic-filter UI that now lives on /search.html. */
/* ============================================================
CONTROLS (sort + filter)
FILTER UI
Originally the Library's sort+filter panel; the sort panel
and numeric filter were dropped in Phase 4 when the Library
unified onto item-cards. The filter panel itself remains in
use on /search.html, which reuses the same classes and
filter-panel markup.
============================================================ */
.library-controls {
margin-bottom: 2.5rem;
}
.library-controls-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.library-controls-label {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-faint);
}
.library-controls-options {
display: flex;
gap: 0.3rem;
}
.library-sort-btn {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 2px;
padding: 0.15em 0.55em;
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.library-sort-btn:hover {
border-color: var(--border-muted);
color: var(--text);
}
.library-sort-btn.is-active {
border-color: var(--text-muted);
color: var(--text);
font-weight: 600;
}
/* Filter toggle */
.library-filter-toggle {
font-family: var(--font-sans);
font-size: 0.75rem;
@ -81,10 +33,6 @@
font-weight: 600;
}
/* ============================================================
FILTER PANEL
============================================================ */
.library-filters {
border-top: 1px solid var(--border);
padding-top: 0.75rem;
@ -181,12 +129,6 @@
color: var(--text);
}
/* Filtered state */
.is-filtered {
display: none !important;
}
/* Search-page result filtering (applied via search-filters.js) */
.search-filtered {
@ -201,17 +143,10 @@
margin-bottom: 0.5rem;
}
/* Empty state message */
.library-empty {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-faint);
font-style: italic;
}
/* ============================================================
PORTAL SECTIONS
Shared by the Library and by /bibliography/<keyword>/ pages
for the Writings + References section headers.
============================================================ */
.library-section {
@ -225,10 +160,8 @@
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: lowercase;
font-weight: 600;
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
font-weight: 500;
margin-bottom: 0.5rem;
}
.library-section h2 a {
@ -241,50 +174,87 @@
}
/* ============================================================
ENTRY LIST
SEE ALSO compact chain (Phase 4)
Secondary navigation above the portal sections: New,
Commonplace, Colophon, Bibliography. Single-line `·`-separated
list, sans-serif, muted, reads as a utility row rather than
content.
============================================================ */
.library-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.library-entry-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.library-entry-title {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text);
text-decoration: none;
line-height: 1.3;
}
.library-entry-title:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.library-entry-date {
.library-see-also {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap;
flex-shrink: 0;
}
.library-entry-abstract {
font-family: var(--font-sans);
font-size: var(--text-size-small);
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
margin: 0.2rem 0 0;
margin: 0.25rem 0 2rem;
}
.library-see-also a {
color: var(--text-muted);
text-decoration: underline;
text-decoration-color: var(--border);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.2em;
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
}
.library-see-also a:hover {
color: var(--text);
text-decoration-color: var(--border-muted);
}
.library-see-also-sep {
color: var(--text-faint);
padding: 0 0.35em;
user-select: none;
}
/* ============================================================
BIBLIOGRAPHY INDEX alphabet jump strip + letter headers
Complete AZ row at top of /bibliography/ so the page reads
as a scannable reference. Absent letters render as muted
non-links so the strip stays visually complete without gaps.
============================================================ */
.bibliography-alphabet {
display: flex;
flex-wrap: wrap;
gap: 0.15em;
margin: 0.25rem 0 1.5rem;
font-family: var(--font-sans);
font-size: 0.85rem;
line-height: 1.4;
}
.bibliography-alphabet .alpha {
display: inline-block;
min-width: 1.2em;
padding: 0.05em 0.2em;
text-align: center;
color: var(--text-muted);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.bibliography-alphabet a.alpha:hover {
color: var(--text);
border-bottom-color: var(--border-muted);
}
.bibliography-alphabet .alpha-empty {
color: var(--text-faint);
opacity: 0.45;
cursor: default;
}
.bibliography-letter {
font-family: var(--font-sans);
font-size: 0.85rem;
font-variant: small-caps;
text-transform: lowercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 500;
margin: 1.5rem 0 0.5rem;
scroll-margin-top: 1rem;
}

View File

@ -0,0 +1,26 @@
(function () {
var STORAGE_KEY = 'list-page-count';
var DEFAULT = 25;
function applyCount(n) {
var entries = document.querySelectorAll('.item-card');
var limit = (n === 'all') ? Infinity : parseInt(n, 10);
entries.forEach(function (el, i) {
el.hidden = i >= limit;
});
document.querySelectorAll('.list-count-btn').forEach(function (btn) {
btn.classList.toggle('is-active', btn.dataset.count === String(n));
});
try { localStorage.setItem(STORAGE_KEY, n); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
var saved;
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
applyCount(saved || DEFAULT);
document.querySelectorAll('.list-count-btn').forEach(function (btn) {
btn.addEventListener('click', function () { applyCount(btn.dataset.count); });
});
});
}());

View File

@ -0,0 +1,9 @@
<main id="markdownBody">
<h1 class="page-title">Bibliography</h1>
<nav class="library-see-also" aria-label="See also">
<a href="/library.html">Library</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/new.html">New</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/commonplace.html">Commonplace</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/colophon.html">Colophon</a>
</nav>
$bibliography-entries$
</main>

View File

@ -0,0 +1,39 @@
<main id="markdownBody">
<h1 class="page-title">$title$</h1>
<nav class="see-also" aria-label="See also">
<ul class="see-also-list">
<li class="see-also-item see-also-parent">
<a class="see-also-name" href="/bibliography/">Bibliography</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true"></span><span class="see-also-desc">$portal-tooltip$</span>$endif$
</li>
</ul>
</nav>
<nav class="library-see-also" aria-label="Site navigation">
<a href="/library.html">Library</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/new.html">New</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/commonplace.html">Commonplace</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/colophon.html">Colophon</a>
</nav>
$if(portal-intro)$
<section class="portal-intro">
$portal-intro$
</section>
$endif$
$if(has-writings)$
<section class="library-section">
<h2 id="writings">Writings</h2>
<ul class="item-card-list">
$for(writings)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(references)$
<section class="library-section">
<h2 id="references">References</h2>
$references$
</section>
$endif$
</main>

View File

@ -1,3 +1,10 @@
<div id="markdownBody">
$body$
<nav class="hp-portals">
$for(portals)$
<div class="hp-portal-item">
<p><a class="hp-portal-name" href="$portal-url$">$portal-name$</a>$if(portal-tooltip)$<span class="hp-portal-dash" aria-hidden="true"></span><span class="hp-portal-desc">$portal-tooltip$</span>$endif$</p>
</div>
$endfor$
</nav>
</div>

View File

@ -1,489 +1,96 @@
<div id="markdownBody">
<h1 class="page-title">Library</h1>
<p class="library-intro">Everything on this site, organized by portal.</p>
<div class="library-controls">
<div class="library-controls-row">
<span class="library-controls-label">Sort by</span>
<div class="library-controls-options" role="group" aria-label="Sort order">
<button class="library-sort-btn" data-sort="date">date</button>
<button class="library-sort-btn" data-sort="title">title</button>
<button class="library-sort-btn" data-sort="score">trust</button>
</div>
<button class="library-filter-toggle" aria-expanded="false" aria-controls="library-filters">
Filters<span class="filter-toggle-badge"></span>
</button>
</div>
<div id="library-filters" class="library-filters" hidden>
<div class="filter-row">
<span class="filter-label" data-ep-term="status">status</span>
<div class="filter-options">
<button class="filter-btn filter-status-btn" data-value="draft">draft</button>
<button class="filter-btn filter-status-btn" data-value="working model">working model</button>
<button class="filter-btn filter-status-btn" data-value="durable">durable</button>
<button class="filter-btn filter-status-btn" data-value="refined">refined</button>
<button class="filter-btn filter-status-btn" data-value="superseded">superseded</button>
<button class="filter-btn filter-status-btn" data-value="deprecated">deprecated</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="confidence">confidence</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<input type="number" id="filter-confidence" class="filter-number" min="0" max="100" placeholder="&mdash;" aria-label="Minimum confidence" />
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="importance">importance</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-threshold-btn" data-field="importance" data-value="1">1</button>
<button class="filter-btn filter-threshold-btn" data-field="importance" data-value="2">2</button>
<button class="filter-btn filter-threshold-btn" data-field="importance" data-value="3">3</button>
<button class="filter-btn filter-threshold-btn" data-field="importance" data-value="4">4</button>
<button class="filter-btn filter-threshold-btn" data-field="importance" data-value="5">5</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="evidence">evidence</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-threshold-btn" data-field="evidence" data-value="1">1</button>
<button class="filter-btn filter-threshold-btn" data-field="evidence" data-value="2">2</button>
<button class="filter-btn filter-threshold-btn" data-field="evidence" data-value="3">3</button>
<button class="filter-btn filter-threshold-btn" data-field="evidence" data-value="4">4</button>
<button class="filter-btn filter-threshold-btn" data-field="evidence" data-value="5">5</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="trust">trust</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<input type="number" id="filter-score" class="filter-number" min="0" max="100" placeholder="&mdash;" aria-label="Minimum trust score" />
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="scope">scope</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-ordinal-btn" data-field="scope" data-index="0">personal</button>
<button class="filter-btn filter-ordinal-btn" data-field="scope" data-index="1">average</button>
<button class="filter-btn filter-ordinal-btn" data-field="scope" data-index="2">broad</button>
<button class="filter-btn filter-ordinal-btn" data-field="scope" data-index="3">civilizational</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="novelty">novelty</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-ordinal-btn" data-field="novelty" data-index="0">conventional</button>
<button class="filter-btn filter-ordinal-btn" data-field="novelty" data-index="1">moderate</button>
<button class="filter-btn filter-ordinal-btn" data-field="novelty" data-index="2">idiosyncratic</button>
<button class="filter-btn filter-ordinal-btn" data-field="novelty" data-index="3">innovative</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="practicality">practicality</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-ordinal-btn" data-field="practicality" data-index="0">abstract</button>
<button class="filter-btn filter-ordinal-btn" data-field="practicality" data-index="1">moderate</button>
<button class="filter-btn filter-ordinal-btn" data-field="practicality" data-index="2">high</button>
<button class="filter-btn filter-ordinal-btn" data-field="practicality" data-index="3">exceptional</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label" data-ep-term="stability">stability</span>
<div class="filter-options">
<span class="filter-prefix">&ge;</span>
<button class="filter-btn filter-ordinal-btn" data-field="stability" data-index="0">volatile</button>
<button class="filter-btn filter-ordinal-btn" data-field="stability" data-index="1">revising</button>
<button class="filter-btn filter-ordinal-btn" data-field="stability" data-index="2">fairly stable</button>
<button class="filter-btn filter-ordinal-btn" data-field="stability" data-index="3">stable</button>
<button class="filter-btn filter-ordinal-btn" data-field="stability" data-index="4">established</button>
</div>
</div>
<div class="filter-row filter-row-actions">
<button class="filter-clear-btn">Clear all</button>
</div>
</div>
</div>
<p class="library-empty is-filtered">No entries match the current filters.</p>
<nav class="library-see-also" aria-label="See also">
<a href="/new.html">New</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/commonplace.html">Commonplace</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/colophon.html">Colophon</a><span class="library-see-also-sep" aria-hidden="true">·</span><a href="/bibliography/">Bibliography</a>
</nav>
$if(research-entries)$
<section class="library-section">
<h2 id="research"><a href="/research/">Research</a></h2>
<ul class="library-list">$for(research-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(research-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(nonfiction-entries)$
<section class="library-section">
<h2 id="nonfiction"><a href="/nonfiction/">Nonfiction</a></h2>
<ul class="library-list">$for(nonfiction-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(nonfiction-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(fiction-entries)$
<section class="library-section">
<h2 id="fiction"><a href="/fiction/">Fiction</a></h2>
<ul class="library-list">$for(fiction-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(fiction-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(poetry-entries)$
<section class="library-section">
<h2 id="poetry"><a href="/poetry/">Poetry</a></h2>
<ul class="library-list">$for(poetry-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(poetry-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(music-entries)$
<section class="library-section">
<h2 id="music"><a href="/music/">Music</a></h2>
<ul class="library-list">$for(music-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(music-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(ai-entries)$
<section class="library-section">
<h2 id="ai"><a href="/ai/">AI</a></h2>
<ul class="library-list">$for(ai-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(ai-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(tech-entries)$
<section class="library-section">
<h2 id="tech"><a href="/tech/">Tech</a></h2>
<ul class="library-list">$for(tech-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(tech-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
$if(miscellany-entries)$
<section class="library-section">
<h2 id="miscellany"><a href="/miscellany/">Miscellany</a></h2>
<ul class="library-list">$for(miscellany-entries)$
<li class="library-entry" data-date="$date-iso$"$if(overall-score)$ data-score="$overall-score$"$endif$$if(status)$ data-status="$status$"$endif$$if(confidence)$ data-confidence="$confidence$"$endif$$if(importance)$ data-importance="$importance$"$endif$$if(evidence)$ data-evidence="$evidence$"$endif$$if(scope)$ data-scope="$scope$"$endif$$if(novelty)$ data-novelty="$novelty$"$endif$$if(practicality)$ data-practicality="$practicality$"$endif$$if(stability)$ data-stability="$stability$"$endif$>
<div class="library-entry-header">
<a class="library-entry-title" href="$url$">$title$</a>
<span class="library-entry-date">$date-created$</span>
</div>
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
</li>$endfor$</ul>
<ul class="item-card-list">
$for(miscellany-entries)$
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</section>
$endif$
</div>
<script>
(function () {
'use strict';
var KEY = 'library-state';
var SORT_MODES = { date: 1, title: 1, score: 1 };
var SCALES = {
scope: ['personal', 'average', 'broad', 'civilizational'],
novelty: ['conventional', 'moderate', 'idiosyncratic', 'innovative'],
practicality: ['abstract', 'moderate', 'high', 'exceptional'],
stability: ['volatile', 'revising', 'fairly stable', 'stable', 'established']
};
var state = {
sort: 'date',
status: [],
confidence: null,
importance: null,
evidence: null,
score: null,
scope: null,
novelty: null,
practicality: null,
stability: null
};
/* ---- Persistence ---- */
function load() {
try {
var raw = localStorage.getItem(KEY);
if (raw) {
var obj = JSON.parse(raw);
for (var k in state) {
if (obj.hasOwnProperty(k)) state[k] = obj[k];
}
} else {
var old = localStorage.getItem('library-sort');
if (old && SORT_MODES[old]) state.sort = old;
}
} catch (e) {}
}
function save() {
try {
localStorage.setItem(KEY, JSON.stringify(state));
localStorage.removeItem('library-sort');
} catch (e) {}
}
/* ---- Filtering ---- */
function passes(entry) {
var d = entry.dataset;
if (state.status.length) {
var s = (d.status || '').toLowerCase();
if (!s || state.status.indexOf(s) === -1) return false;
}
if (state.confidence !== null) {
if (!d.confidence || +d.confidence < state.confidence) return false;
}
if (state.importance !== null) {
if (!d.importance || +d.importance < state.importance) return false;
}
if (state.evidence !== null) {
if (!d.evidence || +d.evidence < state.evidence) return false;
}
if (state.score !== null) {
if (!d.score || +d.score < state.score) return false;
}
var ords = ['scope', 'novelty', 'practicality', 'stability'];
for (var i = 0; i < ords.length; i++) {
var k = ords[i];
if (state[k] !== null) {
var v = (d[k] || '').toLowerCase();
var idx = SCALES[k].indexOf(v);
if (idx === -1 || idx < state[k]) return false;
}
}
return true;
}
/* ---- Sorting ---- */
function titleOf(e) {
var el = e.querySelector('.library-entry-title');
return el ? el.textContent.trim().toLowerCase() : '';
}
function compare(a, b) {
var m = state.sort;
if (m === 'title') return titleOf(a).localeCompare(titleOf(b));
if (m === 'score') {
var hA = a.dataset.score !== undefined;
var hB = b.dataset.score !== undefined;
if (hA && !hB) return -1;
if (!hA && hB) return 1;
if (hA && hB) {
var diff = Number(b.dataset.score) - Number(a.dataset.score);
if (diff !== 0) return diff;
}
}
var da = a.dataset.date || '';
var db = b.dataset.date || '';
return da < db ? 1 : da > db ? -1 : 0;
}
/* ---- Apply ---- */
function apply() {
if (!SORT_MODES[state.sort]) state.sort = 'date';
document.querySelectorAll('.library-entry').forEach(function (e) {
e.classList.toggle('is-filtered', !passes(e));
});
document.querySelectorAll('.library-list').forEach(function (list) {
var entries = [].slice.call(list.querySelectorAll('.library-entry'));
entries.sort(compare);
entries.forEach(function (e) { list.appendChild(e); });
});
document.querySelectorAll('.library-section').forEach(function (sec) {
sec.classList.toggle('is-filtered', !sec.querySelector('.library-entry:not(.is-filtered)'));
});
var any = document.querySelector('.library-section:not(.is-filtered)');
var msg = document.querySelector('.library-empty');
if (msg) msg.classList.toggle('is-filtered', !!any);
syncUI();
save();
}
/* ---- UI sync ---- */
function activeCount() {
var n = 0;
if (state.status.length) n++;
var fields = ['confidence', 'importance', 'evidence', 'score',
'scope', 'novelty', 'practicality', 'stability'];
for (var i = 0; i < fields.length; i++) {
if (state[fields[i]] !== null) n++;
}
return n;
}
function syncUI() {
document.querySelectorAll('.library-sort-btn').forEach(function (btn) {
btn.classList.toggle('is-active', btn.dataset.sort === state.sort);
});
var badge = document.querySelector('.filter-toggle-badge');
var n = activeCount();
if (badge) badge.textContent = n ? ' (' + n + ')' : '';
document.querySelectorAll('.filter-status-btn').forEach(function (btn) {
btn.classList.toggle('is-active', state.status.indexOf(btn.dataset.value) !== -1);
});
var ci = document.getElementById('filter-confidence');
if (ci) ci.value = state.confidence !== null ? state.confidence : '';
var si = document.getElementById('filter-score');
if (si) si.value = state.score !== null ? state.score : '';
document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) {
btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.value);
});
document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) {
btn.classList.toggle('is-active', state[btn.dataset.field] === +btn.dataset.index);
});
}
/* ---- Init ---- */
document.addEventListener('DOMContentLoaded', function () {
load();
apply();
var panel = document.getElementById('library-filters');
var toggle = document.querySelector('.library-filter-toggle');
if (activeCount() > 0 && panel && toggle) {
panel.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
}
document.querySelectorAll('.library-sort-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
state.sort = btn.dataset.sort;
apply();
});
});
if (toggle && panel) {
toggle.addEventListener('click', function () {
var opening = panel.hidden;
panel.hidden = !opening;
toggle.setAttribute('aria-expanded', opening ? 'true' : 'false');
});
}
document.querySelectorAll('.filter-status-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var v = btn.dataset.value;
var i = state.status.indexOf(v);
if (i === -1) state.status.push(v);
else state.status.splice(i, 1);
apply();
});
});
document.querySelectorAll('.filter-threshold-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var f = btn.dataset.field;
var v = +btn.dataset.value;
state[f] = (state[f] === v) ? null : v;
apply();
});
});
document.querySelectorAll('.filter-ordinal-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var f = btn.dataset.field;
var idx = +btn.dataset.index;
state[f] = (state[f] === idx) ? null : idx;
apply();
});
});
['confidence', 'score'].forEach(function (field) {
var el = document.getElementById('filter-' + (field === 'score' ? 'score' : field));
if (!el) return;
el.addEventListener('input', function () {
var v = el.value.trim();
state[field] = v !== '' ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : null;
apply();
});
});
var clearBtn = document.querySelector('.filter-clear-btn');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.status = [];
state.confidence = null;
state.importance = null;
state.evidence = null;
state.score = null;
state.scope = null;
state.novelty = null;
state.practicality = null;
state.stability = null;
apply();
});
}
});
}());
</script>

View File

@ -1,54 +1,9 @@
<main id="markdownBody">
<h1 class="page-title">New</h1>
<div class="new-controls">
<span class="new-controls-label">Show</span>
<div class="new-controls-options" role="group" aria-label="Number of entries to show">
<button class="new-count-btn" data-count="25">25</button>
<button class="new-count-btn" data-count="50">50</button>
<button class="new-count-btn" data-count="100">100</button>
<button class="new-count-btn" data-count="all">All</button>
</div>
</div>
<ul class="new-list">
$partial("templates/partials/list-controls.html")$
<ul class="item-card-list">
$for(recent-items)$
<li class="new-entry">
<span class="new-entry-kind">$item-kind$</span>
<div class="new-entry-main">
<div class="new-entry-header">
<a class="new-entry-title" href="$url$">$title$</a>
<time class="new-entry-date" datetime="$date-iso$">$date-created$</time>
</div>
$if(abstract)$<p class="new-entry-abstract">$abstract$</p>$endif$
</div>
</li>
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
</main>
<script>
(function () {
var STORAGE_KEY = 'new-page-count';
var DEFAULT = 25;
function applyCount(n) {
var entries = document.querySelectorAll('.new-entry');
var limit = (n === 'all') ? Infinity : parseInt(n, 10);
entries.forEach(function (el, i) {
el.hidden = i >= limit;
});
document.querySelectorAll('.new-count-btn').forEach(function (btn) {
btn.classList.toggle('is-active', btn.dataset.count === String(n));
});
try { localStorage.setItem(STORAGE_KEY, n); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
var saved;
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
applyCount(saved || DEFAULT);
document.querySelectorAll('.new-count-btn').forEach(function (btn) {
btn.addEventListener('click', function () { applyCount(btn.dataset.count); });
});
});
}());
</script>

View File

@ -19,8 +19,9 @@ $if(home)$<title>Levi Neuwirth</title>$else$<title>$title$ — Levi Neuwirth</ti
<link rel="stylesheet" href="/css/images.css">
$if(home)$<link rel="stylesheet" href="/css/home.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/item-card.css">$endif$
$if(search)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(new-page)$<link rel="stylesheet" href="/css/new.css">$endif$
$if(list-page)$<link rel="stylesheet" href="/css/item-card.css">$endif$
$if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$
$if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$
$if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$
@ -43,6 +44,7 @@ $if(viz)$
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6" defer></script>
<script src="/js/viz.js" defer></script>
$endif$
$if(list-page)$<script src="/js/list-pagination.js" defer></script>$endif$
<script src="/js/collapse.js" defer></script>
<script src="/js/transclude.js" defer></script>
<script src="/js/copy.js" defer></script>

View File

@ -0,0 +1,16 @@
<li class="item-card">
<span class="item-card-kind">$item-kind$</span>
<div class="item-card-main">
<div class="item-card-header">
<a class="item-card-title" href="$url$">$title$</a>
<time class="item-card-date" datetime="$date-iso$">$date-display$$if(date-original)$ · revised from $date-original$$endif$</time>
</div>
$if(abstract)$<p class="item-card-abstract$if(full-abstract)$ is-full$endif$">$abstract$</p>$endif$
$if(revision-note)$<p class="item-card-revision-note"><em>$revision-note$</em></p>$endif$
$if(item-tags)$
<div class="item-card-tags">
$for(item-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
</div>
</li>

View File

@ -0,0 +1,9 @@
<div class="list-controls">
<span class="list-controls-label">Show</span>
<div class="list-controls-options" role="group" aria-label="Number of entries to show">
<button class="list-count-btn" data-count="25">25</button>
<button class="list-count-btn" data-count="50">50</button>
<button class="list-count-btn" data-count="100">100</button>
<button class="list-count-btn" data-count="all">All</button>
</div>
</div>

View File

@ -4,6 +4,11 @@
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
$if(essay-keywords)$
<div class="meta-row meta-keywords">
$for(essay-keywords)$<a class="meta-keyword" href="$kw-url$">$kw-name$</a>$endfor$
</div>
$endif$
$if(abstract)$
<div class="meta-row meta-description">
$abstract$

View File

@ -1,22 +1,36 @@
<main id="markdownBody">
<h1 class="page-title">$title$</h1>
$if(has-see-also)$
<nav class="see-also" aria-label="See also">
<ul class="see-also-list">
$for(see-also-parent)$
<li class="see-also-item see-also-parent">
<a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true"></span><span class="see-also-desc">$portal-tooltip$</span>$endif$
</li>
$endfor$
$for(see-also-siblings)$
<li class="see-also-item see-also-sibling">
<a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true"></span><span class="see-also-desc">$portal-tooltip$</span>$endif$
</li>
$endfor$
$for(see-also-children)$
<li class="see-also-item see-also-child">
<a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true"></span><span class="see-also-desc">$portal-tooltip$</span>$endif$
</li>
$endfor$
</ul>
</nav>
$endif$
$if(portal-intro)$
<section class="portal-intro">
$portal-intro$
</section>
$endif$
$if(items)$
<ul class="essay-list">
$partial("templates/partials/list-controls.html")$
<ul class="item-card-list">
$for(items)$
<li class="essay-list-item">
<a href="$url$">$title$</a>
$if(date)$
<span class="essay-list-date">$date$</span>
$endif$
$if(abstract)$
<p class="essay-list-abstract">$abstract$</p>
$endif$
$if(item-tags)$
<div class="essay-list-tags">
$for(item-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
</li>
$partial("templates/partials/item-card.html")$
$endfor$
</ul>
$else$