Inline code reference previews

This commit is contained in:
Levi Neuwirth 2026-05-02 10:40:43 -04:00
parent b12f6cc387
commit f41311a3eb
10 changed files with 455 additions and 6 deletions

View File

@ -11,6 +11,7 @@ import Text.Pandoc.Definition (Pandoc)
import qualified Filters.Sidenotes as Sidenotes import qualified Filters.Sidenotes as Sidenotes
import qualified Filters.Typography as Typography import qualified Filters.Typography as Typography
import qualified Filters.Links as Links import qualified Filters.Links as Links
import qualified Filters.SourceRefs as SourceRefs
import qualified Filters.Smallcaps as Smallcaps import qualified Filters.Smallcaps as Smallcaps
import qualified Filters.Dropcaps as Dropcaps import qualified Filters.Dropcaps as Dropcaps
import qualified Filters.Math as Math import qualified Filters.Math as Math
@ -32,7 +33,8 @@ import qualified Filters.Aftermatter as Aftermatter
-- resolution of co-located assets. -- resolution of co-located assets.
applyAll :: FilePath -> Pandoc -> IO Pandoc applyAll :: FilePath -> Pandoc -> IO Pandoc
applyAll srcDir doc = do applyAll srcDir doc = do
imagesDone <- Images.apply srcDir doc imagesDone <- Images.apply srcDir doc
sourceRefsDone <- SourceRefs.apply imagesDone
pure pure
. Aftermatter.apply . Aftermatter.apply
. Sidenotes.apply . Sidenotes.apply
@ -42,7 +44,7 @@ applyAll srcDir doc = do
. Dropcaps.apply . Dropcaps.apply
. Math.apply . Math.apply
. Code.apply . Code.apply
$ imagesDone $ sourceRefsDone
-- | Apply source-level preprocessors to the raw Markdown string. -- | Apply source-level preprocessors to the raw Markdown string.
-- Order matters: EmbedPdf must run before Transclusion, because the -- Order matters: EmbedPdf must run before Transclusion, because the

View File

@ -43,6 +43,12 @@ classifyPdfLink (Link (ident, classes, kvs) ils (url, title))
classifyPdfLink x = x classifyPdfLink x = x
classifyLink :: Inline -> Inline classifyLink :: Inline -> Inline
classifyLink l@(Link (_, classes, _) _ _)
-- Source-ref links are owned by Filters.SourceRefs: they keep the
-- inline-code chrome of their body, must not receive an external
-- brand icon stamp, and have their own popup provider. Leave them
-- entirely alone.
| "source-ref" `elem` classes = l
classifyLink (Link (ident, classes, kvs) ils (url, title)) classifyLink (Link (ident, classes, kvs) ils (url, title))
| isExternal url = | isExternal url =
let icon = domainIcon url let icon = domainIcon url

193
build/Filters/SourceRefs.hs Normal file
View File

