| |
| |
| |
| |
| |
| |
|
|
| 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; |
| |
| primaryHue?: number; |
| } |
|
|
| export interface CitationData { |
| entries: any[]; |
| orderedKeys: string[]; |
| style: string; |
| } |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| 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>`; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function renderPrimaryColorOverride(primaryHue?: number): string { |
| if (typeof primaryHue !== "number" || Number.isNaN(primaryHue)) return ""; |
| |
| |
| |
| return `:root { --primary-base: ${oklchFromHue(primaryHue)}; }`; |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| } |
|
|
| function renderMetaBar(meta: PublishMeta): string { |
| |
| |
| 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[] = []; |
|
|
| |
| 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 ? ", " : ""; |
| return `<li>${name}${sup}${sep}</li>`; |
| }).join(""); |
| cells.push(`<div class="meta-container-cell"><h3>Authors</h3><ul class="authors">${items}</ul></div>`); |
| } |
|
|
| |
| 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>`); |
| } |
|
|
| |
| if (meta.date) { |
| cells.push(`<div class="meta-container-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`); |
| } |
|
|
| |
| 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 = ""; |
|
|
| |
| 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>`; |
|
|
| |
| 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>`; |
| } |
|
|
| |
| 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>`; |
| } |
|
|
| |
| sections += ` |
| <section class="references-block"></section>`; |
|
|
| |
| |
| |
| |
| sections += ` |
| <div class="template-credit"> |
| <p>Made with ❤️ 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>`; |
| } |
|
|