epistemic v2

This commit is contained in:
Levi Neuwirth 2026-03-26 09:10:35 -04:00
parent 866001ba7d
commit a5495035be
11 changed files with 339 additions and 7 deletions

View File

@ -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. # Source .env for GITHUB_TOKEN and GITHUB_REPO if it exists.
# .env format: KEY=value (one per line, no `export` prefix, no quotes needed). # .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)" @git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@date +%s > data/build-start.txt @date +%s > data/build-start.txt
@./tools/convert-images.sh @./tools/convert-images.sh
@$(MAKE) -s pdf-thumbs
cabal run site -- build cabal run site -- build
pagefind --site _site pagefind --site _site
@if [ -d .venv ]; then \ @if [ -d .venv ]; then \
@ -35,6 +36,23 @@ download-model:
convert-images: convert-images:
@./tools/convert-images.sh @./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 deploy: build sign
@if [ -z "$(GITHUB_TOKEN)" ] || [ -z "$(GITHUB_REPO)" ]; then \ @if [ -z "$(GITHUB_TOKEN)" ] || [ -z "$(GITHUB_REPO)" ]; then \
echo "Skipping GitHub push: set GITHUB_TOKEN and GITHUB_REPO in .env"; \ echo "Skipping GitHub push: set GITHUB_TOKEN and GITHUB_REPO in .env"; \

View File

@ -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 ## Math
Pandoc parses LaTeX math and wraps it in `class="math inline"` / `class="math display"` Pandoc parses LaTeX math and wraps it in `class="math inline"` / `class="math display"`

View File

@ -58,7 +58,6 @@ affiliationField = listFieldWith "affiliation-links" ctx $ \item -> do
parseEntry s = case break (== '|') s of parseEntry s = case break (== '|') s of
(name, '|' : url) -> (trim name, trim url) (name, '|' : url) -> (trim name, trim url)
(name, _) -> (trim name, "") (name, _) -> (trim name, "")
trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Build time field -- Build time field
@ -178,11 +177,37 @@ confidenceTrendField = field "confidence-trend" $ \item -> do
| otherwise -> return "\x2192" -- → | otherwise -> return "\x2192" -- →
_ -> return "\x2192" _ -> return "\x2192"
-- | @$overall-score$@: weighted composite of confidence (50 %),
-- evidence quality (30 %), and importance (20 %), expressed as an
-- integer on a 0100 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 (01)
-- 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. -- | All epistemic context fields composed.
epistemicCtx :: Context String epistemicCtx :: Context String
epistemicCtx = epistemicCtx =
dotsField "importance-dots" "importance" dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence" <> dotsField "evidence-dots" "evidence"
<> overallScoreField
<> confidenceTrendField <> confidenceTrendField
<> stabilityField <> stabilityField
<> lastReviewedField <> lastReviewedField

View File

@ -16,6 +16,7 @@ import qualified Filters.Dropcaps as Dropcaps
import qualified Filters.Math as Math import qualified Filters.Math as Math
import qualified Filters.Wikilinks as Wikilinks import qualified Filters.Wikilinks as Wikilinks
import qualified Filters.Transclusion as Transclusion import qualified Filters.Transclusion as Transclusion
import qualified Filters.EmbedPdf as EmbedPdf
import qualified Filters.Code as Code import qualified Filters.Code as Code
import qualified Filters.Images as Images import qualified Filters.Images as Images
@ -33,6 +34,7 @@ applyAll
. Images.apply . Images.apply
-- | Apply source-level preprocessors to the raw Markdown string. -- | 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 :: String -> String
preprocessSource = Transclusion.preprocess . Wikilinks.preprocess preprocessSource = Transclusion.preprocess . EmbedPdf.preprocess . Wikilinks.preprocess

81
build/Filters/EmbedPdf.hs Normal file
View File

@ -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 "<div class=\"pdf-embed-wrapper\">"
++ "<iframe class=\"pdf-embed\""
++ " src=\"" ++ viewerUrl ++ "\""
++ " title=\"PDF document\""
++ " loading=\"lazy\""
++ " allowfullscreen></iframe>"
++ "</div>"
-- | 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 (== ' ')

View File

