levineuwirth.org/static/js/popups.js

1018 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* popups.js — Hover preview popups.
Content providers (in dispatch priority order):
1. Local annotations — /data/annotations.json (any URL, author-defined)
2. Citations — DOM lookup, cite-link[href^="#ref-"]
3. Internal pages — same-origin fetch, title + authors + tags + abstract + stats
4. Wikipedia — MediaWiki action API, full lead section
5. arXiv — export.arxiv.org Atom API
6. DOI / CrossRef — api.crossref.org, title/authors/abstract
7. GitHub — api.github.com, repo description + stars
8. Open Library — openlibrary.org JSON API, book description
9. bioRxiv / medRxiv — api.biorxiv.org, abstract
10. YouTube — oEmbed, title + channel (no key required)
11. Internet Archive — archive.org/metadata, title + description
12. PubMed — NCBI esummary, title + authors + journal
Production nginx CSP must add to connect-src:
https://*.wikipedia.org https://api.crossref.org
https://api.github.com https://openlibrary.org
https://api.biorxiv.org https://www.youtube.com
(The wildcard covers per-language Wikipedias: en, es, fr, simple,
zh-yue, be-tarask, … — the popup picks the host from the link URL.)
Production nginx must also reverse-proxy three CORS-broken upstreams
(immutable metadata — long cache TTL is safe). See nginx/popup-proxy.conf.
/proxy/arxiv/ -> https://export.arxiv.org/
/proxy/archive/ -> https://archive.org/
/proxy/pubmed/ -> https://eutils.ncbi.nlm.nih.gov/
*/
(function () {
'use strict';
var SHOW_DELAY = 250;
var HIDE_DELAY = 150;
var popup = null;
var showTimer = null;
var hideTimer = null;
var activeTarget = null;
var cache = Object.create(null); /* url → html; only successful results stored */
var annotations = null; /* null = not yet loaded */
/* ------------------------------------------------------------------
Init — load annotations first, then bind all targets
------------------------------------------------------------------ */
function init() {
// Hover popups are meaningless on touch-primary devices and interfere
// with tap navigation (first tap = hover, second tap = follow link).
if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) return;
popup = document.createElement('div');
popup.className = 'link-popup';
popup.setAttribute('aria-live', 'polite');
popup.setAttribute('aria-hidden', 'true');
document.body.appendChild(popup);
popup.addEventListener('mouseenter', cancelHide);
popup.addEventListener('mouseleave', scheduleHide);
loadAnnotations().then(function () {
bindTargets(document.body);
});
}
/* Epistemic term definitions — concise summaries from the colophon,
shown on hover for filter labels, metadata strip items, etc. */
var EP_DEFS = {
status: 'A controlled vocabulary describing where the work stands: Draft, Working model, Durable, Refined, Superseded, or Deprecated.',
confidence: 'An integer from 0\u2013100, representing the author\u2019s credence in the central thesis.',
importance: 'How much the author thinks this matters, on a 1\u20135 dot scale. Useful for orienting a reader who has limited time.',
evidence: 'How well-evidenced the claims are, on a 1\u20135 scale. High importance and low evidence indicates a speculative position.',
trust: 'A 0\u2013100 score derived automatically from confidence (60%) and evidence quality (40%). Answers \u201chow much should you trust the central claim?\u201d and nothing else.',
scope: 'An orientation from personal to civilizational. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
novelty: 'An orientation from conventional to innovative. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
practicality: 'An orientation from abstract to exceptional. Not a rating\u2009\u2014\u2009deliberately not folded into the trust score.',
stability: 'Auto-computed from git history. Very new or barely-touched documents are volatile; actively-revised are revising; older settled documents are fairly stable, stable, or established.'
};
function bindTargets(root) {
/* Epistemic term definitions — filter labels, metadata strip, footer */
root.querySelectorAll('[data-ep-term]').forEach(function (el) {
bind(el, epistemicTermContent);
});
/* Citation markers */
root.querySelectorAll('a.cite-link[href^="#ref-"]').forEach(function (el) {
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) {
/* Author links, backlink source links, and Related items always get popups */
var inAuthors = el.closest('.meta-authors');
var isBacklink = el.classList.contains('backlink-source');
var isSimilar = el.classList.contains('similar-link');
/* PDF-typed Related items are handled below by the pdf-link binder */
if (isSimilar && el.classList.contains('pdf-link')) return;
if (!inAuthors && !isBacklink && !isSimilar) {
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;
if (el.classList.contains('content-divider-logo') || el.classList.contains('aftermatter-logo')) return;
}
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 */
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);
});
/* External links — single dispatcher handles all providers */
root.querySelectorAll('a[href^="http"]').forEach(function (el) {
if (el.closest('nav, #toc, footer, .page-meta-footer')) return;
var provider = getProvider(el.getAttribute('href') || '');
if (provider) bind(el, provider);
});
/* Date hover popups — any element tagged with data-date-start.
Handles the frontmatter range link, version-history list items,
last-reviewed in the epistemic block, blog post dates, etc. */
root.querySelectorAll('[data-date-start]').forEach(function (el) {
bind(el, dateContent);
});
}
/* Public re-init hook used by transclude.js after it injects new
content into the DOM. Idempotent — bind() marks each element so
repeated calls don't stack listeners. */
window.reinitPopups = function (container) {
if (!popup) return; /* init() not yet run / touch device */
if (annotations === null) {
loadAnnotations().then(function () { bindTargets(container || document.body); });
} else {
bindTargets(container || document.body);
}
};
/* Returns the appropriate provider function for a given URL, or null.
Local annotations win over the table; otherwise the first entry in
PROVIDERS whose `match` regex hits is selected. */
function getProvider(href) {
if (!href) return null;
if (annotations && annotations[href]) return annotationContent;
for (var i = 0; i < PROVIDERS.length; i++) {
if (PROVIDERS[i].match.test(href)) {
var entry = PROVIDERS[i];
return function (target) { return providerContent(target, entry); };
}
}
return null;
}
function bind(el, provider) {
/* Idempotent: skip elements that already have a popup binding,
so reinitPopups() called from transclude.js cannot stack
listeners on already-bound nodes. */
if (el.dataset.popupBound === '1') return;
el.dataset.popupBound = '1';
el.addEventListener('mouseenter', function () { scheduleShow(el, provider); });
el.addEventListener('mouseleave', scheduleHide);
el.addEventListener('focus', function () { scheduleShow(el, provider); });
el.addEventListener('blur', scheduleHide);
}
/* ------------------------------------------------------------------
Lifecycle
------------------------------------------------------------------ */
function scheduleShow(target, provider) {
cancelHide();
clearTimeout(showTimer);
activeTarget = target;
showTimer = setTimeout(function () {
provider(target).then(function (content) {
if (!content || activeTarget !== target) return;
/* Providers may return either an HTML string or a DOM
Node — the latter is used by epistemicContent so the
popup receives cloned nodes instead of a re-parsed
HTML round-trip. Handle both forms. */
popup.innerHTML = '';
if (typeof content === 'string') {
popup.innerHTML = content;
} else if (content instanceof Node) {
popup.appendChild(content);
} else {
return;
}
positionPopup(target);
popup.classList.add('is-visible');
popup.setAttribute('aria-hidden', 'false');
}).catch(function () { /* silently fail */ });
}, SHOW_DELAY);
}
function scheduleHide() {
clearTimeout(showTimer);
hideTimer = setTimeout(function () {
popup.classList.remove('is-visible');
popup.setAttribute('aria-hidden', 'true');
activeTarget = null;
}, HIDE_DELAY);
}
function cancelHide() { clearTimeout(hideTimer); }
/* ------------------------------------------------------------------
Positioning — centres below target, flips above if clipped
------------------------------------------------------------------ */
function positionPopup(target) {
var rect = target.getBoundingClientRect();
var pw = popup.offsetWidth;
var ph = popup.offsetHeight;
var vw = window.innerWidth;
var vh = window.innerHeight;
var sy = window.scrollY;
var sx = window.scrollX;
var GAP = 10;
var left = rect.left + sx + rect.width / 2 - pw / 2;
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
var top = (rect.bottom + GAP + ph <= vh)
? rect.bottom + sy + GAP
: rect.top + sy - ph - GAP;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
}
/* ------------------------------------------------------------------
Content providers
------------------------------------------------------------------ */
/* Cross-origin JSON fetch helper.
Validates Content-Type before parsing so a CORS-enabled endpoint
cannot return text/html and have it interpreted as JSON. The
caller's `.catch` still applies if the JSON parse itself fails.
Mirror helpers exist for text/* (XML/Atom) and HTML responses. */
function fetchJson(url, init) {
return fetch(url, init).then(function (r) {
if (!r.ok) return null;
var ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct && !/(?:^|[\s;,])(?:application\/[a-z+.-]*json|text\/json)\b/.test(ct)) {
return null;
}
return r.json();
});
}
function fetchXml(url, init) {
return fetch(url, init).then(function (r) {
if (!r.ok) return null;
var ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct && !/(?:xml|atom)/.test(ct)) {
return null;
}
return r.text();
});
}
/* 0. Local annotations — synchronous map lookup after eager load */
function loadAnnotations() {
if (annotations !== null) return Promise.resolve(annotations);
return fetch('/data/annotations.json', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : {}; })
.then(function (data) { annotations = data; return data; })
.catch(function () { annotations = {}; return {}; });
}
function annotationContent(target) {
var href = target.getAttribute('href');
var ann = href && annotations && annotations[href];
if (!ann) return Promise.resolve(null);
return Promise.resolve(
'<div class="popup-annotation">'
+ (ann.title ? '<div class="popup-title">' + esc(ann.title) + '</div>' : '')
+ (ann.annotation ? '<div class="popup-abstract">' + esc(ann.annotation) + '</div>' : '')
+ '</div>'
);
}
/* 1. Citations — synchronous DOM lookup; supports multi-citation groups
via data-cite-keys (space-separated list of ref-* IDs).
Returns a DocumentFragment of cloned bibliography entries instead
of stringifying innerHTML, so a malicious or malformed cite target
cannot smuggle markup through the popup's innerHTML setter. */
function citationContent(target) {
return new Promise(function (resolve) {
var keysAttr = target.getAttribute('data-cite-keys');
var ids = keysAttr
? keysAttr.trim().split(/\s+/)
: [(target.getAttribute('href') || '').slice(1)];
var entries = ids
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
if (!entries.length) { resolve(null); return; }
var wrapper = document.createElement('div');
wrapper.className = 'popup-citation';
entries.forEach(function (entry) {
var item = document.createElement('div');
item.className = 'popup-citation-entry';
Array.prototype.forEach.call(entry.childNodes, function (n) {
item.appendChild(n.cloneNode(true));
});
wrapper.appendChild(item);
});
resolve(wrapper);
});
}
/* 2. Internal pages — same-origin fetch, rich preview */
function internalContent(target) {
/* Resolve relative hrefs (../../foo) to canonical path (/foo) for fetch + cache. */
var raw = target.getAttribute('href');
if (!raw) return Promise.resolve(null);
var href = new URL(raw, window.location.href).pathname;
if (cache[href]) return Promise.resolve(cache[href]);
return fetch(href, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (text) {
if (!text) return null;
var doc = new DOMParser().parseFromString(text, 'text/html');
var titleEl = doc.querySelector('h1.page-title');
if (!titleEl) return null;
/* Abstract */
var abstrEl = doc.querySelector('.meta-description');
var abstract = abstrEl ? abstrEl.textContent.trim() : '';
if (abstract.length > 300)
abstract = abstract.slice(0, 300).replace(/\s\S+$/, '') + '\u2026';
/* Authors */
var authorEls = doc.querySelectorAll('.meta-authors a');
var authors = Array.from(authorEls).map(function (a) {
return a.textContent.trim();
}).join(', ');
/* Tags */
var tagEls = doc.querySelectorAll('.meta-tags a');
var tags = Array.from(tagEls).map(function (a) {
return a.textContent.trim();
}).join(' · ');
/* Reading stats — word count and reading time from meta block */
var wcEl = doc.querySelector('.meta-word-count');
var rtEl = doc.querySelector('.meta-reading-time');
var stats = [
wcEl ? wcEl.textContent.trim() : '',
rtEl ? rtEl.textContent.trim() : ''
].filter(Boolean).join(' · ');
return store(href,
'<div class="popup-internal">'
+ srcHtml('internal', 'levineuwirth.org')
+ (tags ? '<div class="popup-tags">' + esc(tags) + '</div>' : '')
+ '<div class="popup-title">' + esc(titleEl.textContent.trim()) + '</div>'
+ (authors ? '<div class="popup-authors">' + esc(authors) + '</div>' : '')
+ (abstract ? '<div class="popup-abstract">' + esc(abstract) + '</div>' : '')
+ (stats ? '<div class="popup-meta">' + esc(stats) + '</div>' : '')
+ '</div>');
})
.catch(function () { return null; });
}
/* ------------------------------------------------------------------
External providers — declarative table.
Each entry drives a generic fetch + render pipeline. Adding a new
source means: write a URL regex, a URL builder, and a parser that
maps the upstream response to the normalized field shape below.
{ title, authors?, meta?, abstract? | extract?, tags? }
providerContent() handles cache/fetch/error-swallow; renderPopup()
handles HTML composition and truncation. Per-provider quirks live
inside each parse() — e.g. CrossRef + Internet Archive strip
upstream HTML before returning. The shared truncate step only
normalizes whitespace and applies the length cap.
Render order is fixed: tags → title → authors → meta → body →
stats. `meta` and `stats` share the `.popup-meta` CSS class but
differ in position: `meta` reads as a subtitle (journal, year),
while `stats` reads as a footer line (language, star count).
Quirk fields on a provider entry:
icon — override for data-popup-source (CSS icon key) when
it differs from the provider name.
fetchInit — options passed to fetch() (e.g. GitHub's Accept).
bodyLimit — char cap for abstract/extract (default 500). */
function truncate(s, limit) {
if (!s) return '';
s = s.replace(/\s+/g, ' ').trim();
if (s.length > limit) s = s.slice(0, limit).replace(/\s\S+$/, '') + '\u2026';
return s;
}
/* Authors: array → "a, b, c et al." (3 max); string → trimmed
passthrough (some parsers pre-join their own bylines, e.g. Internet
Archive composes "creator, year"). */
function formatAuthors(a) {
if (!a) return '';
if (typeof a === 'string') return a.trim();
if (!a.length) return '';
var head = a.slice(0, 3).join(', ');
return a.length > 3 ? head + ' et\u00a0al.' : head;
}
function renderPopup(p, fields) {
if (!fields || !fields.title) return null;
var iconKey = p.icon || p.name;
var authors = formatAuthors(fields.authors);
var bodyKey = fields.extract !== undefined ? 'extract' : 'abstract';
var body = truncate(fields[bodyKey], p.bodyLimit || 500);
var html = '<div class="popup-' + p.name + '">'
+ srcHtml(iconKey, p.label);
if (fields.tags) html += '<div class="popup-tags">' + esc(fields.tags) + '</div>';
html += '<div class="popup-title">' + esc(fields.title) + '</div>';
if (authors) html += '<div class="popup-authors">' + esc(authors) + '</div>';
if (fields.meta) html += '<div class="popup-meta">' + esc(fields.meta) + '</div>';
if (body) html += '<div class="popup-' + bodyKey + '">' + esc(body) + '</div>';
if (fields.stats) html += '<div class="popup-meta">' + esc(fields.stats) + '</div>';
html += '</div>';
return html;
}
function providerContent(target, p) {
var href = target.getAttribute('href');
if (!href) return Promise.resolve(null);
if (cache[href]) return Promise.resolve(cache[href]);
var match = href.match(p.match);
if (!match) return Promise.resolve(null);
var ctx = { match: match, href: href };
var url = p.url(ctx);
var fetcher = p.fetchType === 'xml' ? fetchXml : fetchJson;
return fetcher(url, p.fetchInit).then(function (data) {
if (!data) return null;
var html = renderPopup(p, p.parse(data, ctx));
return html ? store(href, html) : null;
}).catch(function () { return null; });
}
/* bioRxiv + medRxiv share the response schema, so both entries below
use this parser — only the upstream path differs. */
function biorxivParse(data) {
var paper = data && data.collection && data.collection[0];
if (!paper || !paper.title) return null;
var authors = paper.authors
? paper.authors.split(';').map(function (s) { return s.trim(); }).filter(Boolean)
: [];
return { title: paper.title, authors: authors, abstract: paper.abstract || '' };
}
var PROVIDERS = [
/* Wikipedia — MediaWiki action API, full lead section, text-only.
Uses .popup-extract rather than .popup-abstract; the parser
signals this by returning `extract` instead of `abstract`.
The API host matches the link's own subdomain, so es.wikipedia.org
links fetch the Spanish extract, de.wikipedia.org fetches German,
etc. Bare wikipedia.org and www. fall through to en. */
{
name: 'wikipedia', label: 'Wikipedia',
match: /wikipedia\.org\/wiki\/([^#?]+)/,
fetchType: 'json',
bodyLimit: 600,
url: function (ctx) {
var hostMatch = ctx.href.match(/\/\/([a-z0-9-]+)\.wikipedia\.org\//i);
var sub = hostMatch ? hostMatch[1].toLowerCase() : 'en';
if (sub === 'www') sub = 'en';
return 'https://' + sub + '.wikipedia.org/w/api.php'
+ '?action=query&prop=extracts&exintro=1&format=json&redirects=1'
+ '&titles=' + encodeURIComponent(decodeURIComponent(ctx.match[1]))
+ '&origin=*';
},
parse: function (data) {
var pages = data && data.query && data.query.pages;
if (!pages) return null;
var page = Object.values(pages)[0];
if (!page || page.missing !== undefined) return null;
var doc = new DOMParser().parseFromString(page.extract || '', 'text/html');
/* Math elements blend display chars with raw LaTeX source
in the DOM — strip them before textContent extraction. */
doc.querySelectorAll('.mwe-math-element').forEach(function (el) {
el.parentNode.removeChild(el);
});
var text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return null;
return { title: page.title, extract: text };
}
},
/* arXiv — Atom API (CORS-broken upstream, proxied). */
{
name: 'arxiv', label: 'arXiv',
match: /arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5}(?:v\d+)?)/,
fetchType: 'xml',
url: function (ctx) {
return '/proxy/arxiv/api/query?id_list='
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, ''));
},
parse: function (xml) {
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var titleEl = doc.querySelector('entry > title');
var summaryEl = doc.querySelector('entry > summary');
if (!titleEl || !summaryEl) return null;
return {
title: titleEl.textContent.trim().replace(/\s+/g, ' '),
authors: Array.from(doc.querySelectorAll('entry > author > name'))
.map(function (el) { return el.textContent.trim(); }),
abstract: summaryEl.textContent.trim().replace(/\s+/g, ' ')
};
}
},
/* DOI → CrossRef — strips upstream JATS-HTML from abstract. */
{
name: 'doi', label: 'CrossRef',
match: /(?:dx\.)?doi\.org\/(10\.[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.crossref.org/works/' + encodeURIComponent(ctx.match[1]);
},
parse: function (data) {
var msg = data && data.message;
if (!msg) return null;
var title = (msg.title && msg.title[0]) || '';
if (!title) return null;
var journal = (msg['container-title'] && msg['container-title'][0]) || '';
var parts = msg.issued && msg.issued['date-parts'];
var year = parts && parts[0] && parts[0][0];
return {
title: title,
authors: (msg.author || []).map(function (a) {
return (a.given ? a.given + ' ' : '') + (a.family || '');
}),
meta: [journal, year].filter(Boolean).join(', '),
abstract: (msg.abstract || '').replace(/<[^>]+>/g, '')
};
}
},
/* GitHub — repo description + language + stars. */
{
name: 'github', label: 'GitHub',
match: /github\.com\/([^/]+)\/([^/?#]+)/,
fetchType: 'json',
fetchInit: { headers: { 'Accept': 'application/vnd.github.v3+json' } },
url: function (ctx) {
return 'https://api.github.com/repos/' + ctx.match[1] + '/' + ctx.match[2];
},
parse: function (data) {
if (!data || !data.full_name) return null;
var stars = data.stargazers_count;
return {
title: data.full_name,
abstract: data.description || '',
stats: [data.language,
stars != null ? '\u2605\u00a0' + stars : null]
.filter(Boolean).join(' \u00b7 ')
};
}
},
/* Forgejo (self-hosted git) — same shape as GitHub, but field
naming differs (`stars_count` vs `stargazers_count`). */
{
name: 'forgejo', label: 'Forgejo',
match: /git\.levineuwirth\.org\/([^/]+)\/([^/?#]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://git.levineuwirth.org/api/v1/repos/'
+ ctx.match[1] + '/' + ctx.match[2];
},
parse: function (data) {
if (!data || !data.full_name) return null;
var stars = data.stars_count;
return {
title: data.full_name,
abstract: data.description || '',
stats: [data.language,
stars != null ? '\u2605\u00a0' + stars : null]
.filter(Boolean).join(' \u00b7 ')
};
}
},
/* Open Library — works/books JSON appended to href. */
{
name: 'openlibrary', label: 'Open Library',
match: /openlibrary\.org\/(?:works|books)\//,
fetchType: 'json',
bodyLimit: 300,
url: function (ctx) { return ctx.href.replace(/[?#].*$/, '') + '.json'; },
parse: function (data) {
if (!data || !data.title) return null;
var desc = data.description;
if (desc && typeof desc === 'object') desc = desc.value;
return { title: data.title, abstract: desc || '' };
}
},
/* bioRxiv — shares schema with medRxiv via biorxivParse. */
{
name: 'biorxiv', label: 'bioRxiv',
match: /biorxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.biorxiv.org/details/biorxiv/'
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json';
},
parse: biorxivParse
},
/* medRxiv — identical shape as bioRxiv; different upstream path. */
{
name: 'medrxiv', label: 'medRxiv',
match: /medrxiv\.org\/content\/(10\.\d{4,}\/[^?#\s]+)/,
fetchType: 'json',
url: function (ctx) {
return 'https://api.biorxiv.org/details/medrxiv/'
+ encodeURIComponent(ctx.match[1].replace(/v\d+$/, '')) + '/json';
},
parse: biorxivParse
},
/* YouTube — oEmbed (no API key required). */
{
name: 'youtube', label: 'YouTube',
match: /youtube\.com\/watch|youtu\.be\//,
fetchType: 'json',
url: function (ctx) {
return 'https://www.youtube.com/oembed?url='
+ encodeURIComponent(ctx.href) + '&format=json';
},
parse: function (data) {
if (!data || !data.title) return null;
return { title: data.title, authors: data.author_name || '' };
}
},
/* Internet Archive — item metadata (CORS-broken upstream, proxied).
CSS icon key is `internet-archive` (hyphenated), but the
provider/class name stays short — hence the `icon` override. */
{
name: 'archive', label: 'Internet Archive', icon: 'internet-archive',
match: /archive\.org\/details\/([^/?#]+)/,
fetchType: 'json',
bodyLimit: 280,
url: function (ctx) {
return '/proxy/archive/metadata/' + encodeURIComponent(ctx.match[1]);
},
parse: function (data) {
var meta = data && data.metadata;
if (!meta) return null;
var first = function (v) { return Array.isArray(v) ? v[0] : (v || ''); };
var title = first(meta.title);
if (!title) return null;
var creator = first(meta.creator);
var year = first(meta.year);
return {
title: title,
authors: [creator, year].filter(Boolean).join(', '),
abstract: first(meta.description).replace(/<[^>]+>/g, '')
};
}
},
/* PubMed — NCBI esummary (CORS-broken upstream, proxied). */
{
name: 'pubmed', label: 'PubMed',
match: /pubmed\.ncbi\.nlm\.nih\.gov\/(\d+)/,
fetchType: 'json',
url: function (ctx) {
return '/proxy/pubmed/entrez/eutils/esummary.fcgi'
+ '?db=pubmed&id=' + ctx.match[1] + '&retmode=json';
},
parse: function (data, ctx) {
var paper = data && data.result && data.result[ctx.match[1]];
if (!paper || !paper.title) return null;
return {
title: paper.title,
authors: (paper.authors || []).map(function (a) { return a.name; }),
meta: [paper.fulljournalname || paper.source || '',
(paper.pubdate || '').slice(0, 4)].filter(Boolean).join(', ')
};
}
}
];
/* ------------------------------------------------------------------
Helpers
------------------------------------------------------------------ */
function store(href, html) {
cache[href] = html;
return html;
}
/* Epistemic jump link — pulls the parallel-tag strip from the top of
the page (which holds the author-declared orientation tags) and
the expanded DL from the #epistemic footer section (which holds
the git-derived stability/last-reviewed/trend). The popup combines
both so a reader hovering the link mid-page sees the full profile
without scrolling.
Returns a DocumentFragment instead of an HTML string so the popup
receives cloned nodes (defense in depth — if a future change ever
allowed user-authored HTML into the source section, the popup
would still see exactly the same already-rendered DOM rather than
a re-parsed string). */
function epistemicContent() {
var wrap = document.createElement('div');
wrap.className = 'popup-epistemic';
var strip = document.querySelector('.meta-epistemic-strip');
if (strip) {
wrap.appendChild(strip.cloneNode(true));
}
var section = document.getElementById('epistemic');
var expanded = section ? section.querySelector('.ep-expanded') : null;
if (expanded) {
wrap.appendChild(expanded.cloneNode(true));
}
if (!strip && !expanded) return Promise.resolve(null);
return Promise.resolve(wrap);
}
/* Epistemic term definition — shows a concise description from the
colophon for any element tagged with data-ep-term="<field>". */
function epistemicTermContent(target) {
var term = target.dataset.epTerm;
var def = term && EP_DEFS[term];
if (!def) return Promise.resolve(null);
var label = term.charAt(0).toUpperCase() + term.slice(1);
return Promise.resolve(
'<div class="popup-ep-term">'
+ '<div class="popup-source" data-popup-source="colophon">'
+ '<a href="/colophon.html#living-documents">Colophon</a>'
+ '</div>'
+ '<div class="popup-title">' + esc(label) + '</div>'
+ '<div class="popup-abstract">' + esc(def) + '</div>'
+ '</div>'
);
}
/* 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; });
}
/* 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 */
function sigContent(target) {
var href = target.getAttribute('href');
if (!href) return Promise.resolve(null);
if (cache[href]) return Promise.resolve(cache[href]);
return fetch(href, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.text() : null; })
.then(function (text) {
if (!text) return null;
var html = '<div class="popup-sig"><pre>' + esc(text.trim()) + '</pre></div>';
cache[href] = html;
return html;
});
}
/* ------------------------------------------------------------------
Date popups — explain a date or date range in human terms.
Single date: "6 months ago"
Range: "~4 weeks · started 6 months ago"
Frontmatter: range + "17 revisions" cadence line (only when
data-date-commits is present on the trigger).
------------------------------------------------------------------ */
function dateContent(target) {
var startAttr = target.getAttribute('data-date-start');
if (!startAttr) return Promise.resolve(null);
var start = parseIsoDate(startAttr);
if (!start) return Promise.resolve(null);
var endAttr = target.getAttribute('data-date-end');
var end = endAttr ? parseIsoDate(endAttr) : null;
var commits = target.getAttribute('data-date-commits');
var today = new Date();
var lines = [];
if (end) {
var spanDays = daysBetween(start, end);
var agoDays = daysBetween(start, today);
/* "~" prefix when we've rounded to a unit larger than days. */
var span = humanDuration(spanDays, true);
var ago = humanAgo(agoDays);
lines.push(
'<div class="popup-date-primary">'
+ esc(span) + ' · started ' + esc(ago)
+ '</div>');
if (commits && /^\d+$/.test(commits)) {
var n = parseInt(commits, 10);
lines.push(
'<div class="popup-date-cadence">'
+ n + ' revision' + (n === 1 ? '' : 's')
+ '</div>');
}
} else {
var days = daysBetween(start, today);
lines.push(
'<div class="popup-date-primary">'
+ esc(humanAgo(days)) + '</div>');
}
return Promise.resolve('<div class="popup-date">' + lines.join('') + '</div>');
}
/* Parse "YYYY-MM-DD" (UTC midnight) to a Date. Returns null on failure. */
function parseIsoDate(s) {
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
if (!m) return null;
var d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
return isNaN(d.getTime()) ? null : d;
}
/* Whole-day difference between two Dates, floored (never negative). */
function daysBetween(a, b) {
var ms = Math.abs(b.getTime() - a.getTime());
return Math.floor(ms / 86400000);
}
/* "5 days" / "3 weeks" / "4 months" / "2 years" — the unit is chosen
to match the magnitude so the number stays small and readable.
`approx` prefixes "~" when the returned unit is coarser than days. */
function humanDuration(days, approx) {
if (days <= 1) return '1 day';
if (days < 14) return days + ' days';
if (days < 60) {
var w = Math.round(days / 7);
return (approx ? '~' : '') + w + ' week' + (w === 1 ? '' : 's');
}
if (days < 365) {
var mo = Math.round(days / 30);
return (approx ? '~' : '') + mo + ' month' + (mo === 1 ? '' : 's');
}
var y = Math.round(days / 365);
return (approx ? '~' : '') + y + ' year' + (y === 1 ? '' : 's');
}
/* Past-tense phrasing for a date N days in the past. */
function humanAgo(days) {
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 14) return days + ' days ago';
return humanDuration(days, true) + ' ago';
}
/* Defer to the shared utility (loaded synchronously from
templates/partials/head.html) so this file cannot drift from
annotations.js, semantic-search.js, or build/Utils.hs. */
function esc(s) {
return window.lnUtils.escapeHtml(s);
}
/* Emit a .popup-source label with a data-popup-source attribute so CSS
can prepend the matching icon via ::before + mask-image. */
function srcHtml(key, label) {
return '<div class="popup-source" data-popup-source="' + esc(key) + '">' + esc(label) + '</div>';
}
document.addEventListener('DOMContentLoaded', init);
}());