From a5495035bea948632983304344b7c8d819a51824 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 26 Mar 2026 09:10:35 -0400 Subject: [PATCH] epistemic v2 --- Makefile | 20 ++++++- WRITING.md | 43 +++++++++++++++ build/Contexts.hs | 27 +++++++++- build/Filters.hs | 6 ++- build/Filters/EmbedPdf.hs | 81 +++++++++++++++++++++++++++++ build/Filters/Links.hs | 32 +++++++++++- static/css/components.css | 55 ++++++++++++++++++++ static/css/popups.css | 25 +++++++++ static/js/popups.js | 53 +++++++++++++++++++ templates/partials/metadata.html | 2 +- templates/partials/page-footer.html | 2 +- 11 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 build/Filters/EmbedPdf.hs diff --git a/Makefile b/Makefile index 8f08b6b..e9d5728 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deploy sign download-model convert-images watch clean dev +.PHONY: build deploy sign download-model convert-images pdf-thumbs watch clean dev # Source .env for GITHUB_TOKEN and GITHUB_REPO if it exists. # .env format: KEY=value (one per line, no `export` prefix, no quotes needed). @@ -10,6 +10,7 @@ build: @git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ)" @date +%s > data/build-start.txt @./tools/convert-images.sh + @$(MAKE) -s pdf-thumbs cabal run site -- build pagefind --site _site @if [ -d .venv ]; then \ @@ -35,6 +36,23 @@ download-model: convert-images: @./tools/convert-images.sh +# Generate first-page thumbnails for PDFs in static/papers/ (also runs in build). +# Requires pdftoppm: pacman -S poppler / apt install poppler-utils +# Thumbnails are written as static/papers/foo.thumb.png alongside each PDF. +# Skipped silently when pdftoppm is not installed or static/papers/ is empty. +pdf-thumbs: + @if command -v pdftoppm >/dev/null 2>&1; then \ + find static/papers -name '*.pdf' 2>/dev/null | while read pdf; do \ + thumb="$${pdf%.pdf}.thumb"; \ + if [ ! -f "$${thumb}.png" ] || [ "$$pdf" -nt "$${thumb}.png" ]; then \ + echo " pdf-thumb $$pdf"; \ + pdftoppm -r 100 -f 1 -l 1 -png -singlefile "$$pdf" "$$thumb"; \ + fi; \ + done; \ + else \ + echo "pdf-thumbs: pdftoppm not found — install poppler (skipping)"; \ + fi + deploy: build sign @if [ -z "$(GITHUB_TOKEN)" ] || [ -z "$(GITHUB_REPO)" ]; then \ echo "Skipping GitHub push: set GITHUB_TOKEN and GITHUB_REPO in .env"; \ diff --git a/WRITING.md b/WRITING.md index 66ef277..75c273d 100644 --- a/WRITING.md +++ b/WRITING.md @@ -272,6 +272,49 @@ not derivable from the page title. --- +## PDF Embeds + +Embed a hosted PDF in a full PDF.js viewer (page navigation, zoom, text +selection) using the `{{pdf:...}}` directive on its own line: + +```markdown +{{pdf:/papers/smith2023.pdf}} +{{pdf:/papers/smith2023.pdf#5}} ← open at page 5 (bare integer) +{{pdf:/papers/smith2023.pdf#page=5}} ← explicit form, same result +``` + +The directive produces an iframe pointing at the vendored PDF.js viewer at +`/pdfjs/web/viewer.html?file=...`. The PDF must be served from the same +origin (i.e. it must be a file you host). + +**Storing papers.** Drop PDFs in `static/papers/`; Hakyll's static rule copies +everything under `static/` to `_site/` unchanged. Reference them as +`/papers/filename.pdf`. + +**One-time vendor setup.** PDF.js is not included in the repo. Install it once: + +```bash +npm install pdfjs-dist +mkdir -p static/pdfjs +cp -r node_modules/pdfjs-dist/web static/pdfjs/ +cp -r node_modules/pdfjs-dist/build static/pdfjs/ +rm -rf node_modules package-lock.json +``` + +Then commit `static/pdfjs/` (it is static and changes only when you want to +upgrade PDF.js). Hakyll's existing `static/**` rule copies it through without +any new build rules. + +**Optional: disable the "Open file" button** in the viewer. Edit +`static/pdfjs/web/viewer.html` and set `AppOptions.set('disableOpenFile', true)` +in the `webViewerLoad` callback, or add a thin CSS rule to `viewer.css`: + +```css +#openFile, #secondaryOpenFile { display: none !important; } +``` + +--- + ## Math Pandoc parses LaTeX math and wraps it in `class="math inline"` / `class="math display"` diff --git a/build/Contexts.hs b/build/Contexts.hs index bc2fc5b..fa0c94a 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -58,7 +58,6 @@ affiliationField = listFieldWith "affiliation-links" ctx $ \item -> do parseEntry s = case break (== '|') s of (name, '|' : url) -> (trim name, trim url) (name, _) -> (trim name, "") - trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ') -- --------------------------------------------------------------------------- -- Build time field @@ -178,11 +177,37 @@ confidenceTrendField = field "confidence-trend" $ \item -> do | otherwise -> return "\x2192" -- → _ -> return "\x2192" +-- | @$overall-score$@: weighted composite of confidence (50 %), +-- evidence quality (30 %), and importance (20 %), expressed as an +-- integer on a 0–100 scale. +-- Returns @noResult@ when any contributing field is absent, so +-- @$if(overall-score)$@ guards the template safely. +-- +-- Formula: raw = conf/100·0.5 + ev/5·0.3 + imp/5·0.2 (0–1) +-- score = clamp₀₋₁₀₀(round(raw · 100)) +overallScoreField :: Context String +overallScoreField = field "overall-score" $ \item -> do + meta <- getMetadata (itemIdentifier item) + let readInt s = readMaybe s :: Maybe Int + case ( readInt =<< lookupString "confidence" meta + , readInt =<< lookupString "evidence" meta + , readInt =<< lookupString "importance" meta + ) of + (Just conf, Just ev, Just imp) -> + let raw :: Double + raw = fromIntegral conf / 100.0 * 0.5 + + fromIntegral ev / 5.0 * 0.3 + + fromIntegral imp / 5.0 * 0.2 + score = max 0 (min 100 (round (raw * 100.0) :: Int)) + in return (show score) + _ -> fail "overall-score: confidence, evidence, or importance not set" + -- | All epistemic context fields composed. epistemicCtx :: Context String epistemicCtx = dotsField "importance-dots" "importance" <> dotsField "evidence-dots" "evidence" + <> overallScoreField <> confidenceTrendField <> stabilityField <> lastReviewedField diff --git a/build/Filters.hs b/build/Filters.hs index 010424d..d2c13e7 100644 --- a/build/Filters.hs +++ b/build/Filters.hs @@ -16,6 +16,7 @@ import qualified Filters.Dropcaps as Dropcaps import qualified Filters.Math as Math import qualified Filters.Wikilinks as Wikilinks import qualified Filters.Transclusion as Transclusion +import qualified Filters.EmbedPdf as EmbedPdf import qualified Filters.Code as Code import qualified Filters.Images as Images @@ -33,6 +34,7 @@ applyAll . Images.apply -- | Apply source-level preprocessors to the raw Markdown string. --- Run before 'readPandocWith'. +-- Order matters: EmbedPdf must run before Transclusion, because the +-- transclusion parser would otherwise treat {{pdf:...}} as a broken slug. preprocessSource :: String -> String -preprocessSource = Transclusion.preprocess . Wikilinks.preprocess +preprocessSource = Transclusion.preprocess . EmbedPdf.preprocess . Wikilinks.preprocess diff --git a/build/Filters/EmbedPdf.hs b/build/Filters/EmbedPdf.hs new file mode 100644 index 0000000..5c28716 --- /dev/null +++ b/build/Filters/EmbedPdf.hs @@ -0,0 +1,81 @@ +{-# LANGUAGE GHC2021 #-} +-- | Source-level preprocessor for inline PDF embeds. +-- +-- Rewrites block-level @{{pdf:...}}@ directives to raw HTML that renders the +-- named file inside a vendored PDF.js viewer iframe. +-- +-- Syntax (must be the sole content of a line after trimming): +-- +-- > {{pdf:/papers/foo.pdf}} — embed from page 1 +-- > {{pdf:/papers/foo.pdf#5}} — start at page 5 (bare integer) +-- > {{pdf:/papers/foo.pdf#page=5}} — start at page 5 (explicit form) +-- +-- The file path must be root-relative (begins with @/@). +-- PDF.js is expected to be vendored at @/pdfjs/web/viewer.html@. +module Filters.EmbedPdf (preprocess) where + +import Data.Char (isDigit) +import Data.List (isPrefixOf, isSuffixOf) + +-- | Apply PDF-embed substitution to the raw Markdown source string. +preprocess :: String -> String +preprocess = unlines . map processLine . lines + +processLine :: String -> String +processLine line = + case parseDirective (trim line) of + Nothing -> line + Just (filePath, pageHash) -> renderEmbed filePath pageHash + +-- | Parse a @{{pdf:/path/to/file.pdf}}@ or @{{pdf:/path.pdf#N}}@ directive. +-- Returns @(filePath, pageHash)@ where @pageHash@ is either @""@ or @"#page=N"@. +parseDirective :: String -> Maybe (String, String) +parseDirective s + | not ("{{pdf:" `isPrefixOf` s) = Nothing + | not ("}}" `isSuffixOf` s) = Nothing + | otherwise = + let inner = take (length s - 2) (drop 6 s) -- strip "{{pdf:" and "}}" + (path, frag) = break (== '#') inner + in if null path + then Nothing + else Just (path, parsePageHash frag) + +-- | Convert the fragment part of the directive (e.g. @#5@ or @#page=5@) to a +-- PDF.js-compatible @#page=N@ hash, or @""@ if absent/invalid. +parsePageHash :: String -> String +parsePageHash ('#' : rest) + | "page=" `isPrefixOf` rest = + let n = takeWhile isDigit (drop 5 rest) + in if null n then "" else "#page=" ++ n + | all isDigit rest && not (null rest) = "#page=" ++ rest +parsePageHash _ = "" + +-- | Render the HTML for a PDF embed. +renderEmbed :: String -> String -> String +renderEmbed filePath pageHash = + let viewerUrl = "/pdfjs/web/viewer.html?file=" ++ encodeQueryValue filePath ++ pageHash + in "
" + ++ "" + ++ "
" + +-- | Percent-encode characters that would break a query-string value. +-- Slashes are left unencoded so root-relative paths remain readable and +-- work correctly with PDF.js's internal fetch. +encodeQueryValue :: String -> String +encodeQueryValue = concatMap enc + where + enc ' ' = "%20" + enc '&' = "%26" + enc '?' = "%3F" + enc '+' = "%2B" + enc '"' = "%22" + enc c = [c] + +-- | Strip leading and trailing spaces. +trim :: String -> String +trim = f . f + where f = reverse . dropWhile (== ' ') diff --git a/build/Filters/Links.hs b/build/Filters/Links.hs index 4bd1135..e882fb6 100644 --- a/build/Filters/Links.hs +++ b/build/Filters/Links.hs @@ -16,8 +16,25 @@ import Text.Pandoc.Definition import Text.Pandoc.Walk (walk) -- | Apply link classification to the entire document. +-- Two passes: PDF links first (rewrites href to viewer URL), then external +-- link classification (operates on http/https, so no overlap). apply :: Pandoc -> Pandoc -apply = walk classifyLink +apply = walk classifyLink . walk classifyPdfLink + +-- | Rewrite root-relative PDF links to open via the vendored PDF.js viewer. +-- Preserves the original path in @data-pdf-src@ so the popup thumbnail +-- provider can locate the corresponding @.thumb.png@ file. +-- Skips links that are already pointing at the viewer (idempotent). +classifyPdfLink :: Inline -> Inline +classifyPdfLink (Link (ident, classes, kvs) ils (url, title)) + | "/" `T.isPrefixOf` url + , ".pdf" `T.isSuffixOf` T.toLower url + , "pdf-link" `notElem` classes = + let viewerUrl = "/pdfjs/web/viewer.html?file=" <> encodeQueryValue url + classes' = classes ++ ["pdf-link"] + kvs' = kvs ++ [("data-pdf-src", url)] + in Link (ident, classes', kvs') ils (viewerUrl, title) +classifyPdfLink x = x classifyLink :: Inline -> Inline classifyLink (Link (ident, classes, kvs) ils (url, title)) @@ -49,3 +66,16 @@ domainIcon url | "doi.org" `T.isInfixOf` url = "doi" | "github.com" `T.isInfixOf` url = "github" | otherwise = "external" + +-- | Percent-encode characters that would break a @?file=@ query-string value. +-- Slashes are intentionally left unencoded so root-relative paths remain +-- readable and work correctly with PDF.js's internal fetch. +encodeQueryValue :: Text -> Text +encodeQueryValue = T.concatMap enc + where + enc ' ' = "%20" + enc '&' = "%26" + enc '?' = "%3F" + enc '+' = "%2B" + enc '"' = "%22" + enc c = T.singleton c diff --git a/static/css/components.css b/static/css/components.css index f4e50b3..8a3b601 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -605,6 +605,15 @@ nav.site-nav { text-decoration: underline; text-underline-offset: 2px; } +/* When the score box is present, suppress the link-level underline and + restore it only on the label text — leaves the gap and box undecorated. */ +.meta-pagelinks a:has(.meta-overall-score):hover { + text-decoration: none; +} +.meta-pagelinks a:has(.meta-overall-score):hover .ep-link-label { + text-decoration: underline; + text-underline-offset: 2px; +} .meta-pagelinks a + a::before { content: " · "; color: var(--border); @@ -860,6 +869,24 @@ details[open] > .ep-summary::before { content: "▾\00a0"; } } +/* Overall score percentage in the top metadata nav (adjacent to Epistemic link). + Renders as a compact numeric chip: "72%" in small-caps sans, separated from + the link text by a faint centerdot. */ +.meta-overall-score { + font-family: var(--font-sans); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.03em; + color: var(--text-muted); + display: inline-block; + text-decoration: none; + border: 1px solid var(--border-muted); + border-radius: 2px; + padding: 0.05em 0.5em; + margin-left: 0.5em; + vertical-align: 0.05em; +} + /* ============================================================ PAGINATION NAV ============================================================ */ @@ -1393,3 +1420,31 @@ pre:hover .copy-btn, /* Copy button: always visible on touch (no persistent hover) */ .copy-btn { opacity: 1; } } + +/* ============================================================ + PDF EMBED + Inline PDF.js viewer iframes rendered from {{pdf:...}} directives. + ============================================================ */ + +.pdf-embed-wrapper { + margin: 1.5rem 0; + border: 1px solid var(--border); + border-radius: 2px; + overflow: hidden; + background: var(--bg-subtle); +} + +.pdf-embed { + display: block; + width: 100%; + height: 70vh; + min-height: 420px; + border: none; +} + +@media (max-width: 600px) { + .pdf-embed { + height: 60vh; + min-height: 300px; + } +} diff --git a/static/css/popups.css b/static/css/popups.css index c9cdcf3..4572719 100644 --- a/static/css/popups.css +++ b/static/css/popups.css @@ -104,6 +104,31 @@ display: none; } +/* Epistemic preview popup — content cloned from #epistemic; ep-* styles inherited */ +.popup-epistemic { + min-width: 230px; +} + +/* PDF thumbnail popup — first-page image generated by pdftoppm at build time */ +.link-popup:has(.popup-pdf) { + padding: 0; + overflow: hidden; +} + +.popup-pdf { + line-height: 0; /* collapse whitespace gap below */ +} + +.popup-pdf-thumb { + display: block; + width: 320px; + max-width: 100%; + height: auto; + max-height: 420px; + object-fit: contain; + object-position: top left; +} + /* PGP signature popup */ .popup-sig pre { margin: 0; diff --git a/static/js/popups.js b/static/js/popups.js index 98cb528..667228c 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -62,6 +62,11 @@ bind(el, citationContent); }); + /* Epistemic jump link — preview of the status/confidence/dot block */ + root.querySelectorAll('a[href="#epistemic"]').forEach(function (el) { + bind(el, epistemicContent); + }); + /* 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) { @@ -71,10 +76,16 @@ if (!inAuthors && !isBacklink) { 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; } bind(el, internalContent); }); + /* PDF links — rewritten to viewer URL by Links.hs; thumbnail on hover */ + root.querySelectorAll('a.pdf-link[data-pdf-src]').forEach(function (el) { + bind(el, pdfContent); + }); + /* PGP signature links in footer */ root.querySelectorAll('a.footer-sig-link').forEach(function (el) { bind(el, sigContent); @@ -550,6 +561,48 @@ return html; } + /* Epistemic jump link — reads the #epistemic section already in the DOM + and renders a compact summary: status/confidence/dots + expanded DL. + All ep-* classes are already styled via components.css. */ + function epistemicContent() { + var section = document.getElementById('epistemic'); + if (!section) return Promise.resolve(null); + + var html = ''; + return Promise.resolve(html || null); + } + + /* Local PDF — shows the build-time first-page thumbnail (.thumb.png). + Returns null (no popup) if the thumbnail file does not exist. */ + function pdfContent(target) { + var src = target.dataset.pdfSrc; + if (!src) return Promise.resolve(null); + var thumb = src.replace(/\.pdf$/i, '.thumb.png'); + if (cache[thumb]) return Promise.resolve(cache[thumb]); + /* HEAD request: verify thumbnail exists before committing to a popup. */ + return fetch(thumb, { method: 'HEAD', credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) return null; + return store(thumb, + ''); + }) + .catch(function () { return null; }); + } + /* PGP signature — fetch the .sig file and display ASCII armor */ function sigContent(target) { var href = target.getAttribute('href'); diff --git a/templates/partials/metadata.html b/templates/partials/metadata.html index a015563..69390d8 100644 --- a/templates/partials/metadata.html +++ b/templates/partials/metadata.html @@ -19,7 +19,7 @@ $endif$ diff --git a/templates/partials/page-footer.html b/templates/partials/page-footer.html index f8db21e..b3a1322 100644 --- a/templates/partials/page-footer.html +++ b/templates/partials/page-footer.html @@ -34,7 +34,7 @@ $if(status)$