@ -16,8 +16,25 @@ import Text.Pandoc.Definition
import Text.Pandoc.Walk (walk) import Text.Pandoc.Walk (walk)
-- | Apply link classification to the entire document. -- | 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 :: 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 :: Inline -> Inline
classifyLink (Link (ident, classes, kvs) ils (url, title)) classifyLink (Link (ident, classes, kvs) ils (url, title))
@ -49,3 +66,16 @@ domainIcon url
| "doi.org" `T.isInfixOf` url = "doi" | "doi.org" `T.isInfixOf` url = "doi"
| "github.com" `T.isInfixOf` url = "github" | "github.com" `T.isInfixOf` url = "github"
| otherwise = "external" | 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

View File

@ -605,6 +605,15 @@ nav.site-nav {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; 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 { .meta-pagelinks a + a::before {
content: " · "; content: " · ";
color: var(--border); 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 PAGINATION NAV
============================================================ */ ============================================================ */
@ -1393,3 +1420,31 @@ pre:hover .copy-btn,
/* Copy button: always visible on touch (no persistent hover) */ /* Copy button: always visible on touch (no persistent hover) */
.copy-btn { opacity: 1; } .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;
}
}

View File

@ -104,6 +104,31 @@
display: none; 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 <img> */
}
.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 */ /* PGP signature popup */
.popup-sig pre { .popup-sig pre {
margin: 0; margin: 0;

View File

@ -62,6 +62,11 @@
bind(el, citationContent); 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. /* Internal links absolute (/foo) and relative (../../foo) same-origin hrefs.
relativizeUrls in Hakyll makes index-page links relative, so we must match both. */ relativizeUrls in Hakyll makes index-page links relative, so we must match both. */
root.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]').forEach(function (el) { root.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="../"]').forEach(function (el) {
@ -71,10 +76,16 @@
if (!inAuthors && !isBacklink) { if (!inAuthors && !isBacklink) {
if (el.closest('nav, #toc, footer, .page-meta-footer, .metadata')) return; 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('cite-link') || el.classList.contains('meta-tag')) return;
if (el.classList.contains('pdf-link')) return;
} }
bind(el, internalContent); 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 */ /* PGP signature links in footer */
root.querySelectorAll('a.footer-sig-link').forEach(function (el) { root.querySelectorAll('a.footer-sig-link').forEach(function (el) {
bind(el, sigContent); bind(el, sigContent);
@ -550,6 +561,48 @@
return html; 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 = '<div class="popup-epistemic">';
var compact = section.querySelector('.ep-compact');
if (compact) {
html += '<div class="ep-compact">' + compact.innerHTML + '</div>';
}
var expanded = section.querySelector('.ep-expanded');
if (expanded) {
html += '<dl class="ep-expanded">' + expanded.innerHTML + '</dl>';
}
html += '</div>';
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,
'<div class="popup-pdf">'
+ '<img class="popup-pdf-thumb" src="' + esc(thumb) + '" alt="PDF first page">'
+ '</div>');
})
.catch(function () { return null; });
}
/* PGP signature — fetch the .sig file and display ASCII armor */ /* PGP signature — fetch the .sig file and display ASCII armor */
function sigContent(target) { function sigContent(target) {
var href = target.getAttribute('href'); var href = target.getAttribute('href');

View File

@ -19,7 +19,7 @@
$endif$ $endif$
<nav class="meta-row meta-pagelinks" aria-label="Page sections"> <nav class="meta-row meta-pagelinks" aria-label="Page sections">
<a href="#version-history">History</a> <a href="#version-history">History</a>
$if(status)$<a href="#epistemic">Epistemic</a>$endif$ $if(status)$<a href="#epistemic">$if(overall-score)$<span class="ep-link-label">Epistemic</span><span class="meta-overall-score" title="Overall score: $overall-score$/100">$overall-score$%</span>$else$Epistemic$endif$</a>$endif$
$if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$ $if(bibliography)$<a href="#bibliography">Bibliography</a>$endif$
$if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$ $if(backlinks)$<a href="#backlinks">Backlinks</a>$endif$
</nav> </nav>

View File

@ -34,7 +34,7 @@
$if(status)$ $if(status)$
<div class="meta-footer-section meta-footer-epistemic" id="epistemic"> <div class="meta-footer-section meta-footer-epistemic" id="epistemic">
<h3>Epistemic</h3> <h3><a href="/colophon.html#living-documents">Epistemic</a></h3>
<div class="ep-compact"> <div class="ep-compact">
<span class="ep-status">$status$</span> <span class="ep-status">$status$</span>
$if(confidence)$<span class="ep-confidence" title="Confidence">$confidence$%</span>$endif$ $if(confidence)$<span class="ep-confidence" title="Confidence">$confidence$%</span>$endif$