major visual changes - dingbats, footer, etc

This commit is contained in:
Levi Neuwirth 2026-04-17 12:48:22 -04:00
parent 7fbc4f8935
commit 1a532f881b
30 changed files with 1408 additions and 449 deletions

View File

@ -1,24 +1,24 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Backlinks with context: build-time computation of which pages link to
-- each page, including the paragraph that contains each link.
-- each page, including the sentence that contains each link.
--
-- Architecture (dependency-correct, no circular deps):
--
-- 1. Each content file is compiled under @version "links"@: a lightweight
-- pass that parses the source, walks the AST block-by-block, and for
-- every internal link records the URL *and* the HTML of its surrounding
-- paragraph. The result is serialised as a JSON array of
-- @{url, context}@ objects.
-- pass that parses the source, walks the AST block-by-block, splits
-- each paragraph into sentences, and for every internal link records
-- the URL *and* the HTML of the sentence that contains it. The result
-- is serialised as a JSON array of @{url, context}@ objects.
--
-- 2. A @create ["data/backlinks.json"]@ rule loads all "links" items,
-- inverts the map, and serialises
-- @target → [{url, title, abstract, context}]@ as JSON.
--
-- 3. @backlinksField@ loads that JSON at page render time and injects
-- an HTML list showing each source's title and context paragraph.
-- The @load@ call establishes a proper Hakyll dependency so pages
-- recompile when backlinks change.
-- an HTML list showing each source's title and a quoted sentence of
-- context. The @load@ call establishes a proper Hakyll dependency so
-- pages recompile when backlinks change.
--
-- Dependency order (no cycles):
-- content "links" versions → data/backlinks.json → content default versions
@ -57,15 +57,23 @@ import qualified Patterns as P
data LinkEntry = LinkEntry
{ leUrl :: T.Text -- internal URL (as found in the AST)
, leContext :: String -- HTML of the surrounding paragraph
, leSentence :: String -- HTML of the sentence containing the link
, leParagraph :: String -- HTML of the full surrounding paragraph
} deriving (Show, Eq)
instance Aeson.ToJSON LinkEntry where
toJSON e = Aeson.object ["url" .= leUrl e, "context" .= leContext e]
toJSON e = Aeson.object
[ "url" .= leUrl e
, "sentence" .= leSentence e
, "paragraph" .= leParagraph e
]
instance Aeson.FromJSON LinkEntry where
parseJSON = Aeson.withObject "LinkEntry" $ \o ->
LinkEntry <$> o Aeson..: "url" <*> o Aeson..: "context"
LinkEntry
<$> o Aeson..: "url"
<*> o Aeson..: "sentence"
<*> o Aeson..: "paragraph"
-- ---------------------------------------------------------------------------
-- Backlink source record (stored in data/backlinks.json)
@ -75,7 +83,8 @@ data BacklinkSource = BacklinkSource
{ blUrl :: String
, blTitle :: String
, blAbstract :: String
, blContext :: String -- raw HTML of the paragraph containing the link
, blSentence :: String -- raw HTML of the sentence containing the link
, blParagraph :: String -- raw HTML of the full paragraph (hover popup)
} deriving (Show, Eq, Ord)
instance Aeson.ToJSON BacklinkSource where
@ -83,7 +92,8 @@ instance Aeson.ToJSON BacklinkSource where
[ "url" .= blUrl bl
, "title" .= blTitle bl
, "abstract" .= blAbstract bl
, "context" .= blContext bl
, "sentence" .= blSentence bl
, "paragraph" .= blParagraph bl
]
instance Aeson.FromJSON BacklinkSource where
@ -92,7 +102,8 @@ instance Aeson.FromJSON BacklinkSource where
<$> o Aeson..: "url"
<*> o Aeson..: "title"
<*> o Aeson..: "abstract"
<*> o Aeson..: "context"
<*> o Aeson..: "sentence"
<*> o Aeson..: "paragraph"
-- ---------------------------------------------------------------------------
-- Writer options for context rendering
@ -138,8 +149,57 @@ renderInlines inlines =
where
doc = Pandoc nullMeta [Plain inlines]
-- | Extract @(internal-url, context-html)@ pairs from a Pandoc document.
-- Context is the HTML of the immediate surrounding paragraph.
-- | Split a list of inlines into sentences by terminator punctuation.
--
-- A @Str@ whose last non-closing-punctuation character is @.@, @!@, or @?@
-- ends a sentence when followed by @Space@, @SoftBreak@, @LineBreak@, or
-- end-of-list. Closing quote/bracket characters after the terminator
-- (e.g. @right-double-quote@, @)@, @]@) are tolerated.
--
-- The splitter is deliberately simple: abbreviations like "e.g." or "Dr."
-- will cause occasional over-splitting. That is acceptable for backlink
-- previews, where a slightly short context is preferable to the complexity
-- of abbreviation detection.
splitSentences :: [Inline] -> [[Inline]]
splitSentences = go []
where
go acc [] = if null acc then [] else [reverse acc]
go acc (tok : rest)
| isTerminator tok && leadingBreak rest =
reverse (tok : acc) : go [] (dropLeadingBreak rest)
| otherwise =
go (tok : acc) rest
isTerminator :: Inline -> Bool
isTerminator (Str s) = endsWithTerminator s
isTerminator _ = False
endsWithTerminator :: T.Text -> Bool
endsWithTerminator t =
case T.unsnoc (T.dropWhileEnd isClosingPunct t) of
Just (_, c) -> c == '.' || c == '!' || c == '?'
Nothing -> False
isClosingPunct :: Char -> Bool
isClosingPunct c = c `elem` (")]\"'\x201D\x2019" :: String)
leadingBreak :: [Inline] -> Bool
leadingBreak [] = True
leadingBreak (Space : _) = True
leadingBreak (SoftBreak : _) = True
leadingBreak (LineBreak : _) = True
leadingBreak _ = False
dropLeadingBreak :: [Inline] -> [Inline]
dropLeadingBreak (Space : xs) = xs
dropLeadingBreak (SoftBreak : xs) = xs
dropLeadingBreak (LineBreak : xs) = xs
dropLeadingBreak xs = xs
-- | Extract @LinkEntry@ records from a Pandoc document.
-- For every internal link in a paragraph, emit an entry carrying the HTML
-- of the sentence containing the link (default display) and the HTML of
-- the full paragraph (hover/popup context).
-- Recurses into Div, BlockQuote, BulletList, and OrderedList.
extractLinksWithContext :: Pandoc -> [LinkEntry]
extractLinksWithContext (Pandoc _ blocks) = concatMap go blocks
@ -152,15 +212,19 @@ extractLinksWithContext (Pandoc _ blocks) = concatMap go blocks
go (OrderedList _ items) = concatMap (concatMap go) items
go _ = []
-- For a Para block: find all internal links it contains, and for each
-- return a LinkEntry with the paragraph's HTML as context.
paraEntries :: [Inline] -> [LinkEntry]
paraEntries inlines =
let urls = filter isPageLink (query getUrl inlines)
let paraHtml = renderInlines inlines
sentences = splitSentences inlines
in concatMap (sentenceEntries paraHtml) sentences
sentenceEntries :: String -> [Inline] -> [LinkEntry]
sentenceEntries paraHtml sentence =
let urls = filter isPageLink (query getUrl sentence)
in if null urls then []
else
let ctx = renderInlines inlines
in map (\u -> LinkEntry u ctx) urls
let sentHtml = renderInlines sentence
in map (\u -> LinkEntry u sentHtml paraHtml) urls
getUrl :: Inline -> [T.Text]
getUrl (Link _ _ (url, _)) = [url]
@ -178,9 +242,14 @@ linksCompiler = do
let src = itemBody body
let body' = itemSetBody (preprocessSource src) body
pandocItem <- readPandocWith readerOpts body'
let entries = nubBy (\a b -> leUrl a == leUrl b && leContext a == leContext b)
let entries = nubBy sameEntry
(extractLinksWithContext (itemBody pandocItem))
makeItem . TL.unpack . TLE.decodeUtf8 . Aeson.encode $ entries
where
sameEntry a b =
leUrl a == leUrl b &&
leSentence a == leSentence b &&
leParagraph a == leParagraph b
-- ---------------------------------------------------------------------------
-- URL normalisation
@ -269,7 +338,9 @@ toSourcePairs item = do
Nothing -> return []
Just entries ->
return [ ( T.pack (normaliseUrl (T.unpack (leUrl e)))
, BacklinkSource srcUrl title abstract (leContext e)
, BacklinkSource srcUrl title abstract
(leSentence e)
(leParagraph e)
)
| e <- entries ]
@ -302,10 +373,14 @@ backlinksField = field "backlinks" $ \item -> do
-- HTML rendering
-- ---------------------------------------------------------------------------
-- | Render backlink sources as an HTML list.
-- Each item shows the source title as a link (always visible) and a
-- <details> element containing the context paragraph (collapsed by default).
-- @blContext@ is already HTML produced by the Pandoc writer — not escaped.
-- | Render backlink sources as an HTML list. Each item shows:
-- * the source title as a link (serif body face),
-- * a <blockquote> of the sentence containing the link (default context),
-- * a small hoverable "¶" affordance that reveals the full paragraph in
-- a CSS-driven popup when hovered or keyboard-focused.
--
-- 'blSentence' and 'blParagraph' are already HTML fragments produced by
-- the Pandoc writer, so they are emitted unescaped.
renderBacklinks :: [BacklinkSource] -> String
renderBacklinks sources =
"<ul class=\"backlinks-list\">\n"
@ -314,11 +389,24 @@ renderBacklinks sources =
where
renderOne bl =
"<li class=\"backlink-item\">"
++ "<a class=\"backlink-source\" href=\"" ++ escapeHtml (blUrl bl) ++ "\">"
++ "<a class=\"backlink-source\" href=\""
++ escapeHtml (blUrl bl) ++ "\">"
++ escapeHtml (blTitle bl) ++ "</a>"
++ ( if null (blContext bl) then ""
else "<details class=\"backlink-details\">"
++ "<summary class=\"backlink-summary\">context</summary>"
++ "<div class=\"backlink-context\">" ++ blContext bl ++ "</div>"
++ "</details>" )
++ ( if null (blSentence bl) then ""
else "<blockquote class=\"backlink-quote\">"
++ blSentence bl
++ paragraphAffordance bl
++ "</blockquote>" )
++ "</li>\n"
paragraphAffordance bl
| null (blParagraph bl) = ""
| blParagraph bl == blSentence bl = ""
| otherwise =
"<span class=\"backlink-full\">"
++ "<button type=\"button\" class=\"backlink-full-trigger\""
++ " aria-label=\"Show full paragraph\" tabindex=\"0\">\x00B6</button>"
++ "<span class=\"backlink-full-popup\" role=\"tooltip\">"
++ blParagraph bl
++ "</span>"
++ "</span>"

