carbon-tokenization / backend /src /publisher /html-renderer.ts
tfrere's picture
tfrere HF Staff
fix(publisher): render math server-side via KaTeX
d062662
Raw
History Blame Contribute Delete
42.5 kB
/**
* Server-side HTML renderer.
*
* Takes TipTap JSON content and generates a full static HTML page
* with SEO tags, dark/light theming, and minimal inline JS.
*/
import { generateHTML } from "@tiptap/html";
import { parseHTML } from "linkedom";
import { getServerExtensions } from "./extensions.js";
import type { PublishCSS } from "./index.js";
import { oklchFromHue } from "../shared/theme.js";
import { transformers, buildEmbedSrcdoc, type TransformContext } from "./transformers/index.js";
export interface PublishAuthor {
name: string;
url?: string;
affiliationIndices: number[];
affiliationNames: string[];
}
export interface PublishAffiliation {
name: string;
url?: string;
}
export interface PublishMeta {
title: string;
subtitle?: string;
description: string;
authors: PublishAuthor[];
affiliations: PublishAffiliation[];
date: string;
doi?: string;
licence?: string;
ogImage?: string;
pdfUrl?: string;
banner?: string;
tableOfContentsAutoCollapse?: boolean;
/** OKLCH hue (0-360) chosen via the editor HueSlider, synced from Y.Doc settings. */
primaryHue?: number;
}
export interface CitationData {
entries: any[];
orderedKeys: string[];
style: string;
}
/**
* Render TipTap JSON document into a complete, self-contained HTML page.
*/
export async function renderArticleHTML(
json: Record<string, unknown>,
meta: PublishMeta,
css: PublishCSS,
citationData?: CitationData,
serverBiblioHtml?: string,
embeds?: Record<string, string>
): Promise<string> {
const extensions = getServerExtensions();
// Extract bibliography HTML before generateHTML escapes it as an attribute
// (fallback to client-side renderedHtml if server formatting not available)
const clientBiblioHtml = extractBibliographyHtml(json);
const biblioHtml = serverBiblioHtml || clientBiblioHtml;
const bodyHtml = generateHTML(json as any, extensions);
const enrichedBody = await postProcess(bodyHtml, biblioHtml, citationData, embeds);
const authorNames = meta.authors.map((a) => a.name);
const authorsStr = authorNames.join(", ");
const safeTitle = escapeHtml(meta.title);
const safeDesc = escapeHtml(meta.description);
let result = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--
Opt out of Chrome/Edge/Firefox in-browser translation. The article
body is authored in English and contains inline math / code that
the translator would happily garble. These metas tell the browser
to never surface the translate prompt even on non-English OS
locales. The google-named tag is the historical Chrome opt-out;
most other engines read the same tag.
-->
<meta name="google" content="notranslate">
<meta name="robots" content="notranslate">
<title>${safeTitle}</title>
<!--
Blocking theme bootstrap (runs before any CSS is parsed).
Sets <html data-theme> to the user's preferred mode so the
FIRST paint already uses the right palette - no dark->light
flash on a "light preference" browser. Must stay:
- inline (no network dependency)
- synchronous (no async/defer)
- placed BEFORE <style> in <head>
The rest of the theme logic (toggle button, icon swap, live
media-query sync) still runs at end-of-body where it is less
critical.
-->
<script>
(function() {
try {
var saved = localStorage.getItem('theme');
var prefersDark = window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
var mode = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.style.colorScheme = mode;
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<meta name="description" content="${safeDesc}">
<meta name="author" content="${escapeHtml(authorsStr)}">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="${safeTitle}">
<meta property="og:description" content="${safeDesc}">
${meta.ogImage ? `<meta property="og:image" content="${escapeHtml(meta.ogImage)}">` : ""}
<meta property="article:published_time" content="${escapeHtml(meta.date)}">
${authorNames.map((a) => `<meta property="article:author" content="${escapeHtml(a)}">`).join("\n ")}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${safeTitle}">
<meta name="twitter:description" content="${safeDesc}">
${meta.ogImage ? `<meta name="twitter:image" content="${escapeHtml(meta.ogImage)}">` : ""}
<!-- KaTeX CSS (version pinned to the katex used server-side in the math transformer) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
integrity="sha384-exTa2AZBTSYZY6XrZeHr+SthVy0uzRopdM/I9+nd/JMLcTcklL0+cMoJGes1iO1i"
crossorigin="anonymous">
<!-- Code blocks are pre-highlighted at publish time with Shiki (dual-theme
via --shiki-dark CSS vars). Zero runtime JS, zero CDN dependency. -->
<!-- Mermaid (renders <pre class="mermaid"> blocks, auto-theme dark/light) -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'neutral';
}
mermaid.initialize({ startOnLoad: false, theme: getTheme() });
document.querySelectorAll('pre.mermaid').forEach(function(el) {
el.setAttribute('data-mermaid-src', el.textContent || '');
});
await mermaid.run();
new MutationObserver(function(ms) {
for (var i = 0; i < ms.length; i++) {
if (ms[i].attributeName === 'data-theme') {
mermaid.initialize({ startOnLoad: false, theme: getTheme() });
document.querySelectorAll('pre.mermaid').forEach(function(el) {
var src = el.getAttribute('data-mermaid-src');
if (src) {
el.removeAttribute('data-processed');
el.textContent = src;
}
});
mermaid.run();
break;
}
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
</script>
<style>
${css.variables}
${css.reset}
${css.editorTokens}
${css.base}
${css.layout}
${css.components}
${css.article}
${css.print}
${css.publisher}
${renderPrimaryColorOverride(meta.primaryHue)}
</style>
</head>
<body>
<button id="theme-toggle" aria-label="Toggle color theme">
<span class="icon-wrapper">
<svg class="icon icon--sun" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon icon--moon" style="opacity:0" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</span>
</button>
<!--
Editor quick-access pill, shown only for visitors whose
/api/auth/status response carries canEdit=true. Hidden by
default so non-editors never see a flash. The fields are
filled by the inline script at the bottom of <body>; we use
placeholder content so layout doesn't shift when the data
arrives.
-->
<a id="edit-link" href="/editor" class="edit-pill" hidden>
<img class="edit-pill__avatar" alt="" width="24" height="24" referrerpolicy="no-referrer">
<span class="edit-pill__text">
Signed in as <strong class="edit-pill__name">@you</strong>
</span>
<span class="edit-pill__cta">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Edit article
</span>
</a>
<!-- Mobile TOC toggle -->
<button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="toc-mobile-backdrop" aria-hidden="true"></div>
<aside class="toc-mobile-sidebar" aria-label="Table of Contents">
<div class="toc-mobile-sidebar__header">
<span class="toc-mobile-sidebar__title">Table of Contents</span>
<div class="toc-mobile-sidebar__actions">
<button class="toc-mobile-sidebar__theme" aria-label="Toggle color theme">
<svg class="icon light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<button class="toc-mobile-sidebar__close" aria-label="Close table of contents">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
</aside>
<!-- Hero -->
<section class="hero">
<h1 class="hero-title">${safeTitle}</h1>
${meta.banner && embeds?.[meta.banner] ? `<div class="hero-banner"><iframe srcdoc="" sandbox="allow-scripts allow-same-origin" loading="lazy" style="width:100%;height:100%;border:none;display:block;background:transparent;" data-banner-src="${escapeHtml(meta.banner)}"></iframe></div>` : ""}
${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
</section>
<!-- Meta bar -->
${renderMetaBar(meta)}
<section class="content-grid">
<nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse="${meta.tableOfContentsAutoCollapse ? "1" : "0"}">
<div class="title">Table of Contents</div>
<div id="toc-placeholder"></div>
</nav>
<main>
<div class="tiptap">
${enrichedBody}
</div>
</main>
</section>
<!-- Footer -->
${renderFooter(meta)}
<!-- Image lightbox -->
<dialog class="lightbox" id="lightbox">
<img id="lightbox-img" src="" alt="">
</dialog>
<script>
(function() {
// Theme toggle (matches template ThemeToggle.astro)
var btn = document.getElementById('theme-toggle');
var sunIcon = btn && btn.querySelector('.icon--sun');
var moonIcon = btn && btn.querySelector('.icon--moon');
var wrapper = btn && btn.querySelector('.icon-wrapper');
var media = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
var prefersDark = media && media.matches;
var saved = localStorage.getItem('theme');
function applyTheme(mode) {
document.documentElement.dataset.theme = mode;
if (sunIcon && moonIcon) {
sunIcon.style.opacity = mode === 'dark' ? '0' : '1';
moonIcon.style.opacity = mode === 'dark' ? '1' : '0';
}
}
applyTheme(saved || (prefersDark ? 'dark' : 'light'));
requestAnimationFrame(function() { if (wrapper) wrapper.classList.add('animated'); });
if (!saved && media) {
var syncSystem = function(e) { applyTheme(e.matches ? 'dark' : 'light'); };
if (media.addEventListener) media.addEventListener('change', syncSystem);
}
if (btn) {
btn.addEventListener('click', function() {
var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
if (wrapper) {
var cls = next === 'dark' ? 'spin-cw' : 'spin-ccw';
wrapper.classList.remove('spin-cw', 'spin-ccw');
void wrapper.offsetWidth;
wrapper.classList.add(cls);
wrapper.addEventListener('animationend', function() { wrapper.classList.remove(cls); }, { once: true });
}
applyTheme(next);
});
}
// Lightbox
document.querySelectorAll('.tiptap img').forEach(function(img) {
img.style.cursor = 'zoom-in';
img.addEventListener('click', function() {
var lbImg = document.getElementById('lightbox-img');
var lbDlg = document.getElementById('lightbox');
if (lbImg) lbImg.src = img.src;
if (lbDlg && lbDlg.showModal) lbDlg.showModal();
});
});
var lb = document.getElementById('lightbox');
if (lb) lb.addEventListener('click', function(e) { if (e.target === this) this.close(); });
// Glossary
document.querySelectorAll('[data-type="glossary"]').forEach(function(el) { el.setAttribute('tabindex', '0'); });
// ---- Table of Contents ----
var holder = document.getElementById('toc-placeholder');
var holderMobile = document.getElementById('toc-mobile-placeholder');
var articleRoot = document.querySelector('.content-grid main');
if (!articleRoot) return;
var headings = articleRoot.querySelectorAll('h2, h3, h4');
if (!headings.length) return;
var headingsArr = Array.from(headings);
// Unique IDs
var usedIds = {};
var slugify = function(s) { return String(s||'').toLowerCase().trim().replace(/\\s+/g,'_').replace(/[^a-z0-9_-]/g,''); };
headingsArr.forEach(function(h) {
var id = (h.id||'').trim() || slugify(h.textContent) || 'section';
var c = id, n = 2;
while (usedIds[c]) c = id+'-'+(n++);
if (h.id !== c) h.id = c;
usedIds[c] = true;
});
// Build nested nav with data-heading-idx
// Each nested <ul> is wrapped in <div class="toc-children"> so we can
// animate expand/collapse via the grid-template-rows 0fr/1fr trick
// (CSS cannot interpolate height:auto).
var nav = document.createElement('nav');
var ulStack = [document.createElement('ul')];
nav.appendChild(ulStack[0]);
var levelOf = function(tag) { return tag==='H2'?2:tag==='H3'?3:4; };
var prev = 2, hIdx = 0;
headingsArr.forEach(function(h) {
var lvl = levelOf(h.tagName);
while (lvl > prev) {
var wrap = document.createElement('div');
wrap.className = 'toc-children';
var ul = document.createElement('ul');
wrap.appendChild(ul);
var last = ulStack[ulStack.length-1].lastElementChild;
if (last) last.appendChild(wrap);
ulStack.push(ul);
prev++;
}
while (lvl < prev) { ulStack.pop(); prev--; }
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#'+h.id; a.textContent = h.textContent;
li.appendChild(a);
li.setAttribute('data-heading-idx', String(hIdx++));
ulStack[ulStack.length-1].appendChild(li);
});
if (holder) holder.appendChild(nav);
var navClone = nav.cloneNode(true);
if (holderMobile) holderMobile.appendChild(navClone);
// Mark navs as collapsible
nav.classList.add('toc-collapsible');
navClone.classList.add('toc-collapsible');
var allLinks = [].concat(
holder ? Array.from(holder.querySelectorAll('a')) : [],
holderMobile ? Array.from(holderMobile.querySelectorAll('a')) : []
);
// Click handler for smooth scroll with offset
var SCROLL_OFFSET = 80;
allLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
var href = link.getAttribute('href');
if (!href || href.charAt(0) !== '#') return;
var el = document.getElementById(href.slice(1));
if (!el) return;
e.preventDefault();
var top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
window.scrollTo({ top: top, behavior: 'smooth' });
history.pushState(null, '', href);
});
});
// ---- Auto-collapse logic ----
var isMobile = window.matchMedia('(max-width: 1100px)');
var tocNav = document.querySelector('.table-of-contents');
var autoCollapseEnabled = tocNav && tocNav.getAttribute('data-auto-collapse') === '1';
function getItemsWithChildren(navEl) {
if (!navEl) return [];
return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
return li.querySelector(':scope > .toc-children');
});
}
function getAncestorIndices(items, targetIdx) {
var toExpand = {};
var activeLi = null;
function find(li) {
if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
var wrap = li.querySelector(':scope > .toc-children');
if (!wrap) return null;
var ul = wrap.querySelector(':scope > ul');
if (!ul) return null;
var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
return null;
}
for (var i = 0; i < items.length; i++) { activeLi = find(items[i]); if (activeLi) break; }
if (!activeLi) return toExpand;
toExpand[targetIdx] = true;
var cur = activeLi;
while (cur) {
var parent = cur.parentElement ? cur.parentElement.closest('li[data-heading-idx]') : null;
if (parent) { toExpand[Number(parent.getAttribute('data-heading-idx'))] = true; cur = parent; }
else break;
}
return toExpand;
}
// Just toggle the .collapsed class on the li. The CSS rule
// .toc-collapsible li.collapsed > .toc-children { grid-template-rows: 0fr }
// drives the actual animation (no JS height math).
function applyCollapseState(navEl, activeIdx) {
if (!navEl) return;
var items = getItemsWithChildren(navEl);
var ancestors = getAncestorIndices(items, activeIdx);
items.forEach(function(li) {
var idx = Number(li.getAttribute('data-heading-idx'));
var allDesc = li.querySelectorAll('li[data-heading-idx]');
var related = [idx];
allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
var shouldExpand = related.some(function(i) { return ancestors[i]; });
li.classList.toggle('collapsed', !shouldExpand);
});
}
function expandAll(navEl) {
if (!navEl) return;
getItemsWithChildren(navEl).forEach(function(li) {
li.classList.remove('collapsed');
});
}
// Initial state: collapse only if auto-collapse is enabled.
// We suppress transitions for the first paint so the user doesn't see
// a close-animation at page load when auto-collapse is on.
function initCollapse() {
if (!autoCollapseEnabled) return;
if (nav) nav.classList.add('toc-no-transition');
if (navClone) navClone.classList.add('toc-no-transition');
if (isMobile.matches) {
expandAll(holder ? holder.querySelector('nav') : null);
expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
} else {
applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
}
requestAnimationFrame(function(){
requestAnimationFrame(function(){
if (nav) nav.classList.remove('toc-no-transition');
if (navClone) navClone.classList.remove('toc-no-transition');
});
});
}
initCollapse();
isMobile.addEventListener('change', function() {
if (!autoCollapseEnabled) return;
if (isMobile.matches) {
expandAll(holder ? holder.querySelector('nav') : null);
expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
} else {
applyCollapseState(holder ? holder.querySelector('nav') : null, prevActiveIdx);
applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, prevActiveIdx);
}
});
// ---- Scroll tracking (IntersectionObserver-driven) ----
// Semantics: active heading = the last one whose top has crossed the
// OFFSET line ("section I'm currently reading"). This matches typical
// documentation UX (MDN, Docusaurus, etc.) and is intentionally different
// from the editor-side TOC which picks the topmost visible heading in its
// scroll container (better suited to non-linear editing navigation).
//
// We use IntersectionObserver only as a trigger: it fires precisely when
// a heading crosses the OFFSET boundary, which are the only moments the
// active heading can change. No per-frame scroll handler, no throttling.
var OFFSET = 60, prevActiveIdx = -1, prevActiveId = null, rafPending = false;
function findActiveHeading() {
for (var i = headingsArr.length - 1; i >= 0; i--) {
var h = headingsArr[i];
// Skip headings removed from DOM or moved outside article (e.g. footnotes moved to footer)
if (!h.isConnected || !articleRoot.contains(h)) continue;
if (h.getBoundingClientRect().top - OFFSET <= 0) {
return { idx: i, id: h.id };
}
}
return { idx: -1, id: null };
}
function updateActive() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function() {
rafPending = false;
var result = findActiveHeading();
var activeIdx = result.idx, activeId = result.id;
if (activeId === prevActiveId && activeIdx === prevActiveIdx) return;
allLinks.forEach(function(l) { l.classList.remove('active'); });
if (activeId) {
var href = '#' + activeId;
allLinks.forEach(function(l) {
if (l.getAttribute('href') === href) l.classList.add('active');
});
}
if (activeIdx >= 0 && activeIdx !== prevActiveIdx) {
if (autoCollapseEnabled && !isMobile.matches) {
applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
}
}
prevActiveIdx = activeIdx;
prevActiveId = activeId;
});
}
if (typeof IntersectionObserver !== 'undefined') {
var spyObserver = new IntersectionObserver(updateActive, {
rootMargin: '-' + OFFSET + 'px 0px 0px 0px',
threshold: 0,
});
headingsArr.forEach(function(h) {
if (articleRoot.contains(h)) spyObserver.observe(h);
});
// rootMargin changes on resize (viewport height affects the bottom edge
// of the root), but the top offset is fixed, so no need to recreate.
} else {
// Fallback for browsers without IntersectionObserver (very old).
window.addEventListener('scroll', updateActive, { passive: true });
}
updateActive();
// ---- Mobile sidebar ----
var sidebar = document.querySelector('.toc-mobile-sidebar');
var backdrop = document.querySelector('.toc-mobile-backdrop');
var toggleBtn = document.querySelector('.toc-mobile-toggle');
var closeBtn = document.querySelector('.toc-mobile-sidebar__close');
function openSidebar() {
sidebar.classList.add('open'); backdrop.classList.add('open');
toggleBtn.setAttribute('aria-expanded','true');
document.body.style.overflow = 'hidden';
requestAnimationFrame(function() {
var active = sidebar.querySelector('a.active');
if (active) {
var body = sidebar.querySelector('.toc-mobile-sidebar__body');
if (body) body.scrollTop = Math.max(0, active.offsetTop - body.offsetTop - body.clientHeight/3);
}
});
}
function closeSidebar() {
sidebar.classList.remove('open'); backdrop.classList.remove('open');
toggleBtn.setAttribute('aria-expanded','false');
document.body.style.overflow = '';
}
if (toggleBtn) toggleBtn.addEventListener('click', openSidebar);
if (closeBtn) closeBtn.addEventListener('click', closeSidebar);
if (backdrop) backdrop.addEventListener('click', closeSidebar);
if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
// Sidebar theme toggle
var sidebarThemeBtn = document.querySelector('.toc-mobile-sidebar__theme');
if (sidebarThemeBtn) {
sidebarThemeBtn.addEventListener('click', function() {
var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
});
}
// ---- Footer: move references & footnotes ----
(function() {
var footer = document.querySelector('footer.footer');
if (!footer) return;
var target = footer.querySelector('.references-block');
if (!target) return;
var contentRoot = document.querySelector('.content-grid main') || document.body;
var ensureHeading = function(text) {
var exists = Array.from(target.children).some(function(c) {
return c.classList.contains('footer-heading') && c.textContent.trim().toLowerCase() === text.toLowerCase();
});
if (!exists) {
var h = document.createElement('p');
h.className = 'footer-heading';
h.setAttribute('role', 'heading');
h.setAttribute('aria-level', '2');
h.textContent = text;
target.appendChild(h);
}
};
var moveIntoFooter = function(el, headingText) {
if (!el) return false;
var firstH = el.querySelector(':scope > h1, :scope > h2, :scope > h3, :scope > .footer-heading, :scope > .bibliography-title');
if (firstH) {
var t = (firstH.textContent || '').trim().toLowerCase();
if (t === headingText.toLowerCase() || t.includes('reference') || t.includes('bibliograph')) firstH.remove();
}
ensureHeading(headingText);
target.appendChild(el);
return true;
};
var findAllOutsideFooter = function(selectors) {
var results = [];
for (var i = 0; i < selectors.length; i++) {
var els = contentRoot.querySelectorAll(selectors[i]);
els.forEach(function(el) { if (!footer.contains(el) && results.indexOf(el) === -1) results.push(el); });
}
return results;
};
var findFirst = function(selectors) { var a = findAllOutsideFooter(selectors); return a.length ? a[0] : null; };
var run = function() {
if (footer.dataset.processed === 'true') return;
var refs = findAllOutsideFooter(['[data-type="bibliography"]', '#bibliography-references-list', '#references', '#refs', '.bibliography']);
var notes = findFirst(['.footnotes', 'section.footnotes', 'div.footnotes']);
var moved = false;
refs.forEach(function(el) { if (moveIntoFooter(el, 'References')) moved = true; });
if (moveIntoFooter(notes, 'Footnotes')) moved = true;
if (moved) footer.dataset.processed = 'true';
};
run();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true });
window.addEventListener('load', function() { setTimeout(run, 100); }, { once: true });
setTimeout(run, 300);
})();
// Sync theme with embed iframes (body embeds + hero banner)
function syncEmbedThemes() {
var theme = document.documentElement.dataset.theme || 'dark';
document.querySelectorAll('.html-embed-container iframe, .hero-banner iframe').forEach(function(iframe) {
try { iframe.contentWindow.postMessage({ type: 'setTheme', theme: theme }, '*'); } catch(e) {}
});
}
var obs = new MutationObserver(function(ms) {
for (var i = 0; i < ms.length; i++) {
if (ms[i].attributeName === 'data-theme') { syncEmbedThemes(); break; }
}
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Auto-size embed iframes from postMessage height reports.
// Dedupe: only apply when the new height differs from the last applied
// (prevents style thrash during tight ResizeObserver bursts).
var _lastEmbedHeight = new WeakMap();
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'embedResize') return;
var h = Math.max(0, Math.ceil(e.data.height));
if (!h) return;
document.querySelectorAll('.html-embed-container iframe').forEach(function(iframe) {
try {
if (iframe.contentWindow !== e.source) return;
if (_lastEmbedHeight.get(iframe) === h) return;
_lastEmbedHeight.set(iframe, h);
iframe.style.height = h + 'px';
} catch(ex) {}
});
});
// Initial theme sync after iframes load
window.addEventListener('load', function() { setTimeout(syncEmbedThemes, 200); });
// Hash navigation
if (window.location.hash) {
var target = document.querySelector(window.location.hash);
if (target) setTimeout(function() { target.scrollIntoView({block:'start'}); }, 100);
}
window.addEventListener('popstate', function() {
var h = window.location.hash;
if (h) { var t = document.querySelector(h); if (t) t.scrollIntoView({block:'start'}); }
else window.scrollTo({top:0});
});
// ----------------------------------------------------------------
// Editor quick-access pill
// ----------------------------------------------------------------
// The published page lives on the same Space as the editor, so we
// can probe /api/auth/status to know if the current visitor has
// edit rights. When they do, fill in their avatar/handle and
// surface the pill top-right. Otherwise the pill stays hidden
// (no flash for anonymous / read-only visitors).
//
// A network failure (offline preview, exported HTML opened from
// disk, ...) is silently ignored - the pill just stays hidden.
(function() {
var link = document.getElementById('edit-link');
if (!link) return;
fetch('/api/auth/status', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.canEdit) return;
var u = data.user || {};
var handle = u.name || 'editor';
var avatar = link.querySelector('.edit-pill__avatar');
var nameEl = link.querySelector('.edit-pill__name');
if (avatar) {
var src = u.avatarUrl ||
('https://huggingface.co/api/users/' +
encodeURIComponent(handle) + '/avatar');
avatar.src = src;
avatar.alt = handle + ' avatar';
}
if (nameEl) {
nameEl.textContent = '@' + handle;
}
link.removeAttribute('hidden');
})
.catch(function() {});
})();
})();
</script>
</body>
</html>`;
// Fill banner iframe srcdoc if present.
// `fullBleed: true` MUST match the editor's FrontmatterHero preview
// (buildDoc(..., { fullBleed: true })): the banner iframe is size-driven
// by the parent's 5:2 aspect-ratio box, so it needs the edge-to-edge
// stylesheet (no body padding, 100% height chain) and must skip the
// height reporter. Without it the published banner gets 16px padding and
// resizes to its content scrollHeight, diverging from the editor view.
if (meta.banner && embeds?.[meta.banner]) {
const bannerSrcdoc = buildEmbedSrcdoc(embeds[meta.banner], { fullBleed: true });
const { document: d } = parseHTML(result);
const bannerIframe = d.querySelector('iframe[data-banner-src]');
if (bannerIframe) {
bannerIframe.setAttribute("srcdoc", bannerSrcdoc);
result = "<!DOCTYPE html>" + d.documentElement.outerHTML;
}
}
return result;
}
/**
* Emit a `:root` CSS override pinning `--primary-color` / `--primary-color-hover`
* to the hue chosen via the editor HueSlider. Placed after the base `_variables.css`
* so it always wins on specificity order.
*
* Returns an empty string if no hue was configured, leaving the template default.
*/
function renderPrimaryColorOverride(primaryHue?: number): string {
if (typeof primaryHue !== "number" || Number.isNaN(primaryHue)) return "";
// Only override --primary-base: _variables.css already derives --primary-color
// and --primary-color-hover from it via CSS relative-color syntax, so the
// cascade handles the rest.
return `:root { --primary-base: ${oklchFromHue(primaryHue)}; }`;
}
/**
* Walk the TipTap JSON tree (recursively) to find a bibliography node,
* extract its renderedHtml attribute, and clear it so generateHTML
* outputs a clean div.
*/
function extractBibliographyHtml(json: Record<string, unknown>): string {
function walk(nodes: any[]): string {
for (const node of nodes) {
if (node.type === "bibliography") {
const html = node.attrs?.renderedHtml || "";
if (node.attrs) node.attrs.renderedHtml = "";
return html;
}
if (Array.isArray(node.content)) {
const found = walk(node.content);
if (found) return found;
}
}
return "";
}
const content = (json as any)?.content;
if (!Array.isArray(content)) return "";
return walk(content);
}
/**
* Post-process the raw `generateHTML` output by running the transformer
* registry over a parsed DOM. Each transformer is a small module living in
* `transformers/` and is responsible for one component type.
*
* @see ./transformers/index.ts for the ordered list and why order matters.
*/
/** @internal - exported for testing */
export async function postProcess(
html: string,
biblioHtml: string,
citationData?: CitationData,
embeds?: Record<string, string>
): Promise<string> {
const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
const ctx: TransformContext = {
biblioHtml,
citationData,
embeds,
footnoteTexts: [],
};
for (const transformer of transformers) {
await transformer.apply(document as unknown as Document, ctx);
}
let result = document.body.innerHTML;
if (ctx.footnoteTexts.length > 0) {
const items = ctx.footnoteTexts
.map((text, i) => `<li id="fn-${i + 1}"><p>${escapeHtml(text)} <a href="#fnref-${i + 1}" class="footnote-backref" aria-label="Back to text">\u21A9</a></p></li>`)
.join("\n");
result += `<section class="footnotes"><h2>Footnotes</h2><ol>${items}</ol></section>`;
}
return result;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderMetaBar(meta: PublishMeta): string {
// DOI intentionally excluded from the hero meta bar: it only appears in the
// footer (`.doi-block`), matching the template convention the user expects.
const hasContent =
meta.authors.length > 0 ||
meta.affiliations.length > 0 ||
meta.date ||
meta.pdfUrl;
if (!hasContent) return "";
const multipleAff = meta.affiliations.length > 1;
const cells: string[] = [];
// Authors cell
if (meta.authors.length > 0) {
const items = meta.authors.map((a, i) => {
const name = a.url
? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
: escapeHtml(a.name);
const sup = multipleAff && a.affiliationIndices.length > 0
? `<sup>${a.affiliationIndices.join(",")}</sup>`
: "";
const sep = i < meta.authors.length - 1 ? ",&nbsp;" : "";
return `<li>${name}${sup}${sep}</li>`;
}).join("");
cells.push(`<div class="meta-container-cell"><h3>Authors</h3><ul class="authors">${items}</ul></div>`);
}
// Affiliations cell
if (meta.affiliations.length > 0) {
let affContent: string;
if (multipleAff) {
const items = meta.affiliations.map((aff) => {
const name = aff.url
? `<a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a>`
: escapeHtml(aff.name);
return `<li>${name}</li>`;
}).join("");
affContent = `<ol class="affiliations">${items}</ol>`;
} else {
const aff = meta.affiliations[0];
affContent = aff.url
? `<p><a href="${escapeHtml(aff.url)}" target="_blank" rel="noopener">${escapeHtml(aff.name)}</a></p>`
: `<p>${escapeHtml(aff.name)}</p>`;
}
cells.push(`<div class="meta-container-cell"><h3>Affiliations</h3>${affContent}</div>`);
}
// Published cell
if (meta.date) {
cells.push(`<div class="meta-container-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`);
}
// PDF download cell - styled as a primary button (matches research-article-template)
if (meta.pdfUrl) {
cells.push(
`<div class="meta-container-cell meta-container-cell--pdf">` +
`<h3>PDF</h3>` +
`<p><a href="${escapeHtml(meta.pdfUrl)}" class="button pdf-link" target="_blank" rel="noopener" download aria-label="Download PDF">` +
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>` +
`Download PDF` +
`</a></p>` +
`</div>`
);
}
return `<header class="meta"><div class="meta-container">${cells.join("")}</div></header>`;
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch {
return dateStr;
}
}
function extractYear(dateStr?: string): number | undefined {
if (!dateStr) return undefined;
const d = new Date(dateStr);
if (!Number.isNaN(d.getTime())) return d.getFullYear();
const m = dateStr.match(/(19|20)\d{2}/);
return m ? Number(m[0]) : undefined;
}
function buildCitationText(meta: PublishMeta): string {
const authorNames = meta.authors.map((a) => a.name);
const authorsStr = authorNames.join(", ");
const year = extractYear(meta.date);
const title = meta.title.replace(/\s+/g, " ").trim();
return `${authorsStr}${year ? ` (${year})` : ""}. "${title}".`;
}
function buildBibtex(meta: PublishMeta): string {
const authorNames = meta.authors.map((a) => a.name);
const authorsBib = authorNames.join(" and ");
const title = meta.title.replace(/\s+/g, " ").trim();
const year = extractYear(meta.date);
const keyAuthor = (authorNames[0] || "article")
.split(/\s+/)
.slice(-1)[0]
.toLowerCase();
const keyTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_|_$/g, "");
const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`;
const parts = [
` title={${title}}`,
` author={${authorsBib}}`,
];
if (year) parts.push(` year={${year}}`);
if (meta.doi) parts.push(` doi={${meta.doi}}`);
return `@misc{${bibKey},\n${parts.join(",\n")}\n}`;
}
function renderFooter(meta: PublishMeta): string {
const citationText = buildCitationText(meta);
const bibtex = buildBibtex(meta);
let sections = "";
// Citation block
sections += `
<section class="citation-block">
<p class="footer-heading" role="heading" aria-level="2">Citation</p>
<p>For attribution in academic contexts, please cite this work as</p>
<pre class="citation short">${escapeHtml(citationText)}</pre>
<p>BibTeX citation</p>
<pre class="citation long">${escapeHtml(bibtex)}</pre>
</section>`;
// DOI block
if (meta.doi) {
sections += `
<section class="doi-block">
<p class="footer-heading" role="heading" aria-level="2">DOI</p>
<p><a href="https://doi.org/${escapeHtml(meta.doi)}" target="_blank" rel="noopener noreferrer">${escapeHtml(meta.doi)}</a></p>
</section>`;
}
// Licence / Reuse block
if (meta.licence) {
sections += `
<section class="reuse-block">
<p class="footer-heading" role="heading" aria-level="2">Reuse</p>
<p>${escapeHtml(meta.licence)}</p>
</section>`;
}
// References placeholder (JS will move bibliography + footnotes here)
sections += `
<section class="references-block"></section>`;
// Template credit. Point at the editor Space (which IS the
// template you fork to start a new article) instead of the legacy
// Astro-only static template - the editor is now the canonical
// entry point and bundles the static renderer.
sections += `
<div class="template-credit">
<p>Made with &#10084;&#65039; with <a href="https://huggingface.co/spaces/tfrere/research-article-template-editor" target="_blank" rel="noopener noreferrer">research article template editor</a></p>
</div>`;
return `<footer class="footer"><div class="footer-inner">${sections}</div></footer>`;
}