@ -0,0 +1,193 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Detect repo-relative source-file references in prose and wrap them
-- in a link that triggers a hover-preview popup of the file's contents.
--
-- Two trigger forms:
--
-- * Inline @\`build\/Filters\/Links.hs\`@ — Markdown inline code whose
-- text passes a conservative source-path heuristic.
-- * A Markdown link to
-- @https:\/\/git.levineuwirth.org\/neuwirth\/levineuwirth.org\/(src|raw)\/branch\/<branch>\/<path>@.
--
-- Both produce
-- @\<a class="source-ref" data-source-path="…" href="…">@. The href
-- points to the Forgejo source viewer so a click without JS — or a
-- popup that fails to fetch — still resolves to a useful target.
-- The popup provider in @static\/js\/popups.js@ fetches
-- @\/source\/\<path\>@ (a same-origin copy emitted by the Hakyll
-- source-preview rule in 'Site.rules') and renders a
-- syntax-highlighted snippet via Prism.
--
-- Conservative-by-design: the trigger only fires on paths under a
-- short whitelist of top-level directories, or a small set of named
-- root files. This keeps the parser cheap and avoids false positives
-- on words that happen to contain a slash and a dot.
module Filters.SourceRefs (apply, isSourcePath, forgejoSourceUrl) where
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesFileExist)
import System.IO.Unsafe (unsafePerformIO)
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
-- | Two passes: lift Forgejo source URLs in existing Markdown links
-- first, then wrap inline-code source paths. Both passes only add
-- the @source-ref@ class when it is not already present, so re-runs
-- are idempotent.
--
-- Runs in 'IO' because the heuristic confirms each candidate is a
-- real on-disk file before wrapping. This rules out paths like
-- @data/backlinks.json@ that look like source but are Hakyll build
-- artifacts produced into @_site/@ — wrapping those would emit a
-- link whose popup is guaranteed to 404.
apply :: Pandoc -> IO Pandoc
apply doc = do
afterLinks <- walkM classifyExistingLink doc
walkM wrapInlineCode afterLinks
-- | Inline @`path`@ → @\<a class="source-ref" data-source-path="path"\>\<code\>path\<\/code\>\<\/a\>@.
-- The original 'Code' node is preserved as the link's body so the
-- inline-code chrome (mono font, background) survives unchanged.
wrapInlineCode :: Inline -> IO Inline
wrapInlineCode orig@(Code (cIdent, cClasses, cKvs) txt)
| "source-ref" `notElem` cClasses
, isSourcePath txt = do
exists <- existsCached txt
if exists
then pure $ Link
( ""
, ["source-ref"]
, [ ("data-source-path", txt)
, ("target", "_blank")
, ("rel", "noopener noreferrer")
]
)
[Code (cIdent, cClasses, cKvs) txt]
(forgejoSourceUrl txt, "")
else pure orig
wrapInlineCode x = pure x
-- | Existing Markdown link to a Forgejo source URL on this site's git
-- host → tagged @source-ref@ and given a @data-source-path@ pointing
-- at the same path the popup provider expects.
classifyExistingLink :: Inline -> IO Inline
classifyExistingLink orig@(Link (ident, classes, kvs) ils (url, title))
| "source-ref" `notElem` classes
, Just path <- forgejoSourcePath url
, isSourcePath path = do
exists <- existsCached path
if exists
then pure $ Link
( ident
, classes ++ ["source-ref"]
, kvs ++ [("data-source-path", path)]
)
ils (url, title)
else pure orig
classifyExistingLink x = pure x
-- ---------------------------------------------------------------------------
-- Heuristic
-- ---------------------------------------------------------------------------
-- | True when the text looks like a repo-relative path under one of
-- the whitelisted directories (or is a whitelisted root file), ends
-- in a known source extension, and contains only safe path
-- characters. Conservative by design — the goal is no false
-- positives on prose that incidentally contains a slash and a dot.
isSourcePath :: Text -> Bool
isSourcePath t = and
[ not (T.null t)
, T.all safeChar t
, (hasKnownPrefix t && hasKnownExt t) || isKnownRootFile t
]
where
safeChar c =
('a' <= c && c <= 'z')
|| ('A' <= c && c <= 'Z')
|| ('0' <= c && c <= '9')
|| c == '/' || c == '.' || c == '_' || c == '-' || c == '+'
hasKnownPrefix :: Text -> Bool
hasKnownPrefix t = any (`T.isPrefixOf` t)
[ "build/", "static/", "templates/", "tools/"
, "nginx/", "data/", "content/", "yaml-source/"
]
hasKnownExt :: Text -> Bool
hasKnownExt t =
let lower = T.toLower t
in any (`T.isSuffixOf` lower)
[ ".hs", ".js", ".mjs", ".css", ".html"
, ".py", ".cabal", ".md", ".yaml", ".yml"
, ".toml", ".sh", ".bash", ".svg", ".conf"
, ".json", ".ini", ".tex"
]
isKnownRootFile :: Text -> Bool
isKnownRootFile t = t `elem`
[ "Makefile"
, "levineuwirth.cabal"
, "cabal.project", "cabal.project.freeze"
, "pyproject.toml", "uv.lock"
, "WRITING.md", "HOMEPAGE.md", "PHOTOGRAPHY.md", "README.md"
, "LICENSE", "checklist.md"
]
-- ---------------------------------------------------------------------------
-- File existence cache
-- ---------------------------------------------------------------------------
-- | Process-wide memo of @doesFileExist@ results, keyed by the same
-- path the popup will fetch. Hakyll runs this filter once per
-- compiled page and the same source-file references recur across
-- many pages (e.g. @build\/Filters\/Links.hs@ in the Links page,
-- the Colophon, several essays); the cache turns N stats into one
-- per distinct path. The build process's working directory is the
-- project root, so the path can be passed straight to
-- 'doesFileExist' without prefixing.
{-# NOINLINE existsCacheRef #-}
existsCacheRef :: IORef (Map.Map Text Bool)
existsCacheRef = unsafePerformIO (newIORef Map.empty)
existsCached :: Text -> IO Bool
existsCached path = do
cache <- readIORef existsCacheRef
case Map.lookup path cache of
Just b -> pure b
Nothing -> do
b <- doesFileExist (T.unpack path)
atomicModifyIORef' existsCacheRef (\m -> (Map.insert path b m, ()))
pure b
-- ---------------------------------------------------------------------------
-- Forgejo URL helpers
-- ---------------------------------------------------------------------------
-- | Forgejo source-viewer URL for a repo-relative path. Pinned to the
-- @main@ branch so previews always reflect the deployed tip.
forgejoSourceUrl :: Text -> Text
forgejoSourceUrl path =
"https://git.levineuwirth.org/neuwirth/levineuwirth.org/src/branch/main/"
<> path
-- | Inverse of 'forgejoSourceUrl': extract the repo-relative path from
-- a Forgejo URL on this site's git host. Recognises both the
-- @\/src\/branch\/<b>\/@ web view and the @\/raw\/branch\/<b>\/@
-- variants. Returns 'Nothing' for any other URL.
forgejoSourcePath :: Text -> Maybe Text
forgejoSourcePath url = do
rest <- T.stripPrefix repoBase url
afterBranch <-
case T.stripPrefix "src/branch/" rest of
Just r -> Just r
Nothing -> T.stripPrefix "raw/branch/" rest
let (_branch, slashAndPath) = T.breakOn "/" afterBranch
path = T.drop 1 slashAndPath
if T.null path then Nothing else Just path
where
repoBase = "https://git.levineuwirth.org/neuwirth/levineuwirth.org/"

View File

@ -197,6 +197,50 @@ rules = do
-- Templates -- Templates
match "templates/**" $ compile templateBodyCompiler match "templates/**" $ compile templateBodyCompiler
-- ---------------------------------------------------------------------------
-- Source-preview corpus — raw copies of source files, served at
-- @/source/<path>@, fetched on hover by the popup provider in
-- @static/js/popups.js@ (sourceContent → Prism highlighting).
--
-- Conservative whitelist: must stay aligned with 'isSourcePath' in
-- @build/Filters/SourceRefs.hs@ so that every link the filter
-- emits has a corresponding @/source/…@ target. Files in @static/@
-- are also served under their normal /js/, /css/ paths via a
-- separate rule above; the @"source-preview"@ version lets Hakyll
-- compile the same identifier twice without conflict.
--
-- Anything not matched here will silently 404 on hover and the
-- popup will simply not appear, which is the right failure mode
-- if the heuristic ever wraps a path we did not mean to expose.
-- ---------------------------------------------------------------------------
let sourcePreviewable =
"build/**.hs"
.||. "static/js/**"
.||. "static/css/**"
.||. "templates/**"
.||. "tools/**.sh"
.||. "tools/**.py"
.||. "nginx/**.conf"
.||. "data/*.json"
.||. "data/*.yaml"
.||. "data/*.md"
.||. "data/*.bib"
.||. "*.cabal"
.||. "cabal.project"
.||. "cabal.project.freeze"
.||. "Makefile"
.||. "pyproject.toml"
.||. "uv.lock"
.||. "LICENSE"
.||. "checklist.md"
.||. "WRITING.md"
.||. "HOMEPAGE.md"
.||. "PHOTOGRAPHY.md"
.||. "README.md"
match sourcePreviewable $ version "source-preview" $ do
route $ customRoute (\ident -> "source/" ++ toFilePath ident)
compile copyFileCompiler
-- Link annotations — author-defined previews for any URL -- Link annotations — author-defined previews for any URL
match "data/annotations.json" $ do match "data/annotations.json" $ do
route idRoute route idRoute

View File

@ -13,7 +13,7 @@ beside each link are stamped automatically by the build's link classifier
## <span class="links-section-ornament" data-ornament="academic" aria-hidden="true"></span>Academic { #academic } ## <span class="links-section-ornament" data-ornament="academic" aria-hidden="true"></span>Academic { #academic }
- [Brown CS](https://cs.brown.edu/people/ugrad/lneuwirt/) - [Brown CS](https://cs.brown.edu/people/ugrad/lneuwirt/)
- [Google Scholar](https://scholar.google.com/citations?user=9_62MFgAAAAJ&hl=en&oi=ao)
- [ORCID](https://orcid.org/0000-0000-0000-0000) - [ORCID](https://orcid.org/0000-0000-0000-0000)
</section> </section>
@ -22,7 +22,7 @@ beside each link are stamped automatically by the build's link classifier
## <span class="links-section-ornament" data-ornament="artistic" aria-hidden="true"></span>Artistic { #artistic } ## <span class="links-section-ornament" data-ornament="artistic" aria-hidden="true"></span>Artistic { #artistic }
Coming soon! - [YouTube](https://www.youtube.com/@levineuwirth)
</section> </section>
@ -31,7 +31,6 @@ Coming soon!
## <span class="links-section-ornament" data-ornament="code" aria-hidden="true"></span>Code { #code } ## <span class="links-section-ornament" data-ornament="code" aria-hidden="true"></span>Code { #code }
- [Forgejo](https://git.levineuwirth.org/neuwirth) - [Forgejo](https://git.levineuwirth.org/neuwirth)
- [GitHub](https://github.com/levineuwirth) - [GitHub](https://github.com/levineuwirth)
</section> </section>
@ -41,7 +40,9 @@ Coming soon!
## <span class="links-section-ornament" data-ornament="miscellaneous" aria-hidden="true"></span>Miscellaneous { #miscellaneous } ## <span class="links-section-ornament" data-ornament="miscellaneous" aria-hidden="true"></span>Miscellaneous { #miscellaneous }
- [English Wikipedia](https://en.wikipedia.org/wiki/User:LudicrousSengir) - [English Wikipedia](https://en.wikipedia.org/wiki/User:LudicrousSengir)
- [French Wikipedia](https://fr.wikipedia.org/wiki/Utilisateur:LudicrousSengir)
- [iNaturalist](https://inaturalist.org/people/lneuwirth)
- [Spanish Wikipedia](https://es.wikipedia.org/wiki/User:LudicrousSengir)
</section> </section>
<section class="links-section"> <section class="links-section">

View File

@ -39,6 +39,7 @@ executable site
Filters.Transclusion Filters.Transclusion
Filters.EmbedPdf Filters.EmbedPdf
Filters.Links Filters.Links
Filters.SourceRefs
Filters.Math Filters.Math
Filters.Code Filters.Code
Filters.Images Filters.Images

View File

@ -231,6 +231,96 @@
object-position: top left; object-position: top left;
} }
/* ============================================================
SOURCE-FILE PREVIEW POPUP
Shown on hover for inline `path/to/file` references and for
Markdown links to the Forgejo source viewer (see
build/Filters/SourceRefs.hs). The body is fetched from
/source/<path> and Prism-highlighted client-side.
============================================================ */
.link-popup:has(.popup-source-code) {
max-width: 560px;
padding: 0;
overflow: hidden;
}
.popup-source-code {
display: block;
}
/* Header strip naming the file. Doubles as a visual seam between the
chrome (border, padding) and the code body below. */
.popup-source-path {
padding: 0.45rem 0.65rem 0.4rem;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--text-faint);
word-break: break-all;
background: var(--bg);
}
.popup-source-pre {
margin: 0;
padding: 0.55rem 0.65rem;
max-height: 360px;
overflow: auto;
background: var(--bg);
font-family: var(--font-mono);
font-size: 0.7rem;
line-height: 1.5;
color: var(--text);
white-space: pre;
border: none;
border-radius: 0;
}
.popup-source-pre code {
background: transparent;
padding: 0;
border: none;
font-family: inherit;
font-size: inherit;
color: inherit;
white-space: pre;
}
/* "N more lines · view full file →" visually parallel to the path
header strip; together they bracket the code body. */
.popup-source-truncated {
padding: 0.4rem 0.65rem;
border-top: 1px solid var(--border);
font-size: 0.65rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
background: var(--bg);
}
/* Inline source-ref link styling: keep the inline-code chrome of the
wrapped <code> intact and resist the link-classifier's external/
internal stamp (Filters.Links explicitly skips us). The hover hint
is a faint dotted underline same convention as date-hover and
epistemic-term cues. */
a.source-ref {
color: inherit;
text-decoration: none;
}
a.source-ref:hover {
text-decoration-line: underline;
text-decoration-style: dotted;
text-decoration-color: var(--border-muted);
text-underline-offset: 0.2em;
}
a.source-ref code {
/* The underlying inline-code styling is inherited from typography.css;
this rule exists only to ensure the link wrapper does not fight it. */
color: inherit;
}
/* PGP signature popup */ /* PGP signature popup */
.popup-sig pre { .popup-sig pre {
margin: 0; margin: 0;

Binary file not shown.

View File

@ -110,6 +110,15 @@
bind(el, internalContent); bind(el, internalContent);
}); });
/* Source-file references wrapped at build time by
build/Filters/SourceRefs.hs around inline `path` and Forgejo
links. Bind before the generic external-link loop so the
idempotent guard in bind() prevents the Forgejo provider
from also claiming these. */
root.querySelectorAll('a.source-ref[data-source-path]').forEach(function (el) {
bind(el, sourceContent);
});
/* PDF links — rewritten to viewer URL by Links.hs; thumbnail on hover */ /* PDF links — rewritten to viewer URL by Links.hs; thumbnail on hover */
root.querySelectorAll('a.pdf-link[data-pdf-src]').forEach(function (el) { root.querySelectorAll('a.pdf-link[data-pdf-src]').forEach(function (el) {
bind(el, pdfContent); bind(el, pdfContent);
@ -785,6 +794,109 @@
.catch(function () { return null; }); .catch(function () { return null; });
} }
/* Source-file preview fetches /source/<path> (a same-origin copy
emitted by the source-preview Hakyll rule), runs Prism on the
first chunk of lines, and returns a DocumentFragment so the popup
receives ready-highlighted DOM rather than re-parsing innerHTML.
The raw response is cached; rendering is repeated per hover so
a cached entry never gets re-parented (a Node can only live in
one place at a time). */
function sourceContent(target) {
var path = target.dataset.sourcePath;
if (!path) return Promise.resolve(null);
var fetchUrl = '/source/' + path;
var cached = cache[fetchUrl];
var pending = (cached !== undefined)
? Promise.resolve(cached)
: fetch(fetchUrl, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (text) { cache[fetchUrl] = text; return text; })
.catch(function () { return null; });
return pending.then(function (text) {
if (text == null) return null;
return renderSourcePopup(path, text);
});
}
/* Build the popup body for sourceContent. Truncates to MAX_LINES
so a 2,000-line file doesn't blow the popup height; the link's
href still points at the Forgejo full-file viewer for readers
who want more. */
function renderSourcePopup(path, text) {
var MAX_LINES = 80;
var lines = text.split('\n');
var truncated = lines.length > MAX_LINES;
var preview = lines.slice(0, MAX_LINES).join('\n');
var lang = languageFromPath(path);
var wrap = document.createElement('div');
wrap.className = 'popup-source-code';
var label = document.createElement('div');
label.className = 'popup-source-path';
label.textContent = path;
wrap.appendChild(label);
var pre = document.createElement('pre');
pre.className = lang ? 'popup-source-pre language-' + lang : 'popup-source-pre';
var code = document.createElement('code');
if (lang) code.className = 'language-' + lang;
code.textContent = preview;
pre.appendChild(code);
wrap.appendChild(pre);
/* Prism is loaded with `defer` from the page template; by the
time a hover delay fires it is reliably available. Guard
anyway so a missing component (e.g. an unrecognised lang)
degrades to plain monospace rather than throwing. */
if (lang && window.Prism && Prism.languages && Prism.languages[lang]) {
try { Prism.highlightElement(code); } catch (_) { /* keep plain */ }
}
if (truncated) {
var more = document.createElement('div');
more.className = 'popup-source-truncated';
var n = lines.length - MAX_LINES;
more.textContent =
n + ' more line' + (n === 1 ? '' : 's')
+ '·view full file →';
wrap.appendChild(more);
}
return wrap;
}
/* Map a path's extension (or basename, for Makefile) onto the set
of Prism languages bundled in /js/prism.min.js: bash, haskell,
javascript, css, markup, yaml, python, makefile. Returns null
when no mapping applies; the caller falls back to plain text. */
function languageFromPath(path) {
var basename = path.split('/').pop();
if (basename === 'Makefile') return 'makefile';
var m = path.match(/\.([a-z0-9]+)$/i);
if (!m) return null;
switch (m[1].toLowerCase()) {
case 'hs':
case 'cabal': return 'haskell';
case 'js':
case 'mjs': return 'javascript';
case 'css': return 'css';
case 'html':
case 'svg': return 'markup';
case 'py': return 'python';
case 'sh':
case 'bash':
case 'conf': return 'bash';
case 'yaml':
case 'yml': return 'yaml';
case 'md': return 'markup';
default: 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');

Binary file not shown.