View File

@ -29,8 +29,11 @@ import Text.Pandoc (runPure, readMarkdown, writeHtml5String, Pandoc(
import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
import Hakyll hiding (trim)
import Backlinks (backlinksField)
import Dingbat (dingbatField)
import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, versionHistoryField)
import Stability (stabilityField, lastReviewedField, versionHistoryField,
versionHistoryPrimaryField, versionHistoryRestField,
versionHistoryRangeField)
import Utils (authorSlugify, authorNameOf, trim)
-- ---------------------------------------------------------------------------
@ -241,6 +244,7 @@ siteCtx =
<> pageScriptsField
<> abstractField
<> summaryField
<> dingbatField
<> defaultContext
-- ---------------------------------------------------------------------------
@ -391,6 +395,9 @@ essayCtx =
<> similarLinksField
<> epistemicCtx
<> versionHistoryField
<> versionHistoryPrimaryField
<> versionHistoryRestField
<> versionHistoryRangeField
<> dateField "date-created" "%-d %B %Y"
<> dateField "date-modified" "%-d %B %Y"
<> constField "math" "true"

79
build/Dingbat.hs Normal file
View File

@ -0,0 +1,79 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Section-break ornament selection.
--
-- Every page exposes a @$dingbat$@ template variable naming the ornament to
-- use in place of the standard @<hr>@. The template renders this as
-- @data-dingbat="..."@ on @<body>@; CSS attribute selectors then swap the
-- @hr::after@ glyph.
--
-- Resolution order:
--
-- 1. @dingbat:@ frontmatter key on the page (must be in 'knownDingbats').
-- 2. Section default derived from the item's route ('sectionDefault').
-- 3. Fallback ('fallbackDingbat').
--
-- Best practice: set @dingbat:@ explicitly in frontmatter. The section
-- defaults are a safety net, not a substitute.
--
-- Adding a new ornament:
--
-- 1. Add its name to 'knownDingbats'.
-- 2. Optionally assign a section default in 'sectionDefault'.
-- 3. Add a matching @body[data-dingbat="…"]@ rule in typography.css.
module Dingbat
( dingbatField
, knownDingbats
) where
import Data.List (isPrefixOf)
import Data.Maybe (fromMaybe)
import Hakyll
-- | Curated palette. Extend here when adding a new ornament.
knownDingbats :: [String]
knownDingbats =
[ "asterism" -- ⁂ typographic asterism (neutral fallback)
, "asterisks" -- * * * spaced asterisks (classic scene break)
, "fleuron" -- Aldine leaf (literary essays)
, "trefoil" -- three-lobed ornament (poetry)
, "lozenge" -- diamond/rhombus (blog)
, "clef" -- musical ornament (music)
, "memento" -- mourning ornament (memento-mori)
, "tech" -- tech ornament
, "ai" -- AI ornament (the cute robot)
]
-- | Last-resort default when neither frontmatter nor section rule applies.
fallbackDingbat :: String
fallbackDingbat = "asterism"
-- | Section defaults matched against the item's route prefix.
-- First matching prefix wins. Unmatched routes use 'fallbackDingbat'.
sectionDefault :: String -> String
sectionDefault r
| "essays/" `isPrefixOf` r = "fleuron"
| "blog/" `isPrefixOf` r = "lozenge"
| "poetry/" `isPrefixOf` r = "trefoil"
| "fiction/" `isPrefixOf` r = "asterisks"
| "music/" `isPrefixOf` r = "clef"
| "memento-mori/" `isPrefixOf` r = "memento"
| otherwise = fallbackDingbat
-- | @$dingbat$@: name of the ornament to use on this page.
dingbatField :: Context a
dingbatField = field "dingbat" $ \item -> do
meta <- getMetadata (itemIdentifier item)
r <- fromMaybe "" <$> getRoute (itemIdentifier item)
let sectionD = sectionDefault r
case lookupString "dingbat" meta of
Nothing -> return sectionD
Just name
| name `elem` knownDingbats -> return name
| otherwise -> do
let ident = toFilePath (itemIdentifier item)
unsafeCompiler $ putStrLn $
"[Dingbat] " ++ ident ++ ": unknown dingbat \""
++ name ++ "\" — using \"" ++ sectionD ++ "\""
return sectionD

View File

@ -121,9 +121,11 @@ domainIcon url
| "github.com" `T.isInfixOf` url = "github"
| "git.levineuwirth.org" `T.isInfixOf` url = "forgejo"
| "tensorflow.org" `T.isInfixOf` url = "tensorflow"
-- AI companies
-- AI companies (consumer products share a brand icon with the lab)
| "anthropic.com" `T.isInfixOf` url = "anthropic"
| "claude.ai" `T.isInfixOf` url = "anthropic"
| "openai.com" `T.isInfixOf` url = "openai"
| "chatgpt.com" `T.isInfixOf` url = "openai"
-- Social / media
| "twitter.com" `T.isInfixOf` url = "twitter"
| "x.com" `T.isInfixOf` url = "twitter"
@ -133,6 +135,7 @@ domainIcon url
| "tiktok.com" `T.isInfixOf` url = "tiktok"
| "substack.com" `T.isInfixOf` url = "substack"
| "news.ycombinator.com" `T.isInfixOf` url = "hacker-news"
| "lesswrong.com" `T.isInfixOf` url = "lesswrong"
-- News
| "nytimes.com" `T.isInfixOf` url = "new-york-times"
-- Institutions

View File

@ -48,10 +48,21 @@ instance Aeson.FromJSON SimilarEntry where
-- Context field
-- ---------------------------------------------------------------------------
-- | Maximum entries rendered in the "Related" block. The on-disk JSON may
-- contain more (embed.py's TOP_N); the template caps the display.
maxSimilar :: Int
maxSimilar = 3
-- | Provides @$similar-links$@ (HTML list) and @$has-similar-links$@
-- (boolean flag for template guards).
-- Returns @noResult@ when the JSON file is absent, unparseable, or the
-- current page has no similar entries.
--
-- Note on normalisation: 'tools/embed.py' emits map keys using the live
-- site URL (e.g. @/essays/foo.html@ for a flat page, @/essays/foo/@ for a
-- directory-index page), while Hakyll's route gives @essays/foo.html@.
-- 'normaliseUrl' collapses both forms to a canonical stem, and we apply
-- it to every JSON key on load so the lookup cannot miss.
similarLinksField :: Context String
similarLinksField = field "similar-links" $ \item -> do
-- Load with dependency tracking — pages recompile when the JSON changes.
@ -59,13 +70,14 @@ similarLinksField = field "similar-links" $ \item -> do
case Aeson.decodeStrict (TE.encodeUtf8 (T.pack (itemBody slItem)))
:: Maybe (Map T.Text [SimilarEntry]) of
Nothing -> fail "similar-links: could not parse data/similar-links.json"
Just slMap -> do
Just rawMap -> do
mRoute <- getRoute (itemIdentifier item)
case mRoute of
Nothing -> fail "similar-links: item has no route"
Just r ->
let key = T.pack (normaliseUrl ("/" ++ r))
entries = fromMaybe [] (Map.lookup key slMap)
let normMap = Map.mapKeys (T.pack . normaliseUrl . T.unpack) rawMap
key = T.pack (normaliseUrl ("/" ++ r))
entries = take maxSimilar (fromMaybe [] (Map.lookup key normMap))
in if null entries
then fail "no similar links"
else return (renderSimilarLinks entries)
@ -113,15 +125,45 @@ percentDecode = T.unpack . TE.decodeUtf8With TE.lenientDecode . BS.pack . go
-- HTML rendering
-- ---------------------------------------------------------------------------
-- | Render the Related block. Each anchor gets:
-- * @class="similar-link"@ — whitelist for popups.js so the default
-- footer-exclusion does not fire (content preview on hover).
-- * @data-link-icon@ / @data-link-icon-type@ — page or document icon,
-- rendered via the existing a[data-link-icon] mask-image system in
-- typography.css.
-- * For PDFs: @class="pdf-link"@ + @data-pdf-src@ + href rewritten to
-- the PDF.js viewer, matching the rest of the site. The pdfContent
-- provider in popups.js binds on @.pdf-link[data-pdf-src]@.
renderSimilarLinks :: [SimilarEntry] -> String
renderSimilarLinks entries =
"<ul class=\"similar-links-list\">\n"
++ concatMap renderOne entries
++ "</ul>"
where
renderOne se =
renderOne se
| isPdfUrl (seUrl se) = renderPdf se
| otherwise = renderPage se
renderPage se =
"<li class=\"similar-links-item\">"
++ "<a href=\"" ++ escapeHtml (seUrl se) ++ "\">"
++ "<a class=\"similar-link\""
++ " href=\"" ++ escapeHtml (seUrl se) ++ "\""
++ " data-link-icon=\"internal\" data-link-icon-type=\"svg\">"
++ escapeHtml (seTitle se)
++ "</a>"
++ "</li>\n"
++ "</a></li>\n"
renderPdf se =
let raw = seUrl se
viewerUrl = "/pdfjs/web/viewer.html?file=" ++ escapeHtml raw
in "<li class=\"similar-links-item\">"
++ "<a class=\"similar-link pdf-link\""
++ " href=\"" ++ viewerUrl ++ "\""
++ " data-pdf-src=\"" ++ escapeHtml raw ++ "\""
++ " data-link-icon=\"document\" data-link-icon-type=\"svg\">"
++ escapeHtml (seTitle se)
++ "</a></li>\n"
isPdfUrl u =
let lower = T.toLower (T.pack u)
(path, _) = T.break (== '#') lower
in ".pdf" `T.isSuffixOf` path

View File

@ -19,6 +19,9 @@ module Stability
( stabilityField
, lastReviewedField
, versionHistoryField
, versionHistoryPrimaryField
, versionHistoryRestField
, versionHistoryRangeField
) where
import Control.Exception (catch, IOException)
@ -186,30 +189,91 @@ parseFmHistory meta =
gitLogHistory :: FilePath -> IO [VHEntry]
gitLogHistory fp = map (\d -> VHEntry (fmtIso d) Nothing) <$> gitDates fp
-- | Context list field @$version-history$@ providing @$vh-date$@ and
-- (when present) @$vh-message$@ per entry.
--
-- Priority:
-- 1. Frontmatter @history:@ list — dates + authored notes.
-- 2. Git log dates — date-only, no annotation.
-- 3. Empty list — template falls back to @$date-created$@ / @$date-modified$@.
versionHistoryField :: Context String
versionHistoryField = listFieldWith "version-history" vhCtx $ \item -> do
-- | Maximum entries shown by default in the version-history footer block.
-- The remainder is revealed via a <details>/<summary> expand affordance,
-- matching the cap on the RELATED column.
versionHistoryHeadCount :: Int
versionHistoryHeadCount = 3
-- | Load version-history entries for an item.
-- Priority: frontmatter @history:@ list → git log dates → empty.
loadVersionHistory :: Item a -> Compiler [VHEntry]
loadVersionHistory item = do
let srcPath = toFilePath (itemIdentifier item)
meta <- getMetadata (itemIdentifier item)
let fmEntries = parseFmHistory meta
entries <-
if not (null fmEntries)
then return fmEntries
else unsafeCompiler (gitLogHistory srcPath)
if null entries
then fail "no version history"
else return $ zipWith
(\i e -> Item (fromFilePath ("vh" ++ show (i :: Int))) e)
[1..] entries
where
vhCtx =
-- | Wrap a list of 'VHEntry' as Hakyll Items with unique paths so the
-- list field works correctly inside @$for$@.
vhItems :: String -> [VHEntry] -> [Item VHEntry]
vhItems tag =
zipWith (\i e -> Item (fromFilePath (tag ++ "-" ++ show (i :: Int))) e)
[1..]
-- | Shared sub-context for version-history entries: @$vh-date$@ and
-- (optionally) @$vh-message$@.
vhEntryCtx :: Context VHEntry
vhEntryCtx =
field "vh-date" (return . vhDate . itemBody)
<> field "vh-message" (\i -> case vhMessage (itemBody i) of
Nothing -> fail "no message"
Just m -> return m)
-- | Context list field @$version-history$@ — full list, kept for callers
-- (e.g. feeds, stats) that want every entry in one pass.
versionHistoryField :: Context String
versionHistoryField =
listFieldWith "version-history" vhEntryCtx $ \item -> do
entries <- loadVersionHistory item
if null entries
then fail "no version history"
else return (vhItems "vh" entries)
-- | @$version-history-primary$@ — first 'versionHistoryHeadCount' entries,
-- rendered outside the expand affordance.
versionHistoryPrimaryField :: Context String
versionHistoryPrimaryField =
listFieldWith "version-history-primary" vhEntryCtx $ \item -> do
entries <- loadVersionHistory item
let primary = take versionHistoryHeadCount entries
if null primary
then fail "no version history"
else return (vhItems "vh-p" primary)
-- | @$version-history-rest$@ — overflow entries (count > head cap), which
-- the template wraps in a <details> block. Fails (noResult) when the total
-- fits inside the head cap, so @$if(version-history-rest)$@ collapses
-- cleanly.
versionHistoryRestField :: Context String
versionHistoryRestField =
listFieldWith "version-history-rest" vhEntryCtx $ \item -> do
entries <- loadVersionHistory item
let rest = drop versionHistoryHeadCount entries
if null rest
then fail "no overflow"
else return (vhItems "vh-r" rest)
-- | @$version-history-range$@ — formatted span between the oldest and
-- newest entry. A single-date history renders as that date alone; a
-- multi-date history renders as "OLDEST \x2013 NEWEST" (en-dash).
-- Fails when no history is available so @$if(version-history-range)$@
-- in the template falls back to a literal label.
--
-- Dates in the underlying VHEntry list are already pre-formatted
-- ("12 April 2026") by 'parseFmHistory' / 'gitLogHistory'.
versionHistoryRangeField :: Context String
versionHistoryRangeField = field "version-history-range" $ \item -> do
entries <- loadVersionHistory item
case entries of
[] -> fail "no version-history range"
[one] -> return (vhDate one)
(newest : more) ->
let oldest = last (newest : more)
newD = vhDate newest
oldD = vhDate oldest
in if newD == oldD
then return newD
else return (oldD ++ " \x2013 " ++ newD)

View File

@ -102,7 +102,7 @@ constraints: any.Glob ==0.10.2,
any.http-types ==0.12.4,
any.http2 ==5.1.1,
any.indexed-traversable ==0.1.4,
any.indexed-traversable-instances ==0.1.2,
any.indexed-traversable-instances ==0.1.2.1,
any.integer-conversion ==0.1.1,
any.integer-gmp ==1.1,
any.integer-logarithms ==1.0.4,

View File

@ -3,7 +3,7 @@ title: Colophon
date: 2026-03-21
modified: 2026-04-12
status: "Durable"
confidence: 85
confidence: 93
tags: [meta]
abstract: On the design, tools, and philosophy of this site — and by extension, its author.
---

View File

@ -24,6 +24,12 @@ Running a lab that develops frontier LLMs is somewhat like playing a game that,
Then there's the news that the stock market doesn't want to hear. Ask yourself: who is deliberately left off the above list? If you're thinking of models like GLM, Qwen, MiniMax, and the notorious Deepseek, then we're on the same page. These models are rapidly approaching the capabilities of the frontier models that remain behind intrusive "competitive moats"^[This phrasing is adopted from Jared James Grogan's 2026 paper ["The End of the Foundation Model Era](https://arxiv.org/abs/2604.06217)] that do little more than violate the rights of their users. The advantages that such models provide are immense, and labs of the first list cannot ignore the likelihood of their precedence increasing in the weeks and months to come. In fact, I hypothesize that we are already seeing the reaction of frontier labs to these increasing capabilities, through the lense of juxtaposition: the jargon has remained constant, as if to negate any possibility of an "AI Bubble" bursting, but the quiet actions of the companies that aren't notoriously announced and decreed have shifted.
## Inference is the Name of the Game
## The Dilemma
### Inference is the Name of the Game
Very few users of an LLM have ever attempted to train an LLM. Even those users who are technical powerhouses - and there are many of these^[Per OpenAI's account, Codex [has reached](https://openai.com/index/accelerating-the-next-phase-ai/) 2,000,000 active weekly users, and while I could not find any specific numbers that Anthropic has released regarding Claude Code's weekly user count, I presume it is higher than that of Codex.] -
likely are not intricately familiar with the inner workings of transformers. Even those who, perhaps from coursework, perhaps from curiosity, perhaps from [a chat](https://claude.ai/share/5282e1b8-24ce-4cf8-983e-55df95f5fbdc) with an LLM of choice have enough technical prowess to in theory write code that could facilitate the training of a naive transformer are unlikely to be able to train any model of substance, due to computational constraints. Consider, for instance, that [over 200,000 GPUs](https://x.ai/news/grok-3) were used to train Grok 3, which is a model from early 2025.
likely are not intricately familiar with the inner workings of transformers. Even those who, perhaps from coursework, perhaps from curiosity, perhaps from [a chat](https://claude.ai/share/5282e1b8-24ce-4cf8-983e-55df95f5fbdc) with an LLM of choice have enough technical prowess to in theory write code that could facilitate the training of a naive transformer are unlikely to be able to train any model of substance, due to computational constraints. Consider, for instance, that [over 200,000 GPUs](https://x.ai/news/grok-3) were used to train Grok 3, which is a model from early 2025; the [aspirations of xAI](https://www.spacex.com/updates#xai-joins-spacex) in particular with regards to expansion of compute (into outer space) have, more recently, been the source of much controversy. To be absolutely precise, the inherent computational cost of training a model does not provide companies that do train models any safeguards nor guarantees that users cannot find more open alternatives.
Inference is the primary concern for multiple reasons. Inference is what creates the opportunity for an AI lab to generate revenue. Training a model, in principle, enables the capability for inference to be provided as a service to paying users, but there is no inherent revenue that is generated as a direct consequence of the training pipeline. Inference is also the primary logistical and computational concern. We have neglected in our previous discussion of training that training clusters may be provisioned; powerful GPUs are available to rent by the hour, and though doing this at the scale of training a frontier LLM is economically out of reach for the general population, for venture-capital backed startups, cash is abundantly available as a resource to burn. Inference, on the other hand, is not provisional; to provide inference at a scale that enables revenue, GPUs must be available to serve the requests of paying customers at all times. This is often not the case, as we will soon explore^[A detailed analysis of how even minute per-request inference costs scale to unfathomable overall costs is provided in CMU's ["Agents of Change."](https://www.cmu.edu/cmist/tech-and-policy/agents-of-change/index.html)].
## A deficit of compute
We are already seeing the extensive effects of the fact that inference cannot truly be provisioned at scale. Inference can be provisioned at smaller scales - indeed, as a student at Brown University, I make extensive use of our own [self-hosted interface](https://docs.ccv.brown.edu/ai-tools/services/librechat), which provides access to various frontier LLMs.

View File

@ -17,6 +17,7 @@ executable site
Catalog
Commonplace
Backlinks
Dingbat
SimilarLinks
Compilers
Contexts

103
nginx/popup-proxy.conf Normal file
View File

@ -0,0 +1,103 @@
# popup-proxy.conf — same-origin reverse proxy for popups.js providers
# whose upstream APIs do not send CORS headers (arXiv, NCBI/PubMed,
# Internet Archive). All three return immutable metadata, so the cache
# TTL is generous; a manual `proxy_cache_purge` is unnecessary.
#
# Place this file at /etc/nginx/snippets/popup-proxy.conf and `include`
# it inside the server { } block of the levineuwirth.org vhost. The
# `proxy_cache_path` directive must live in the http { } context — put
# it in nginx.conf or the relevant conf.d/ file.
#
# http {
# proxy_cache_path /var/cache/nginx/popup-proxy
# levels=1:2 keys_zone=popup_proxy:16m
# max_size=512m inactive=60d use_temp_path=off;
# ...
# }
#
# server {
# server_name levineuwirth.org;
# ...
# include snippets/popup-proxy.conf;
# }
# Shared resolver — needed because proxy_pass uses a variable upstream
# (literal upstreams are resolved once at startup; variables defer DNS
# to request time, which lets nginx start without the upstream being
# reachable and survives upstream IP changes).
resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s;
resolver_timeout 5s;
# ── arXiv ────────────────────────────────────────────────────────────
# Atom feed of paper metadata. Abstracts never change after publication
# (revisions get distinct IDs like 2604.06217v2), so 30d is safe.
location /proxy/arxiv/ {
set $upstream_arxiv export.arxiv.org;
proxy_pass https://$upstream_arxiv/;
proxy_set_header Host $upstream_arxiv;
proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on;
proxy_cache popup_proxy;
proxy_cache_valid 200 30d;
proxy_cache_valid any 5m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
# Belt-and-suspenders: even though same-origin doesn't need CORS, a
# future migration of popups.js to a worker or different origin would.
add_header Access-Control-Allow-Origin "$scheme://$host" always;
}
# ── Internet Archive ─────────────────────────────────────────────────
# Item metadata JSON. Item descriptions are author-edited and could
# change, but rarely; 7d strikes a reasonable balance.
location /proxy/archive/ {
set $upstream_archive archive.org;
proxy_pass https://$upstream_archive/;
proxy_set_header Host $upstream_archive;
proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on;
proxy_cache popup_proxy;
proxy_cache_valid 200 7d;
proxy_cache_valid any 5m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
add_header Access-Control-Allow-Origin "$scheme://$host" always;
}
# ── PubMed (NCBI E-utilities) ────────────────────────────────────────
# Article summaries. NCBI requests a tool=/email= identifier on every
# request (https://www.ncbi.nlm.nih.gov/books/NBK25497/); we inject
# them server-side so popups.js stays focused on rendering.
location /proxy/pubmed/ {
set $upstream_pubmed eutils.ncbi.nlm.nih.gov;
proxy_pass https://$upstream_pubmed/;
proxy_set_header Host $upstream_pubmed;
proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on;
# NCBI etiquette: rate-limit to <3 req/s without an API key. With
# caching this is rarely exercised, but the burst guards a hot page.
limit_req zone=pubmed burst=3 nodelay;
proxy_cache popup_proxy;
proxy_cache_valid 200 30d;
proxy_cache_valid any 5m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
add_header Access-Control-Allow-Origin "$scheme://$host" always;
}
# Companion directive for the limit_req above. Place in http { } context:
#
# http {
# limit_req_zone $binary_remote_addr zone=pubmed:1m rate=3r/s;
# ...
# }

View File

@ -931,13 +931,16 @@ nav.site-nav {
width: 100%;
}
/* Metadata entries: horizontal auto-grid, centered */
/* Secondary row: RELATED | EPISTEMIC | VERSION HISTORY.
auto-fit + minmax lets the grid render 3 columns when all are present,
2 when RELATED is absent (zero similar pages), and stack gracefully
at narrow viewports without any further media queries. */
.meta-footer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
justify-content: center;
gap: 1.65rem 2rem;
padding-top: 0.5rem;
gap: 1.65rem 2.25rem;
padding-top: 0.25rem;
}
.meta-footer-section p,
@ -959,89 +962,232 @@ nav.site-nav {
}
/* ============================================================
BACKLINKS LIST
BACKLINKS promoted to a full-width aftermatter section.
Each entry: source title in serif body face, then a blockquote of
the sentence containing the link. A small "¶" affordance reveals
the full paragraph in a hover/focus-activated popup.
============================================================ */
#backlinks .backlinks-list {
padding-left: 0;
list-style: none;
margin: 0;
/* The "BACKLINKS" small-caps label is noticeably larger than the
secondary-row headings (which stay at 0.78rem) because Backlinks
is now a primary aftermatter section. */
.meta-footer-backlinks > h3 {
font-size: 1.05rem;
letter-spacing: 0.09em;
margin-bottom: 1rem;
}
#backlinks .backlinks-list li::before {
.meta-footer-backlinks .backlinks-list {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.meta-footer-backlinks .backlinks-list li::before {
content: none;
}
.backlink-item {
padding: 0.3rem 0;
padding: 0;
}
/* Source title — always visible */
/* Source title — serif body face, prominent but restrained */
.backlink-source {
font-size: 0.78rem;
font-family: var(--font-sans);
font-variant: small-caps;
letter-spacing: 0.02em;
color: var(--text-muted);
text-decoration: none;
}
.backlink-source:hover {
display: inline-block;
font-family: var(--font-serif);
font-size: 1.32rem;
font-variant: normal;
font-weight: 500;
letter-spacing: 0;
color: var(--text);
text-decoration: none;
border-bottom: 1px solid var(--border-muted);
padding-bottom: 0.05em;
transition: color var(--transition-fast),
border-color var(--transition-fast);
}
/* Context toggle — collapsed by default */
.backlink-details {
display: inline;
.backlink-source:hover,
.backlink-source:focus-visible {
color: var(--text);
border-color: var(--border);
}
.backlink-summary {
display: inline;
margin-left: 0.4em;
font-size: 0.68rem;
font-family: var(--font-sans);
color: var(--text-faint);
cursor: pointer;
list-style: none;
user-select: none;
}
.backlink-summary::-webkit-details-marker {
display: none;
}
.backlink-summary::before {
content: "▸\00a0";
font-size: 0.6em;
vertical-align: 0.1em;
}
details[open] > .backlink-summary::before {
content: "▾\00a0";
}
.backlink-summary:hover {
color: var(--text-muted);
}
/* Context paragraph — shown when <details> is open */
.backlink-context {
margin-top: 0.35rem;
margin-bottom: 0.2rem;
padding-left: 0.75rem;
/* Sentence-level context quoted beneath the title */
.backlink-quote {
margin: 0.55rem 0 0 0;
padding: 0 0 0 1rem;
border-left: 2px solid var(--border-muted);
font-size: 0.75rem;
color: var(--text-faint);
line-height: 1.5;
font-family: var(--font-serif);
font-size: 1.12rem;
font-style: italic;
line-height: 1.55;
color: var(--text-muted);
quotes: none;
}
.backlink-context a {
color: var(--text-faint);
.backlink-quote::before,
.backlink-quote::after {
content: none;
}
.backlink-quote a {
color: var(--text-muted);
text-decoration-color: var(--border-muted);
}
.backlink-context a:hover {
.backlink-quote a:hover {
color: var(--text);
text-decoration-color: var(--border);
}
/* Hover/focus popup — full paragraph context */
.backlink-full {
position: relative;
display: inline-block;
margin-left: 0.35em;
vertical-align: baseline;
}
.backlink-full-trigger {
background: none;
border: 1px solid var(--border-muted);
border-radius: 999px;
width: 1.1rem;
height: 1.1rem;
padding: 0;
line-height: 1;
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
cursor: help;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast),
border-color var(--transition-fast),
background-color var(--transition-fast);
}
.backlink-full-trigger:hover,
.backlink-full:focus-within .backlink-full-trigger {
color: var(--text);
border-color: var(--border);
background-color: var(--bg-offset);
}
.backlink-full-popup {
display: none;
position: absolute;
z-index: 200;
bottom: calc(100% + 0.4rem);
left: 0;
width: min(34rem, 80vw);
padding: 0.75rem 0.95rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.08);
font-family: var(--font-serif);
font-style: normal;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text-muted);
text-align: left;
}
.backlink-full:hover .backlink-full-popup,
.backlink-full:focus-within .backlink-full-popup {
display: block;
}
.backlink-full-popup a {
color: var(--text-muted);
text-decoration-color: var(--border-muted);
}
.backlink-full-popup a:hover {
color: var(--text);
}
/* ============================================================
RELATED (SIMILAR LINKS) compact vertical list of up to 3 titles.
Source file: data/similar-links.json (produced by tools/embed.py).
============================================================ */
#similar-links .similar-links-list {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
#similar-links .similar-links-list li::before {
content: none;
}
#similar-links .similar-links-item a {
font-family: var(--font-serif);
font-size: 0.92rem;
color: var(--text-muted);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color var(--transition-fast),
border-color var(--transition-fast);
}
#similar-links .similar-links-item a:hover {
color: var(--text);
border-bottom-color: var(--border-muted);
}
/* ============================================================
VERSION HISTORY cap at 3 with a <details> "more" affordance.
.version-history-list inherits list styling from .meta-footer-section ul.
============================================================ */
.version-history-more {
margin-top: 0.35rem;
}
.version-history-more > summary {
list-style: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.72rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.06em;
color: var(--text-faint);
user-select: none;
padding-left: 1em;
transition: color var(--transition-fast);
}
.version-history-more > summary::-webkit-details-marker {
display: none;
}
.version-history-more > summary::before {
content: "▸\00a0";
font-size: 0.7em;
vertical-align: 0.1em;
}
.version-history-more[open] > summary::before {
content: "▾\00a0";
}
.version-history-more > summary:hover {
color: var(--text-muted);
}
.version-history-more > .version-history-list {
margin-top: 0.25rem;
}

View File

@ -34,7 +34,8 @@
.toc-toggle,
.section-toggle,
.metadata .meta-pagelinks,
.page-meta-footer .meta-footer-section#backlinks,
.page-meta-footer #backlinks,
.page-meta-footer #similar-links,
.nav-portals {
display: none !important;
}

View File

@ -83,19 +83,107 @@
color: var(--text);
}
/* 5. The Typographic Asterism (Replaces standard <hr>) */
/* 5. Section-break ornaments (replace standard <hr>).
Selected by `data-dingbat` on <body>, resolved in build/Dingbat.hs:
1. frontmatter `dingbat:` key, 2. section default, 3. fallback.
Best practice: set `dingbat:` explicitly in frontmatter.
Typographic ornaments (asterism, asterisks) use character glyphs.
Designed ornaments use SVGs from /images/dingbats/ rendered via mask-image
so they inherit currentColor ( --text-muted on <hr>).
To add a new ornament: extend build/Dingbat.hs and add both a character
or SVG rule below (plus the SVG file if applicable). */
#markdownBody hr {
border: none;
text-align: center;
margin: 3.3rem 0; /* 2x baseline grid */
color: var(--text-muted);
font-family: var(--font-serif);
}
#markdownBody hr::after {
content: "⁂";
display: inline-block;
font-size: 1.5em;
/* Fallback when no data-dingbat is set. */
content: "⁂";
letter-spacing: 0.5em;
padding-left: 0.5em; /* Optically center the letter-spacing */
font-family: var(--font-serif);
}
/* --- Typographic ornaments ------------------------------------------ */
/* asterism — ⁂ typographic asterism */
body[data-dingbat="asterism"] #markdownBody hr::after {
content: "⁂";
letter-spacing: 0.5em;
padding-left: 0.5em;
}
/* asterisks — classic spaced asterisks used in print fiction */
body[data-dingbat="asterisks"] #markdownBody hr::after {
content: "* * *";
letter-spacing: 0.4em;
padding-left: 0;
font-size: 1.3em;
}
/* --- SVG ornaments -------------------------------------------------- */
/* Shared mask/sizing for every SVG-driven variant. The per-variant rules
below only supply the mask-image URL. */
body[data-dingbat="fleuron"] #markdownBody hr::after,
body[data-dingbat="trefoil"] #markdownBody hr::after,
body[data-dingbat="lozenge"] #markdownBody hr::after,
body[data-dingbat="clef"] #markdownBody hr::after,
body[data-dingbat="memento"] #markdownBody hr::after,
body[data-dingbat="tech"] #markdownBody hr::after,
body[data-dingbat="ai"] #markdownBody hr::after {
content: "";
width: 1.6em;
height: 1.6em;
font-size: 1em; /* override the 1.5em from the base rule */
vertical-align: middle;
letter-spacing: 0;
padding-left: 0;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
body[data-dingbat="fleuron"] #markdownBody hr::after {
mask-image: url('/images/dingbats/fleuron.svg');
-webkit-mask-image: url('/images/dingbats/fleuron.svg');
}
body[data-dingbat="trefoil"] #markdownBody hr::after {
mask-image: url('/images/dingbats/trefoil.svg');
-webkit-mask-image: url('/images/dingbats/trefoil.svg');
}
body[data-dingbat="lozenge"] #markdownBody hr::after {
mask-image: url('/images/dingbats/lozenge.svg');
-webkit-mask-image: url('/images/dingbats/lozenge.svg');
}
body[data-dingbat="clef"] #markdownBody hr::after {
mask-image: url('/images/dingbats/clef.svg');
-webkit-mask-image: url('/images/dingbats/clef.svg');
}
body[data-dingbat="memento"] #markdownBody hr::after {
mask-image: url('/images/dingbats/memento.svg');
-webkit-mask-image: url('/images/dingbats/memento.svg');
}
body[data-dingbat="tech"] #markdownBody hr::after {
mask-image: url('/images/dingbats/tech.svg');
-webkit-mask-image: url('/images/dingbats/tech.svg');
}
body[data-dingbat="ai"] #markdownBody hr::after {
mask-image: url('/images/dingbats/ai.svg');
-webkit-mask-image: url('/images/dingbats/ai.svg');
}
@ -712,6 +800,11 @@ a[data-link-icon="hacker-news"]::after {
-webkit-mask-image: url('/images/link-icons/hacker-news.svg');
}
a[data-link-icon="lesswrong"]::after {
mask-image: url('/images/link-icons/lesswrong.svg');
-webkit-mask-image: url('/images/link-icons/lesswrong.svg');
}
a[data-link-icon="new-york-times"]::after {
mask-image: url('/images/link-icons/new-york-times.svg');
-webkit-mask-image: url('/images/link-icons/new-york-times.svg');
@ -731,3 +824,8 @@ a[data-link-icon="internal"]::after {
mask-image: url('/images/link-icons/internal.svg');
-webkit-mask-image: url('/images/link-icons/internal.svg');
}
a[data-link-icon="document"]::after {
mask-image: url('/images/link-icons/document.svg');
-webkit-mask-image: url('/images/link-icons/document.svg');
}

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="2.8" r="1.2"/>
<rect x="11.5" y="3.8" width="1" height="3"/>
<path fill-rule="evenodd" d="M5.5 7 L18.5 7 A 1.5 1.5 0 0 1 20 8.5 L20 17.5 A 1.5 1.5 0 0 1 18.5 19 L5.5 19 A 1.5 1.5 0 0 1 4 17.5 L4 8.5 A 1.5 1.5 0 0 1 5.5 7 Z M8 11 L11 11 L11 14 L8 14 Z M13 11 L16 11 L16 14 L13 14 Z"/>
<rect x="9.5" y="16" width="5" height="0.8" rx="0.4"/>
<circle cx="2.5" cy="12" r="0.7"/>
<circle cx="21.5" cy="12" r="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<ellipse cx="7" cy="18" rx="3.4" ry="2.4" transform="rotate(-22 7 18)"/>
<rect x="9.2" y="4" width="1.6" height="14"/>
<ellipse cx="15" cy="18" rx="3.4" ry="2.4" transform="rotate(-22 15 18)"/>
<rect x="17.2" y="4" width="1.6" height="14"/>
<path d="M9.2 4 L18.8 4 L18.8 7 L9.2 7 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 C 6 8 6 16 12 22 C 18 16 18 8 12 2 Z"/>
<path d="M11.5 4 L11.5 22 L12.5 22 L12.5 4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<path d="M12 2 L22 12 L12 22 L2 12 Z M12 6 L18 12 L12 18 L6 12 Z"/>
<circle cx="12" cy="12" r="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<rect x="5" y="2" width="14" height="2"/>
<rect x="5" y="20" width="14" height="2"/>
<path d="M7 4 L17 4 L12.5 12 L17 20 L7 20 L11.5 12 Z M9 6 L15 6 L12 11 Z M9 18 L15 18 L12 13 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<path d="M7 7 L17 7 L17 17 L7 17 Z M9.5 9.5 L14.5 9.5 L14.5 14.5 L9.5 14.5 Z"/>
<rect x="3" y="9" width="4" height="1"/>
<rect x="3" y="11.5" width="4" height="1"/>
<rect x="3" y="14" width="4" height="1"/>
<rect x="17" y="9" width="4" height="1"/>
<rect x="17" y="11.5" width="4" height="1"/>
<rect x="17" y="14" width="4" height="1"/>
<rect x="9" y="3" width="1" height="4"/>
<rect x="11.5" y="3" width="1" height="4"/>
<rect x="14" y="3" width="1" height="4"/>
<rect x="9" y="17" width="1" height="4"/>
<rect x="11.5" y="17" width="1" height="4"/>
<rect x="14" y="17" width="1" height="4"/>
</svg>

After

Width:  |  Height:  |  Size: 728 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="6" r="4"/>
<circle cx="6.5" cy="14" r="4"/>
<circle cx="17.5" cy="14" r="4"/>
<rect x="11.25" y="10" width="1.5" height="11"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 60" fill="none" stroke="#000" stroke-width="10" stroke-linejoin="miter"><path d="M10 8 L10 52 L36 52"/><path d="M44 8 L54 52 L64 22 L74 52 L84 8"/></svg>

After

Width:  |  Height:  |  Size: 211 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M181.9 256.1c-5-16-4.9-46.9-2-46.9 8.4 0 7.6 36.9 2 46.9zm-1.7 47.2c-7.7 20.2-17.3 43.3-28.4 62.7 18.3-7 39-17.2 62.9-21.9-12.7-9.6-24.9-23.4-34.5-40.8zM86.1 428.1c0 .8 13.2-5.4 34.9-40.2-6.7 6.3-29.1 24.5-34.9 40.2zM248 160h136v328c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V24C0 10.7 10.7 0 24 0h200v136c0 13.2 10.8 24 24 24zm-8 171.8c-20-12.2-33.3-29-42.7-53.8 4.5-18.5 11.6-46.6 6.2-64.2-4.7-29.4-42.4-26.5-47.8-6.8-5 18.3-.4 44.1 8.1 77-11.6 27.6-28.7 64.6-40.8 85.8-.1 0-.1.1-.2.1-27.1 13.9-73.6 44.5-54.5 68 5.6 6.9 16 10 21.5 10 17.9 0 35.7-18 61.1-61.8 25.8-8.5 54.1-19.1 79-23.2 21.7 11.8 47.1 19.5 64 19.5 29.2 0 31.2-32 19.7-43.4-13.9-13.6-54.3-9.7-73.6-7.2zM377 105L279 7c-4.5-4.5-10.6-7-17-7h-6v128h128v-6.1c0-6.3-2.5-12.4-7-16.9zm-74.1 255.3c4.1-2.7-2.5-11.9-42.8-9 37.1 15.8 42.8 9 42.8 9z"/></svg>

After

Width:  |  Height:  |  Size: 888 B

View File

@ -13,12 +13,16 @@
11. Internet Archive archive.org/metadata, title + description
12. PubMed NCBI esummary, title + authors + journal
Production nginx CSP must add:
connect-src https://en.wikipedia.org https://export.arxiv.org
https://api.crossref.org https://api.github.com
https://openlibrary.org https://api.biorxiv.org
https://www.youtube.com https://archive.org
https://eutils.ncbi.nlm.nih.gov
Production nginx CSP must add to connect-src:
https://en.wikipedia.org https://api.crossref.org
https://api.github.com https://openlibrary.org
https://api.biorxiv.org https://www.youtube.com
Production nginx must also reverse-proxy three CORS-broken upstreams
(immutable metadata long cache TTL is safe). See nginx/popup-proxy.conf.
/proxy/arxiv/ -> https://export.arxiv.org/
/proxy/archive/ -> https://archive.org/
/proxy/pubmed/ -> https://eutils.ncbi.nlm.nih.gov/
*/
(function () {
'use strict';
@ -89,13 +93,17 @@
/* Internal links absolute (/foo) and relative (../../foo) same-origin hrefs.
relativizeUrls in Hakyll makes index-page links relative, so we must match both. */
root.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]').forEach(function (el) {
/* Author links in .meta-authors and backlink source links always get popups */
/* Author links, backlink source links, and Related items always get popups */
var inAuthors = el.closest('.meta-authors');
var isBacklink = el.classList.contains('backlink-source');
if (!inAuthors && !isBacklink) {
var isSimilar = el.classList.contains('similar-link');
/* PDF-typed Related items are handled below by the pdf-link binder */
if (isSimilar && el.classList.contains('pdf-link')) return;
if (!inAuthors && !isBacklink && !isSimilar) {
if (el.closest('nav, #toc, footer, .page-meta-footer, .metadata')) return;
if (el.classList.contains('cite-link') || el.classList.contains('meta-tag')) return;
if (el.classList.contains('pdf-link')) return;
if (el.classList.contains('content-divider-logo') || el.classList.contains('aftermatter-logo')) return;
}
bind(el, internalContent);
});
@ -130,21 +138,18 @@
}
};
/* Returns the appropriate provider function for a given URL, or null. */
/* Returns the appropriate provider function for a given URL, or null.
Local annotations win over the table; otherwise the first entry in
PROVIDERS whose `match` regex hits is selected. */
function getProvider(href) {
if (!href) return null;
/* Local annotation takes priority over everything */
if (annotations && annotations[href]) return annotationContent;
if (/wikipedia\.org\/wiki\//.test(href)) return wikipediaContent;
if (/arxiv\.org\/(?:abs|pdf)\/\d{4}\.\d{4,5}/.test(href)) return arxivContent;
if (/(?:dx\.)?doi\.org\/10\./.test(href)) return doiContent;
if (/github\.com\/[^/]+\/[^/?#]+/.test(href)) return githubContent;
if (/git\.levineuwirth\.org\/[^/]+\/[^/?#]+/.test(href)) return forgejoContent;
if (/openlibrary\.org\/(?:works|books)\//.test(href)) return openlibraryContent;
if (/(?:bio|med)rxiv\.org\/content\/10\./.test(href)) return biorxivContent;
if (/(?:youtube\.com\/watch|youtu\.be\/)/.test(href)) return youtubeContent;
if (/archive\.org\/details\//.test(href)) return archiveContent;
if (/pubmed\.ncbi\.nlm\.nih\.gov\/\d/.test(href)) return pubmedContent;
for (var i = 0; i < PROVIDERS.length; i++) {
if (PROVIDERS[i].match.test(href)) {
var entry = PROVIDERS[i];
return function (target) { return providerContent(target, entry); };
}
}
return null;
}
@ -352,305 +357,328 @@
.catch(function () { return null; });
}
/* 3. Wikipedia — MediaWiki action API, full lead section, text-only */
function wikipediaContent(target) {
/* ------------------------------------------------------------------
External providers declarative table.
Each entry drives a generic fetch + render pipeline. Adding a new
source means: write a URL regex, a URL builder, and a parser that
maps the upstream response to the normalized field shape below.
{ title, authors?, meta?, abstract? | extract?, tags? }
providerContent() handles cache/fetch/error-swallow; renderPopup()
handles HTML composition and truncation. Per-provider quirks live
inside each parse() e.g. CrossRef + Internet Archive strip
upstream HTML before returning. The shared truncate step only
normalizes whitespace and applies the length cap.
Render order is fixed: tags title authors meta body
stats. `meta` and `stats` share the `.popup-meta` CSS class but
differ in position: `meta` reads as a subtitle (journal, year),
while `stats` reads as a footer line (language, star count).
Quirk fields on a provider entry:
icon override for data-popup-source (CSS icon key) when
it differs from the provider name.
fetchInit options passed to fetch() (e.g. GitHub's Accept).
bodyLimit char cap for abstract/extract (default 500). */
function truncate(s, limit) {
if (!s) return '';
s = s.replace(/\s+/g, ' ').trim();
if (s.length > limit) s = s.slice(0, limit).replace(/\s\S+$/, '') + '\u2026';
return s;
}
/* Authors: array "a, b, c et al." (3 max); string trimmed
passthrough (some parsers pre-join their own bylines, e.g. Internet
Archive composes "creator, year"). */
function formatAuthors(a) {
if (!a) return '';
if (typeof a === 'string') return a.trim();
if (!a.length) return '';
var head = a.slice(0, 3).join(', ');
return a.length > 3 ? head + ' et\u00a0al.' : head;
}
function renderPopup(p, fields) {
if (!fields || !fields.title) return null;
var iconKey = p.icon || p.name;
var authors = formatAuthors(fields.authors);
var bodyKey = fields.extract !== undefined ? 'extract' : 'abstract';
var body = truncate(fields[bodyKey], p.bodyLimit || 500);
var html = '<div class="popup-' + p.name + '">'
+ srcHtml(iconKey, p.label);
if (fields.tags) html += '<div class="popup-tags">' + esc(fields.tags) + '</div>';
html += '<div class="popup-title">' + esc(fields.title) + '</div>';
if (authors) html += '<div class="popup-authors">' + esc(authors) + '</div>';
if (fields.meta) html += '<div class="popup-meta">' + esc(fields.meta) + '</div>';
if (body) html += '<div class="popup-' + bodyKey + '">' + esc(body) + '</div>';
if (fields.stats) html += '<div class="popup-meta">' + esc(fields.stats) + '</div>';
html += '</div>';
return html;
}
function providerContent(target, p) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
if (!href) return Promise.resolve(null);
if (cache[href]) return Promise.resolve(cache[href]);
var m = href.match(/wikipedia\.org\/wiki\/([^#?]+)/);
if (!m) return Promise.resolve(null);
var match = href.match(p.match);
if (!match) return Promise.resolve(null);
var apiUrl = 'https://en.wikipedia.org/w/api.php'
var ctx = { match: match, href: href };
var url = p.url(ctx);
var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson;
return fetcher(url, p.fetchInit).then(function (data) {
if (!data) return null;
var html = renderPopup(p, p.parse(data, ctx));
return html ? store(href, html) : null;
}).catch(function () { return null; });
}
/* bioRxiv + medRxiv share the response schema, so both entries below
use this parser only the upstream path differs. */
function biorxivParse(data) {
var paper = data && data.collection && data.collection[0];
if (!paper || !paper.title) return null;
var authors = paper.authors
? paper.authors.split(';').map(function (s) { return s.trim(); }).filter(Boolean)
: [];
return { title: paper.title, authors: authors, abstract: paper.abstract || '' };
}
var PROVIDERS = [
/* Wikipedia MediaWiki action API, full lead section, text-only.
Uses .popup-extract rather than .popup-abstract; the parser
signals this by returning `extract` instead of `abstract`. */
{
name: 'wikipedia', label: 'Wikipedia',
match: /wikipedia\.org\/wiki\/([^#?]+)/,
fetchType: 'json',
bodyLimit: 600,
url: function (ctx) {
return 'https://en.wikipedia.org/w/api.php'
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
+ '&titles=' + encodeURIComponent(decodeURIComponent(m[1])) + '&origin=*';
return fetchJson(apiUrl)
.then(function (data) {
+ '&titles=' + encodeURIComponent(decodeURIComponent(ctx.match[1]))
+ '&origin=*';
},
parse: function (data) {
var pages = data && data.query && data.query.pages;
if (!pages) return null;
var page = Object.values(pages)[0];
if (!page || page.missing !== undefined) return null;
var doc = new DOMParser().parseFromString(page.extract || '', 'text/html');
/* Remove math elements before extracting text their DOM includes both
display characters and raw LaTeX source, producing garbled output. */
/* Math elements blend display chars with raw LaTeX source
in the DOM strip them before textContent extraction. */
doc.querySelectorAll('.mwe-math-element').forEach(function (el) {
el.parentNode.removeChild(el);
});
var text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return null;
if (text.length > 600) text = text.slice(0, 600).replace(/\s\S+$/, '') + '\u2026';
return store(href,
'<div class="popup-wikipedia">'
+ srcHtml('wikipedia', 'Wikipedia')
+ '<div class="popup-title">' + esc(page.title) + '</div>'
+ '<div class="popup-extract">' + esc(text) + '</div>'
+ '</div>');
})
.catch(function () { return null; });
return { title: page.title, extract: text };
}
},
/* 4. arXiv — Atom API, title + authors + abstract */
function arxivContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5}(?:v\d+)?)/);
if (!m) return Promise.resolve(null);
var id = m[1].replace(/v\d+$/, '');
return fetchXml('https://export.arxiv.org/api/query?id_list=' + encodeURIComponent(id))
.then(function (xml) {
if (!xml) return null;
/* arXiv — Atom API (CORS-broken upstream, proxied). */
{
name: 'arxiv', label: 'arXiv',
match: /arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5}(?:v\d+)?)/,
fetchType: 'xml',
url: function (ctx) {
return '/proxy/arxiv/api/query?id_list='
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, ''));
},
parse: function (xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var titleEl = doc.querySelector('entry > title');
var summaryEl = doc.querySelector('entry > summary');
if (!titleEl || !summaryEl) return null;
var title = titleEl.textContent.trim().replace(/\s+/g, ' ');
var summary = summaryEl.textContent.trim().replace(/\s+/g, ' ');
if (summary.length > 500) summary = summary.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var authors = Array.from(doc.querySelectorAll('entry > author > name'))
.map(function (el) { return el.textContent.trim(); });
var authorStr = authors.slice(0, 3).join(', ');
if (authors.length > 3) authorStr += ' et\u00a0al.';
return store(href,
'<div class="popup-arxiv">'
+ srcHtml('arxiv', 'arXiv')
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (authorStr ? '<div class="popup-authors">' + esc(authorStr) + '</div>' : '')
+ '<div class="popup-abstract">' + esc(summary) + '</div>'
+ '</div>');
})
.catch(function () { return null; });
return {
title: titleEl.textContent.trim().replace(/\s+/g, ' '),
authors: Array.from(doc.querySelectorAll('entry > author > name'))
.map(function (el) { return el.textContent.trim(); }),
abstract: summaryEl.textContent.trim().replace(/\s+/g, ' ')
};
}
},
/* 5. DOI / CrossRef — title, authors, journal, year, abstract */
function doiContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/);
if (!m) return Promise.resolve(null);
return fetchJson('https://api.crossref.org/works/' + encodeURIComponent(m[1]))
.then(function (data) {
/* DOI → CrossRef — strips upstream JATS-HTML from abstract. */
{
name: 'doi', label: 'CrossRef',
match: /(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.crossref.org/works/' + encodeURIComponent(ctx.match[1]);
},
parse: function (data) {
var msg = data && data.message;
if (!msg) return null;
var title = (msg.title && msg.title[0]) || '';
if (!title) return null;
var authors = (msg.author || []).slice(0, 3)
.map(function (a) { return (a.given ? a.given + ' ' : '') + (a.family || ''); })
.join(', ');
if ((msg.author || []).length > 3) authors += ' et\u00a0al.';
var journal = (msg['container-title'] && msg['container-title'][0]) || '';
var parts = msg.issued && msg.issued['date-parts'];
var year = parts && parts[0] && parts[0][0];
var abstract = (msg.abstract || '').replace(/<[^>]+>/g, '').trim();
if (abstract.length > 500) abstract = abstract.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var meta = [journal, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-doi">'
+ srcHtml('doi', 'CrossRef')
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
return {
title: title,
authors: (msg.author || []).map(function (a) {
return (a.given ? a.given + ' ' : '') + (a.family || '');
}),
meta: [journal, year].filter(Boolean).join(', '),
abstract: (msg.abstract || '').replace(/<[^>]+>/g, '')
};
}
},
/* 6. GitHub — repo description, language, stars */
function githubContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/github\.com\/([^/]+)\/([^/?#]+)/);
if (!m) return Promise.resolve(null);
return fetchJson('https://api.github.com/repos/' + m[1] + '/' + m[2],
{ headers: { 'Accept': 'application/vnd.github.v3+json' } })
.then(function (data) {
/* GitHub — repo description + language + stars. */
{
name: 'github', label: 'GitHub',
match: /github\.com\/([^/]+)\/([^/?#]+)/,
fetchType: 'json',
fetchInit: { headers: { 'Accept': 'application/vnd.github.v3+json' } },
url: function (ctx) {
return 'https://api.github.com/repos/' + ctx.match[1] + '/' + ctx.match[2];
},
parse: function (data) {
if (!data || !data.full_name) return null;
var meta = [data.language, data.stargazers_count != null ? '\u2605\u00a0' + data.stargazers_count : null]
.filter(Boolean).join(' \u00b7 ');
return store(href,
'<div class="popup-github">'
+ srcHtml('github', 'GitHub')
+ '<div class="popup-title">' + esc(data.full_name) + '</div>'
+ (data.description ? '<div class="popup-abstract">' + esc(data.description) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
var stars = data.stargazers_count;
return {
title: data.full_name,
abstract: data.description || '',
stats: [data.language,
stars != null ? '\u2605\u00a0' + stars : null]
.filter(Boolean).join(' \u00b7 ')
};
}
},
/* 6.5 Forgejo — repo description, language, stars */
function forgejoContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/git\.levineuwirth\.org\/([^/]+)\/([^/?#]+)/);
if (!m) return Promise.resolve(null);
var apiUrl = 'https://git.levineuwirth.org/api/v1/repos/' + m[1] + '/' + m[2];
return fetchJson(apiUrl)
.then(function (data) {
/* Forgejo (self-hosted git) same shape as GitHub, but field
naming differs (`stars_count` vs `stargazers_count`). */
{
name: 'forgejo', label: 'Forgejo',
match: /git\.levineuwirth\.org\/([^/]+)\/([^/?#]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://git.levineuwirth.org/api/v1/repos/'
+ ctx.match[1] + '/' + ctx.match[2];
},
parse: function (data) {
if (!data || !data.full_name) return null;
var meta = [data.language, data.stars_count != null ? '\u2605\u00a0' + data.stars_count : null]
.filter(Boolean).join(' \u00b7 ');
return store(href,
'<div class="popup-forgejo">'
+ srcHtml('forgejo', 'Forgejo')
+ '<div class="popup-title">' + esc(data.full_name) + '</div>'
+ (data.description ? '<div class="popup-abstract">' + esc(data.description) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
var stars = data.stars_count;
return {
title: data.full_name,
abstract: data.description || '',
stats: [data.language,
stars != null ? '\u2605\u00a0' + stars : null]
.filter(Boolean).join(' \u00b7 ')
};
}
},
/* 7. Open Library — book title + description */
function openlibraryContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var base = href.replace(/[?#].*$/, '');
var apiUrl = base + '.json';
return fetchJson(apiUrl)
.then(function (data) {
/* Open Library — works/books JSON appended to href. */
{
name: 'openlibrary', label: 'Open Library',
match: /openlibrary\.org\/(?:works|books)\//,
fetchType: 'json',
bodyLimit: 300,
url: function (ctx) { return ctx.href.replace(/[?#].*$/, '') + '.json'; },
parse: function (data) {
if (!data || !data.title) return null;
var desc = data.description;
if (desc && typeof desc === 'object') desc = desc.value;
desc = (desc || '').replace(/\s+/g, ' ').trim();
if (desc.length > 300) desc = desc.slice(0, 300).replace(/\s\S+$/, '') + '\u2026';
return store(href,
'<div class="popup-openlibrary">'
+ srcHtml('openlibrary', 'Open Library')
+ '<div class="popup-title">' + esc(data.title) + '</div>'
+ (desc ? '<div class="popup-abstract">' + esc(desc) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
return { title: data.title, abstract: desc || '' };
}
},
/* 8. bioRxiv / medRxiv — abstract via biorxiv content server API */
function biorxivContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
/* bioRxiv — shares schema with medRxiv via biorxivParse. */
{
name: 'biorxiv', label: 'bioRxiv',
match: /biorxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.biorxiv.org/details/biorxiv/'
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json';
},
parse: biorxivParse
},
var m = href.match(/(?:bio|med)rxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/);
if (!m) return Promise.resolve(null);
/* medRxiv — identical shape as bioRxiv; different upstream path. */
{
name: 'medrxiv', label: 'medRxiv',
match: /medrxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.biorxiv.org/details/medrxiv/'
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json';
},
parse: biorxivParse
},
var doi = m[1].replace(/v\d+$/, '');
var server = /medrxiv/.test(href) ? 'medrxiv' : 'biorxiv';
var label = server === 'medrxiv' ? 'medRxiv' : 'bioRxiv';
return fetchJson('https://api.biorxiv.org/details/' + server + '/' + encodeURIComponent(doi) + '/json')
.then(function (data) {
var paper = data && data.collection && data.collection[0];
if (!paper || !paper.title) return null;
var abstract = (paper.abstract || '').replace(/\s+/g, ' ').trim();
if (abstract.length > 500) abstract = abstract.slice(0, 500).replace(/\s\S+$/, '') + '\u2026';
var authorStr = '';
if (paper.authors) {
var list = paper.authors.split(';').map(function (s) { return s.trim(); }).filter(Boolean);
authorStr = list.slice(0, 3).join(', ');
if (list.length > 3) authorStr += ' et\u00a0al.';
}
return store(href,
'<div class="popup-biorxiv">'
+ srcHtml(server, label)
+ '<div class="popup-title">' + esc(paper.title) + '</div>'
+ (authorStr ? '<div class="popup-authors">' + esc(authorStr) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* 9. YouTube — oEmbed, title + channel name */
function youtubeContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
return fetchJson('https://www.youtube.com/oembed?url=' + encodeURIComponent(href) + '&format=json')
.then(function (data) {
/* YouTube — oEmbed (no API key required). */
{
name: 'youtube', label: 'YouTube',
match: /youtube\.com\/watch|youtu\.be\//,
fetchType: 'json',
url: function (ctx) {
return 'https://www.youtube.com/oembed?url='
+ encodeURIComponent(ctx.href) + '&format=json';
},
parse: function (data) {
if (!data || !data.title) return null;
return store(href,
'<div class="popup-youtube">'
+ srcHtml('youtube', 'YouTube')
+ '<div class="popup-title">' + esc(data.title) + '</div>'
+ (data.author_name ? '<div class="popup-authors">' + esc(data.author_name) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
return { title: data.title, authors: data.author_name || '' };
}
},
/* 10. Internet Archive — title, creator, description */
function archiveContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/archive\.org\/details\/([^/?#]+)/);
if (!m) return Promise.resolve(null);
return fetchJson('https://archive.org/metadata/' + encodeURIComponent(m[1]))
.then(function (data) {
/* Internet Archive item metadata (CORS-broken upstream, proxied).
CSS icon key is `internet-archive` (hyphenated), but the
provider/class name stays short hence the `icon` override. */
{
name: 'archive', label: 'Internet Archive', icon: 'internet-archive',
match: /archive\.org\/details\/([^/?#]+)/,
fetchType: 'json',
bodyLimit: 280,
url: function (ctx) {
return '/proxy/archive/metadata/' + encodeURIComponent(ctx.match[1]);
},
parse: function (data) {
var meta = data && data.metadata;
if (!meta) return null;
var first = function (v) { return Array.isArray(v) ? v[0] : (v || ''); };
var title = first(meta.title);
if (!title) return null;
var creator = first(meta.creator);
var year = first(meta.year);
var desc = first(meta.description);
if (!title) return null;
desc = desc.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (desc.length > 280) desc = desc.slice(0, 280).replace(/\s\S+$/, '') + '\u2026';
var byline = [creator, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-archive">'
+ srcHtml('internet-archive', 'Internet Archive')
+ '<div class="popup-title">' + esc(title) + '</div>'
+ (byline ? '<div class="popup-authors">' + esc(byline) + '</div>' : '')
+ (desc ? '<div class="popup-abstract">' + esc(desc) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
return {
title: title,
authors: [creator, year].filter(Boolean).join(', '),
abstract: first(meta.description).replace(/<[^>]+>/g, '')
};
}
},
/* 11. PubMed — NCBI esummary, title + authors + journal */
function pubmedContent(target) {
var href = target.getAttribute('href');
if (!href || cache[href]) return Promise.resolve(cache[href] || null);
var m = href.match(/pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/);
if (!m) return Promise.resolve(null);
var pmid = m[1];
var apiUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
+ '?db=pubmed&id=' + pmid + '&retmode=json';
return fetchJson(apiUrl)
.then(function (data) {
var paper = data && data.result && data.result[pmid];
/* PubMed — NCBI esummary (CORS-broken upstream, proxied). */
{
name: 'pubmed', label: 'PubMed',
match: /pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/,
fetchType: 'json',
url: function (ctx) {
return '/proxy/pubmed/entrez/eutils/esummary.fcgi'
+ '?db=pubmed&id=' + ctx.match[1] + '&retmode=json';
},
parse: function (data, ctx) {
var paper = data && data.result && data.result[ctx.match[1]];
if (!paper || !paper.title) return null;
var authors = (paper.authors || []).slice(0, 3)
.map(function (a) { return a.name; }).join(', ');
if ((paper.authors || []).length > 3) authors += ' et\u00a0al.';
var journal = paper.fulljournalname || paper.source || '';
var year = (paper.pubdate || '').slice(0, 4);
var meta = [journal, year].filter(Boolean).join(', ');
return store(href,
'<div class="popup-pubmed">'
+ srcHtml('pubmed', 'PubMed')
+ '<div class="popup-title">' + esc(paper.title) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (meta ? '<div class="popup-meta">' + esc(meta) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
return {
title: paper.title,
authors: (paper.authors || []).map(function (a) { return a.name; }),
meta: [paper.fulljournalname || paper.source || '',
(paper.pubdate || '').slice(0, 4)].filter(Boolean).join(', ')
};
}
}
];
/* ------------------------------------------------------------------
Helpers

View File

@ -6,10 +6,29 @@
$body$
$if(backlinks)$
<footer class="page-meta-footer">
<div class="meta-footer-section" id="backlinks">
<div class="meta-footer-full meta-footer-backlinks" id="backlinks">
<h3>Backlinks</h3>
$backlinks$
</div>
$if(similar-links)$
<div class="meta-footer-grid">
<div class="meta-footer-section" id="similar-links">
<h3>Related</h3>
$similar-links$
</div>
</div>
$endif$
</footer>
$else$
$if(similar-links)$
<footer class="page-meta-footer">
<div class="meta-footer-grid">
<div class="meta-footer-section" id="similar-links">
<h3>Related</h3>
$similar-links$
</div>
</div>
</footer>
$endif$
$endif$
</main>

View File

@ -3,7 +3,7 @@
<head>
$partial("templates/partials/head.html")$
</head>
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$>
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$$if(dingbat)$ data-dingbat="$dingbat$"$endif$>
<a class="skip-link" href="#markdownBody">Skip to content</a>
$partial("templates/partials/nav.html")$
$if(search)$

View File

@ -30,7 +30,7 @@
</div>
$endif$
<nav class="meta-row meta-pagelinks" aria-label="Page sections">
<a href="#version-history">History</a>
<a href="#version-history">$if(version-history-range)$$version-history-range$$else$History$endif$</a>
$if(status)$<a href="#epistemic">Epistemic</a>$endif$
$if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$
$if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$

View File

@ -57,23 +57,21 @@
</div>
$endif$
$if(backlinks)$
<div class="meta-footer-full meta-footer-backlinks" id="backlinks">
<h3>Backlinks</h3>
$backlinks$
</div>
$endif$
<div class="meta-footer-grid">
<div class="meta-footer-section" id="version-history">
<h3>Version history</h3>
$if(version-history)$
<ul>
$for(version-history)$
<li>$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
$endfor$
</ul>
$else$
<ul>
$if(date-created)$<li>$date-created$ &middot; Created</li>$endif$
$if(date-modified)$<li>$date-modified$ &middot; Last modified</li>$endif$
</ul>
$endif$
$if(similar-links)$
<div class="meta-footer-section" id="similar-links">
<h3>Related</h3>
$similar-links$
</div>
$endif$
$if(status)$
<div class="meta-footer-section meta-footer-epistemic" id="epistemic">
@ -86,19 +84,31 @@
</div>
$endif$
$if(backlinks)$
<div class="meta-footer-section" id="backlinks">
<h3>Backlinks</h3>
$backlinks$
</div>
<div class="meta-footer-section" id="version-history">
<h3>Version history</h3>
$if(version-history-primary)$
<ul class="version-history-list">
$for(version-history-primary)$
<li>$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
$endfor$
</ul>
$if(version-history-rest)$
<details class="version-history-more">
<summary>More</summary>
<ul class="version-history-list">
$for(version-history-rest)$
<li>$vh-date$$if(vh-message)$ &middot; $vh-message$$endif$</li>
$endfor$
</ul>
</details>
$endif$
$if(similar-links)$
<div class="meta-footer-section" id="similar-links">
<h3>Related</h3>
$similar-links$
</div>
$else$
<ul class="version-history-list">
$if(date-created)$<li>$date-created$ &middot; Created</li>$endif$
$if(date-modified)$<li>$date-modified$ &middot; Last modified</li>$endif$
</ul>
$endif$
</div>
</div>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

213
tools/add-popup-source.sh Executable file
View File

@ -0,0 +1,213 @@
#!/usr/bin/env bash
# add-popup-source.sh — Scaffold a new popup provider end-to-end.
#
# Prompts for the handful of facts unique to a new source (name, label,
# a sample URL), then:
# 1. Probes the upstream for CORS support and content-type.
# 2. Prints a PROVIDERS entry stub you paste into static/js/popups.js.
# 3. If CORS-broken, prints + (on confirmation) appends an nginx
# location block to nginx/popup-proxy.conf.
# 4. Prints a remaining-steps checklist (icon SVG, CSS, CSP comment).
#
# The parse() body is left as a TODO — writing it requires eyes on the
# actual API response, which this tool also fetches and pretty-prints so
# you can inspect the response shape without a second terminal.
#
# Never writes to static/js/popups.js automatically — that file has too
# much surrounding structure to edit blindly. Copy-paste is safer.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
POPUPS_JS="$REPO_ROOT/static/js/popups.js"
NGINX_CONF="$REPO_ROOT/nginx/popup-proxy.conf"
ORIGIN="https://levineuwirth.org"
# ── helpers ──────────────────────────────────────────────────────────
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; }
prompt() { local v; read -r -p "$1 " v; printf '%s' "$v"; }
usage() {
cat <<EOF
Usage: tools/add-popup-source.sh [--help]
Interactive scaffold for a new popups.js provider. Asks a few
questions, probes the upstream, and prints the code you paste into
the provider table (and, if needed, an nginx reverse-proxy block).
Requires: curl, jq (for JSON pretty-printing), xmllint (optional).
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
# ── interactive prompts ──────────────────────────────────────────────
bold "── new popup provider ──"
NAME=$(prompt "slug (lowercase, used as class + data-popup-source key, e.g. 'zenodo'):")
[[ -z "$NAME" ]] && { warn "slug required"; exit 1; }
LABEL=$(prompt "display label (e.g. 'Zenodo'):")
[[ -z "$LABEL" ]] && LABEL="$NAME"
SAMPLE_URL=$(prompt "sample link URL (the kind readers will click):")
[[ -z "$SAMPLE_URL" ]] && { warn "sample URL required"; exit 1; }
API_URL=$(prompt "sample API URL (leave blank if you haven't found it yet):")
# ── CORS + content-type probe ────────────────────────────────────────
echo
bold "── probing upstream ──"
if [[ -n "$API_URL" ]]; then
HEADERS=$(curl -sSI -H "Origin: $ORIGIN" "$API_URL" 2>&1 || true)
# grep returns 1 when no match — tolerate that under `set -e` + pipefail.
CORS=$(printf '%s\n' "$HEADERS" | grep -i 'access-control-allow-origin' | head -1 | tr -d '\r' || true)
CT=$(printf '%s\n' "$HEADERS" | grep -i '^content-type' | head -1 | tr -d '\r' | awk '{print tolower($2)}' || true)
if [[ -n "$CORS" ]]; then
echo " CORS ✓ $CORS"
NEEDS_PROXY=0
else
warn " CORS ✗ none — upstream needs a reverse proxy"
NEEDS_PROXY=1
fi
echo " Content-Type: ${CT:-unknown}"
case "$CT" in
*xml*|*atom*) FETCH_TYPE=xml ;;
*json*) FETCH_TYPE=json ;;
*) FETCH_TYPE=json; warn " (unrecognized type — defaulting to json)" ;;
esac
echo " fetchType → $FETCH_TYPE"
else
warn " no API URL supplied; assuming json + CORS-OK (edit by hand if wrong)"
FETCH_TYPE=json
NEEDS_PROXY=0
fi
# ── sample response dump (helps write the parser) ────────────────────
if [[ -n "$API_URL" ]]; then
echo
bold "── sample response (first ~40 lines) ──"
RAW=$(curl -sS "$API_URL" 2>&1 || true)
if [[ "$FETCH_TYPE" == json ]] && command -v jq >/dev/null; then
printf '%s\n' "$RAW" | jq -C . 2>/dev/null | head -40 || printf '%s\n' "$RAW" | head -40
elif [[ "$FETCH_TYPE" == xml ]] && command -v xmllint >/dev/null; then
printf '%s\n' "$RAW" | xmllint --format - 2>/dev/null | head -40 || printf '%s\n' "$RAW" | head -40
else
printf '%s\n' "$RAW" | head -40
fi
fi
# ── proxy prefix + upstream host derivation ──────────────────────────
if [[ "$NEEDS_PROXY" -eq 1 ]]; then
UPSTREAM_HOST=$(printf '%s' "$API_URL" | awk -F/ '{print $3}')
UPSTREAM_PATH=$(printf '%s' "$API_URL" | awk -F/ 'BEGIN{OFS="/"} {$1=""; $2=""; $3=""; print}' | sed 's|^///||')
PROXY_PATH="/proxy/$NAME/"
PROXY_API_URL="$PROXY_PATH${UPSTREAM_PATH%%\?*}"
[[ "$API_URL" == *"?"* ]] && PROXY_API_URL="$PROXY_API_URL?${API_URL#*\?}"
else
UPSTREAM_HOST=""
PROXY_API_URL="$API_URL"
fi
# ── PROVIDERS entry stub ─────────────────────────────────────────────
echo
bold "── paste into static/js/popups.js (PROVIDERS array) ──"
dim " Insertion order = match priority; pick a spot before less-specific entries."
echo
cat <<EOF
/* $LABEL — TODO: one-line description. */
{
name: '$NAME', label: '$LABEL',
match: /TODO-regex-for-public-URL/,
fetchType: '$FETCH_TYPE',
url: function (ctx) {
return '${PROXY_API_URL:-TODO-upstream-URL}'; // uses ctx.match / ctx.href
},
parse: function (data) {
// TODO: map response to { title, authors?, meta?, abstract?, stats? }
if (!data || !data.TITLE_FIELD) return null;
return {
title: data.TITLE_FIELD,
authors: data.AUTHORS_FIELD || [],
abstract: data.ABSTRACT_FIELD || ''
};
}
},
EOF
# ── nginx proxy block ────────────────────────────────────────────────
if [[ "$NEEDS_PROXY" -eq 1 ]]; then
echo
bold "── nginx block for $NGINX_CONF ──"
NGINX_BLOCK=$(cat <<EOF
# ── $LABEL ───────────────────────────────────────────────────────────
# TODO: note the upstream + why it's CORS-broken + cache justification.
location /proxy/$NAME/ {
set \$upstream_$NAME $UPSTREAM_HOST;
proxy_pass https://\$upstream_$NAME/;
proxy_set_header Host \$upstream_$NAME;
proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on;
proxy_cache popup_proxy;
proxy_cache_valid 200 30d;
proxy_cache_valid any 5m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status \$upstream_cache_status always;
add_header Access-Control-Allow-Origin "\$scheme://\$host" always;
}
EOF
)
printf '%s\n' "$NGINX_BLOCK"
echo
ANSWER=$(prompt "append this block to nginx/popup-proxy.conf now? [y/N]")
if [[ "$ANSWER" =~ ^[Yy] ]]; then
printf '%s\n' "$NGINX_BLOCK" >> "$NGINX_CONF"
echo " appended to $NGINX_CONF"
else
echo " skipped — paste manually when ready"
fi
fi
# ── remaining-steps checklist ────────────────────────────────────────
echo
bold "── remaining manual steps ──"
cat <<EOF
1. In static/js/popups.js: paste the PROVIDERS entry into the table
(it lives after the provider helpers; order = match priority) and
fill the TODO regex + parse() body.
2. In static/css/popups.css: add an icon rule (copy any existing
.popup-source[data-popup-source="…"]::before block and swap in the
$NAME key + the mask-image path).
3. Add static/images/link-icons/$NAME.svg — used by both the inline
link icon and the popup source label. Monochrome SVG, currentColor.
4. In build/Filters/Links.hs: add a clause to \`domainIcon\` so links
to this source get data-link-icon="$NAME" at build time.
EOF
if [[ "$NEEDS_PROXY" -eq 0 && -n "$UPSTREAM_HOST" ]]; then
echo " 5. In static/js/popups.js top-comment: add $UPSTREAM_HOST to the"
echo " connect-src CSP list."
fi
echo
dim "done."