diff --git a/build/BibExtras.hs b/build/BibExtras.hs new file mode 100644 index 0000000..77c3120 --- /dev/null +++ b/build/BibExtras.hs @@ -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 diff --git a/build/Citations.hs b/build/Citations.hs index f7fbc8e..3850936 100644 --- a/build/Citations.hs +++ b/build/Citations.hs @@ -1,4 +1,5 @@ {-# LANGUAGE GHC2021 #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} -- | Citation processing pipeline. -- @@ -25,9 +26,13 @@ -- caller (read from Hakyll's own metadata via lookupStringList). -- -- NOTE: Does not import Contexts to avoid cycles. -module Citations (applyCitations) where +module Citations + ( applyCitations + -- * For synthetic bibliography pages (Phase 6b) + , renderBibliographyHtml + ) where -import Data.List (intercalate, nub, partition, sortBy) +import Data.List (intercalate, intersperse, nub, partition, sortBy) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe, mapMaybe) @@ -38,6 +43,8 @@ import Text.Pandoc import Text.Pandoc.Citeproc (processCitations) import Text.Pandoc.Walk +import BibExtras (BibExtra (..), emptyBibExtra, parseBibExtras) + -- --------------------------------------------------------------------------- -- Public API @@ -54,11 +61,81 @@ applyCitations :: [Text] -> Text -> Pandoc -> IO (Pandoc, Text, Text) applyCitations frKeys bibPath doc | not (hasCitations frKeys doc) = return (doc, "", "") | otherwise = do + -- Read custom fields (@file:@, @keywords:@) from the .bib file + -- in parallel with citeproc. These don't affect citation + -- resolution — they enhance the rendered bibliography entries. + extras <- parseBibExtras (T.unpack bibPath) let doc1 = injectMeta frKeys bibPath doc processed <- runIOorExplode $ processCitations doc1 - let (body, citedHtml, furtherHtml) = transformAndExtract frKeys processed + let (body, citedHtml, furtherHtml) = transformAndExtract extras frKeys processed return (body, citedHtml, furtherHtml) +-- | Render a standalone bibliography section from a list of citekeys and +-- a set of @.bib@ file paths. Used by the synthetic @\/bibliography\/@ +-- pages (Phase 6b) to produce CSL-formatted entries outside of any +-- essay's citation context. +-- +-- Given citekeys are passed to citeproc via a synthesized @nocite@ +-- metadata entry on an otherwise empty document; citeproc emits a +-- @refs@ Div whose children are the rendered entries. We then reorder +-- the children to match the caller-supplied @keys@ list (citeproc's +-- own ordering is overridden so callers control sort), enhance each +-- entry with the Phase 6a PDF-link and keyword-strip hooks, and +-- render to HTML wrapped in @\
@. +-- +-- @extras@ is the combined 'BibExtra' map for the same @.bib@ files; +-- passed in so that 'enhanceEntry' can consult @file:@ and +-- @keywords:@ without each entry re-parsing the files. +renderBibliographyHtml :: [FilePath] -- ^ .bib paths + -> Map String BibExtra -- ^ enhancement map + -> [String] -- ^ citekeys, in desired order + -> IO Text +renderBibliographyHtml _ _ [] = return "" +renderBibliographyHtml bibPaths extras keys = do + let doc = synthesizeNociteDoc bibPaths keys + processed <- runIOorExplode $ processCitations doc + let refsDivs = concatMap unwrapRefs (pandocBlocks processed) + ordered = reorderByKeys keys refsDivs + enhanced = map (enhanceEntry extras) ordered + return (renderEntries "csl-bib-body" enhanced) + where + pandocBlocks (Pandoc _ bs) = bs + unwrapRefs (Div ("refs", _, _) children) = children + unwrapRefs _ = [] + +-- | Build a Pandoc doc whose only citation-relevant content is a +-- @nocite@ metadata entry listing every supplied citekey. Runs +-- through 'processCitations' to emit a fully-formatted @refs@ Div +-- containing every entry. +synthesizeNociteDoc :: [FilePath] -> [String] -> Pandoc +synthesizeNociteDoc bibPaths keys = + let meta = Meta $ Map.fromList + [ ("bibliography", bibPathMeta bibPaths) + , ("csl", MetaString "data/chicago-notes.csl") + , ("nocite", nociteVal (map T.pack keys)) + ] + in Pandoc meta [] + where + bibPathMeta [p] = MetaString (T.pack p) + bibPathMeta ps = MetaList (map (MetaString . T.pack) ps) + + nociteVal ks = MetaInlines (intercalate [Space] (map mkCite ks)) + mkCite k = [Cite [Citation k [] [] AuthorInText 1 0] [Str ("@" <> k)]] + +-- | Reorder a list of @csl-entry@ Divs to match a requested key order. +-- Divs not in the key list (shouldn't happen in practice, but safe +-- by construction) drop to the end in their original order. +reorderByKeys :: [String] -> [Block] -> [Block] +reorderByKeys keys divs = + let divMap = Map.fromList [ (T.unpack (stripRefPrefix d), blk) + | blk@(Div (d, _, _) _) <- divs ] + found = mapMaybe (`Map.lookup` divMap) keys + leftovers = filter (\blk -> case blk of + Div (d, _, _) _ -> + T.unpack (stripRefPrefix d) `notElem` keys + _ -> True) divs + in found ++ leftovers + -- --------------------------------------------------------------------------- -- Detection @@ -108,14 +185,14 @@ insertMeta k v (Meta m) = Meta (Map.insert k v m) -- --------------------------------------------------------------------------- -- | Number citation Cite nodes and extract the bibliography div. -transformAndExtract :: [Text] -> Pandoc -> (Pandoc, Text, Text) -transformAndExtract frKeys doc@(Pandoc meta _) = +transformAndExtract :: Map String BibExtra -> [Text] -> Pandoc -> (Pandoc, Text, Text) +transformAndExtract extras frKeys doc@(Pandoc meta _) = let citeOrder = collectCiteOrder doc -- keys, first-appearance order keyNums = Map.fromList (zip citeOrder [1 :: Int ..]) -- Replace Cite nodes with numbered superscript markers doc' = walk (transformInline keyNums) doc -- Pull bibliography div out of body and render to HTML - (bodyBlocks, citedHtml, furtherHtml) = extractBibliography citeOrder frKeys + (bodyBlocks, citedHtml, furtherHtml) = extractBibliography extras citeOrder frKeys (pandocBlocks doc') in (Pandoc meta bodyBlocks, citedHtml, furtherHtml) where @@ -164,12 +241,13 @@ markerHtml keys firstKey firstNum nums = -- | Separate the @refs@ div from body blocks and render it to HTML. -- Returns @(bodyBlocks, citedHtml, furtherHtml)@. -extractBibliography :: [Text] -> [Text] -> [Block] -> ([Block], Text, Text) -extractBibliography citeOrder frKeys blocks = +extractBibliography :: Map String BibExtra -> [Text] -> [Text] -> [Block] + -> ([Block], Text, Text) +extractBibliography extras citeOrder frKeys blocks = let (bodyBlocks, refDivs) = partition (not . isRefsDiv) blocks (citedHtml, furtherHtml) = case refDivs of [] -> ("", "") - (d:_) -> renderBibDiv citeOrder frKeys d + (d:_) -> renderBibDiv extras citeOrder frKeys d in (bodyBlocks, citedHtml, furtherHtml) where isRefsDiv (Div ("refs", _, _) _) = True @@ -178,11 +256,17 @@ extractBibliography citeOrder frKeys blocks = -- | Render the citeproc @refs@ Div into two HTML strings: -- @(citedHtml, furtherHtml)@ — each is empty when there are no entries -- in that section. Headings are rendered in the template, not here. -renderBibDiv :: [Text] -> [Text] -> Block -> (Text, Text) -renderBibDiv citeOrder _frKeys (Div _ children) = - let keyIndex = Map.fromList (zip citeOrder [0 :: Int ..]) +-- +-- Entry bodies are enhanced before numbering: title-wrapped as a +-- @.pdf-link[data-pdf-src]@ when the .bib @file:@ field is set (so +-- popups.js's PDF hover preview fires), and a trailing +-- @\
@ appended when @keywords:@ is set. +renderBibDiv :: Map String BibExtra -> [Text] -> [Text] -> Block -> (Text, Text) +renderBibDiv extras citeOrder _frKeys (Div _ children) = + let enhanced = map (enhanceEntry extras) children + keyIndex = Map.fromList (zip citeOrder [0 :: Int ..]) (citedEntries, furtherEntries) = - partition (isCited keyIndex) children + partition (isCited keyIndex) enhanced sorted = sortBy (comparing (entryOrder keyIndex)) citedEntries numbered = zipWith addNumber [1..] sorted citedHtml = renderEntries "csl-bib-body cite-refs" numbered @@ -190,7 +274,81 @@ renderBibDiv citeOrder _frKeys (Div _ children) = | null furtherEntries = "" | otherwise = renderEntries "csl-bib-body further-reading-refs" furtherEntries in (citedHtml, furtherHtml) -renderBibDiv _ _ _ = ("", "") +renderBibDiv _ _ _ _ = ("", "") + + +-- --------------------------------------------------------------------------- +-- Bib entry enhancement (Phase 6a) +-- --------------------------------------------------------------------------- + +-- | Augment a single @csl-entry@ Div with the custom fields we parsed +-- from the .bib file. Other Blocks pass through unchanged. +enhanceEntry :: Map String BibExtra -> Block -> Block +enhanceEntry extras b@(Div attrs@(divId, _, _) blocks) = + let key = T.unpack (stripRefPrefix divId) + extra = fromMaybe emptyBibExtra (Map.lookup key extras) + withLink = case bibFile extra of + Nothing -> blocks + Just fp -> map (wrapFirstTitleBlock (T.pack fp)) blocks + withKw = withLink ++ keywordsBlocks (bibKeywords extra) + in case (bibFile extra, bibKeywords extra) of + (Nothing, []) -> b + _ -> Div attrs withKw +enhanceEntry _ b = b + +-- | In one block of an entry, wrap the first title-bearing inline +-- with a @.pdf-link@ anchor. Pandoc's CSL-formatted references +-- render the title as either a @Quoted@ (article titles in +-- Chicago-notes: "Paper Title") or an @Emph@ (book titles: +-- /Book Title/), and those are the first such inline in each +-- entry. We wrap at the block level and fall back to passing the +-- block through if no matching inline appears. +wrapFirstTitleBlock :: Text -> Block -> Block +wrapFirstTitleBlock href = \case + Para ils -> Para (wrapFirstTitle href ils) + Plain ils -> Plain (wrapFirstTitle href ils) + other -> other + +-- | Left-to-right scan: wrap the first title-bearing inline in a link +-- pointing at the PDF. Pandoc's CSL renderer emits article titles as +-- @Span@ nodes (whose rendered HTML wraps quotation marks around the +-- title text) and book titles as @Emph@; @Quoted@ appears in some +-- other CSL styles. First match of any of these is treated as the +-- title; subsequent ones pass through — journal names are also +-- @Emph@ on @\@article@ entries but come after the @Span@ title, so +-- the article case picks the right target. +wrapFirstTitle :: Text -> [Inline] -> [Inline] +wrapFirstTitle href inls = reverse . fst $ foldl step ([], False) inls + where + step (acc, True) inl = (inl:acc, True) + step (acc, False) inl = case inl of + Span _ _ -> (asPdfLink href [inl] : acc, True) + Quoted _ _ -> (asPdfLink href [inl] : acc, True) + Emph _ -> (asPdfLink href [inl] : acc, True) + _ -> (inl:acc, False) + +-- | Build the @.pdf-link[data-pdf-src]@ anchor that popups.js binds to. +-- See @static/js/popups.js:112@ for the matching selector. +asPdfLink :: Text -> [Inline] -> Inline +asPdfLink href content = + Link ("", ["pdf-link"], [("data-pdf-src", href)]) + content + (href, "") + +-- | Trailing keyword strip, linking each keyword to the future +-- @/bibliography/\/@ page. Returns @[]@ when the keyword +-- list is empty so the entry gets no extra block at all. +keywordsBlocks :: [String] -> [Block] +keywordsBlocks [] = [] +keywordsBlocks ks = + [ Div ("", ["bib-keywords"], []) + [Plain (intersperse (Str ", ") (map keywordLink ks))] + ] + where + keywordLink k = + Link ("", ["bib-keyword"], []) + [Str (T.pack k)] + (T.pack ("/bibliography/" ++ k ++ "/"), "") isCited :: Map Text Int -> Block -> Bool isCited keyIndex (Div (rid, _, _) _) = Map.member (stripRefPrefix rid) keyIndex diff --git a/build/Compilers.hs b/build/Compilers.hs index dfd3640..72da06c 100644 --- a/build/Compilers.hs +++ b/build/Compilers.hs @@ -7,6 +7,7 @@ module Compilers , poetryCompiler , fictionCompiler , compositionCompiler + , sidecarCompiler , readerOpts , writerOpts ) where @@ -200,6 +201,28 @@ fictionCompiler = essayCompiler compositionCompiler :: Compiler (Item String) compositionCompiler = essayCompiler +-- | Reduced pipeline for tag-meta sidecar markdown files. Applies +-- source-level preprocessors and AST filters (wikilinks, sidenotes, +-- smallcaps, links, etc.) so sidecar prose can use the same rich +-- markdown features as essays, then saves the rendered HTML under +-- the @"body"@ snapshot. Skips TOC, word count, reading time, and +-- citations — none of those belong in a portal intro. The item +-- itself is not routed; the body is consumed only via snapshot +-- loads by the tag-index rule and the home-page grid. +sidecarCompiler :: Compiler (Item String) +sidecarCompiler = do + body <- getResourceBody + let src = itemBody body + body' = itemSetBody (preprocessSource src) body + filePath <- getResourceFilePath + let srcDir = takeDirectory filePath + pandocItem <- readPandocWith readerOpts body' + pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem) + let pandocItem' = itemSetBody pandocFiltered pandocItem + let htmlItem = writePandocWith writerOpts pandocItem' + _ <- saveSnapshot "body" htmlItem + return htmlItem + -- | Compiler for simple pages: filters applied, no TOC snapshot. pageCompiler :: Compiler (Item String) pageCompiler = do diff --git a/build/Contexts.hs b/build/Contexts.hs index ee3a833..10a9cf3 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -11,17 +11,26 @@ module Contexts , contentKindField , abstractField , tagLinksField + , tagLinksFieldExcludingScope + , tagLinksFieldExcludingTopSegment + , keywordLinksField , authorLinksField + , dateDisplayField + , revisionDateFields + , recentFirstByDisplay + , Revision (..) + , getRevisions ) where import Data.Aeson (Value (..)) import qualified Data.Aeson.KeyMap as KM import qualified Data.Vector as V -import Data.List (intercalate, isPrefixOf) -import Data.Maybe (fromMaybe) +import Data.List (intercalate, isPrefixOf, sortBy) +import Data.Maybe (fromMaybe, mapMaybe) +import Data.Ord (comparing) import Data.Time.Calendar (toGregorian) -import Data.Time.Clock (getCurrentTime, utctDay) -import Data.Time.Format (formatTime, defaultTimeLocale) +import Data.Time.Clock (UTCTime, getCurrentTime, utctDay) +import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM) import System.FilePath (takeDirectory, takeFileName) import Text.Read (readMaybe) import qualified Data.Text as T @@ -152,6 +161,129 @@ tagLinksField fieldName = listFieldWith fieldName ctx $ \item -> ctx = field "tag-name" (return . itemBody) <> field "tag-url" (\i -> return $ "/" ++ itemBody i ++ "/") +-- | Variant of 'tagLinksField' that suppresses tags equal to or ancestral +-- to the given scope. Used on tag index pages to hide the redundant +-- filing ribbon entry for the current page's own scope. +-- +-- Suppression is equality-based on the scope plus its prefix-ancestors: +-- on @\/nonfiction\/@ (scope = @"nonfiction"@) only the literal +-- @"nonfiction"@ tag is hidden; @"nonfiction/philosophy"@ still renders. +-- On @\/nonfiction\/philosophy\/@ both @"nonfiction"@ and +-- @"nonfiction/philosophy"@ are hidden; sibling and cross-filed tags +-- remain. +-- +-- When every tag is suppressed, the field fails with 'noResult' so +-- @$if(...)$@ is false and the tag-ribbon wrapper is omitted entirely +-- instead of rendering as an empty @
@. +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/\/@, +-- 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)$\$kw-name$\$endfor$ +-- @ +keywordLinksField :: String -> Context a +keywordLinksField fieldName = listFieldWith fieldName ctx $ \item -> do + meta <- getMetadata (itemIdentifier item) + let kws = case lookupStringList "keywords" meta of + Just xs -> xs + Nothing -> case lookupString "keywords" meta of + Just s -> filter (not . null) (map trim (splitOn ',' s)) + Nothing -> [] + visible = filter (not . null . trim) kws + if null visible + then noResult "no keywords" + else return (map toItem visible) + where + toItem k = Item (fromFilePath (k ++ "/index.html")) k + ctx = field "kw-name" (return . itemBody) + <> field "kw-url" (\i -> return $ "/bibliography/" ++ itemBody i ++ "/") + + splitOn :: Char -> String -> [String] + splitOn c s = case break (== c) s of + (before, []) -> [before] + (before, _ : rest) -> before : splitOn c rest + -- --------------------------------------------------------------------------- -- Author links field -- --------------------------------------------------------------------------- @@ -385,6 +517,144 @@ epistemicCtx = -- Essay context -- --------------------------------------------------------------------------- +-- --------------------------------------------------------------------------- +-- Display date (revision-aware) +-- --------------------------------------------------------------------------- + +-- | Resolve an item's display date as a 'UTCTime': the most-recent +-- 'revisionDateISO' if the item has a 'revised:' entry, else the +-- creation date via 'getItemUTC'. Falls back to the creation date +-- when a revision's ISO string fails to parse. +-- +-- Shared by every revision-aware field below and by +-- 'recentFirstByDisplay', so they always agree on what the item's +-- display date is. +itemDisplayUTC :: Item a -> Compiler UTCTime +itemDisplayUTC item = do + meta <- getMetadata (itemIdentifier item) + case getRevisions meta of + (r:_) -> case parseTimeM True defaultTimeLocale "%Y-%m-%d" + (revisionDateISO r) :: Maybe UTCTime of + Just utc -> return utc + Nothing -> getItemUTC defaultTimeLocale (itemIdentifier item) + [] -> getItemUTC defaultTimeLocale (itemIdentifier item) + +-- | @$date-display$@ — the date shown next to an item in list renderings. +-- Most-recent revision date if the item has a 'revised:' entry, else +-- its creation date. Formatted "17 April 2026". +dateDisplayField :: Context String +dateDisplayField = field "date-display" $ \item -> + formatTime defaultTimeLocale "%-d %B %Y" <$> itemDisplayUTC item + +-- | @$date-iso$@ — ISO-8601 form of the display date, for +-- @
- diff --git a/content/tag-meta/ai.md b/content/tag-meta/ai.md new file mode 100644 index 0000000..81b2e15 --- /dev/null +++ b/content/tag-meta/ai.md @@ -0,0 +1,3 @@ +--- +tooltip: "on intelligence, artificial and otherwise" +--- diff --git a/content/tag-meta/fiction.md b/content/tag-meta/fiction.md new file mode 100644 index 0000000..d57ba51 --- /dev/null +++ b/content/tag-meta/fiction.md @@ -0,0 +1,3 @@ +--- +tooltip: "stories and a novel in progress" +--- diff --git a/content/tag-meta/miscellany.md b/content/tag-meta/miscellany.md new file mode 100644 index 0000000..9b411e7 --- /dev/null +++ b/content/tag-meta/miscellany.md @@ -0,0 +1,3 @@ +--- +tooltip: "everything that defies category" +--- diff --git a/content/tag-meta/music.md b/content/tag-meta/music.md new file mode 100644 index 0000000..984568e --- /dev/null +++ b/content/tag-meta/music.md @@ -0,0 +1,3 @@ +--- +tooltip: "compositions, scores, and recordings" +--- diff --git a/content/tag-meta/nonfiction.md b/content/tag-meta/nonfiction.md new file mode 100644 index 0000000..beda46f --- /dev/null +++ b/content/tag-meta/nonfiction.md @@ -0,0 +1,3 @@ +--- +tooltip: "living documents and essays" +--- diff --git a/content/tag-meta/poetry.md b/content/tag-meta/poetry.md new file mode 100644 index 0000000..1403a58 --- /dev/null +++ b/content/tag-meta/poetry.md @@ -0,0 +1,3 @@ +--- +tooltip: "verse, free and rigid" +--- diff --git a/content/tag-meta/research.md b/content/tag-meta/research.md new file mode 100644 index 0000000..699c101 --- /dev/null +++ b/content/tag-meta/research.md @@ -0,0 +1,3 @@ +--- +tooltip: "formal and less formal inquiry" +--- diff --git a/content/tag-meta/tech.md b/content/tag-meta/tech.md new file mode 100644 index 0000000..f9ec205 --- /dev/null +++ b/content/tag-meta/tech.md @@ -0,0 +1,3 @@ +--- +tooltip: "systems, tools, and craft" +--- diff --git a/levineuwirth.cabal b/levineuwirth.cabal index 98d5b56..77eef3a 100644 --- a/levineuwirth.cabal +++ b/levineuwirth.cabal @@ -27,6 +27,7 @@ executable site Tags Pagination Citations + BibExtras Filters Filters.Typography Filters.Sidenotes diff --git a/static/css/new.css b/static/css/item-card.css similarity index 52% rename from static/css/new.css rename to static/css/item-card.css index 8f3da37..bd191b9 100644 --- a/static/css/new.css +++ b/static/css/item-card.css @@ -1,35 +1,99 @@ -/* new.css — Recently published content page */ +/* item-card.css — shared list-page components (item cards, count toggle, See Also) */ -.new-intro { +/* ============================================================ + SEE ALSO + Supplementary navigation block at the top of a tag page — + parent, siblings, and direct children of the current scope. + Quieter than the home portal grid; reads as a subordinate + wayfinding aid rather than primary content. Content flows + inline (name, em-dash, description) so long descriptions + wrap naturally on narrow screens instead of overflowing. + ============================================================ */ + +.see-also { + margin: 0.15rem 0 1.5rem; font-family: var(--font-sans); - font-size: var(--text-size-small); + font-size: 0.85rem; color: var(--text-muted); - margin-bottom: 2rem; + line-height: 1.45; } +.see-also-list { + list-style: none; + margin: 0; + padding: 0; +} + +.see-also-item { + padding: 0.03rem 0; +} + +.see-also-name { + color: var(--text-muted); + text-decoration: underline; + text-decoration-color: var(--border); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; + transition: color var(--transition-fast), text-decoration-color var(--transition-fast); +} + +.see-also-name:hover { + color: var(--text); + text-decoration-color: var(--border-muted); +} + +.see-also-dash { + color: var(--text-faint); + padding: 0 0.2em; + user-select: none; +} + +.see-also-desc { + color: var(--text-faint); +} + +/* Hierarchy markers — parent up, children right-and-down. + Subtle enough that it reads at a glance without calling attention. */ +.see-also-parent .see-also-name::before { + content: "↑ "; + color: var(--text-faint); +} + +.see-also-child { + padding-left: 0.9rem; +} + +.see-also-child .see-also-name::before { + content: "↳ "; + color: var(--text-faint); + padding-right: 0.1em; +} + + + /* ============================================================ COUNT CONTROL ============================================================ */ -.new-controls { +.list-controls { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.75rem; } -.new-controls-label { +.list-controls-label { font-family: var(--font-sans); font-size: 0.75rem; color: var(--text-faint); } -.new-controls-options { +.list-controls-options { display: flex; gap: 0.3rem; } -.new-count-btn { +.list-count-btn { font-family: var(--font-sans); font-size: 0.75rem; color: var(--text-muted); @@ -41,12 +105,12 @@ transition: border-color 0.1s, color 0.1s; } -.new-count-btn:hover { +.list-count-btn:hover { border-color: var(--border-muted); color: var(--text); } -.new-count-btn.is-active { +.list-count-btn.is-active { border-color: var(--text-muted); color: var(--text); font-weight: 600; @@ -56,7 +120,7 @@ ENTRY LIST ============================================================ */ -.new-list { +.item-card-list { list-style: none; margin: 0; padding: 0; @@ -64,7 +128,7 @@ flex-direction: column; } -.new-entry { +.item-card { display: flex; gap: 0.9rem; align-items: flex-start; @@ -72,7 +136,7 @@ border-bottom: 1px solid var(--border); } -.new-entry:first-child { +.item-card:first-child { border-top: 1px solid var(--border); } @@ -80,20 +144,16 @@ KIND BADGE ============================================================ */ -.new-entry-kind { +.item-card-kind { font-family: var(--font-sans); - font-size: 0.63rem; + font-size: 0.68rem; font-variant: all-small-caps; letter-spacing: 0.07em; color: var(--text-faint); - background: var(--bg-offset); - border: 1px solid var(--border); - border-radius: 2px; - padding: 0.15em 0.5em; flex-shrink: 0; - margin-top: 0.25em; + margin-top: 0.35em; min-width: 5.5rem; - text-align: center; + text-align: left; line-height: 1.6; } @@ -101,19 +161,19 @@ ENTRY CONTENT ============================================================ */ -.new-entry-main { +.item-card-main { flex: 1; min-width: 0; } -.new-entry-header { +.item-card-header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } -.new-entry-title { +.item-card-title { font-family: var(--font-serif); font-size: 1rem; color: var(--text); @@ -121,12 +181,12 @@ line-height: 1.35; } -.new-entry-title:hover { +.item-card-title:hover { text-decoration: underline; text-underline-offset: 0.15em; } -.new-entry-date { +.item-card-date { font-family: var(--font-sans); font-size: 0.72rem; color: var(--text-faint); @@ -135,7 +195,7 @@ font-variant-numeric: tabular-nums; } -.new-entry-abstract { +.item-card-abstract { font-family: var(--font-sans); font-size: var(--text-size-small); color: var(--text-muted); @@ -148,38 +208,56 @@ overflow: hidden; } +/* Library (and any other surface that sets $full-abstract$) renders the + abstract unclamped. Resetting display drops the -webkit-box flex that + drives the clamp; overflow reset keeps long abstracts visible. */ +.item-card-abstract.is-full { + display: block; + overflow: visible; +} + +/* Revision note — italicized line under the abstract carrying the + most-recent 'revised:' entry's note. Muted color, smaller than + body, matches the card's secondary-text register. */ +.item-card-revision-note { + font-family: var(--font-sans); + font-size: 0.78rem; + color: var(--text-faint); + margin: 0.2rem 0 0; + line-height: 1.4; +} + /* ============================================================ MOBILE (≤540px) The desktop layout packs [badge | title ... date] in a single flex row. On narrow phones the date's nowrap width + title min-content + gap overflows the viewport, pushing titles off the right edge. Stack title-over-date and shrink - the badge so .new-entry-main gets real room. + the badge so .item-card-main gets real room. ============================================================ */ @media (max-width: 540px) { - .new-entry { + .item-card { gap: 0.65rem; } - .new-entry-kind { + .item-card-kind { min-width: 0; - padding: 0.15em 0.4em; - font-size: 0.58rem; + font-size: 0.62rem; } - .new-entry-header { + .item-card-header { flex-direction: column; align-items: flex-start; gap: 0.15rem; } - .new-entry-title { + .item-card-title { font-size: 0.95rem; overflow-wrap: anywhere; } - .new-entry-date { + .item-card-date { font-size: 0.68rem; } } diff --git a/static/css/library.css b/static/css/library.css index 3fe9e1b..55a835e 100644 --- a/static/css/library.css +++ b/static/css/library.css @@ -1,63 +1,15 @@ -/* library.css — Comprehensive site index page */ - -.library-intro { - font-family: var(--font-sans); - font-size: var(--text-size-small); - color: var(--text-muted); - margin-top: 0.25rem; - margin-bottom: 1.25rem; -} +/* library.css — Library + Bibliography page components, plus the + epistemic-filter UI that now lives on /search.html. */ /* ============================================================ - CONTROLS (sort + filter) + FILTER UI + Originally the Library's sort+filter panel; the sort panel + and numeric filter were dropped in Phase 4 when the Library + unified onto item-cards. The filter panel itself remains in + use on /search.html, which reuses the same classes and + filter-panel markup. ============================================================ */ -.library-controls { - margin-bottom: 2.5rem; -} - -.library-controls-row { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.library-controls-label { - font-family: var(--font-sans); - font-size: 0.75rem; - color: var(--text-faint); -} - -.library-controls-options { - display: flex; - gap: 0.3rem; -} - -.library-sort-btn { - font-family: var(--font-sans); - font-size: 0.75rem; - color: var(--text-muted); - background: none; - border: 1px solid var(--border); - border-radius: 2px; - padding: 0.15em 0.55em; - cursor: pointer; - transition: border-color 0.1s, color 0.1s; -} - -.library-sort-btn:hover { - border-color: var(--border-muted); - color: var(--text); -} - -.library-sort-btn.is-active { - border-color: var(--text-muted); - color: var(--text); - font-weight: 600; -} - -/* Filter toggle */ - .library-filter-toggle { font-family: var(--font-sans); font-size: 0.75rem; @@ -81,10 +33,6 @@ font-weight: 600; } -/* ============================================================ - FILTER PANEL - ============================================================ */ - .library-filters { border-top: 1px solid var(--border); padding-top: 0.75rem; @@ -181,12 +129,6 @@ color: var(--text); } -/* Filtered state */ - -.is-filtered { - display: none !important; -} - /* Search-page result filtering (applied via search-filters.js) */ .search-filtered { @@ -201,17 +143,10 @@ margin-bottom: 0.5rem; } -/* Empty state message */ - -.library-empty { - font-family: var(--font-sans); - font-size: var(--text-size-small); - color: var(--text-faint); - font-style: italic; -} - /* ============================================================ PORTAL SECTIONS + Shared by the Library and by /bibliography// pages + for the Writings + References section headers. ============================================================ */ .library-section { @@ -225,10 +160,8 @@ letter-spacing: 0.08em; color: var(--text-muted); text-transform: lowercase; - font-weight: 600; - margin-bottom: 0.75rem; - padding-bottom: 0.4rem; - border-bottom: 1px solid var(--border); + font-weight: 500; + margin-bottom: 0.5rem; } .library-section h2 a { @@ -241,50 +174,87 @@ } /* ============================================================ - ENTRY LIST + SEE ALSO — compact chain (Phase 4) + Secondary navigation above the portal sections: New, + Commonplace, Colophon, Bibliography. Single-line `·`-separated + list, sans-serif, muted, reads as a utility row rather than + content. ============================================================ */ -.library-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.9rem; -} - -.library-entry-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; -} - -.library-entry-title { - font-family: var(--font-serif); - font-size: 1rem; - color: var(--text); - text-decoration: none; - line-height: 1.3; -} - -.library-entry-title:hover { - text-decoration: underline; - text-underline-offset: 0.15em; -} - -.library-entry-date { +.library-see-also { font-family: var(--font-sans); - font-size: 0.72rem; - color: var(--text-faint); - white-space: nowrap; - flex-shrink: 0; -} - -.library-entry-abstract { - font-family: var(--font-sans); - font-size: var(--text-size-small); + font-size: 0.85rem; color: var(--text-muted); - line-height: 1.5; - margin: 0.2rem 0 0; + margin: 0.25rem 0 2rem; +} + +.library-see-also a { + color: var(--text-muted); + text-decoration: underline; + text-decoration-color: var(--border); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; + transition: color var(--transition-fast), text-decoration-color var(--transition-fast); +} + +.library-see-also a:hover { + color: var(--text); + text-decoration-color: var(--border-muted); +} + +.library-see-also-sep { + color: var(--text-faint); + padding: 0 0.35em; + user-select: none; +} + +/* ============================================================ + BIBLIOGRAPHY INDEX — alphabet jump strip + letter headers + Complete 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; } diff --git a/static/js/list-pagination.js b/static/js/list-pagination.js new file mode 100644 index 0000000..485f02c --- /dev/null +++ b/static/js/list-pagination.js @@ -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); }); + }); + }); +}()); diff --git a/templates/bibliography-index.html b/templates/bibliography-index.html new file mode 100644 index 0000000..2f16e13 --- /dev/null +++ b/templates/bibliography-index.html @@ -0,0 +1,9 @@ +
+

Bibliography

+ + + +$bibliography-entries$ +
diff --git a/templates/bibliography-keyword.html b/templates/bibliography-keyword.html new file mode 100644 index 0000000..649835d --- /dev/null +++ b/templates/bibliography-keyword.html @@ -0,0 +1,39 @@ +
+

$title$

+ + + + + +$if(portal-intro)$ +
+ $portal-intro$ +
+$endif$ + +$if(has-writings)$ +
+

Writings

+
    +$for(writings)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
+
+$endif$ + +$if(references)$ +
+

References

+$references$ +
+$endif$ +
diff --git a/templates/home.html b/templates/home.html index 590cd6f..8acfdb6 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,3 +1,10 @@
$body$ +
diff --git a/templates/library.html b/templates/library.html index d5a32d1..2eb008d 100644 --- a/templates/library.html +++ b/templates/library.html @@ -1,489 +1,96 @@

Library

-

Everything on this site, organized by portal.

-
-
- Sort by -
- - - -
- -
- -
- -

No entries match the current filters.

+ $if(research-entries)$

Research

-
    $for(research-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(research-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(nonfiction-entries)$

Nonfiction

-
    $for(nonfiction-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(nonfiction-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(fiction-entries)$

Fiction

-
    $for(fiction-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(fiction-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(poetry-entries)$

Poetry

-
    $for(poetry-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(poetry-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(music-entries)$

Music

-
    $for(music-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(music-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(ai-entries)$

AI

-
    $for(ai-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(ai-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(tech-entries)$

Tech

-
    $for(tech-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(tech-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$ $if(miscellany-entries)$

Miscellany

-
    $for(miscellany-entries)$ -
  • -
    - $title$ - -
    - $if(abstract)$

    $abstract$

    $endif$ -
  • $endfor$
+
    +$for(miscellany-entries)$ +$partial("templates/partials/item-card.html")$ +$endfor$ +
$endif$
- diff --git a/templates/new.html b/templates/new.html index 5a59016..54a414f 100644 --- a/templates/new.html +++ b/templates/new.html @@ -1,54 +1,9 @@

New

-
- Show -
- - - - -
-
-
    + $partial("templates/partials/list-controls.html")$ +
      $for(recent-items)$ -
    • - $item-kind$ -
      -
      - $title$ - -
      - $if(abstract)$

      $abstract$

      $endif$ -
      -
    • + $partial("templates/partials/item-card.html")$ $endfor$
- diff --git a/templates/partials/head.html b/templates/partials/head.html index a9e11e0..b19615d 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -19,8 +19,9 @@ $if(home)$Levi Neuwirth$else$$title$ — Levi Neuwirth</ti <link rel="stylesheet" href="/css/images.css"> $if(home)$<link rel="stylesheet" href="/css/home.css">$endif$ $if(library)$<link rel="stylesheet" href="/css/library.css">$endif$ +$if(library)$<link rel="stylesheet" href="/css/item-card.css">$endif$ $if(search)$<link rel="stylesheet" href="/css/library.css">$endif$ -$if(new-page)$<link rel="stylesheet" href="/css/new.css">$endif$ +$if(list-page)$<link rel="stylesheet" href="/css/item-card.css">$endif$ $if(memento-mori)$<link rel="stylesheet" href="/css/memento-mori.css">$endif$ $if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$ $if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$ @@ -43,6 +44,7 @@ $if(viz)$ <script src="https://cdn.jsdelivr.net/npm/vega-embed@6" defer></script> <script src="/js/viz.js" defer></script> $endif$ +$if(list-page)$<script src="/js/list-pagination.js" defer></script>$endif$ <script src="/js/collapse.js" defer></script> <script src="/js/transclude.js" defer></script> <script src="/js/copy.js" defer></script> diff --git a/templates/partials/item-card.html b/templates/partials/item-card.html new file mode 100644 index 0000000..a2f55db --- /dev/null +++ b/templates/partials/item-card.html @@ -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> diff --git a/templates/partials/list-controls.html b/templates/partials/list-controls.html new file mode 100644 index 0000000..38ad838 --- /dev/null +++ b/templates/partials/list-controls.html @@ -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> diff --git a/templates/partials/metadata.html b/templates/partials/metadata.html index 67d966a..a76bcf1 100644 --- a/templates/partials/metadata.html +++ b/templates/partials/metadata.html @@ -4,6 +4,11 @@ $for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$ </div> $endif$ + $if(essay-keywords)$ + <div class="meta-row meta-keywords"> + $for(essay-keywords)$<a class="meta-keyword" href="$kw-url$">$kw-name$</a>$endfor$ + </div> + $endif$ $if(abstract)$ <div class="meta-row meta-description"> $abstract$ diff --git a/templates/tag-index.html b/templates/tag-index.html index 526e199..25ffbf3 100644 --- a/templates/tag-index.html +++ b/templates/tag-index.html @@ -1,22 +1,36 @@ <main id="markdownBody"> <h1 class="page-title">$title$</h1> + $if(has-see-also)$ + <nav class="see-also" aria-label="See also"> + <ul class="see-also-list"> + $for(see-also-parent)$ + <li class="see-also-item see-also-parent"> + <a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true">—</span><span class="see-also-desc">$portal-tooltip$</span>$endif$ + </li> + $endfor$ + $for(see-also-siblings)$ + <li class="see-also-item see-also-sibling"> + <a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true">—</span><span class="see-also-desc">$portal-tooltip$</span>$endif$ + </li> + $endfor$ + $for(see-also-children)$ + <li class="see-also-item see-also-child"> + <a class="see-also-name" href="$see-also-url$">$see-also-name$</a>$if(portal-tooltip)$<span class="see-also-dash" aria-hidden="true">—</span><span class="see-also-desc">$portal-tooltip$</span>$endif$ + </li> + $endfor$ + </ul> + </nav> + $endif$ + $if(portal-intro)$ + <section class="portal-intro"> + $portal-intro$ + </section> + $endif$ $if(items)$ - <ul class="essay-list"> + $partial("templates/partials/list-controls.html")$ + <ul class="item-card-list"> $for(items)$ - <li class="essay-list-item"> - <a href="$url$">$title$</a> - $if(date)$ - <span class="essay-list-date">$date$</span> - $endif$ - $if(abstract)$ - <p class="essay-list-abstract">$abstract$</p> - $endif$ - $if(item-tags)$ - <div class="essay-list-tags"> - $for(item-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$ - </div> - $endif$ - </li> + $partial("templates/partials/item-card.html")$ $endfor$ </ul> $else$