Inline code reference previews
This commit is contained in:
parent
b12f6cc387
commit
f41311a3eb
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
BIN
static/cv.pdf
BIN
static/cv.pdf
Binary file not shown.
|
|
@ -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.
Loading…
Reference in New Issue