epistemic v2
This commit is contained in:
parent
866001ba7d
commit
a5495035be
20
Makefile
20
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"; \
|
||||
|
|
|
|||
43
WRITING.md
43
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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (== ' ')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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 */
|
||||
.popup-sig pre {
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -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 = '<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 */
|
||||
function sigContent(target) {
|
||||
var href = target.getAttribute('href');
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
$endif$
|
||||
<nav class="meta-row meta-pagelinks" aria-label="Page sections">
|
||||
<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(backlinks)$<a href="#backlinks">Backlinks</a>$endif$
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
$if(status)$
|
||||
<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">
|
||||
<span class="ep-status">$status$</span>
|
||||
$if(confidence)$<span class="ep-confidence" title="Confidence">$confidence$%</span>$endif$
|
||||
|
|
|
|||
Loading…
Reference in New Issue