Navigation refactor
This commit is contained in:
parent
237380c4be
commit
908136b646
|
|
@ -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
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{-# LANGUAGE GHC2021 #-}
|
{-# LANGUAGE GHC2021 #-}
|
||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
-- | Citation processing pipeline.
|
-- | Citation processing pipeline.
|
||||||
--
|
--
|
||||||
|
|
@ -25,9 +26,13 @@
|
||||||
-- caller (read from Hakyll's own metadata via lookupStringList).
|
-- caller (read from Hakyll's own metadata via lookupStringList).
|
||||||
--
|
--
|
||||||
-- NOTE: Does not import Contexts to avoid cycles.
|
-- 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 Data.Map.Strict (Map)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.Maybe (fromMaybe, mapMaybe)
|
import Data.Maybe (fromMaybe, mapMaybe)
|
||||||
|
|
@ -38,6 +43,8 @@ import Text.Pandoc
|
||||||
import Text.Pandoc.Citeproc (processCitations)
|
import Text.Pandoc.Citeproc (processCitations)
|
||||||
import Text.Pandoc.Walk
|
import Text.Pandoc.Walk
|
||||||
|
|
||||||
|
import BibExtras (BibExtra (..), emptyBibExtra, parseBibExtras)
|
||||||
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Public API
|
-- Public API
|
||||||
|
|
@ -54,11 +61,81 @@ applyCitations :: [Text] -> Text -> Pandoc -> IO (Pandoc, Text, Text)
|
||||||
applyCitations frKeys bibPath doc
|
applyCitations frKeys bibPath doc
|
||||||
| not (hasCitations frKeys doc) = return (doc, "", "")
|
| not (hasCitations frKeys doc) = return (doc, "", "")
|
||||||
| otherwise = do
|
| 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
|
let doc1 = injectMeta frKeys bibPath doc
|
||||||
processed <- runIOorExplode $ processCitations doc1
|
processed <- runIOorExplode $ processCitations doc1
|
||||||
let (body, citedHtml, furtherHtml) = transformAndExtract frKeys processed
|
let (body, citedHtml, furtherHtml) = transformAndExtract extras frKeys processed
|
||||||
return (body, citedHtml, furtherHtml)
|
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
|
-- 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.
|
-- | Number citation Cite nodes and extract the bibliography div.
|
||||||
transformAndExtract :: [Text] -> Pandoc -> (Pandoc, Text, Text)
|
transformAndExtract :: Map String BibExtra -> [Text] -> Pandoc -> (Pandoc, Text, Text)
|
||||||
transformAndExtract frKeys doc@(Pandoc meta _) =
|
transformAndExtract extras frKeys doc@(Pandoc meta _) =
|
||||||
let citeOrder = collectCiteOrder doc -- keys, first-appearance order
|
let citeOrder = collectCiteOrder doc -- keys, first-appearance order
|
||||||
keyNums = Map.fromList (zip citeOrder [1 :: Int ..])
|
keyNums = Map.fromList (zip citeOrder [1 :: Int ..])
|
||||||
-- Replace Cite nodes with numbered superscript markers
|
-- Replace Cite nodes with numbered superscript markers
|
||||||
doc' = walk (transformInline keyNums) doc
|
doc' = walk (transformInline keyNums) doc
|
||||||
-- Pull bibliography div out of body and render to HTML
|
-- 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')
|
(pandocBlocks doc')
|
||||||
in (Pandoc meta bodyBlocks, citedHtml, furtherHtml)
|
in (Pandoc meta bodyBlocks, citedHtml, furtherHtml)
|
||||||
where
|
where
|
||||||
|
|
@ -164,12 +241,13 @@ markerHtml keys firstKey firstNum nums =
|
||||||
|
|
||||||
-- | Separate the @refs@ div from body blocks and render it to HTML.
|
-- | Separate the @refs@ div from body blocks and render it to HTML.
|
||||||
-- Returns @(bodyBlocks, citedHtml, furtherHtml)@.
|
-- Returns @(bodyBlocks, citedHtml, furtherHtml)@.
|
||||||
extractBibliography :: [Text] -> [Text] -> [Block] -> ([Block], Text, Text)
|
extractBibliography :: Map String BibExtra -> [Text] -> [Text] -> [Block]
|
||||||
extractBibliography citeOrder frKeys blocks =
|
-> ([Block], Text, Text)
|
||||||
|
extractBibliography extras citeOrder frKeys blocks =
|
||||||
let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks
|
let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks
|
||||||
(citedHtml, furtherHtml) = case refDivs of
|
(citedHtml, furtherHtml) = case refDivs of
|
||||||
[] -> ("", "")
|
[] -> ("", "")
|
||||||
(d:_) -> renderBibDiv citeOrder frKeys d
|
(d:_) -> renderBibDiv extras citeOrder frKeys d
|
||||||
in (bodyBlocks, citedHtml, furtherHtml)
|
in (bodyBlocks, citedHtml, furtherHtml)
|
||||||
where
|
where
|
||||||
isRefsDiv (Div ("refs", _, _) _) = True
|
isRefsDiv (Div ("refs", _, _) _) = True
|
||||||
|
|
@ -178,11 +256,17 @@ extractBibliography citeOrder frKeys blocks =
|
||||||
-- | Render the citeproc @refs@ Div into two HTML strings:
|
-- | Render the citeproc @refs@ Div into two HTML strings:
|
||||||
-- @(citedHtml, furtherHtml)@ — each is empty when there are no entries
|
-- @(citedHtml, furtherHtml)@ — each is empty when there are no entries
|
||||||
-- in that section. Headings are rendered in the template, not here.
|
-- in that section. Headings are rendered in the template, not here.
|
||||||
renderBibDiv :: [Text] -> [Text] -> Block -> (Text, Text)
|
--
|
||||||
renderBibDiv citeOrder _frKeys (Div _ children) =
|
-- Entry bodies are enhanced before numbering: title-wrapped as a
|
||||||
let keyIndex = Map.fromList (zip citeOrder [0 :: Int ..])
|
-- @.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) =
|
(citedEntries, furtherEntries) =
|
||||||
partition (isCited keyIndex) children
|
partition (isCited keyIndex) enhanced
|
||||||
sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries
|
sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries
|
||||||
numbered = zipWith addNumber [1..] sorted
|
numbered = zipWith addNumber [1..] sorted
|
||||||
citedHtml = renderEntries "csl-bib-body cite-refs" numbered
|
citedHtml = renderEntries "csl-bib-body cite-refs" numbered
|
||||||
|
|
@ -190,7 +274,81 @@ renderBibDiv citeOrder _frKeys (Div _ children) =
|
||||||
| null furtherEntries = ""
|
| null furtherEntries = ""
|
||||||
| otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries
|
| otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries
|
||||||
in (citedHtml, furtherHtml)
|
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 :: Map Text Int -> Block -> Bool
|
||||||
isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex
|
isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ module Compilers
|
||||||
, poetryCompiler
|
, poetryCompiler
|
||||||
, fictionCompiler
|
, fictionCompiler
|
||||||
, compositionCompiler
|
, compositionCompiler
|
||||||
|
, sidecarCompiler
|
||||||
, readerOpts
|
, readerOpts
|
||||||
, writerOpts
|
, writerOpts
|
||||||
) where
|
) where
|
||||||
|
|
@ -200,6 +201,28 @@ fictionCompiler = essayCompiler
|
||||||
compositionCompiler :: Compiler (Item String)
|
compositionCompiler :: Compiler (Item String)
|
||||||
compositionCompiler = essayCompiler
|
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.
|
-- | Compiler for simple pages: filters applied, no TOC snapshot.
|
||||||
pageCompiler :: Compiler (Item String)
|
pageCompiler :: Compiler (Item String)
|
||||||
pageCompiler = do
|
pageCompiler = do
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,26 @@ module Contexts
|
||||||
, contentKindField
|
, contentKindField
|
||||||
, abstractField
|
, abstractField
|
||||||
, tagLinksField
|
, tagLinksField
|
||||||
|
, tagLinksFieldExcludingScope
|
||||||
|
, tagLinksFieldExcludingTopSegment
|
||||||
|
, keywordLinksField
|
||||||
, authorLinksField
|
, authorLinksField
|
||||||
|
, dateDisplayField
|
||||||
|
, revisionDateFields
|
||||||
|
, recentFirstByDisplay
|
||||||
|
, Revision (..)
|
||||||
|
, getRevisions
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Aeson (Value (..))
|
import Data.Aeson (Value (..))
|
||||||
import qualified Data.Aeson.KeyMap as KM
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import Data.List (intercalate, isPrefixOf)
|
import Data.List (intercalate, isPrefixOf, sortBy)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe, mapMaybe)
|
||||||
|
import Data.Ord (comparing)
|
||||||
import Data.Time.Calendar (toGregorian)
|
import Data.Time.Calendar (toGregorian)
|
||||||
import Data.Time.Clock (getCurrentTime, utctDay)
|
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
|
||||||
import Data.Time.Format (formatTime, defaultTimeLocale)
|
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
|
||||||
import System.FilePath (takeDirectory, takeFileName)
|
import System.FilePath (takeDirectory, takeFileName)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
@ -152,6 +161,129 @@ tagLinksField fieldName = listFieldWith fieldName ctx $ \item ->
|
||||||
ctx = field "tag-name" (return . itemBody)
|
ctx = field "tag-name" (return . itemBody)
|
||||||
<> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/")
|
<> 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
|
-- Author links field
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -385,6 +517,144 @@ epistemicCtx =
|
||||||
-- Essay context
|
-- 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 :: Context String
|
||||||
essayCtx =
|
essayCtx =
|
||||||
authorLinksField
|
authorLinksField
|
||||||
|
|
@ -406,8 +676,10 @@ essayCtx =
|
||||||
<> versionHistoryCommitsField
|
<> versionHistoryCommitsField
|
||||||
<> dateField "date-created" "%-d %B %Y"
|
<> dateField "date-created" "%-d %B %Y"
|
||||||
<> dateField "date-modified" "%-d %B %Y"
|
<> dateField "date-modified" "%-d %B %Y"
|
||||||
|
<> revisionDateFields
|
||||||
<> constField "math" "true"
|
<> constField "math" "true"
|
||||||
<> tagLinksField "essay-tags"
|
<> tagLinksField "essay-tags"
|
||||||
|
<> keywordLinksField "essay-keywords"
|
||||||
<> siteCtx
|
<> siteCtx
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,25 @@
|
||||||
module Pagination
|
module Pagination
|
||||||
( pageSize
|
( pageSize
|
||||||
, sortAndGroup
|
, sortAndGroup
|
||||||
|
, sortAndGroupAt
|
||||||
, blogPaginateRules
|
, blogPaginateRules
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Hakyll
|
import Hakyll
|
||||||
|
|
||||||
|
|
||||||
-- | Items per page across all paginated lists.
|
-- | Items per page across most paginated lists (e.g. the blog).
|
||||||
pageSize :: Int
|
pageSize :: Int
|
||||||
pageSize = 20
|
pageSize = 20
|
||||||
|
|
||||||
-- | Sort identifiers by date (most recent first) and split into pages.
|
-- | Sort identifiers by date (most recent first) and split into pages.
|
||||||
sortAndGroup :: (MonadMetadata m, MonadFail m) => [Identifier] -> m [[Identifier]]
|
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 identifier for the blog index.
|
||||||
-- Page 1 → blog/index.html
|
-- Page 1 → blog/index.html
|
||||||
|
|
|
||||||
399
build/Site.hs
399
build/Site.hs
|
|
@ -2,11 +2,18 @@
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
module Site (rules) where
|
module Site (rules) where
|
||||||
|
|
||||||
import Control.Monad (filterM, when)
|
import Control.Monad (filterM, forM, forM_, when)
|
||||||
import Data.List (isPrefixOf)
|
import Data.Char (toUpper)
|
||||||
import Data.Maybe (catMaybes, fromMaybe)
|
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.Environment (lookupEnv)
|
||||||
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
|
import System.FilePath (takeDirectory, takeFileName, takeExtension, replaceExtension, (</>))
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
import qualified Data.Aeson as Aeson
|
import qualified Data.Aeson as Aeson
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LBS
|
import qualified Data.ByteString.Lazy.Char8 as LBS
|
||||||
|
|
@ -14,17 +21,40 @@ import qualified Data.Map.Strict as Map
|
||||||
import Hakyll
|
import Hakyll
|
||||||
import Authors (buildAllAuthors, applyAuthorRules)
|
import Authors (buildAllAuthors, applyAuthorRules)
|
||||||
import Backlinks (backlinkRules)
|
import Backlinks (backlinkRules)
|
||||||
|
import BibExtras (BibExtra (..), emptyBibExtra, firstAuthorSurname, parseBibExtras)
|
||||||
|
import Citations (renderBibliographyHtml)
|
||||||
import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler,
|
import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fictionCompiler,
|
||||||
compositionCompiler)
|
compositionCompiler, sidecarCompiler)
|
||||||
import Catalog (musicCatalogCtx)
|
import Catalog (musicCatalogCtx)
|
||||||
import Commonplace (commonplaceCtx)
|
import Commonplace (commonplaceCtx)
|
||||||
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
|
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
|
||||||
contentKindField)
|
contentKindField, recentFirstByDisplay,
|
||||||
|
tagLinksFieldExcludingTopSegment)
|
||||||
import qualified Patterns as P
|
import qualified Patterns as P
|
||||||
import Tags (buildAllTags, applyTagRules)
|
import Tags (buildAllTags, applyTagRules, sidecarIdentifier,
|
||||||
|
portalIntroField, portalTooltipField)
|
||||||
import Pagination (blogPaginateRules)
|
import Pagination (blogPaginateRules)
|
||||||
import Stats (statsRules)
|
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.
|
-- Poems inside collection subdirectories, excluding their index pages.
|
||||||
collectionPoems :: Pattern
|
collectionPoems :: Pattern
|
||||||
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
|
collectionPoems = "content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md"
|
||||||
|
|
@ -51,6 +81,25 @@ musicFeedConfig = FeedConfiguration
|
||||||
, feedRoot = "https://levineuwirth.org"
|
, 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 :: Rules ()
|
||||||
rules = do
|
rules = do
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -75,11 +124,27 @@ rules = do
|
||||||
authors <- buildAllAuthors
|
authors <- buildAllAuthors
|
||||||
applyAuthorRules authors siteCtx
|
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
|
-- Tag index pages
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
tags <- buildAllTags
|
tags <- buildAllTags
|
||||||
applyTagRules tags siteCtx
|
applyTagRules tags homePortals siteCtx
|
||||||
statsRules tags
|
statsRules tags
|
||||||
|
|
||||||
-- Per-page JS files — authored alongside content in content/**/*.js.
|
-- Per-page JS files — authored alongside content in content/**/*.js.
|
||||||
|
|
@ -133,8 +198,8 @@ rules = do
|
||||||
match "content/index.md" $ do
|
match "content/index.md" $ do
|
||||||
route $ constRoute "index.html"
|
route $ constRoute "index.html"
|
||||||
compile $ pageCompiler
|
compile $ pageCompiler
|
||||||
>>= loadAndApplyTemplate "templates/home.html" pageCtx
|
>>= loadAndApplyTemplate "templates/home.html" homeCtx
|
||||||
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
>>= loadAndApplyTemplate "templates/default.html" homeCtx
|
||||||
>>= relativizeUrls
|
>>= relativizeUrls
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -380,13 +445,12 @@ rules = do
|
||||||
.||. allPoetry
|
.||. allPoetry
|
||||||
.||. "content/music/*/index.md"
|
.||. "content/music/*/index.md"
|
||||||
) .&&. hasNoVersion
|
) .&&. hasNoVersion
|
||||||
items <- recentFirst =<< loadAll allContent
|
items <- recentFirstByDisplay =<< loadAll allContent
|
||||||
let itemCtx = contentKindField
|
let itemCtx = contentKindField
|
||||||
<> dateField "date-iso" "%Y-%m-%d"
|
|
||||||
<> essayCtx
|
<> essayCtx
|
||||||
ctx = listField "recent-items" itemCtx (return items)
|
ctx = listField "recent-items" itemCtx (return items)
|
||||||
<> constField "title" "New"
|
<> constField "title" "New"
|
||||||
<> constField "new-page" "true"
|
<> constField "list-page" "true"
|
||||||
<> siteCtx
|
<> siteCtx
|
||||||
makeItem ""
|
makeItem ""
|
||||||
>>= loadAndApplyTemplate "templates/new.html" ctx
|
>>= loadAndApplyTemplate "templates/new.html" ctx
|
||||||
|
|
@ -394,36 +458,72 @@ rules = do
|
||||||
>>= relativizeUrls
|
>>= 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
|
create ["library.html"] $ do
|
||||||
route idRoute
|
route idRoute
|
||||||
compile $ do
|
compile $ do
|
||||||
-- Helper: filter all content to items whose tags include a given portal.
|
let knownPortals = map snd homePortals
|
||||||
-- A tag matches portal P if it equals "P" or starts with "P/".
|
|
||||||
let hasPortal p item = do
|
-- 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)
|
meta <- getMetadata (itemIdentifier item)
|
||||||
let ts = fromMaybe [] (lookupStringList "tags" meta)
|
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
|
portalList name p = listField name (portalItemCtx p) $ do
|
||||||
essays <- loadAll (allEssays .&&. hasNoVersion)
|
essays <- loadAll (allEssays .&&. hasNoVersion)
|
||||||
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
||||||
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
||||||
poetry <- loadAll (allPoetry .&&. hasNoVersion)
|
poetry <- loadAll (allPoetry .&&. hasNoVersion)
|
||||||
filtered <- filterM (hasPortal p) (essays ++ posts ++ fiction ++ poetry)
|
music <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
|
||||||
recentFirst filtered
|
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"
|
-- Section order matches homePortals — single ordering authority.
|
||||||
<> portalList "fiction-entries" "fiction"
|
let ctx = portalList "research-entries" "research"
|
||||||
<> portalList "miscellany-entries" "miscellany"
|
|
||||||
<> portalList "music-entries" "music"
|
|
||||||
<> portalList "nonfiction-entries" "nonfiction"
|
<> portalList "nonfiction-entries" "nonfiction"
|
||||||
|
<> portalList "fiction-entries" "fiction"
|
||||||
<> portalList "poetry-entries" "poetry"
|
<> portalList "poetry-entries" "poetry"
|
||||||
<> portalList "research-entries" "research"
|
<> portalList "music-entries" "music"
|
||||||
|
<> portalList "ai-entries" "ai"
|
||||||
<> portalList "tech-entries" "tech"
|
<> portalList "tech-entries" "tech"
|
||||||
|
<> portalList "miscellany-entries" "miscellany"
|
||||||
<> constField "title" "Library"
|
<> constField "title" "Library"
|
||||||
<> constField "library" "true"
|
<> constField "library" "true"
|
||||||
<> siteCtx
|
<> siteCtx
|
||||||
|
|
@ -433,6 +533,130 @@ rules = do
|
||||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||||
>>= relativizeUrls
|
>>= 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)
|
-- 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
|
grab name meta = case lookupString name meta of
|
||||||
Just v -> Just (name, v)
|
Just v -> Just (name, v)
|
||||||
Nothing -> Nothing
|
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 A–Z 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
|
||||||
|
|
|
||||||
290
build/Tags.hs
290
build/Tags.hs
|
|
@ -12,16 +12,52 @@
|
||||||
-- research → /research/
|
-- research → /research/
|
||||||
-- research/mathematics → /research/mathematics/
|
-- research/mathematics → /research/mathematics/
|
||||||
-- typography → /typography/
|
-- 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
|
module Tags
|
||||||
( buildAllTags
|
( buildAllTags
|
||||||
, applyTagRules
|
, applyTagRules
|
||||||
|
, tagPaginationThreshold
|
||||||
|
, tagPageSize
|
||||||
|
, sidecarIdentifier
|
||||||
|
, portalIntroField
|
||||||
|
, portalTooltipField
|
||||||
|
, seeAlsoContext
|
||||||
) where
|
) 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 Hakyll
|
||||||
import Pagination (sortAndGroup)
|
import Pagination (sortAndGroupAt)
|
||||||
import Patterns (tagIndexable)
|
import Patterns (tagIndexable)
|
||||||
import Contexts (abstractField, tagLinksField)
|
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 :: String -> Identifier
|
||||||
tagIdentifier = fromFilePath . tagFilePath
|
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
|
-- Building the Tags index
|
||||||
|
|
@ -68,14 +110,173 @@ buildAllTags =
|
||||||
buildTagsWith getExpandedTags tagIndexable tagIdentifier
|
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
|
-- Tag index page rules
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
tagItemCtx :: Context String
|
-- | Item-level context used inside @$for(items)$@ on tag index pages.
|
||||||
tagItemCtx =
|
-- Provides the fields consumed by @templates/partials/item-card.html@
|
||||||
dateField "date" "%-d %B %Y"
|
-- (@$item-kind$@, @$date-iso$@, @$date-created$@, @$abstract$@,
|
||||||
<> tagLinksField "item-tags"
|
-- @$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
|
<> abstractField
|
||||||
<> defaultContext
|
<> defaultContext
|
||||||
|
|
||||||
|
|
@ -86,23 +287,80 @@ tagPageId :: String -> PageNumber -> Identifier
|
||||||
tagPageId tag 1 = fromFilePath $ tag ++ "/index.html"
|
tagPageId tag 1 = fromFilePath $ tag ++ "/index.html"
|
||||||
tagPageId tag n = fromFilePath $ tag ++ "/page/" ++ show n ++ "/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).
|
-- @baseCtx@ should be @siteCtx@ (passed in to avoid a circular import).
|
||||||
applyTagRules :: Tags -> Context String -> Rules ()
|
applyTagRules :: Tags -> [(String, String)] -> Context String -> Rules ()
|
||||||
applyTagRules tags baseCtx = tagsRules tags $ \tag pat -> do
|
applyTagRules tags portalPairs baseCtx = do
|
||||||
paginate <- buildPaginateWith sortAndGroup pat (tagPageId tag)
|
-- Hakyll's @**/*@ glob needs a subdirectory level, so the flat and
|
||||||
paginateRules paginate $ \pageNum pat' -> do
|
-- 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
|
route idRoute
|
||||||
compile $ do
|
compile $ do
|
||||||
items <- recentFirst =<< loadAll (pat' .&&. hasNoVersion)
|
scCtx <- sidecarContext sidecarSet tag
|
||||||
let ctx = listField "items" tagItemCtx (return items)
|
items <- recentFirstByDisplay =<< loadAll (pat .&&. hasNoVersion)
|
||||||
<> paginateContext paginate pageNum
|
let ctx = listField "items" (tagItemCtx tag) (return items)
|
||||||
<> constField "tag" tag
|
<> constField "tag" tag
|
||||||
<> constField "title" tag
|
<> constField "title" tag
|
||||||
|
<> constField "list-page" "true"
|
||||||
|
<> saCtx
|
||||||
|
<> scCtx
|
||||||
<> baseCtx
|
<> baseCtx
|
||||||
makeItem ""
|
makeItem ""
|
||||||
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
|
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
|
||||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||||
>>= relativizeUrls
|
>>= 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
|
||||||
|
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 "list-page" "true"
|
||||||
|
<> saCtx
|
||||||
|
<> scCtx
|
||||||
|
<> baseCtx
|
||||||
|
makeItem ""
|
||||||
|
>>= loadAndApplyTemplate "templates/tag-index.html" ctx
|
||||||
|
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||||
|
>>= relativizeUrls
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ tags:
|
||||||
- nonfiction/philosophy
|
- nonfiction/philosophy
|
||||||
authors:
|
authors:
|
||||||
- "Levi Neuwirth | /me.html"
|
- "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.
|
*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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "on intelligence, artificial and otherwise"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "stories and a novel in progress"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "everything that defies category"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "compositions, scores, and recordings"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "living documents and essays"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "verse, free and rigid"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "formal and less formal inquiry"
|
||||||
|
---
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
tooltip: "systems, tools, and craft"
|
||||||
|
---
|
||||||
|
|
@ -27,6 +27,7 @@ executable site
|
||||||
Tags
|
Tags
|
||||||
Pagination
|
Pagination
|
||||||
Citations
|
Citations
|
||||||
|
BibExtras
|
||||||
Filters
|
Filters
|
||||||
Filters.Typography
|
Filters.Typography
|
||||||
Filters.Sidenotes
|
Filters.Sidenotes
|
||||||
|
|
|
||||||
|
|
@ -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-family: var(--font-sans);
|
||||||
font-size: var(--text-size-small);
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
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
|
COUNT CONTROL
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.new-controls {
|
.list-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-controls-label {
|
.list-controls-label {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-controls-options {
|
.list-controls-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-count-btn {
|
.list-count-btn {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -41,12 +105,12 @@
|
||||||
transition: border-color 0.1s, color 0.1s;
|
transition: border-color 0.1s, color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-count-btn:hover {
|
.list-count-btn:hover {
|
||||||
border-color: var(--border-muted);
|
border-color: var(--border-muted);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-count-btn.is-active {
|
.list-count-btn.is-active {
|
||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -56,7 +120,7 @@
|
||||||
ENTRY LIST
|
ENTRY LIST
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.new-list {
|
.item-card-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -64,7 +128,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry {
|
.item-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -72,7 +136,7 @@
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry:first-child {
|
.item-card:first-child {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,20 +144,16 @@
|
||||||
KIND BADGE
|
KIND BADGE
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.new-entry-kind {
|
.item-card-kind {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.63rem;
|
font-size: 0.68rem;
|
||||||
font-variant: all-small-caps;
|
font-variant: all-small-caps;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
background: var(--bg-offset);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 0.15em 0.5em;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.25em;
|
margin-top: 0.35em;
|
||||||
min-width: 5.5rem;
|
min-width: 5.5rem;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,19 +161,19 @@
|
||||||
ENTRY CONTENT
|
ENTRY CONTENT
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.new-entry-main {
|
.item-card-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-header {
|
.item-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-title {
|
.item-card-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -121,12 +181,12 @@
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-title:hover {
|
.item-card-title:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-date {
|
.item-card-date {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -135,7 +195,7 @@
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-abstract {
|
.item-card-abstract {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-size-small);
|
font-size: var(--text-size-small);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -148,38 +208,56 @@
|
||||||
overflow: hidden;
|
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)
|
MOBILE (≤540px)
|
||||||
The desktop layout packs [badge | title ... date] in a
|
The desktop layout packs [badge | title ... date] in a
|
||||||
single flex row. On narrow phones the date's nowrap width +
|
single flex row. On narrow phones the date's nowrap width +
|
||||||
title min-content + gap overflows the viewport, pushing
|
title min-content + gap overflows the viewport, pushing
|
||||||
titles off the right edge. Stack title-over-date and shrink
|
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) {
|
@media (max-width: 540px) {
|
||||||
.new-entry {
|
.item-card {
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-kind {
|
.item-card-kind {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.15em 0.4em;
|
font-size: 0.62rem;
|
||||||
font-size: 0.58rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-header {
|
.item-card-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-title {
|
.item-card-title {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-entry-date {
|
.item-card-date {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,63 +1,15 @@
|
||||||
/* library.css — Comprehensive site index page */
|
/* library.css — Library + Bibliography page components, plus the
|
||||||
|
epistemic-filter UI that now lives on /search.html. */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
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 {
|
.library-filter-toggle {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
@ -81,10 +33,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
FILTER PANEL
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.library-filters {
|
.library-filters {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
|
|
@ -181,12 +129,6 @@
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filtered state */
|
|
||||||
|
|
||||||
.is-filtered {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search-page result filtering (applied via search-filters.js) */
|
/* Search-page result filtering (applied via search-filters.js) */
|
||||||
|
|
||||||
.search-filtered {
|
.search-filtered {
|
||||||
|
|
@ -201,17 +143,10 @@
|
||||||
margin-bottom: 0.5rem;
|
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
|
PORTAL SECTIONS
|
||||||
|
Shared by the Library and by /bibliography/<keyword>/ pages
|
||||||
|
for the Writings + References section headers.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
.library-section {
|
.library-section {
|
||||||
|
|
@ -225,10 +160,8 @@
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
padding-bottom: 0.4rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-section h2 a {
|
.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 {
|
.library-see-also {
|
||||||
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 {
|
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 0.72rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-faint);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.library-entry-abstract {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: var(--text-size-small);
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.5;
|
margin: 0.25rem 0 2rem;
|
||||||
margin: 0.2rem 0 0;
|
}
|
||||||
|
|
||||||
|
.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 A–Z 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
<div id="markdownBody">
|
<div id="markdownBody">
|
||||||
$body$
|
$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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,489 +1,96 @@
|
||||||
<div id="markdownBody">
|
<div id="markdownBody">
|
||||||
<h1 class="page-title">Library</h1>
|
<h1 class="page-title">Library</h1>
|
||||||
<p class="library-intro">Everything on this site, organized by portal.</p>
|
|
||||||
|
|
||||||
<div class="library-controls">
|
<nav class="library-see-also" aria-label="See also">
|
||||||
<div class="library-controls-row">
|
<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>
|
||||||
<span class="library-controls-label">Sort by</span>
|
</nav>
|
||||||
<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">≥</span>
|
|
||||||
<input type="number" id="filter-confidence" class="filter-number" min="0" max="100" placeholder="—" 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">≥</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">≥</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">≥</span>
|
|
||||||
<input type="number" id="filter-score" class="filter-number" min="0" max="100" placeholder="—" 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">≥</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">≥</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">≥</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">≥</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>
|
|
||||||
|
|
||||||
$if(research-entries)$
|
$if(research-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="research"><a href="/research/">Research</a></h2>
|
<h2 id="research"><a href="/research/">Research</a></h2>
|
||||||
<ul class="library-list">$for(research-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(research-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(nonfiction-entries)$
|
$if(nonfiction-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="nonfiction"><a href="/nonfiction/">Nonfiction</a></h2>
|
<h2 id="nonfiction"><a href="/nonfiction/">Nonfiction</a></h2>
|
||||||
<ul class="library-list">$for(nonfiction-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(nonfiction-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(fiction-entries)$
|
$if(fiction-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="fiction"><a href="/fiction/">Fiction</a></h2>
|
<h2 id="fiction"><a href="/fiction/">Fiction</a></h2>
|
||||||
<ul class="library-list">$for(fiction-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(fiction-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(poetry-entries)$
|
$if(poetry-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="poetry"><a href="/poetry/">Poetry</a></h2>
|
<h2 id="poetry"><a href="/poetry/">Poetry</a></h2>
|
||||||
<ul class="library-list">$for(poetry-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(poetry-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(music-entries)$
|
$if(music-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="music"><a href="/music/">Music</a></h2>
|
<h2 id="music"><a href="/music/">Music</a></h2>
|
||||||
<ul class="library-list">$for(music-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(music-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(ai-entries)$
|
$if(ai-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="ai"><a href="/ai/">AI</a></h2>
|
<h2 id="ai"><a href="/ai/">AI</a></h2>
|
||||||
<ul class="library-list">$for(ai-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(ai-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(tech-entries)$
|
$if(tech-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="tech"><a href="/tech/">Tech</a></h2>
|
<h2 id="tech"><a href="/tech/">Tech</a></h2>
|
||||||
<ul class="library-list">$for(tech-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(tech-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
$if(miscellany-entries)$
|
$if(miscellany-entries)$
|
||||||
<section class="library-section">
|
<section class="library-section">
|
||||||
<h2 id="miscellany"><a href="/miscellany/">Miscellany</a></h2>
|
<h2 id="miscellany"><a href="/miscellany/">Miscellany</a></h2>
|
||||||
<ul class="library-list">$for(miscellany-entries)$
|
<ul class="item-card-list">
|
||||||
<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$>
|
$for(miscellany-entries)$
|
||||||
<div class="library-entry-header">
|
$partial("templates/partials/item-card.html")$
|
||||||
<a class="library-entry-title" href="$url$">$title$</a>
|
$endfor$
|
||||||
<span class="library-entry-date">$date-created$</span>
|
</ul>
|
||||||
</div>
|
|
||||||
$if(abstract)$<p class="library-entry-abstract">$abstract$</p>$endif$
|
|
||||||
</li>$endfor$</ul>
|
|
||||||
</section>
|
</section>
|
||||||
$endif$
|
$endif$
|
||||||
|
|
||||||
</div>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,9 @@
|
||||||
<main id="markdownBody">
|
<main id="markdownBody">
|
||||||
<h1 class="page-title">New</h1>
|
<h1 class="page-title">New</h1>
|
||||||
<div class="new-controls">
|
$partial("templates/partials/list-controls.html")$
|
||||||
<span class="new-controls-label">Show</span>
|
<ul class="item-card-list">
|
||||||
<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">
|
|
||||||
$for(recent-items)$
|
$for(recent-items)$
|
||||||
<li class="new-entry">
|
$partial("templates/partials/item-card.html")$
|
||||||
<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>
|
|
||||||
$endfor$
|
$endfor$
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ $if(home)$<title>Levi Neuwirth</title>$else$<title>$title$ — Levi Neuwirth</ti
|
||||||
<link rel="stylesheet" href="/css/images.css">
|
<link rel="stylesheet" href="/css/images.css">
|
||||||
$if(home)$<link rel="stylesheet" href="/css/home.css">$endif$
|
$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/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(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(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$
|
||||||
$if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$
|
$if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$
|
||||||
$if(commonplace)$<link rel="stylesheet" href="/css/commonplace.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="https://cdn.jsdelivr.net/npm/vega-embed@6" defer></script>
|
||||||
<script src="/js/viz.js" defer></script>
|
<script src="/js/viz.js" defer></script>
|
||||||
$endif$
|
$endif$
|
||||||
|
$if(list-page)$<script src="/js/list-pagination.js" defer></script>$endif$
|
||||||
<script src="/js/collapse.js" defer></script>
|
<script src="/js/collapse.js" defer></script>
|
||||||
<script src="/js/transclude.js" defer></script>
|
<script src="/js/transclude.js" defer></script>
|
||||||
<script src="/js/copy.js" defer></script>
|
<script src="/js/copy.js" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
|
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
|
||||||
</div>
|
</div>
|
||||||
$endif$
|
$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)$
|
$if(abstract)$
|
||||||
<div class="meta-row meta-description">
|
<div class="meta-row meta-description">
|
||||||
$abstract$
|
$abstract$
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,37 @@
|
||||||
<main id="markdownBody">
|
<main id="markdownBody">
|
||||||
<h1 class="page-title">$title$</h1>
|
<h1 class="page-title">$title$</h1>
|
||||||
$if(items)$
|
$if(has-see-also)$
|
||||||
<ul class="essay-list">
|
<nav class="see-also" aria-label="See also">
|
||||||
$for(items)$
|
<ul class="see-also-list">
|
||||||
<li class="essay-list-item">
|
$for(see-also-parent)$
|
||||||
<a href="$url$">$title$</a>
|
<li class="see-also-item see-also-parent">
|
||||||
$if(date)$
|
<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$
|
||||||
<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>
|
</li>
|
||||||
$endfor$
|
$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)$
|
||||||
|
$partial("templates/partials/list-controls.html")$
|
||||||
|
<ul class="item-card-list">
|
||||||
|
$for(items)$
|
||||||
|
$partial("templates/partials/item-card.html")$
|
||||||
|
$endfor$
|
||||||
</ul>
|
</ul>
|
||||||
$else$
|
$else$
|
||||||
<p class="essay-list-empty">No items yet.</p>
|
<p class="essay-list-empty">No items yet.</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue