tfrere HF Staff commited on
Commit
7843436
·
1 Parent(s): f6678ab

refactor(backend/publisher): transformer pipeline + shared registry + shiki highlighting

Browse files

- Split html-renderer post-processing into per-feature transformers (accordion, bibliography,
citation, footnote, highlight-code, html-embed, mermaid) with a shared types contract
- Extract embed iframe srcdoc builder, shiki config and theme resolver into backend/src/shared
so the frontend can reuse the exact same palette and embed chrome
- Keep the renderer entrypoint (index.ts) as an orchestrator and trim the legacy logic to the
new querySelector-based scope selection (simpler + covered by updated snapshot)
- Update publisher/css-resolution/security suites + snapshot to the new output

Made-with: Cursor

backend/src/publisher/extensions.ts CHANGED
@@ -10,16 +10,13 @@
10
  import { Node, mergeAttributes, type Extensions } from "@tiptap/core";
11
  import { SHARED_COMPONENT_DEFS, type SharedComponentDef } from "../shared/component-defs.js";
12
  import StarterKit from "@tiptap/starter-kit";
13
- import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
14
  import Mathematics from "@tiptap/extension-mathematics";
15
  import { Image } from "@tiptap/extension-image";
16
  import { Table } from "@tiptap/extension-table";
17
  import { TableRow } from "@tiptap/extension-table-row";
18
  import { TableCell } from "@tiptap/extension-table-cell";
19
  import { TableHeader } from "@tiptap/extension-table-header";
20
- import { common, createLowlight } from "lowlight";
21
-
22
- const lowlight = createLowlight(common);
23
 
24
  // ---- Custom inline nodes (server versions, no NodeView) ----
25
 
@@ -90,15 +87,22 @@ const GlossaryServer = Node.create({
90
  return [{ tag: 'span[data-type="glossary"]' }];
91
  },
92
  renderHTML({ HTMLAttributes, node }) {
 
 
93
  return [
94
  "span",
95
  mergeAttributes(HTMLAttributes, {
96
  "data-type": "glossary",
97
  class: "glossary-node",
98
- title: node.attrs.definition,
99
  tabindex: "0",
100
  }),
101
- node.attrs.term || "",
 
 
 
 
 
 
102
  ];
103
  },
104
  });
@@ -233,7 +237,7 @@ export function getServerExtensions(): Extensions {
233
  undoRedo: false,
234
  link: { openOnClick: false },
235
  } as any),
236
- CodeBlockLowlight.configure({ lowlight }),
237
  Mathematics.configure({ katexOptions: { throwOnError: false } }),
238
  Image.configure({ allowBase64: true }),
239
  Table.configure({ resizable: false }),
 
10
  import { Node, mergeAttributes, type Extensions } from "@tiptap/core";
11
  import { SHARED_COMPONENT_DEFS, type SharedComponentDef } from "../shared/component-defs.js";
12
  import StarterKit from "@tiptap/starter-kit";
13
+ import { CodeBlock } from "@tiptap/extension-code-block";
14
  import Mathematics from "@tiptap/extension-mathematics";
15
  import { Image } from "@tiptap/extension-image";
16
  import { Table } from "@tiptap/extension-table";
17
  import { TableRow } from "@tiptap/extension-table-row";
18
  import { TableCell } from "@tiptap/extension-table-cell";
19
  import { TableHeader } from "@tiptap/extension-table-header";
 
 
 
20
 
21
  // ---- Custom inline nodes (server versions, no NodeView) ----
22
 
 
87
  return [{ tag: 'span[data-type="glossary"]' }];
88
  },
89
  renderHTML({ HTMLAttributes, node }) {
90
+ const term = (node.attrs.term as string) || "";
91
+ const definition = (node.attrs.definition as string) || "";
92
  return [
93
  "span",
94
  mergeAttributes(HTMLAttributes, {
95
  "data-type": "glossary",
96
  class: "glossary-node",
 
97
  tabindex: "0",
98
  }),
99
+ term,
100
+ [
101
+ "span",
102
+ { class: "pub-tooltip pub-tooltip--glossary", "aria-hidden": "true" },
103
+ ["span", { class: "pub-tooltip__title" }, term],
104
+ ["span", { class: "pub-tooltip__body" }, definition],
105
+ ],
106
  ];
107
  },
108
  });
 
237
  undoRedo: false,
238
  link: { openOnClick: false },
239
  } as any),
240
+ CodeBlock,
241
  Mathematics.configure({ katexOptions: { throwOnError: false } }),
242
  Image.configure({ allowBase64: true }),
243
  Table.configure({ resizable: false }),
backend/src/publisher/html-renderer.ts CHANGED
@@ -9,6 +9,8 @@ import { generateHTML } from "@tiptap/html";
9
  import { parseHTML } from "linkedom";
10
  import { getServerExtensions } from "./extensions.js";
11
  import type { PublishCSS } from "./index.js";
 
 
12
 
13
  export interface PublishAuthor {
14
  name: string;
@@ -33,6 +35,10 @@ export interface PublishMeta {
33
  licence?: string;
34
  ogImage?: string;
35
  pdfUrl?: string;
 
 
 
 
36
  }
37
 
38
  export interface CitationData {
@@ -44,13 +50,14 @@ export interface CitationData {
44
  /**
45
  * Render TipTap JSON document into a complete, self-contained HTML page.
46
  */
47
- export function renderArticleHTML(
48
  json: Record<string, unknown>,
49
  meta: PublishMeta,
50
  css: PublishCSS,
51
  citationData?: CitationData,
52
- serverBiblioHtml?: string
53
- ): string {
 
54
  const extensions = getServerExtensions();
55
 
56
  // Extract bibliography HTML before generateHTML escapes it as an attribute
@@ -59,14 +66,14 @@ export function renderArticleHTML(
59
  const biblioHtml = serverBiblioHtml || clientBiblioHtml;
60
 
61
  const bodyHtml = generateHTML(json as any, extensions);
62
- const enrichedBody = postProcess(bodyHtml, biblioHtml, citationData);
63
 
64
  const authorNames = meta.authors.map((a) => a.name);
65
  const authorsStr = authorNames.join(", ");
66
  const safeTitle = escapeHtml(meta.title);
67
  const safeDesc = escapeHtml(meta.description);
68
 
69
- return `<!DOCTYPE html>
70
  <html lang="en" data-theme="dark">
71
  <head>
72
  <meta charset="UTF-8">
@@ -91,18 +98,41 @@ export function renderArticleHTML(
91
 
92
  <!-- KaTeX CSS -->
93
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
94
- integrity="sha384-zh0CIslj3dQfMxK3pZnPJAb3bprHitCE2vBz9TyOTICAn3fso5GYa90qPNMBclov"
95
  crossorigin="anonymous">
96
 
97
- <!-- highlight.js for code syntax highlighting -->
98
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
99
- <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
100
- <script>hljs.highlightAll();</script>
101
 
102
- <!-- Mermaid (renders <pre class="mermaid"> blocks) -->
103
  <script type="module">
104
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
105
- mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  </script>
107
 
108
  <style>
@@ -115,7 +145,7 @@ ${css.components}
115
  ${css.article}
116
  ${css.print}
117
  ${css.publisher}
118
-
119
  </style>
120
  </head>
121
  <body>
@@ -140,11 +170,21 @@ ${css.publisher}
140
  <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
141
  <div class="toc-mobile-sidebar__header">
142
  <span class="toc-mobile-sidebar__title">Table of Contents</span>
143
- <button class="toc-mobile-sidebar__close" aria-label="Close">
 
 
 
 
 
 
 
 
 
144
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
145
  <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
146
  </svg>
147
  </button>
 
148
  </div>
149
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
150
  </aside>
@@ -152,6 +192,7 @@ ${css.publisher}
152
  <!-- Hero -->
153
  <section class="hero">
154
  <h1 class="hero-title">${safeTitle}</h1>
 
155
  ${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
156
  </section>
157
 
@@ -159,7 +200,7 @@ ${css.publisher}
159
  ${renderMetaBar(meta)}
160
 
161
  <section class="content-grid">
162
- <nav class="table-of-contents" aria-label="Table of Contents">
163
  <div class="title">Table of Contents</div>
164
  <div id="toc-placeholder"></div>
165
  </nav>
@@ -257,6 +298,9 @@ ${css.publisher}
257
  });
258
 
259
  // Build nested nav with data-heading-idx
 
 
 
260
  var nav = document.createElement('nav');
261
  var ulStack = [document.createElement('ul')];
262
  nav.appendChild(ulStack[0]);
@@ -264,7 +308,16 @@ ${css.publisher}
264
  var prev = 2, hIdx = 0;
265
  headingsArr.forEach(function(h) {
266
  var lvl = levelOf(h.tagName);
267
- while (lvl > prev) { var ul = document.createElement('ul'); var last = ulStack[ulStack.length-1].lastElementChild; if (last) last.appendChild(ul); ulStack.push(ul); prev++; }
 
 
 
 
 
 
 
 
 
268
  while (lvl < prev) { ulStack.pop(); prev--; }
269
  var li = document.createElement('li');
270
  var a = document.createElement('a');
@@ -304,11 +357,13 @@ ${css.publisher}
304
 
305
  // ---- Auto-collapse logic ----
306
  var isMobile = window.matchMedia('(max-width: 1100px)');
 
 
307
 
308
  function getItemsWithChildren(navEl) {
309
  if (!navEl) return [];
310
  return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
311
- return li.querySelector(':scope > ul');
312
  });
313
  }
314
 
@@ -317,7 +372,9 @@ ${css.publisher}
317
  var activeLi = null;
318
  function find(li) {
319
  if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
320
- var ul = li.querySelector(':scope > ul');
 
 
321
  if (!ul) return null;
322
  var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
323
  for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
@@ -335,27 +392,21 @@ ${css.publisher}
335
  return toExpand;
336
  }
337
 
 
 
 
338
  function applyCollapseState(navEl, activeIdx) {
339
  if (!navEl) return;
340
  var items = getItemsWithChildren(navEl);
341
  var ancestors = getAncestorIndices(items, activeIdx);
342
 
343
  items.forEach(function(li) {
344
- var sub = li.querySelector(':scope > ul');
345
- if (!sub) return;
346
  var idx = Number(li.getAttribute('data-heading-idx'));
347
  var allDesc = li.querySelectorAll('li[data-heading-idx]');
348
  var related = [idx];
349
  allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
350
  var shouldExpand = related.some(function(i) { return ancestors[i]; });
351
-
352
- if (shouldExpand) {
353
- li.classList.remove('collapsed');
354
- sub.style.height = 'auto';
355
- } else {
356
- li.classList.add('collapsed');
357
- sub.style.height = '0px';
358
- }
359
  });
360
  }
361
 
@@ -363,13 +414,16 @@ ${css.publisher}
363
  if (!navEl) return;
364
  getItemsWithChildren(navEl).forEach(function(li) {
365
  li.classList.remove('collapsed');
366
- var sub = li.querySelector(':scope > ul');
367
- if (sub) sub.style.height = 'auto';
368
  });
369
  }
370
 
371
- // Initial state: collapse all except first section
 
 
372
  function initCollapse() {
 
 
 
373
  if (isMobile.matches) {
374
  expandAll(holder ? holder.querySelector('nav') : null);
375
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
@@ -377,10 +431,17 @@ ${css.publisher}
377
  applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
378
  applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
379
  }
 
 
 
 
 
 
380
  }
381
  initCollapse();
382
 
383
  isMobile.addEventListener('change', function() {
 
384
  if (isMobile.matches) {
385
  expandAll(holder ? holder.querySelector('nav') : null);
386
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
@@ -390,33 +451,70 @@ ${css.publisher}
390
  }
391
  });
392
 
393
- // ---- Scroll tracking ----
394
- var OFFSET = 60, lastScrollTime = 0, prevActiveIdx = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
- function onScroll() {
397
- var now = performance.now();
398
- if (now - lastScrollTime < 50) return;
399
- lastScrollTime = now;
400
  requestAnimationFrame(function() {
401
- var activeIdx = -1, activeId = null;
402
- for (var i = headingsArr.length - 1; i >= 0; i--) {
403
- if (headingsArr[i].getBoundingClientRect().top - OFFSET <= 0) {
404
- activeIdx = i; activeId = headingsArr[i].id; break;
 
 
 
 
 
 
 
 
 
 
405
  }
406
  }
407
- allLinks.forEach(function(l) {
408
- if (activeId && l.getAttribute('href') === '#'+activeId) l.classList.add('active');
409
- else l.classList.remove('active');
410
- });
411
- if (activeIdx !== prevActiveIdx && activeIdx >= 0 && !isMobile.matches) {
412
  prevActiveIdx = activeIdx;
413
- applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
414
- }
415
- if (activeIdx >= 0) prevActiveIdx = activeIdx;
416
  });
417
  }
418
- window.addEventListener('scroll', onScroll, { passive: true });
419
- onScroll();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  // ---- Mobile sidebar ----
422
  var sidebar = document.querySelector('.toc-mobile-sidebar');
@@ -447,6 +545,16 @@ ${css.publisher}
447
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
448
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
449
 
 
 
 
 
 
 
 
 
 
 
450
  // ---- Footer: move references & footnotes ----
451
  (function() {
452
  var footer = document.querySelector('footer.footer');
@@ -508,6 +616,41 @@ ${css.publisher}
508
  setTimeout(run, 300);
509
  })();
510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  // Hash navigation
512
  if (window.location.hash) {
513
  var target = document.querySelector(window.location.hash);
@@ -522,6 +665,34 @@ ${css.publisher}
522
  </script>
523
  </body>
524
  </html>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  }
526
 
527
  /**
@@ -532,17 +703,17 @@ ${css.publisher}
532
  function extractBibliographyHtml(json: Record<string, unknown>): string {
533
  function walk(nodes: any[]): string {
534
  for (const node of nodes) {
535
- if (node.type === "bibliography") {
536
- const html = node.attrs?.renderedHtml || "";
537
- if (node.attrs) node.attrs.renderedHtml = "";
538
- return html;
539
- }
540
  if (Array.isArray(node.content)) {
541
  const found = walk(node.content);
542
  if (found) return found;
543
  }
544
- }
545
- return "";
546
  }
547
  const content = (json as any)?.content;
548
  if (!Array.isArray(content)) return "";
@@ -550,129 +721,36 @@ function extractBibliographyHtml(json: Record<string, unknown>): string {
550
  }
551
 
552
  /**
553
- * Post-process the raw generateHTML output using linkedom DOM manipulation:
554
- * - Transform accordion divs into <details><summary>
555
- * - Convert inline citation spans into anchor links
556
- * - Inject bibliography HTML with entry anchors
557
- * - Transform mermaid divs into <pre class="mermaid">
558
- * - Transform htmlEmbed divs into iframes
559
- * - Collect footnotes and append a footnotes section
560
  */
561
- function postProcess(html: string, biblioHtml: string, citationData?: CitationData): string {
 
 
 
 
 
 
562
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
563
 
564
- // 1. Accordion: div[data-component="accordion"] -> <details><summary>
565
- for (const div of [...document.querySelectorAll('div[data-component="accordion"]')]) {
566
- const title = div.getAttribute("title") || "Details";
567
- const isOpen = div.getAttribute("open") === "true";
568
-
569
- const details = document.createElement("details");
570
- details.setAttribute("data-component", "accordion");
571
- if (isOpen) details.setAttribute("open", "");
572
 
573
- const summary = document.createElement("summary");
574
- summary.textContent = title;
575
- details.appendChild(summary);
576
-
577
- const content = document.createElement("div");
578
- content.className = "accordion-content";
579
- while (div.firstChild) content.appendChild(div.firstChild);
580
- details.appendChild(content);
581
-
582
- div.replaceWith(details);
583
- }
584
-
585
- // 2. Inline citations: span[data-type="citation"] -> <a> links
586
- const NUMERIC_STYLES = new Set(["ieee", "vancouver"]);
587
- const isNumeric = citationData ? NUMERIC_STYLES.has(citationData.style) : false;
588
- const citationKeyOrder: string[] = [];
589
-
590
- for (const span of [...document.querySelectorAll('span[data-type="citation"]')]) {
591
- const key = span.getAttribute("key") || span.getAttribute("data-key");
592
- if (!key) continue;
593
-
594
- if (!citationKeyOrder.includes(key)) citationKeyOrder.push(key);
595
- const idx = citationKeyOrder.indexOf(key) + 1;
596
-
597
- const originalLabel = span.textContent || `[${key}]`;
598
- const displayLabel = isNumeric ? `[${idx}]` : originalLabel;
599
-
600
- const a = document.createElement("a");
601
- a.setAttribute("href", `#ref-${key}`);
602
- a.className = "citation-inline";
603
- a.setAttribute("id", `cite-${key}-${idx}`);
604
- a.setAttribute("title", key);
605
- a.textContent = displayLabel;
606
-
607
- span.replaceWith(a);
608
- }
609
-
610
- // 3. Bibliography: inject pre-formatted HTML into the placeholder
611
- for (const div of [...document.querySelectorAll('div[data-type="bibliography"]')]) {
612
- div.removeAttribute("renderedhtml");
613
-
614
- if (biblioHtml) {
615
- let enrichedBiblio = biblioHtml;
616
- if (citationData && citationData.orderedKeys.length > 0) {
617
- enrichedBiblio = addBibliographyAnchors(biblioHtml, citationData.orderedKeys);
618
- }
619
- div.innerHTML = `<h2 class="bibliography-title">References</h2><div class="bibliography-content">${enrichedBiblio}</div>`;
620
- } else {
621
- div.innerHTML = `<p class="bibliography-empty">No citations</p>`;
622
- }
623
- }
624
-
625
- // 4. Mermaid: div[data-component="mermaid"] -> <pre class="mermaid">
626
- for (const div of [...document.querySelectorAll('div[data-component="mermaid"]')]) {
627
- const code = div.getAttribute("code") || div.getAttribute("data-code") || "";
628
- const pre = document.createElement("pre");
629
- pre.className = "mermaid";
630
- pre.textContent = code;
631
- div.replaceWith(pre);
632
- }
633
-
634
- // 5. HtmlEmbed: div[data-component="htmlEmbed"] -> iframe with srcdoc
635
- for (const div of [...document.querySelectorAll('div[data-component="htmlEmbed"]')]) {
636
- const src = div.getAttribute("src") || div.getAttribute("data-src") || "";
637
-
638
- const container = document.createElement("div");
639
- container.className = "html-embed-container";
640
-
641
- const iframe = document.createElement("iframe");
642
- iframe.setAttribute("srcdoc", "");
643
- iframe.setAttribute("data-embed-src", src);
644
- iframe.setAttribute("style", "width:100%;border:none;min-height:400px;");
645
- iframe.setAttribute("loading", "lazy");
646
- iframe.setAttribute("sandbox", "allow-scripts");
647
- container.appendChild(iframe);
648
-
649
- div.replaceWith(container);
650
- }
651
-
652
- // 6. Footnotes: collect span[data-type="footnote"], replace with superscript links
653
- const footnoteSpans = [...document.querySelectorAll('span[data-type="footnote"]')];
654
- const footnoteTexts: string[] = [];
655
-
656
- for (const span of footnoteSpans) {
657
- const text = span.getAttribute("content") || span.getAttribute("data-content") || span.getAttribute("title") || "";
658
- footnoteTexts.push(text);
659
- const idx = footnoteTexts.length;
660
-
661
- const sup = document.createElement("sup");
662
- sup.className = "footnote-ref";
663
- const a = document.createElement("a");
664
- a.setAttribute("href", `#fn-${idx}`);
665
- a.setAttribute("id", `fnref-${idx}`);
666
- a.textContent = `[${idx}]`;
667
- sup.appendChild(a);
668
-
669
- span.replaceWith(sup);
670
  }
671
 
672
  let result = document.body.innerHTML;
673
 
674
- if (footnoteTexts.length > 0) {
675
- const items = footnoteTexts
676
  .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>`)
677
  .join("\n");
678
  result += `<section class="footnotes"><h2>Footnotes</h2><ol>${items}</ol></section>`;
@@ -681,21 +759,6 @@ function postProcess(html: string, biblioHtml: string, citationData?: CitationDa
681
  return result;
682
  }
683
 
684
- /**
685
- * Add id anchors to bibliography entries so inline citations can link to them.
686
- * citation-js outputs entries as <div class="csl-entry"> elements.
687
- * Uses linkedom for reliable DOM manipulation.
688
- */
689
- function addBibliographyAnchors(html: string, orderedKeys: string[]): string {
690
- const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
691
- const entries = document.querySelectorAll(".csl-entry");
692
- entries.forEach((entry, idx) => {
693
- const key = orderedKeys[idx] || `entry-${idx}`;
694
- entry.setAttribute("id", `ref-${key}`);
695
- });
696
- return document.body.innerHTML;
697
- }
698
-
699
  function escapeHtml(str: string): string {
700
  return str
701
  .replace(/&/g, "&amp;")
@@ -706,7 +769,13 @@ function escapeHtml(str: string): string {
706
  }
707
 
708
  function renderMetaBar(meta: PublishMeta): string {
709
- const hasContent = meta.authors.length > 0 || meta.date || meta.doi;
 
 
 
 
 
 
710
  if (!hasContent) return "";
711
 
712
  const multipleAff = meta.affiliations.length > 1;
@@ -715,8 +784,8 @@ function renderMetaBar(meta: PublishMeta): string {
715
  // Authors cell
716
  if (meta.authors.length > 0) {
717
  const items = meta.authors.map((a, i) => {
718
- const name = a.url
719
- ? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
720
  : escapeHtml(a.name);
721
  const sup = multipleAff && a.affiliationIndices.length > 0
722
  ? `<sup>${a.affiliationIndices.join(",")}</sup>`
@@ -752,14 +821,17 @@ function renderMetaBar(meta: PublishMeta): string {
752
  cells.push(`<div class="meta-container-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`);
753
  }
754
 
755
- // DOI cell
756
- if (meta.doi) {
757
- cells.push(`<div class="meta-container-cell"><h3>DOI</h3><p><a href="https://doi.org/${escapeHtml(meta.doi)}" class="doi-link" target="_blank" rel="noopener">${escapeHtml(meta.doi)}</a></p></div>`);
758
- }
759
-
760
- // PDF download cell
761
  if (meta.pdfUrl) {
762
- cells.push(`<div class="meta-container-cell"><h3>PDF</h3><p><a href="${escapeHtml(meta.pdfUrl)}" class="pdf-link" target="_blank" rel="noopener" download>Download PDF</a></p></div>`);
 
 
 
 
 
 
 
 
763
  }
764
 
765
  return `<header class="meta"><div class="meta-container">${cells.join("")}</div></header>`;
 
9
  import { parseHTML } from "linkedom";
10
  import { getServerExtensions } from "./extensions.js";
11
  import type { PublishCSS } from "./index.js";
12
+ import { oklchFromHue } from "../shared/theme.js";
13
+ import { transformers, buildEmbedSrcdoc, type TransformContext } from "./transformers/index.js";
14
 
15
  export interface PublishAuthor {
16
  name: string;
 
35
  licence?: string;
36
  ogImage?: string;
37
  pdfUrl?: string;
38
+ banner?: string;
39
+ tableOfContentsAutoCollapse?: boolean;
40
+ /** OKLCH hue (0-360) chosen via the editor HueSlider, synced from Y.Doc settings. */
41
+ primaryHue?: number;
42
  }
43
 
44
  export interface CitationData {
 
50
  /**
51
  * Render TipTap JSON document into a complete, self-contained HTML page.
52
  */
53
+ export async function renderArticleHTML(
54
  json: Record<string, unknown>,
55
  meta: PublishMeta,
56
  css: PublishCSS,
57
  citationData?: CitationData,
58
+ serverBiblioHtml?: string,
59
+ embeds?: Record<string, string>
60
+ ): Promise<string> {
61
  const extensions = getServerExtensions();
62
 
63
  // Extract bibliography HTML before generateHTML escapes it as an attribute
 
66
  const biblioHtml = serverBiblioHtml || clientBiblioHtml;
67
 
68
  const bodyHtml = generateHTML(json as any, extensions);
69
+ const enrichedBody = await postProcess(bodyHtml, biblioHtml, citationData, embeds);
70
 
71
  const authorNames = meta.authors.map((a) => a.name);
72
  const authorsStr = authorNames.join(", ");
73
  const safeTitle = escapeHtml(meta.title);
74
  const safeDesc = escapeHtml(meta.description);
75
 
76
+ let result = `<!DOCTYPE html>
77
  <html lang="en" data-theme="dark">
78
  <head>
79
  <meta charset="UTF-8">
 
98
 
99
  <!-- KaTeX CSS -->
100
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
101
+ integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib"
102
  crossorigin="anonymous">
103
 
104
+ <!-- Code blocks are pre-highlighted at publish time with Shiki (dual-theme
105
+ via --shiki-dark CSS vars). Zero runtime JS, zero CDN dependency. -->
 
 
106
 
107
+ <!-- Mermaid (renders <pre class="mermaid"> blocks, auto-theme dark/light) -->
108
  <script type="module">
109
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
110
+ function getTheme() {
111
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'neutral';
112
+ }
113
+ mermaid.initialize({ startOnLoad: false, theme: getTheme() });
114
+
115
+ document.querySelectorAll('pre.mermaid').forEach(function(el) {
116
+ el.setAttribute('data-mermaid-src', el.textContent || '');
117
+ });
118
+ await mermaid.run();
119
+
120
+ new MutationObserver(function(ms) {
121
+ for (var i = 0; i < ms.length; i++) {
122
+ if (ms[i].attributeName === 'data-theme') {
123
+ mermaid.initialize({ startOnLoad: false, theme: getTheme() });
124
+ document.querySelectorAll('pre.mermaid').forEach(function(el) {
125
+ var src = el.getAttribute('data-mermaid-src');
126
+ if (src) {
127
+ el.removeAttribute('data-processed');
128
+ el.textContent = src;
129
+ }
130
+ });
131
+ mermaid.run();
132
+ break;
133
+ }
134
+ }
135
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
136
  </script>
137
 
138
  <style>
 
145
  ${css.article}
146
  ${css.print}
147
  ${css.publisher}
148
+ ${renderPrimaryColorOverride(meta.primaryHue)}
149
  </style>
150
  </head>
151
  <body>
 
170
  <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
171
  <div class="toc-mobile-sidebar__header">
172
  <span class="toc-mobile-sidebar__title">Table of Contents</span>
173
+ <div class="toc-mobile-sidebar__actions">
174
+ <button class="toc-mobile-sidebar__theme" aria-label="Toggle color theme">
175
+ <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">
176
+ <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"/>
177
+ </svg>
178
+ <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">
179
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
180
+ </svg>
181
+ </button>
182
+ <button class="toc-mobile-sidebar__close" aria-label="Close table of contents">
183
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
184
  <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
185
  </svg>
186
  </button>
187
+ </div>
188
  </div>
189
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
190
  </aside>
 
192
  <!-- Hero -->
193
  <section class="hero">
194
  <h1 class="hero-title">${safeTitle}</h1>
195
+ ${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>` : ""}
196
  ${meta.subtitle ? `<p class="hero-desc">${escapeHtml(meta.subtitle)}</p>` : ""}
197
  </section>
198
 
 
200
  ${renderMetaBar(meta)}
201
 
202
  <section class="content-grid">
203
+ <nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse="${meta.tableOfContentsAutoCollapse ? "1" : "0"}">
204
  <div class="title">Table of Contents</div>
205
  <div id="toc-placeholder"></div>
206
  </nav>
 
298
  });
299
 
300
  // Build nested nav with data-heading-idx
301
+ // Each nested <ul> is wrapped in <div class="toc-children"> so we can
302
+ // animate expand/collapse via the grid-template-rows 0fr/1fr trick
303
+ // (CSS cannot interpolate height:auto).
304
  var nav = document.createElement('nav');
305
  var ulStack = [document.createElement('ul')];
306
  nav.appendChild(ulStack[0]);
 
308
  var prev = 2, hIdx = 0;
309
  headingsArr.forEach(function(h) {
310
  var lvl = levelOf(h.tagName);
311
+ while (lvl > prev) {
312
+ var wrap = document.createElement('div');
313
+ wrap.className = 'toc-children';
314
+ var ul = document.createElement('ul');
315
+ wrap.appendChild(ul);
316
+ var last = ulStack[ulStack.length-1].lastElementChild;
317
+ if (last) last.appendChild(wrap);
318
+ ulStack.push(ul);
319
+ prev++;
320
+ }
321
  while (lvl < prev) { ulStack.pop(); prev--; }
322
  var li = document.createElement('li');
323
  var a = document.createElement('a');
 
357
 
358
  // ---- Auto-collapse logic ----
359
  var isMobile = window.matchMedia('(max-width: 1100px)');
360
+ var tocNav = document.querySelector('.table-of-contents');
361
+ var autoCollapseEnabled = tocNav && tocNav.getAttribute('data-auto-collapse') === '1';
362
 
363
  function getItemsWithChildren(navEl) {
364
  if (!navEl) return [];
365
  return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
366
+ return li.querySelector(':scope > .toc-children');
367
  });
368
  }
369
 
 
372
  var activeLi = null;
373
  function find(li) {
374
  if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
375
+ var wrap = li.querySelector(':scope > .toc-children');
376
+ if (!wrap) return null;
377
+ var ul = wrap.querySelector(':scope > ul');
378
  if (!ul) return null;
379
  var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
380
  for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
 
392
  return toExpand;
393
  }
394
 
395
+ // Just toggle the .collapsed class on the li. The CSS rule
396
+ // .toc-collapsible li.collapsed > .toc-children { grid-template-rows: 0fr }
397
+ // drives the actual animation (no JS height math).
398
  function applyCollapseState(navEl, activeIdx) {
399
  if (!navEl) return;
400
  var items = getItemsWithChildren(navEl);
401
  var ancestors = getAncestorIndices(items, activeIdx);
402
 
403
  items.forEach(function(li) {
 
 
404
  var idx = Number(li.getAttribute('data-heading-idx'));
405
  var allDesc = li.querySelectorAll('li[data-heading-idx]');
406
  var related = [idx];
407
  allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
408
  var shouldExpand = related.some(function(i) { return ancestors[i]; });
409
+ li.classList.toggle('collapsed', !shouldExpand);
 
 
 
 
 
 
 
410
  });
411
  }
412
 
 
414
  if (!navEl) return;
415
  getItemsWithChildren(navEl).forEach(function(li) {
416
  li.classList.remove('collapsed');
 
 
417
  });
418
  }
419
 
420
+ // Initial state: collapse only if auto-collapse is enabled.
421
+ // We suppress transitions for the first paint so the user doesn't see
422
+ // a close-animation at page load when auto-collapse is on.
423
  function initCollapse() {
424
+ if (!autoCollapseEnabled) return;
425
+ if (nav) nav.classList.add('toc-no-transition');
426
+ if (navClone) navClone.classList.add('toc-no-transition');
427
  if (isMobile.matches) {
428
  expandAll(holder ? holder.querySelector('nav') : null);
429
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
 
431
  applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
432
  applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
433
  }
434
+ requestAnimationFrame(function(){
435
+ requestAnimationFrame(function(){
436
+ if (nav) nav.classList.remove('toc-no-transition');
437
+ if (navClone) navClone.classList.remove('toc-no-transition');
438
+ });
439
+ });
440
  }
441
  initCollapse();
442
 
443
  isMobile.addEventListener('change', function() {
444
+ if (!autoCollapseEnabled) return;
445
  if (isMobile.matches) {
446
  expandAll(holder ? holder.querySelector('nav') : null);
447
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
 
451
  }
452
  });
453
 
454
+ // ---- Scroll tracking (IntersectionObserver-driven) ----
455
+ // Semantics: active heading = the last one whose top has crossed the
456
+ // OFFSET line ("section I'm currently reading"). This matches typical
457
+ // documentation UX (MDN, Docusaurus, etc.) and is intentionally different
458
+ // from the editor-side TOC which picks the topmost visible heading in its
459
+ // scroll container (better suited to non-linear editing navigation).
460
+ //
461
+ // We use IntersectionObserver only as a trigger: it fires precisely when
462
+ // a heading crosses the OFFSET boundary, which are the only moments the
463
+ // active heading can change. No per-frame scroll handler, no throttling.
464
+ var OFFSET = 60, prevActiveIdx = -1, prevActiveId = null, rafPending = false;
465
+
466
+ function findActiveHeading() {
467
+ for (var i = headingsArr.length - 1; i >= 0; i--) {
468
+ var h = headingsArr[i];
469
+ // Skip headings removed from DOM or moved outside article (e.g. footnotes moved to footer)
470
+ if (!h.isConnected || !articleRoot.contains(h)) continue;
471
+ if (h.getBoundingClientRect().top - OFFSET <= 0) {
472
+ return { idx: i, id: h.id };
473
+ }
474
+ }
475
+ return { idx: -1, id: null };
476
+ }
477
 
478
+ function updateActive() {
479
+ if (rafPending) return;
480
+ rafPending = true;
 
481
  requestAnimationFrame(function() {
482
+ rafPending = false;
483
+ var result = findActiveHeading();
484
+ var activeIdx = result.idx, activeId = result.id;
485
+ if (activeId === prevActiveId && activeIdx === prevActiveIdx) return;
486
+ allLinks.forEach(function(l) { l.classList.remove('active'); });
487
+ if (activeId) {
488
+ var href = '#' + activeId;
489
+ allLinks.forEach(function(l) {
490
+ if (l.getAttribute('href') === href) l.classList.add('active');
491
+ });
492
+ }
493
+ if (activeIdx >= 0 && activeIdx !== prevActiveIdx) {
494
+ if (autoCollapseEnabled && !isMobile.matches) {
495
+ applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
496
  }
497
  }
 
 
 
 
 
498
  prevActiveIdx = activeIdx;
499
+ prevActiveId = activeId;
 
 
500
  });
501
  }
502
+
503
+ if (typeof IntersectionObserver !== 'undefined') {
504
+ var spyObserver = new IntersectionObserver(updateActive, {
505
+ rootMargin: '-' + OFFSET + 'px 0px 0px 0px',
506
+ threshold: 0,
507
+ });
508
+ headingsArr.forEach(function(h) {
509
+ if (articleRoot.contains(h)) spyObserver.observe(h);
510
+ });
511
+ // rootMargin changes on resize (viewport height affects the bottom edge
512
+ // of the root), but the top offset is fixed, so no need to recreate.
513
+ } else {
514
+ // Fallback for browsers without IntersectionObserver (very old).
515
+ window.addEventListener('scroll', updateActive, { passive: true });
516
+ }
517
+ updateActive();
518
 
519
  // ---- Mobile sidebar ----
520
  var sidebar = document.querySelector('.toc-mobile-sidebar');
 
545
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
546
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
547
 
548
+ // Sidebar theme toggle
549
+ var sidebarThemeBtn = document.querySelector('.toc-mobile-sidebar__theme');
550
+ if (sidebarThemeBtn) {
551
+ sidebarThemeBtn.addEventListener('click', function() {
552
+ var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
553
+ localStorage.setItem('theme', next);
554
+ applyTheme(next);
555
+ });
556
+ }
557
+
558
  // ---- Footer: move references & footnotes ----
559
  (function() {
560
  var footer = document.querySelector('footer.footer');
 
616
  setTimeout(run, 300);
617
  })();
618
 
619
+ // Sync theme with embed iframes (body embeds + hero banner)
620
+ function syncEmbedThemes() {
621
+ var theme = document.documentElement.dataset.theme || 'dark';
622
+ document.querySelectorAll('.html-embed-container iframe, .hero-banner iframe').forEach(function(iframe) {
623
+ try { iframe.contentWindow.postMessage({ type: 'setTheme', theme: theme }, '*'); } catch(e) {}
624
+ });
625
+ }
626
+ var obs = new MutationObserver(function(ms) {
627
+ for (var i = 0; i < ms.length; i++) {
628
+ if (ms[i].attributeName === 'data-theme') { syncEmbedThemes(); break; }
629
+ }
630
+ });
631
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
632
+
633
+ // Auto-size embed iframes from postMessage height reports.
634
+ // Dedupe: only apply when the new height differs from the last applied
635
+ // (prevents style thrash during tight ResizeObserver bursts).
636
+ var _lastEmbedHeight = new WeakMap();
637
+ window.addEventListener('message', function(e) {
638
+ if (!e.data || e.data.type !== 'embedResize') return;
639
+ var h = Math.max(0, Math.ceil(e.data.height));
640
+ if (!h) return;
641
+ document.querySelectorAll('.html-embed-container iframe').forEach(function(iframe) {
642
+ try {
643
+ if (iframe.contentWindow !== e.source) return;
644
+ if (_lastEmbedHeight.get(iframe) === h) return;
645
+ _lastEmbedHeight.set(iframe, h);
646
+ iframe.style.height = h + 'px';
647
+ } catch(ex) {}
648
+ });
649
+ });
650
+
651
+ // Initial theme sync after iframes load
652
+ window.addEventListener('load', function() { setTimeout(syncEmbedThemes, 200); });
653
+
654
  // Hash navigation
655
  if (window.location.hash) {
656
  var target = document.querySelector(window.location.hash);
 
665
  </script>
666
  </body>
667
  </html>`;
668
+
669
+ // Fill banner iframe srcdoc if present
670
+ if (meta.banner && embeds?.[meta.banner]) {
671
+ const bannerSrcdoc = buildEmbedSrcdoc(embeds[meta.banner]);
672
+ const { document: d } = parseHTML(result);
673
+ const bannerIframe = d.querySelector('iframe[data-banner-src]');
674
+ if (bannerIframe) {
675
+ bannerIframe.setAttribute("srcdoc", bannerSrcdoc);
676
+ result = "<!DOCTYPE html>" + d.documentElement.outerHTML;
677
+ }
678
+ }
679
+
680
+ return result;
681
+ }
682
+
683
+ /**
684
+ * Emit a `:root` CSS override pinning `--primary-color` / `--primary-color-hover`
685
+ * to the hue chosen via the editor HueSlider. Placed after the base `_variables.css`
686
+ * so it always wins on specificity order.
687
+ *
688
+ * Returns an empty string if no hue was configured, leaving the template default.
689
+ */
690
+ function renderPrimaryColorOverride(primaryHue?: number): string {
691
+ if (typeof primaryHue !== "number" || Number.isNaN(primaryHue)) return "";
692
+ // Only override --primary-base: _variables.css already derives --primary-color
693
+ // and --primary-color-hover from it via CSS relative-color syntax, so the
694
+ // cascade handles the rest.
695
+ return `:root { --primary-base: ${oklchFromHue(primaryHue)}; }`;
696
  }
697
 
698
  /**
 
703
  function extractBibliographyHtml(json: Record<string, unknown>): string {
704
  function walk(nodes: any[]): string {
705
  for (const node of nodes) {
706
+ if (node.type === "bibliography") {
707
+ const html = node.attrs?.renderedHtml || "";
708
+ if (node.attrs) node.attrs.renderedHtml = "";
709
+ return html;
710
+ }
711
  if (Array.isArray(node.content)) {
712
  const found = walk(node.content);
713
  if (found) return found;
714
  }
715
+ }
716
+ return "";
717
  }
718
  const content = (json as any)?.content;
719
  if (!Array.isArray(content)) return "";
 
721
  }
722
 
723
  /**
724
+ * Post-process the raw `generateHTML` output by running the transformer
725
+ * registry over a parsed DOM. Each transformer is a small module living in
726
+ * `transformers/` and is responsible for one component type.
727
+ *
728
+ * @see ./transformers/index.ts for the ordered list and why order matters.
 
 
729
  */
730
+ /** @internal - exported for testing */
731
+ export async function postProcess(
732
+ html: string,
733
+ biblioHtml: string,
734
+ citationData?: CitationData,
735
+ embeds?: Record<string, string>
736
+ ): Promise<string> {
737
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
738
 
739
+ const ctx: TransformContext = {
740
+ biblioHtml,
741
+ citationData,
742
+ embeds,
743
+ footnoteTexts: [],
744
+ };
 
 
745
 
746
+ for (const transformer of transformers) {
747
+ await transformer.apply(document as unknown as Document, ctx);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  }
749
 
750
  let result = document.body.innerHTML;
751
 
752
+ if (ctx.footnoteTexts.length > 0) {
753
+ const items = ctx.footnoteTexts
754
  .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>`)
755
  .join("\n");
756
  result += `<section class="footnotes"><h2>Footnotes</h2><ol>${items}</ol></section>`;
 
759
  return result;
760
  }
761
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
  function escapeHtml(str: string): string {
763
  return str
764
  .replace(/&/g, "&amp;")
 
769
  }
770
 
771
  function renderMetaBar(meta: PublishMeta): string {
772
+ // DOI intentionally excluded from the hero meta bar: it only appears in the
773
+ // footer (`.doi-block`), matching the template convention the user expects.
774
+ const hasContent =
775
+ meta.authors.length > 0 ||
776
+ meta.affiliations.length > 0 ||
777
+ meta.date ||
778
+ meta.pdfUrl;
779
  if (!hasContent) return "";
780
 
781
  const multipleAff = meta.affiliations.length > 1;
 
784
  // Authors cell
785
  if (meta.authors.length > 0) {
786
  const items = meta.authors.map((a, i) => {
787
+ const name = a.url
788
+ ? `<a href="${escapeHtml(a.url)}" class="author-link" target="_blank" rel="noopener">${escapeHtml(a.name)}</a>`
789
  : escapeHtml(a.name);
790
  const sup = multipleAff && a.affiliationIndices.length > 0
791
  ? `<sup>${a.affiliationIndices.join(",")}</sup>`
 
821
  cells.push(`<div class="meta-container-cell"><h3>Published</h3><p>${escapeHtml(formatDate(meta.date))}</p></div>`);
822
  }
823
 
824
+ // PDF download cell - styled as a primary button (matches research-article-template)
 
 
 
 
 
825
  if (meta.pdfUrl) {
826
+ cells.push(
827
+ `<div class="meta-container-cell meta-container-cell--pdf">` +
828
+ `<h3>PDF</h3>` +
829
+ `<p><a href="${escapeHtml(meta.pdfUrl)}" class="button pdf-link" target="_blank" rel="noopener" download aria-label="Download PDF">` +
830
+ `<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>` +
831
+ `Download PDF` +
832
+ `</a></p>` +
833
+ `</div>`
834
+ );
835
  }
836
 
837
  return `<header class="meta"><div class="meta-container">${cells.join("")}</div></header>`;
backend/src/publisher/index.ts CHANGED
@@ -8,7 +8,7 @@ import { readFileSync, existsSync } from "fs";
8
  import { join, dirname } from "path";
9
  import { fileURLToPath } from "url";
10
  import * as Y from "yjs";
11
- import { DATA_DIR as SHARED_DATA_DIR, docPath } from "../utils.js";
12
  import { TiptapTransformer } from "@hocuspocus/transformer";
13
  import { renderArticleHTML, type PublishMeta, type CitationData } from "./html-renderer.js";
14
  import { formatBibliographyServer } from "./format-bibliography.js";
@@ -21,7 +21,8 @@ import {
21
  } from "../hf-storage.js";
22
 
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
- const DATA_DIR = SHARED_DATA_DIR;
 
25
 
26
  export interface PublishCSS {
27
  variables: string;
@@ -39,7 +40,8 @@ export interface PublishCSS {
39
  * Resolve @custom-media at-rules so the CSS works in browsers.
40
  * PostCSS handles this in the Vite build, but the publisher reads raw source files.
41
  */
42
- function resolveCustomMedia(allCss: string): string {
 
43
  const mediaMap = new Map<string, string>();
44
 
45
  // Extract @custom-media declarations
@@ -78,6 +80,7 @@ function loadCSS(): PublishCSS {
78
  const componentFiles = [
79
  "_code.css", "_button.css", "_table.css", "_tag.css",
80
  "_card.css", "_form.css", "_mermaid.css", "_hero.css", "_toc.css",
 
81
  ];
82
  const componentsCss = componentFiles
83
  .map((f) => readSafe(join(dir, "components", f)))
@@ -88,7 +91,7 @@ function loadCSS(): PublishCSS {
88
  const baseRaw = readFileSync(basePath, "utf-8");
89
  const layoutRaw = readSafe(join(dir, "_layout.css"));
90
  const printRaw = readSafe(join(dir, "_print.css"));
91
- const editorTokensRaw = readSafe(join(dir, "_editor-tokens.css"));
92
  const articleRaw = readSafe(join(dir, "article.css"));
93
  const publisherRaw = readSafe(join(dir, "_publisher.css"));
94
 
@@ -126,6 +129,37 @@ interface AffiliationObj {
126
  url?: string;
127
  }
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  /**
130
  * Extract TipTap JSON and frontmatter from a Yjs document.
131
  * Uses @hocuspocus/transformer to convert the XmlFragment to ProseMirror JSON.
@@ -133,11 +167,14 @@ interface AffiliationObj {
133
  * Scalar fields live in Y.Map("frontmatter"), while authors/affiliations/links
134
  * each have their own Y.Array for concurrent-safe editing.
135
  */
136
- function extractFromYDoc(ydoc: Y.Doc): {
 
137
  json: Record<string, unknown>;
138
  frontmatter: Record<string, unknown>;
139
  authors: AuthorObj[];
140
  affiliations: AffiliationObj[];
 
 
141
  } {
142
  const metaMap = ydoc.getMap("frontmatter");
143
  const frontmatter: Record<string, unknown> = {};
@@ -145,6 +182,12 @@ function extractFromYDoc(ydoc: Y.Doc): {
145
  frontmatter[key] = value;
146
  });
147
 
 
 
 
 
 
 
148
  const yAuthors = ydoc.getArray<AuthorObj>("frontmatter.authors");
149
  const yAffiliations = ydoc.getArray<AffiliationObj>("frontmatter.affiliations");
150
  const authors = yAuthors.toArray();
@@ -177,8 +220,16 @@ function extractFromYDoc(ydoc: Y.Doc): {
177
  }
178
  console.log("[publish] Full JSON (truncated):", JSON.stringify(json).slice(0, 2000));
179
 
 
 
 
 
 
 
 
 
180
  if (json && typeof json === "object" && json.type === "doc") {
181
- return { json, frontmatter, authors, affiliations };
182
  }
183
 
184
  console.warn("[publish] TiptapTransformer returned unexpected format, using fallback");
@@ -187,13 +238,16 @@ function extractFromYDoc(ydoc: Y.Doc): {
187
  frontmatter,
188
  authors,
189
  affiliations,
 
 
190
  };
191
  }
192
 
193
  /**
194
  * Extract citation entries and document order from Y.Doc.
195
  */
196
- function extractCitationsFromYDoc(ydoc: Y.Doc, json: Record<string, unknown>): CitationData {
 
197
  const citationsMap = ydoc.getMap("citations");
198
  const settingsMap = ydoc.getMap("settings");
199
 
@@ -236,6 +290,31 @@ export interface PublishResult {
236
  error?: string;
237
  }
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  /**
240
  * Render a preview HTML of the document without saving or uploading anything.
241
  * Useful for testing the publisher pipeline.
@@ -248,26 +327,9 @@ export async function previewDocument(docName: string): Promise<{ html: string }
248
  const data = readFileSync(p);
249
  Y.applyUpdate(ydoc, new Uint8Array(data));
250
 
251
- const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
252
  const citationData = extractCitationsFromYDoc(ydoc, json);
253
-
254
- const meta: PublishMeta = {
255
- title: (frontmatter.title as string) || docName,
256
- subtitle: (frontmatter.subtitle as string) || undefined,
257
- description: (frontmatter.description as string) || "",
258
- authors: authors.map((a) => ({
259
- name: a.name,
260
- url: a.url,
261
- affiliationIndices: a.affiliations || [],
262
- affiliationNames: (a.affiliations || [])
263
- .map((idx) => affiliations[idx - 1]?.name)
264
- .filter(Boolean) as string[],
265
- })),
266
- affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
267
- date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
268
- doi: (frontmatter.doi as string) || undefined,
269
- licence: (frontmatter.licence as string) || (frontmatter.license as string) || undefined,
270
- };
271
 
272
  const css = loadCSS();
273
 
@@ -280,64 +342,66 @@ export async function previewDocument(docName: string): Promise<{ html: string }
280
  }
281
  }
282
 
283
- const html = renderArticleHTML(json, meta, css, citationData, biblioHtml);
284
  return { html };
285
  }
286
 
 
 
 
 
 
 
 
 
 
 
287
  /**
288
  * Run the full publish pipeline for a document.
 
 
 
 
 
 
 
 
 
289
  */
290
- export async function publishDocument(docName: string, token?: string): Promise<PublishResult> {
 
 
 
 
 
291
  const p = docPath(docName);
 
292
 
293
  if (!existsSync(p)) {
294
  return { htmlUrl: null, pdfUrl: null, thumbUrl: null, success: false, error: "Document not found" };
295
  }
296
 
297
- // Load Yjs document
 
298
  const ydoc = new Y.Doc();
299
  const data = readFileSync(p);
300
  Y.applyUpdate(ydoc, new Uint8Array(data));
301
 
302
- // Extract content
303
- const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
304
-
305
- // Extract citation data from Y.Map for server-side bibliography formatting
306
  const citationData = extractCitationsFromYDoc(ydoc, json);
 
307
 
308
- const meta: PublishMeta = {
309
- title: (frontmatter.title as string) || docName,
310
- subtitle: (frontmatter.subtitle as string) || undefined,
311
- description: (frontmatter.description as string) || "",
312
- authors: authors.map((a) => ({
313
- name: a.name,
314
- url: a.url,
315
- affiliationIndices: a.affiliations || [],
316
- affiliationNames: (a.affiliations || [])
317
- .map((idx) => affiliations[idx - 1]?.name)
318
- .filter(Boolean) as string[],
319
- })),
320
- affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
321
- date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
322
- doi: (frontmatter.doi as string) || undefined,
323
- licence: (frontmatter.licence as string) || (frontmatter.license as string) || undefined,
324
- };
325
-
326
- // Pre-compute public URLs so og:image and PDF link are embedded in HTML
327
  const wantPdf = frontmatter.showPdf !== false;
328
  const useHf = isHfStorageEnabled();
329
- if (wantPdf && useHf && isPdfEnabled()) {
330
- meta.ogImage = getPublishedAssetUrl(docName, "thumb.jpg");
331
- meta.pdfUrl = getPublishedAssetUrl(docName, "article.pdf");
332
- }
333
 
334
  const css = loadCSS();
335
  console.log("[publish] CSS variables length:", css.variables.length, "article length:", css.article.length);
336
- console.log("[publish] Meta:", JSON.stringify(meta));
337
 
338
- // Format bibliography server-side from citation entries
339
  let biblioHtml = "";
340
  if (citationData.entries.length > 0) {
 
341
  try {
342
  biblioHtml = await formatBibliographyServer(citationData.entries, citationData.style);
343
  console.log("[publish] Bibliography formatted server-side:", citationData.entries.length, "entries");
@@ -346,56 +410,88 @@ export async function publishDocument(docName: string, token?: string): Promise<
346
  }
347
  }
348
 
349
- // First pass: generate HTML with og:image + PDF link pre-injected
350
- let html = renderArticleHTML(json, meta, css, citationData, biblioHtml);
351
- console.log("[publish] Generated HTML length:", html.length);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- // Generate PDF + thumbnail from the HTML
354
  let pdf: Buffer | null = null;
355
  let thumbnail: Buffer | null = null;
356
 
357
- if (wantPdf && isPdfEnabled()) {
 
 
358
  try {
359
- const assets = await generatePdfAndThumbnail(html);
 
360
  pdf = assets.pdf;
361
  thumbnail = assets.thumbnail;
 
 
 
362
  } catch (err) {
363
  console.error("[publish] PDF generation failed:", err);
364
- // Clear URLs from meta if generation failed
365
- if (!thumbnail) delete meta.ogImage;
366
- if (!pdf) delete meta.pdfUrl;
367
- html = renderArticleHTML(json, meta, css, citationData, biblioHtml);
368
  }
 
 
369
  }
370
 
371
- // Always save locally so GET / serves the fresh HTML immediately
372
- const { writeFileSync: fsWrite, mkdirSync: fsMkdir } = await import("fs");
373
- const safeDirName = docName.replace(/[^a-zA-Z0-9_-]/g, "_");
374
- const publishDir = join(DATA_DIR, "published", safeDirName);
375
- fsMkdir(publishDir, { recursive: true });
376
-
377
- // Local copy uses relative URLs for PDF/thumbnail
378
- const localMeta = { ...meta };
379
- if (thumbnail) localMeta.ogImage = `/published/${safeDirName}/thumb.jpg`;
380
- else delete localMeta.ogImage;
381
- if (pdf) localMeta.pdfUrl = `/published/${safeDirName}/article.pdf`;
382
- else delete localMeta.pdfUrl;
383
- const localHtml = renderArticleHTML(json, localMeta, css, citationData, biblioHtml);
384
-
385
- fsWrite(join(publishDir, "index.html"), localHtml);
386
  if (pdf) fsWrite(join(publishDir, "article.pdf"), pdf);
387
  if (thumbnail) fsWrite(join(publishDir, "thumb.jpg"), thumbnail);
388
- fsWrite(join(publishDir, "meta.json"), JSON.stringify(localMeta, null, 2));
389
- console.log("[publish] local copy written to", publishDir);
390
 
391
- // Also upload to HF dataset for persistence across container restarts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  if (useHf) {
 
 
 
 
 
393
  try {
394
  const urls = await uploadPublishedAssets(docName, {
395
- html,
396
  pdf,
397
  thumbnail,
398
- meta: meta as any,
399
  }, token);
400
  return { ...urls, success: true };
401
  } catch (err) {
 
8
  import { join, dirname } from "path";
9
  import { fileURLToPath } from "url";
10
  import * as Y from "yjs";
11
+ import { getDataDir, docPath, sanitizeName } from "../utils.js";
12
  import { TiptapTransformer } from "@hocuspocus/transformer";
13
  import { renderArticleHTML, type PublishMeta, type CitationData } from "./html-renderer.js";
14
  import { formatBibliographyServer } from "./format-bibliography.js";
 
21
  } from "../hf-storage.js";
22
 
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ // Resolved at call-time for testability
25
+ function dataDir() { return getDataDir(); }
26
 
27
  export interface PublishCSS {
28
  variables: string;
 
40
  * Resolve @custom-media at-rules so the CSS works in browsers.
41
  * PostCSS handles this in the Vite build, but the publisher reads raw source files.
42
  */
43
+ /** @internal - exported for testing */
44
+ export function resolveCustomMedia(allCss: string): string {
45
  const mediaMap = new Map<string, string>();
46
 
47
  // Extract @custom-media declarations
 
80
  const componentFiles = [
81
  "_code.css", "_button.css", "_table.css", "_tag.css",
82
  "_card.css", "_form.css", "_mermaid.css", "_hero.css", "_toc.css",
83
+ "_footer.css", "_embed.css",
84
  ];
85
  const componentsCss = componentFiles
86
  .map((f) => readSafe(join(dir, "components", f)))
 
91
  const baseRaw = readFileSync(basePath, "utf-8");
92
  const layoutRaw = readSafe(join(dir, "_layout.css"));
93
  const printRaw = readSafe(join(dir, "_print.css"));
94
+ const editorTokensRaw = readSafe(join(dir, "tokens.css"));
95
  const articleRaw = readSafe(join(dir, "article.css"));
96
  const publisherRaw = readSafe(join(dir, "_publisher.css"));
97
 
 
129
  url?: string;
130
  }
131
 
132
+ function buildPublishMeta(
133
+ frontmatter: Record<string, unknown>,
134
+ authors: AuthorObj[],
135
+ affiliations: AffiliationObj[],
136
+ fallbackTitle: string,
137
+ settings?: Record<string, unknown>,
138
+ ): PublishMeta {
139
+ const rawHue = settings?.primaryHue;
140
+ const primaryHue = typeof rawHue === "number" && !Number.isNaN(rawHue) ? rawHue : undefined;
141
+ return {
142
+ title: (frontmatter.title as string) || fallbackTitle,
143
+ subtitle: (frontmatter.subtitle as string) || undefined,
144
+ description: (frontmatter.description as string) || "",
145
+ authors: authors.map((a) => ({
146
+ name: a.name,
147
+ url: a.url,
148
+ affiliationIndices: a.affiliations || [],
149
+ affiliationNames: (a.affiliations || [])
150
+ .map((idx) => affiliations[idx - 1]?.name)
151
+ .filter(Boolean) as string[],
152
+ })),
153
+ affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
154
+ date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
155
+ doi: (frontmatter.doi as string) || undefined,
156
+ licence: (frontmatter.licence as string) || (frontmatter.license as string) || undefined,
157
+ banner: (frontmatter.banner as string) || "banner.html",
158
+ tableOfContentsAutoCollapse: frontmatter.tableOfContentsAutoCollapse === true,
159
+ primaryHue,
160
+ };
161
+ }
162
+
163
  /**
164
  * Extract TipTap JSON and frontmatter from a Yjs document.
165
  * Uses @hocuspocus/transformer to convert the XmlFragment to ProseMirror JSON.
 
167
  * Scalar fields live in Y.Map("frontmatter"), while authors/affiliations/links
168
  * each have their own Y.Array for concurrent-safe editing.
169
  */
170
+ /** @internal - exported for testing */
171
+ export function extractFromYDoc(ydoc: Y.Doc): {
172
  json: Record<string, unknown>;
173
  frontmatter: Record<string, unknown>;
174
  authors: AuthorObj[];
175
  affiliations: AffiliationObj[];
176
+ embeds: Record<string, string>;
177
+ settings: Record<string, unknown>;
178
  } {
179
  const metaMap = ydoc.getMap("frontmatter");
180
  const frontmatter: Record<string, unknown> = {};
 
182
  frontmatter[key] = value;
183
  });
184
 
185
+ const settingsMap = ydoc.getMap("settings");
186
+ const settings: Record<string, unknown> = {};
187
+ settingsMap.forEach((value, key) => {
188
+ settings[key] = value;
189
+ });
190
+
191
  const yAuthors = ydoc.getArray<AuthorObj>("frontmatter.authors");
192
  const yAffiliations = ydoc.getArray<AffiliationObj>("frontmatter.affiliations");
193
  const authors = yAuthors.toArray();
 
220
  }
221
  console.log("[publish] Full JSON (truncated):", JSON.stringify(json).slice(0, 2000));
222
 
223
+ // Extract embed HTML from Y.Map("embeds")
224
+ const embedsMap = ydoc.getMap<string>("embeds");
225
+ const embeds: Record<string, string> = {};
226
+ embedsMap.forEach((value, key) => {
227
+ embeds[key] = value;
228
+ });
229
+ console.log("[publish] embeds:", Object.keys(embeds).length, "entries:", Object.keys(embeds));
230
+
231
  if (json && typeof json === "object" && json.type === "doc") {
232
+ return { json, frontmatter, authors, affiliations, embeds, settings };
233
  }
234
 
235
  console.warn("[publish] TiptapTransformer returned unexpected format, using fallback");
 
238
  frontmatter,
239
  authors,
240
  affiliations,
241
+ embeds,
242
+ settings,
243
  };
244
  }
245
 
246
  /**
247
  * Extract citation entries and document order from Y.Doc.
248
  */
249
+ /** @internal - exported for testing */
250
+ export function extractCitationsFromYDoc(ydoc: Y.Doc, json: Record<string, unknown>): CitationData {
251
  const citationsMap = ydoc.getMap("citations");
252
  const settingsMap = ydoc.getMap("settings");
253
 
 
290
  error?: string;
291
  }
292
 
293
+ /**
294
+ * High-level publish stages emitted to the SSE stream so the UI can show
295
+ * honest progress labels instead of a silent spinner. The PDF generator
296
+ * emits additional sub-stages (load, thumbnail, pdf-setup, pdf-toc, rasterize,
297
+ * pdf-render) on the same callback.
298
+ */
299
+ export type PublishStage =
300
+ | "extract"
301
+ | "bibliography"
302
+ | "render"
303
+ | "load"
304
+ | "thumbnail"
305
+ | "pdf-setup"
306
+ | "pdf-toc"
307
+ | "rasterize"
308
+ | "pdf-render"
309
+ | "write"
310
+ | "upload";
311
+
312
+ export type PublishStageEmitter = (stage: PublishStage | string, detail?: Record<string, unknown>) => void;
313
+
314
+ export interface PublishOptions {
315
+ onStage?: PublishStageEmitter;
316
+ }
317
+
318
  /**
319
  * Render a preview HTML of the document without saving or uploading anything.
320
  * Useful for testing the publisher pipeline.
 
327
  const data = readFileSync(p);
328
  Y.applyUpdate(ydoc, new Uint8Array(data));
329
 
330
+ const { json, frontmatter, authors, affiliations, embeds, settings } = extractFromYDoc(ydoc);
331
  const citationData = extractCitationsFromYDoc(ydoc, json);
332
+ const meta = buildPublishMeta(frontmatter, authors, affiliations, docName, settings);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
  const css = loadCSS();
335
 
 
342
  }
343
  }
344
 
345
+ const html = await renderArticleHTML(json, meta, css, citationData, biblioHtml, embeds);
346
  return { html };
347
  }
348
 
349
+ /**
350
+ * Compute the base URL Playwright uses when navigating to the published page.
351
+ * Defaults to the backend's own HTTP listener so the PDF is rendered from
352
+ * the exact same HTML users see.
353
+ */
354
+ function getLocalBaseUrl(): string {
355
+ const port = process.env.PORT || "8080";
356
+ return process.env.PUBLISH_BASE_URL || `http://127.0.0.1:${port}`;
357
+ }
358
+
359
  /**
360
  * Run the full publish pipeline for a document.
361
+ * Emits stage events via `opts.onStage` so the route can stream SSE updates.
362
+ *
363
+ * Pipeline (in order):
364
+ * 1. extract TipTap JSON + metadata from the Y.Doc
365
+ * 2. render the HTML (with local /published/* URLs baked in)
366
+ * 3. write index.html + meta.json to disk *first* so the URL is live
367
+ * 4. Playwright goto() that URL -> thumbnail + PDF
368
+ * 5. on PDF failure, rewrite index.html/meta.json without the broken PDF link
369
+ * 6. on HF upload, re-render with public URLs and push everything
370
  */
371
+ export async function publishDocument(
372
+ docName: string,
373
+ token?: string,
374
+ opts: PublishOptions = {}
375
+ ): Promise<PublishResult> {
376
+ const onStage = opts.onStage ?? (() => {});
377
  const p = docPath(docName);
378
+ const tPublishStart = Date.now();
379
 
380
  if (!existsSync(p)) {
381
  return { htmlUrl: null, pdfUrl: null, thumbUrl: null, success: false, error: "Document not found" };
382
  }
383
 
384
+ onStage("extract");
385
+
386
  const ydoc = new Y.Doc();
387
  const data = readFileSync(p);
388
  Y.applyUpdate(ydoc, new Uint8Array(data));
389
 
390
+ const { json, frontmatter, authors, affiliations, embeds, settings } = extractFromYDoc(ydoc);
 
 
 
391
  const citationData = extractCitationsFromYDoc(ydoc, json);
392
+ const baseMeta = buildPublishMeta(frontmatter, authors, affiliations, docName, settings);
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  const wantPdf = frontmatter.showPdf !== false;
395
  const useHf = isHfStorageEnabled();
396
+ const pdfEnabled = isPdfEnabled();
397
+ const safeDirName = sanitizeName(docName);
 
 
398
 
399
  const css = loadCSS();
400
  console.log("[publish] CSS variables length:", css.variables.length, "article length:", css.article.length);
 
401
 
 
402
  let biblioHtml = "";
403
  if (citationData.entries.length > 0) {
404
+ onStage("bibliography", { entries: citationData.entries.length });
405
  try {
406
  biblioHtml = await formatBibliographyServer(citationData.entries, citationData.style);
407
  console.log("[publish] Bibliography formatted server-side:", citationData.entries.length, "entries");
 
410
  }
411
  }
412
 
413
+ // Meta used for the *local* published copy. `article.pdf`/`thumb.jpg`
414
+ // don't exist on disk yet, but referencing them here means Playwright
415
+ // sees the final hrefs when the PDF is rendered (print CSS hides the
416
+ // Download button anyway, so the broken-href window is harmless).
417
+ const localMeta: PublishMeta = { ...baseMeta };
418
+ if (wantPdf && pdfEnabled) {
419
+ localMeta.ogImage = `/published/${safeDirName}/thumb.jpg`;
420
+ localMeta.pdfUrl = `/published/${safeDirName}/article.pdf`;
421
+ }
422
+ console.log("[publish] Meta:", JSON.stringify(localMeta));
423
+
424
+ onStage("render");
425
+ const tRenderStart = Date.now();
426
+ let localHtml = await renderArticleHTML(json, localMeta, css, citationData, biblioHtml, embeds);
427
+ console.log(
428
+ `[publish] Generated HTML length: ${localHtml.length} (${Date.now() - tRenderStart}ms)`
429
+ );
430
+
431
+ // --- Step 1: write HTML + meta to disk BEFORE Playwright runs ------------
432
+ onStage("write");
433
+ const { writeFileSync: fsWrite, mkdirSync: fsMkdir } = await import("fs");
434
+ const publishDir = join(dataDir(), "published", safeDirName);
435
+ fsMkdir(publishDir, { recursive: true });
436
+ fsWrite(join(publishDir, "index.html"), localHtml);
437
+ fsWrite(join(publishDir, "meta.json"), JSON.stringify(localMeta, null, 2));
438
+ console.log("[publish] index.html written to", publishDir);
439
 
440
+ // --- Step 2: generate PDF + thumbnail from the served URL ---------------
441
  let pdf: Buffer | null = null;
442
  let thumbnail: Buffer | null = null;
443
 
444
+ if (wantPdf && pdfEnabled) {
445
+ const url = `${getLocalBaseUrl()}/published/${safeDirName}/index.html`;
446
+ const tPdfStart = Date.now();
447
  try {
448
+ console.log("[publish] Launching Playwright against", url);
449
+ const assets = await generatePdfAndThumbnail("", { url, onStage });
450
  pdf = assets.pdf;
451
  thumbnail = assets.thumbnail;
452
+ console.log(
453
+ `[publish] Playwright done in ${Date.now() - tPdfStart}ms - pdf=${pdf ? pdf.byteLength : 0}B thumb=${thumbnail ? thumbnail.byteLength : 0}B`
454
+ );
455
  } catch (err) {
456
  console.error("[publish] PDF generation failed:", err);
 
 
 
 
457
  }
458
+ } else if (wantPdf && !pdfEnabled) {
459
+ console.log("[publish] PDF generation skipped (Playwright unavailable or ENABLE_PDF=false)");
460
  }
461
 
462
+ // --- Step 3: if PDF/thumbnail succeeded, persist them. If either failed,
463
+ // strip the references from the meta & rewrite the HTML to avoid broken
464
+ // Download/og:image links.
 
 
 
 
 
 
 
 
 
 
 
 
465
  if (pdf) fsWrite(join(publishDir, "article.pdf"), pdf);
466
  if (thumbnail) fsWrite(join(publishDir, "thumb.jpg"), thumbnail);
 
 
467
 
468
+ const finalLocalMeta = { ...localMeta };
469
+ if (!thumbnail) delete finalLocalMeta.ogImage;
470
+ if (!pdf) delete finalLocalMeta.pdfUrl;
471
+
472
+ if (wantPdf && pdfEnabled && (!pdf || !thumbnail)) {
473
+ console.log("[publish] rewriting index.html without missing asset references");
474
+ localHtml = await renderArticleHTML(json, finalLocalMeta, css, citationData, biblioHtml, embeds);
475
+ fsWrite(join(publishDir, "index.html"), localHtml);
476
+ }
477
+ fsWrite(join(publishDir, "meta.json"), JSON.stringify(finalLocalMeta, null, 2));
478
+ console.log(
479
+ `[publish] local copy ready at ${publishDir} (total ${Date.now() - tPublishStart}ms)`
480
+ );
481
+
482
+ // --- Step 4: HF upload (if enabled): rebuild HTML once more with public URLs
483
  if (useHf) {
484
+ onStage("upload");
485
+ const hfMeta: PublishMeta = { ...baseMeta };
486
+ if (thumbnail) hfMeta.ogImage = getPublishedAssetUrl(docName, "thumb.jpg");
487
+ if (pdf) hfMeta.pdfUrl = getPublishedAssetUrl(docName, "article.pdf");
488
+ const hfHtml = await renderArticleHTML(json, hfMeta, css, citationData, biblioHtml, embeds);
489
  try {
490
  const urls = await uploadPublishedAssets(docName, {
491
+ html: hfHtml,
492
  pdf,
493
  thumbnail,
494
+ meta: hfMeta as any,
495
  }, token);
496
  return { ...urls, success: true };
497
  } catch (err) {
backend/src/publisher/pdf-generator.ts CHANGED
@@ -1,8 +1,24 @@
1
  /**
2
  * PDF and OG thumbnail generation via Playwright.
3
  *
4
- * Launches headless Chromium, loads the published HTML,
5
- * waits for rendering, then captures PDF + screenshot.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  */
7
 
8
  let playwrightAvailable = false;
@@ -10,7 +26,6 @@ let playwrightAvailable = false;
10
  let chromium: any;
11
 
12
  try {
13
- // Dynamic import: playwright is optional (only in Docker production image)
14
  const pw = await import(/* @vite-ignore */ "playwright");
15
  chromium = pw.chromium;
16
  playwrightAvailable = true;
@@ -27,16 +42,335 @@ export interface GeneratedAssets {
27
  thumbnail: Buffer | null;
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  /**
31
- * Generate PDF and OG thumbnail from HTML string.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  */
33
  export async function generatePdfAndThumbnail(
34
- html: string
 
35
  ): Promise<GeneratedAssets> {
36
  if (!playwrightAvailable) {
37
  return { pdf: null, thumbnail: null };
38
  }
39
 
 
 
 
 
40
  const browser = await chromium.launch({
41
  args: ["--no-sandbox", "--disable-setuid-sandbox"],
42
  });
@@ -44,24 +378,67 @@ export async function generatePdfAndThumbnail(
44
  try {
45
  const page = await browser.newPage();
46
 
47
- await page.setContent(html, { waitUntil: "networkidle" });
 
 
 
 
 
 
 
 
 
 
48
 
49
- // Wait a bit for fonts, KaTeX, images
50
- await page.waitForTimeout(2000);
 
 
 
 
 
 
 
 
 
 
51
 
52
- // OG Thumbnail (1200x630)
53
  await page.setViewportSize({ width: 1200, height: 630 });
 
 
54
  const thumbnail = await page.screenshot({
55
  type: "jpeg",
56
  quality: 85,
57
  clip: { x: 0, y: 0, width: 1200, height: 630 },
58
  });
59
 
60
- // PDF
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  const pdf = await page.pdf({
62
- format: "A4",
63
  printBackground: true,
64
- margin: { top: "1cm", bottom: "1cm", left: "1.5cm", right: "1.5cm" },
 
 
65
  });
66
 
67
  return {
 
1
  /**
2
  * PDF and OG thumbnail generation via Playwright.
3
  *
4
+ * Pipeline (aligned with research-article-template's export-pdf.mjs):
5
+ *
6
+ * 1. launch headless Chromium
7
+ * 2. `page.goto(url)` on the real published page (served by the backend)
8
+ * - this guarantees the PDF matches exactly what users see, including
9
+ * scripts that move footnotes/bibliography into the references block.
10
+ * - a `setContent` fallback is kept for the unit tests that don't spin
11
+ * up the HTTP server.
12
+ * 3. Pass A - thumbnail: viewport 1200x630, no print emulation, JPEG screenshot
13
+ * 4. Pass B - PDF:
14
+ * a. emulateMedia({ media: 'print' }) <- activates @media print rules
15
+ * b. setViewportSize to the printable width (so layout matches PDF page)
16
+ * c. waitForAssets (fonts, images, iframes readyState, rAF x2, stable layout)
17
+ * d. injectPdfToc (build a linear .pdf-toc from h2/h3 for the PDF only)
18
+ * e. waitForMermaid (ensure all mermaid diagrams finished rendering)
19
+ * f. rasterizeMermaid (screenshot pre.mermaid -> <img data-URL>)
20
+ * g. rasterizeEmbeds (screenshot iframes -> <img data-URL>)
21
+ * h. page.pdf({ format, margin, printBackground: true })
22
  */
23
 
24
  let playwrightAvailable = false;
 
26
  let chromium: any;
27
 
28
  try {
 
29
  const pw = await import(/* @vite-ignore */ "playwright");
30
  chromium = pw.chromium;
31
  playwrightAvailable = true;
 
42
  thumbnail: Buffer | null;
43
  }
44
 
45
+ export type PageFormat = "A4" | "Letter";
46
+
47
+ export interface PdfMargin {
48
+ top: string;
49
+ right: string;
50
+ bottom: string;
51
+ left: string;
52
+ }
53
+
54
+ export interface PdfOptions {
55
+ format?: PageFormat;
56
+ margin?: PdfMargin;
57
+ /**
58
+ * When set, Playwright navigates to this URL instead of calling
59
+ * `setContent(html)`. Prefer this - the PDF then matches exactly what the
60
+ * user sees at that URL (scripts that move footnotes/bibliography into the
61
+ * footer run naturally under `networkidle`).
62
+ */
63
+ url?: string;
64
+ /** Emitted at each step so the route can stream SSE events. */
65
+ onStage?: (stage: string, detail?: Record<string, unknown>) => void;
66
+ }
67
+
68
+ const DEFAULT_MARGIN: PdfMargin = { top: "12mm", right: "16mm", bottom: "16mm", left: "16mm" };
69
+
70
+ const FORMAT_MM: Record<PageFormat, { w: number; h: number }> = {
71
+ A4: { w: 210, h: 297 },
72
+ Letter: { w: 215.9, h: 279.4 },
73
+ };
74
+
75
+ function cssLengthToMm(len: string): number {
76
+ const m = len.trim().match(/^([\d.]+)\s*(mm|cm|in|pt|px)?$/i);
77
+ if (!m) return 0;
78
+ const n = parseFloat(m[1]);
79
+ const unit = (m[2] || "mm").toLowerCase();
80
+ switch (unit) {
81
+ case "mm": return n;
82
+ case "cm": return n * 10;
83
+ case "in": return n * 25.4;
84
+ case "pt": return (n * 25.4) / 72;
85
+ case "px": return (n * 25.4) / 96;
86
+ default: return n;
87
+ }
88
+ }
89
+
90
+ function computePrintableWidthPx(format: PageFormat, margin: PdfMargin): number {
91
+ const fmt = FORMAT_MM[format];
92
+ const innerMm = fmt.w - cssLengthToMm(margin.left) - cssLengthToMm(margin.right);
93
+ return Math.max(320, Math.round((innerMm / 25.4) * 96));
94
+ }
95
+
96
+ /**
97
+ * Wait for fonts, images, iframes to load, then two rAFs, then a stable layout.
98
+ */
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ async function waitForAssets(page: any, timeoutMs = 10_000): Promise<void> {
101
+ await page.evaluate(async () => {
102
+ const d = document as Document & { fonts?: { ready?: Promise<unknown> } };
103
+ if (d.fonts?.ready) {
104
+ try { await d.fonts.ready; } catch { /* ignore */ }
105
+ }
106
+ const imgs = Array.from(document.images).filter((i) => !i.complete);
107
+ await Promise.all(
108
+ imgs.map(
109
+ (i) =>
110
+ new Promise<void>((resolve) => {
111
+ const done = () => resolve();
112
+ i.addEventListener("load", done, { once: true });
113
+ i.addEventListener("error", done, { once: true });
114
+ setTimeout(done, 5_000);
115
+ })
116
+ )
117
+ );
118
+ const iframes = Array.from(document.querySelectorAll("iframe"));
119
+ await Promise.all(
120
+ iframes.map(
121
+ (f) =>
122
+ new Promise<void>((resolve) => {
123
+ const done = () => resolve();
124
+ try {
125
+ if (f.contentDocument?.readyState === "complete") {
126
+ done();
127
+ return;
128
+ }
129
+ } catch { /* cross-origin: fall through */ }
130
+ f.addEventListener("load", done, { once: true });
131
+ setTimeout(done, 4_000);
132
+ })
133
+ )
134
+ );
135
+ await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
136
+ });
137
+ await waitForStableLayout(page, timeoutMs);
138
+ }
139
+
140
+ /**
141
+ * Poll scrollHeight every 100ms; return when stable 3 times in a row,
142
+ * or when timeoutMs is reached.
143
+ */
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ async function waitForStableLayout(page: any, timeoutMs = 5_000): Promise<void> {
146
+ const start = Date.now();
147
+ let last = -1;
148
+ let stable = 0;
149
+ while (Date.now() - start < timeoutMs) {
150
+ const h = (await page.evaluate(() => document.documentElement.scrollHeight)) as number;
151
+ if (h === last) stable += 1;
152
+ else stable = 0;
153
+ last = h;
154
+ if (stable >= 3) return;
155
+ await new Promise((r) => setTimeout(r, 100));
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Build a linear .pdf-toc from h2/h3 inside .tiptap, inserted right after
161
+ * the meta bar so it appears between cover and body in the PDF.
162
+ */
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ async function injectPdfToc(page: any): Promise<void> {
165
+ await page.evaluate(() => {
166
+ if (document.querySelector(".pdf-toc")) return;
167
+ const headings = Array.from(
168
+ document.querySelectorAll<HTMLElement>(".tiptap h2, .tiptap h3")
169
+ ).filter((h) => (h.textContent || "").trim().length > 0);
170
+ if (headings.length === 0) return;
171
+
172
+ const toc = document.createElement("nav");
173
+ toc.className = "pdf-toc";
174
+ toc.setAttribute("aria-label", "Table of Contents");
175
+
176
+ const title = document.createElement("h2");
177
+ title.className = "pdf-toc__title";
178
+ title.textContent = "Table of Contents";
179
+ toc.appendChild(title);
180
+
181
+ const ul = document.createElement("ul");
182
+ ul.className = "pdf-toc__list";
183
+ for (const h of headings) {
184
+ const li = document.createElement("li");
185
+ li.className = h.tagName === "H3" ? "pdf-toc__item pdf-toc__item--sub" : "pdf-toc__item";
186
+ const a = document.createElement("a");
187
+ const id = h.getAttribute("id");
188
+ if (id) a.setAttribute("href", `#${id}`);
189
+ a.textContent = (h.textContent || "").trim();
190
+ li.appendChild(a);
191
+ ul.appendChild(li);
192
+ }
193
+ toc.appendChild(ul);
194
+
195
+ const metaBar = document.querySelector(".meta-container, .hero-meta");
196
+ const hero = document.querySelector(".hero");
197
+ const anchor = metaBar ?? hero ?? document.querySelector(".content-grid");
198
+ if (anchor?.parentNode) {
199
+ anchor.parentNode.insertBefore(toc, anchor.nextSibling);
200
+ } else {
201
+ document.body.insertBefore(toc, document.body.firstChild);
202
+ }
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Wait for every `pre.mermaid` element to finish rendering (mermaid marks
208
+ * processed nodes with `data-processed="true"` and inserts an <svg>). The
209
+ * diagram runtime is loaded as an ESM module from a CDN, so it typically
210
+ * completes *after* Playwright's `networkidle` signal fires.
211
+ *
212
+ * Resolves early when no mermaid block is present. Silently returns once the
213
+ * timeout is hit so the pipeline keeps moving rather than failing the whole
214
+ * publish on a slow CDN.
215
+ */
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ async function waitForMermaid(page: any, timeoutMs = 10_000): Promise<void> {
218
+ try {
219
+ await page.waitForFunction(
220
+ () => {
221
+ const nodes = document.querySelectorAll("pre.mermaid");
222
+ if (nodes.length === 0) return true;
223
+ return Array.from(nodes).every(
224
+ (n) =>
225
+ (n as HTMLElement).getAttribute("data-processed") === "true" ||
226
+ !!(n as HTMLElement).querySelector("svg"),
227
+ );
228
+ },
229
+ { timeout: timeoutMs },
230
+ );
231
+ } catch {
232
+ console.warn("[pdf] mermaid did not finish rendering before timeout");
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Replace each rendered Mermaid block with a PNG <img> so the PDF engine
238
+ * doesn't try to paginate the SVG (foreignObject labels get chopped across
239
+ * page breaks and some gradients / markers render blank in print media).
240
+ *
241
+ * Runs AFTER `waitForMermaid` so every `pre.mermaid` has its SVG in place.
242
+ */
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
+ async function rasterizeMermaid(page: any, onStage?: PdfOptions["onStage"]): Promise<void> {
245
+ const handles = await page.$$("pre.mermaid");
246
+ const total = handles.length;
247
+ if (total === 0) return;
248
+
249
+ for (let i = 0; i < handles.length; i += 1) {
250
+ const pre = handles[i];
251
+ onStage?.("rasterize", { kind: "mermaid", index: i + 1, total });
252
+
253
+ try {
254
+ await pre.scrollIntoViewIfNeeded({ timeout: 1_000 });
255
+ } catch { /* ignore */ }
256
+ await new Promise((r) => setTimeout(r, 100));
257
+
258
+ // Skip if the node somehow never rendered an SVG - leaves the original
259
+ // markup in place so debugging stays possible.
260
+ const hasSvg = await pre.evaluate((el: HTMLElement) => !!el.querySelector("svg"));
261
+ if (!hasSvg) continue;
262
+
263
+ const box = await pre.boundingBox();
264
+ if (!box || box.width < 10 || box.height < 10) continue;
265
+
266
+ let buffer: Buffer;
267
+ try {
268
+ buffer = await pre.screenshot({ type: "png", omitBackground: true });
269
+ } catch (err) {
270
+ console.warn("[pdf] failed to screenshot mermaid, leaving SVG as-is:", (err as Error).message);
271
+ continue;
272
+ }
273
+
274
+ const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`;
275
+ const width = Math.round(box.width);
276
+ const height = Math.round(box.height);
277
+
278
+ await pre.evaluate(
279
+ (el: HTMLElement, args: { src: string; w: number; h: number }) => {
280
+ const img = el.ownerDocument!.createElement("img");
281
+ img.src = args.src;
282
+ img.setAttribute("width", String(args.w));
283
+ img.setAttribute("height", String(args.h));
284
+ img.className = "mermaid-rasterized";
285
+ img.style.width = "100%";
286
+ img.style.height = "auto";
287
+ img.style.display = "block";
288
+ img.setAttribute("alt", "Mermaid diagram");
289
+ el.replaceWith(img);
290
+ },
291
+ { src: dataUrl, w: width, h: height },
292
+ );
293
+ }
294
+ }
295
+
296
  /**
297
+ * Replace each embed iframe (charts, hero banner) with a PNG <img> generated
298
+ * from a Playwright element screenshot. Chromium paginates iframes poorly
299
+ * (often cut in half or rendered blank across pages), so we flatten them.
300
+ */
301
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
+ async function rasterizeEmbeds(page: any, onStage?: PdfOptions["onStage"]): Promise<void> {
303
+ const handles = await page.$$(
304
+ ".html-embed-container > iframe, .hero-banner > iframe"
305
+ );
306
+ const total = handles.length;
307
+ if (total === 0) return;
308
+
309
+ for (let i = 0; i < handles.length; i += 1) {
310
+ const iframe = handles[i];
311
+ onStage?.("rasterize", { kind: "embed", index: i + 1, total });
312
+
313
+ try {
314
+ await iframe.scrollIntoViewIfNeeded({ timeout: 1_000 });
315
+ } catch { /* ignore */ }
316
+ await new Promise((r) => setTimeout(r, 150));
317
+
318
+ const box = await iframe.boundingBox();
319
+ if (!box || box.width < 10 || box.height < 10) continue;
320
+
321
+ let buffer: Buffer;
322
+ try {
323
+ buffer = await iframe.screenshot({ type: "png", omitBackground: true });
324
+ } catch (err) {
325
+ console.warn("[pdf] failed to screenshot embed, leaving iframe as-is:", (err as Error).message);
326
+ continue;
327
+ }
328
+
329
+ const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`;
330
+ const width = Math.round(box.width);
331
+ const height = Math.round(box.height);
332
+
333
+ await iframe.evaluate(
334
+ (el: HTMLIFrameElement, args: { src: string; w: number; h: number }) => {
335
+ const img = el.ownerDocument!.createElement("img");
336
+ img.src = args.src;
337
+ img.setAttribute("width", String(args.w));
338
+ img.setAttribute("height", String(args.h));
339
+ img.style.width = "100%";
340
+ img.style.height = "auto";
341
+ img.style.display = "block";
342
+ img.setAttribute("alt", el.getAttribute("title") || "Embedded chart");
343
+ el.replaceWith(img);
344
+ },
345
+ { src: dataUrl, w: width, h: height }
346
+ );
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Generate PDF and OG thumbnail from the same source HTML.
352
+ *
353
+ * Two load strategies are supported:
354
+ * - `opts.url` (preferred): navigate Playwright to the live published page.
355
+ * The PDF then matches exactly what users see, and the JS that moves
356
+ * footnotes/bibliography into `.references-block` runs naturally.
357
+ * - `html` fallback: `page.setContent(html)` for tests and any call-site
358
+ * without an HTTP server to hit.
359
+ *
360
+ * The PDF honours @media print; the thumbnail uses the default (screen) media.
361
  */
362
  export async function generatePdfAndThumbnail(
363
+ html: string,
364
+ opts: PdfOptions = {}
365
  ): Promise<GeneratedAssets> {
366
  if (!playwrightAvailable) {
367
  return { pdf: null, thumbnail: null };
368
  }
369
 
370
+ const format: PageFormat = opts.format ?? "A4";
371
+ const margin: PdfMargin = opts.margin ?? DEFAULT_MARGIN;
372
+ const onStage = opts.onStage;
373
+
374
  const browser = await chromium.launch({
375
  args: ["--no-sandbox", "--disable-setuid-sandbox"],
376
  });
 
378
  try {
379
  const page = await browser.newPage();
380
 
381
+ // tsx/esbuild wraps named functions with __name(...) helper calls at
382
+ // transpile time. When we pass an arrow function to page.evaluate, the
383
+ // transpiled source is serialized to the browser context where __name is
384
+ // undefined, crashing every evaluate. Install a no-op stub so those calls
385
+ // pass through unchanged. We pass the script as a raw string so esbuild
386
+ // doesn't touch it (passing a function would re-introduce the same bug
387
+ // inside the init script itself).
388
+ await page.addInitScript({
389
+ content:
390
+ "if (typeof window.__name !== 'function') { window.__name = function (fn) { return fn; }; }",
391
+ });
392
 
393
+ onStage?.("load", opts.url ? { url: opts.url } : undefined);
394
+ if (opts.url) {
395
+ await page.goto(opts.url, { waitUntil: "networkidle", timeout: 30_000 });
396
+ } else {
397
+ await page.setContent(html, { waitUntil: "networkidle" });
398
+ }
399
+ // Defensive: guarantee __name exists before the first evaluate, even if
400
+ // addInitScript didn't fire in time for this document.
401
+ await page.evaluate(
402
+ "window.__name = window.__name || function (fn) { return fn; };"
403
+ );
404
+ await waitForAssets(page, 8_000);
405
 
406
+ onStage?.("thumbnail");
407
  await page.setViewportSize({ width: 1200, height: 630 });
408
+ await page.evaluate(() => window.scrollTo(0, 0));
409
+ await new Promise((r) => setTimeout(r, 200));
410
  const thumbnail = await page.screenshot({
411
  type: "jpeg",
412
  quality: 85,
413
  clip: { x: 0, y: 0, width: 1200, height: 630 },
414
  });
415
 
416
+ onStage?.("pdf-setup");
417
+ await page.emulateMedia({ media: "print" });
418
+ const printableWidth = computePrintableWidthPx(format, margin);
419
+ await page.setViewportSize({ width: printableWidth, height: 1400 });
420
+ await page.evaluate(() => window.scrollTo(0, 0));
421
+ await waitForAssets(page, 8_000);
422
+
423
+ onStage?.("pdf-toc");
424
+ await injectPdfToc(page);
425
+
426
+ // Mermaid diagrams must be fully rendered before we screenshot them.
427
+ // `networkidle` fires before the ESM module from the CDN finishes running.
428
+ await waitForMermaid(page, 10_000);
429
+ await rasterizeMermaid(page, onStage);
430
+
431
+ await rasterizeEmbeds(page, onStage);
432
+
433
+ await waitForStableLayout(page, 3_000);
434
+
435
+ onStage?.("pdf-render");
436
  const pdf = await page.pdf({
437
+ format,
438
  printBackground: true,
439
+ displayHeaderFooter: false,
440
+ preferCSSPageSize: false,
441
+ margin,
442
  });
443
 
444
  return {
backend/src/publisher/transformers/accordion.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ /**
4
+ * Transforms `<div data-component="accordion">` authored in the editor into
5
+ * native `<details><summary>` collapsibles in the published output.
6
+ */
7
+ export const accordionTransformer: Transformer = {
8
+ name: "accordion",
9
+ apply(document) {
10
+ for (const div of [...document.querySelectorAll('div[data-component="accordion"]')]) {
11
+ const title = div.getAttribute("title") || "Details";
12
+ const isOpen = div.getAttribute("open") === "true";
13
+
14
+ const details = document.createElement("details");
15
+ details.setAttribute("data-component", "accordion");
16
+ if (isOpen) details.setAttribute("open", "");
17
+
18
+ const summary = document.createElement("summary");
19
+ summary.textContent = title;
20
+ details.appendChild(summary);
21
+
22
+ const content = document.createElement("div");
23
+ content.className = "accordion-content";
24
+ while (div.firstChild) content.appendChild(div.firstChild);
25
+ details.appendChild(content);
26
+
27
+ div.replaceWith(details);
28
+ }
29
+ },
30
+ };
backend/src/publisher/transformers/bibliography.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { parseHTML } from "linkedom";
2
+ import type { Transformer } from "./types.js";
3
+
4
+ /**
5
+ * Add id anchors to bibliography entries so inline citations can link to them.
6
+ * citation-js outputs entries as `<div class="csl-entry">` elements; we tag each
7
+ * with `id="ref-<key>"` in the order specified by `citationData.orderedKeys`.
8
+ */
9
+ function addBibliographyAnchors(html: string, orderedKeys: string[]): string {
10
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
11
+ const entries = document.querySelectorAll(".csl-entry");
12
+ entries.forEach((entry, idx) => {
13
+ const key = orderedKeys[idx] || `entry-${idx}`;
14
+ entry.setAttribute("id", `ref-${key}`);
15
+ });
16
+ return document.body.innerHTML;
17
+ }
18
+
19
+ /**
20
+ * Inject the pre-formatted bibliography HTML into the placeholder
21
+ * `<div data-type="bibliography">` left by the TipTap extension.
22
+ * Falls back to an empty-state message when there are no citations.
23
+ */
24
+ export const bibliographyTransformer: Transformer = {
25
+ name: "bibliography",
26
+ apply(document, ctx) {
27
+ for (const div of [...document.querySelectorAll('div[data-type="bibliography"]')]) {
28
+ div.removeAttribute("renderedhtml");
29
+
30
+ if (ctx.biblioHtml) {
31
+ let enriched = ctx.biblioHtml;
32
+ if (ctx.citationData && ctx.citationData.orderedKeys.length > 0) {
33
+ enriched = addBibliographyAnchors(ctx.biblioHtml, ctx.citationData.orderedKeys);
34
+ }
35
+ div.innerHTML = `<h2 class="bibliography-title">References</h2><div class="bibliography-content">${enriched}</div>`;
36
+ } else {
37
+ div.innerHTML = `<p class="bibliography-empty">No citations</p>`;
38
+ }
39
+ }
40
+ },
41
+ };
backend/src/publisher/transformers/citation.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ const NUMERIC_STYLES = new Set(["ieee", "vancouver"]);
4
+
5
+ /**
6
+ * Converts inline `<span data-type="citation">` markers produced by TipTap
7
+ * into anchor links pointing at the bibliography entries.
8
+ *
9
+ * - Numeric styles (IEEE, Vancouver) display sequential `[n]` labels.
10
+ * - Author-date styles keep the original inline label (e.g. "(Smith 2020)").
11
+ *
12
+ * Must run before the bibliography transformer so the `#ref-<key>` anchors
13
+ * line up with the entry ids created there.
14
+ */
15
+ export const citationTransformer: Transformer = {
16
+ name: "citation",
17
+ apply(document, ctx) {
18
+ const isNumeric = ctx.citationData ? NUMERIC_STYLES.has(ctx.citationData.style) : false;
19
+ const citationKeyOrder: string[] = [];
20
+
21
+ // Index bibliography entries by key so we can enrich each inline citation
22
+ // with a hover tooltip showing title / journal / DOI.
23
+ const entriesByKey = new Map<string, any>();
24
+ for (const entry of ctx.citationData?.entries ?? []) {
25
+ const entryKey = (entry?.id as string) || (entry?.key as string);
26
+ if (entryKey) entriesByKey.set(entryKey, entry);
27
+ }
28
+
29
+ for (const span of [...document.querySelectorAll('span[data-type="citation"]')]) {
30
+ const key = span.getAttribute("key") || span.getAttribute("data-key");
31
+ if (!key) continue;
32
+
33
+ if (!citationKeyOrder.includes(key)) citationKeyOrder.push(key);
34
+ const idx = citationKeyOrder.indexOf(key) + 1;
35
+
36
+ const originalLabel = span.textContent || `[${key}]`;
37
+ const displayLabel = isNumeric ? `[${idx}]` : originalLabel;
38
+
39
+ const a = document.createElement("a");
40
+ a.setAttribute("href", `#ref-${key}`);
41
+ a.className = "citation-inline";
42
+ a.setAttribute("id", `cite-${key}-${idx}`);
43
+ a.setAttribute("title", key);
44
+ a.textContent = displayLabel;
45
+
46
+ const entry = entriesByKey.get(key);
47
+ if (entry) {
48
+ const tooltip = document.createElement("span");
49
+ tooltip.className = "pub-tooltip pub-tooltip--citation";
50
+ tooltip.setAttribute("aria-hidden", "true");
51
+
52
+ const titleEl = document.createElement("span");
53
+ titleEl.className = "pub-tooltip__title";
54
+ titleEl.textContent = (entry.title as string) || key;
55
+ tooltip.appendChild(titleEl);
56
+
57
+ const journalText = (entry["container-title"] as string) || "";
58
+ if (journalText) {
59
+ const journal = document.createElement("span");
60
+ journal.className = "pub-tooltip__journal";
61
+ const parts: string[] = [journalText];
62
+ if (entry.volume) parts.push(String(entry.volume));
63
+ if (entry.page) parts.push(String(entry.page));
64
+ journal.textContent = parts.join(", ");
65
+ tooltip.appendChild(journal);
66
+ }
67
+
68
+ if (entry.DOI) {
69
+ const doi = document.createElement("span");
70
+ doi.className = "pub-tooltip__doi";
71
+ doi.textContent = `doi: ${entry.DOI}`;
72
+ tooltip.appendChild(doi);
73
+ }
74
+
75
+ a.appendChild(tooltip);
76
+ }
77
+
78
+ span.replaceWith(a);
79
+ }
80
+ },
81
+ };
backend/src/publisher/transformers/footnote.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ /**
4
+ * Replaces every `<span data-type="footnote" content="...">` with a numbered
5
+ * superscript link. The footnote texts are pushed into `ctx.footnoteTexts` so
6
+ * the orchestrator can render the `<section class="footnotes">` block once
7
+ * all transformers have run.
8
+ */
9
+ export const footnoteTransformer: Transformer = {
10
+ name: "footnote",
11
+ apply(document, ctx) {
12
+ for (const span of [...document.querySelectorAll('span[data-type="footnote"]')]) {
13
+ const text =
14
+ span.getAttribute("content") ||
15
+ span.getAttribute("data-content") ||
16
+ span.getAttribute("title") ||
17
+ "";
18
+ ctx.footnoteTexts.push(text);
19
+ const idx = ctx.footnoteTexts.length;
20
+
21
+ const sup = document.createElement("sup");
22
+ sup.className = "footnote-ref";
23
+ const a = document.createElement("a");
24
+ a.setAttribute("href", `#fn-${idx}`);
25
+ a.setAttribute("id", `fnref-${idx}`);
26
+ a.textContent = `[${idx}]`;
27
+ sup.appendChild(a);
28
+
29
+ span.replaceWith(sup);
30
+ }
31
+ },
32
+ };
backend/src/publisher/transformers/highlight-code.ts ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Element as HastElement, ElementContent, Root as HastRoot } from "hast";
2
+ import { getSharedHighlighter, isSupportedLang, normalizeLang, SHIKI_THEMES } from "../../shared/shiki-config.js";
3
+ import type { Transformer } from "./types.js";
4
+
5
+ /**
6
+ * Syntax-highlights every `<pre><code class="language-X">` block emitted by
7
+ * the TipTap `codeBlock` node using the shared Shiki highlighter, and tags
8
+ * `<pre>` with `data-lang="X"` so the stylesheet can render the language
9
+ * label via `pre::after { content: attr(data-lang) }`.
10
+ *
11
+ * Rationale: TipTap's `generateHTML()` produces plain text inside `<code>`,
12
+ * because syntax highlighting is a view-only ProseMirror plugin that does not
13
+ * run server-side. We do the highlighting here instead of shipping the JS to
14
+ * every reader (no CDN, no FOUC, offline-safe). Using the same Shiki config
15
+ * as the editor guarantees identical supported languages and identical token
16
+ * colors in edit vs. published view.
17
+ *
18
+ * Line numbers are rendered as `<span class="code-line-num">N</span>` inserted
19
+ * as the first child of every Shiki-emitted `<span class="line">`. The editor
20
+ * uses PM widget decorations to inject the exact same markup, so a single
21
+ * stylesheet rule targets both views. This markup-based approach survives
22
+ * soft-wrap (the number sits on the first visual row of its source line) and
23
+ * avoids the pitfalls of CSS counters or PM's overlapping-decoration merging.
24
+ */
25
+
26
+ /**
27
+ * Serialize a hast AST back to HTML. Hand-rolled (3 cases, no dependency
28
+ * on `hast-util-to-html`) because Shiki already produces a tree that only
29
+ * contains elements + text nodes with trivial attributes (class, style).
30
+ */
31
+ function hastToHtml(nodes: readonly ElementContent[]): string {
32
+ let out = "";
33
+ for (const n of nodes) {
34
+ if (n.type === "text") {
35
+ out += escapeHtmlText(n.value);
36
+ continue;
37
+ }
38
+ if (n.type === "element") {
39
+ out += `<${n.tagName}`;
40
+ const props = n.properties || {};
41
+ for (const [key, value] of Object.entries(props)) {
42
+ if (value === undefined || value === null || value === false) continue;
43
+ const attr = propToAttr(key);
44
+ const str = Array.isArray(value) ? value.join(" ") : String(value);
45
+ out += ` ${attr}="${escapeAttr(str)}"`;
46
+ }
47
+ if (isVoidElement(n.tagName)) {
48
+ out += " />";
49
+ } else {
50
+ out += ">";
51
+ out += hastToHtml(n.children);
52
+ out += `</${n.tagName}>`;
53
+ }
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function propToAttr(key: string): string {
60
+ if (key === "className") return "class";
61
+ if (key === "htmlFor") return "for";
62
+ return key.toLowerCase();
63
+ }
64
+
65
+ function escapeHtmlText(s: string): string {
66
+ return s.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
67
+ }
68
+
69
+ function escapeAttr(s: string): string {
70
+ return s.replace(/[&"]/g, (c) => (c === "&" ? "&amp;" : "&quot;"));
71
+ }
72
+
73
+ function isVoidElement(tag: string): boolean {
74
+ return ["br", "hr", "img", "input", "meta", "link"].includes(tag);
75
+ }
76
+
77
+ /**
78
+ * Extract language from a `<code>` class list. TipTap writes
79
+ * `class="language-python"`; anything else falls back to plain-text.
80
+ */
81
+ function extractLang(code: Element): string {
82
+ const cls = code.getAttribute("class") || "";
83
+ const match = cls.match(/language-([\w+-]+)/i);
84
+ return match ? match[1] : "";
85
+ }
86
+
87
+ /**
88
+ * Prepend `<span class="code-line-num">N</span>` to every Shiki `.line`
89
+ * wrapper. Matches the editor's widget markup exactly so the stylesheet can
90
+ * target a single selector in both views.
91
+ */
92
+ function injectLineNumbers(codeChildren: ElementContent[]): void {
93
+ let n = 0;
94
+ for (const child of codeChildren) {
95
+ if (child.type !== "element") continue;
96
+ // Shiki's HAST stores the class as a plain `class` string (not
97
+ // `className`), so inspect both just in case the config changes.
98
+ const props = child.properties ?? {};
99
+ const classValue = props.class ?? props.className;
100
+ const classTokens = Array.isArray(classValue)
101
+ ? classValue.map(String)
102
+ : typeof classValue === "string"
103
+ ? classValue.split(/\s+/)
104
+ : [];
105
+ if (!classTokens.includes("line")) continue;
106
+ n += 1;
107
+ const numSpan: HastElement = {
108
+ type: "element",
109
+ tagName: "span",
110
+ properties: { class: "code-line-num", "aria-hidden": "true" },
111
+ children: [{ type: "text", value: String(n) }],
112
+ };
113
+ child.children.unshift(numSpan);
114
+ }
115
+ }
116
+
117
+ export const highlightCodeTransformer: Transformer = {
118
+ name: "highlightCode",
119
+ async apply(document) {
120
+ const blocks = [...document.querySelectorAll("pre > code")];
121
+ if (blocks.length === 0) return;
122
+
123
+ const highlighter = await getSharedHighlighter();
124
+
125
+ for (const codeEl of blocks) {
126
+ const pre = codeEl.parentElement;
127
+ if (!pre || pre.tagName.toLowerCase() !== "pre") continue;
128
+ if (pre.classList.contains("mermaid")) continue;
129
+
130
+ const rawLang = extractLang(codeEl as unknown as Element);
131
+ const lang = normalizeLang(rawLang);
132
+ const source = codeEl.textContent || "";
133
+ if (!source) continue;
134
+
135
+ let hast: HastRoot;
136
+ try {
137
+ hast = highlighter.codeToHast(source, {
138
+ lang: isSupportedLang(lang) ? lang : "text",
139
+ themes: SHIKI_THEMES,
140
+ defaultColor: false,
141
+ }) as HastRoot;
142
+ } catch {
143
+ continue;
144
+ }
145
+
146
+ const shikiPre = hast.children.find((c): c is HastElement => c.type === "element" && c.tagName === "pre");
147
+ const shikiCode = shikiPre?.children.find((c): c is HastElement => c.type === "element" && c.tagName === "code");
148
+ if (!shikiCode) continue;
149
+
150
+ injectLineNumbers(shikiCode.children);
151
+ codeEl.innerHTML = hastToHtml(shikiCode.children);
152
+
153
+ pre.classList.add("shiki");
154
+ if (isSupportedLang(lang)) {
155
+ pre.setAttribute("data-lang", lang);
156
+ } else {
157
+ pre.removeAttribute("data-lang");
158
+ }
159
+ const shikiStyle = shikiPre?.properties?.style;
160
+ if (typeof shikiStyle === "string" && shikiStyle) {
161
+ const existing = pre.getAttribute("style") || "";
162
+ pre.setAttribute("style", existing ? `${existing};${shikiStyle}` : shikiStyle);
163
+ }
164
+ }
165
+ },
166
+ };
backend/src/publisher/transformers/html-embed.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+ import { buildEmbedSrcdoc, DEFAULT_EMBED_HEIGHT } from "../../shared/embed-doc.js";
3
+
4
+ export { buildEmbedSrcdoc };
5
+
6
+ /**
7
+ * Transforms `<div data-component="htmlEmbed" src="..." title="..." desc="..." height="...">`
8
+ * into:
9
+ * <figure class="html-embed">
10
+ * <div class="html-embed-container">
11
+ * <iframe srcdoc="..." ... />
12
+ * </div>
13
+ * <figcaption class="html-embed__desc">...</figcaption>
14
+ * </figure>
15
+ *
16
+ * - The stored `height` is used as the initial `height:` on the iframe so
17
+ * the page reserves the right space before the iframe even loads
18
+ * (important with `loading=lazy`), but is NOT used as `min-height`: that
19
+ * would prevent the iframe from shrinking if the chart, on a narrower
20
+ * viewport, reports a smaller scrollHeight.
21
+ * - A small safety `min-height` keeps the iframe from collapsing to 0 while
22
+ * the embed is still loading.
23
+ * - `srcdoc` is built from the shared `buildEmbedSrcdoc` which also embeds
24
+ * the height reporter and the theme listener.
25
+ * - If `title` or `desc` is present, a <figcaption> is generated so the
26
+ * publisher CSS (`figure.html-embed`) can number the figure.
27
+ */
28
+ const SAFETY_MIN_HEIGHT = 80;
29
+
30
+ export const htmlEmbedTransformer: Transformer = {
31
+ name: "htmlEmbed",
32
+ apply(document, ctx) {
33
+ for (const div of [...document.querySelectorAll('div[data-component="htmlEmbed"]')]) {
34
+ const src = div.getAttribute("src") || div.getAttribute("data-src") || "";
35
+ const rawHeight = div.getAttribute("height") || div.getAttribute("data-height");
36
+ const initialHeight = Math.max(
37
+ parseInt(rawHeight || "", 10) || DEFAULT_EMBED_HEIGHT,
38
+ SAFETY_MIN_HEIGHT,
39
+ );
40
+ const title = div.getAttribute("title") || div.getAttribute("data-title") || "";
41
+ const desc = div.getAttribute("desc") || div.getAttribute("data-desc") || "";
42
+ const embedHtml = ctx.embeds?.[src] || "";
43
+
44
+ const figure = document.createElement("figure");
45
+ figure.className = "html-embed";
46
+ if (src) figure.setAttribute("data-embed-src", src);
47
+
48
+ const container = document.createElement("div");
49
+ container.className = "html-embed-container";
50
+
51
+ const iframe = document.createElement("iframe");
52
+ iframe.setAttribute("data-embed-src", src);
53
+ iframe.setAttribute(
54
+ "style",
55
+ `width:100%;border:none;display:block;height:${initialHeight}px;min-height:${SAFETY_MIN_HEIGHT}px;background:transparent;`,
56
+ );
57
+ iframe.setAttribute("loading", "lazy");
58
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
59
+ if (title) iframe.setAttribute("title", title);
60
+
61
+ iframe.setAttribute("srcdoc", embedHtml ? buildEmbedSrcdoc(embedHtml) : "");
62
+
63
+ container.appendChild(iframe);
64
+ figure.appendChild(container);
65
+
66
+ if (title || desc) {
67
+ const caption = document.createElement("figcaption");
68
+ caption.className = "html-embed__desc";
69
+ if (title && desc) {
70
+ const strong = document.createElement("strong");
71
+ strong.textContent = title;
72
+ caption.appendChild(strong);
73
+ caption.appendChild(document.createTextNode(" " + desc));
74
+ } else {
75
+ caption.textContent = title || desc;
76
+ }
77
+ figure.appendChild(caption);
78
+ }
79
+
80
+ div.replaceWith(figure);
81
+ }
82
+ },
83
+ };
backend/src/publisher/transformers/index.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Ordered registry of DOM transformers applied during `postProcess()`.
3
+ *
4
+ * Order matters:
5
+ * 1. Accordion — must run early so nested content is moved before other
6
+ * transformers query it.
7
+ * 2. Citation — inline markers must be resolved before bibliography
8
+ * anchors are attached, so the `#ref-<key>` hrefs align.
9
+ * 3. Bibliography — consumes the ordered keys produced by the citation step.
10
+ * 4. Mermaid — must run BEFORE `highlightCode`: it converts
11
+ * `<div data-component="mermaid">` into `<pre class="mermaid">`,
12
+ * and the highlighter explicitly skips `pre.mermaid`. If
13
+ * this ran after, the highlighter would try to syntax-color
14
+ * the mermaid source.
15
+ * 5. HighlightCode — runs Shiki over every remaining `<pre><code>`.
16
+ * 6. HtmlEmbed — independent; converts placeholder divs into iframes.
17
+ * 7. Footnote — runs last so collected texts include those inside every
18
+ * other transformed block (tables, callouts, accordions...).
19
+ */
20
+ import type { Transformer } from "./types.js";
21
+ import { accordionTransformer } from "./accordion.js";
22
+ import { citationTransformer } from "./citation.js";
23
+ import { bibliographyTransformer } from "./bibliography.js";
24
+ import { mermaidTransformer } from "./mermaid.js";
25
+ import { highlightCodeTransformer } from "./highlight-code.js";
26
+ import { htmlEmbedTransformer } from "./html-embed.js";
27
+ import { footnoteTransformer } from "./footnote.js";
28
+
29
+ export const transformers: Transformer[] = [
30
+ accordionTransformer,
31
+ citationTransformer,
32
+ bibliographyTransformer,
33
+ mermaidTransformer,
34
+ highlightCodeTransformer,
35
+ htmlEmbedTransformer,
36
+ footnoteTransformer,
37
+ ];
38
+
39
+ export type { Transformer, TransformContext } from "./types.js";
40
+ export { buildEmbedSrcdoc } from "./html-embed.js";
backend/src/publisher/transformers/mermaid.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Transformer } from "./types.js";
2
+
3
+ /**
4
+ * Converts the editor's `<div data-component="mermaid" code="...">` into the
5
+ * `<pre class="mermaid">` format the Mermaid runtime expects client-side.
6
+ */
7
+ export const mermaidTransformer: Transformer = {
8
+ name: "mermaid",
9
+ apply(document) {
10
+ for (const div of [...document.querySelectorAll('div[data-component="mermaid"]')]) {
11
+ const code = div.getAttribute("code") || div.getAttribute("data-code") || "";
12
+ const pre = document.createElement("pre");
13
+ pre.className = "mermaid";
14
+ pre.textContent = code;
15
+ div.replaceWith(pre);
16
+ }
17
+ },
18
+ };
backend/src/publisher/transformers/types.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DOM transformer contract used by the publisher post-processing pipeline.
3
+ *
4
+ * `postProcess()` in `html-renderer.ts` walks through the registry in order
5
+ * and lets each transformer mutate the already-parsed document in place.
6
+ * Keeping each concern in its own file makes the pipeline obvious and
7
+ * lets us unit-test individual transformers in isolation.
8
+ */
9
+
10
+ import type { CitationData } from "../html-renderer.js";
11
+
12
+ /** Mutable state passed through the pipeline (footnote collector, etc.). */
13
+ export interface TransformContext {
14
+ /** Pre-formatted bibliography HTML, usually server-side from citation-js. */
15
+ biblioHtml: string;
16
+ /** Citation metadata (ordered keys, style). */
17
+ citationData?: CitationData;
18
+ /** Filename → raw HTML content for every htmlEmbed block. */
19
+ embeds?: Record<string, string>;
20
+
21
+ /**
22
+ * Footnote texts collected during the pipeline. Appended to the body as a
23
+ * `<section class="footnotes">` block at the end.
24
+ */
25
+ footnoteTexts: string[];
26
+ }
27
+
28
+ export interface Transformer {
29
+ /** Human readable id (used by tests and logs). */
30
+ name: string;
31
+ /**
32
+ * Mutates `document` in place. May be async (e.g. the code highlighter
33
+ * transformer awaits Shiki's shared highlighter on first call). No
34
+ * exceptions: every transformer must be idempotent and resilient to
35
+ * missing nodes.
36
+ */
37
+ apply(document: Document, ctx: TransformContext): void | Promise<void>;
38
+ }
backend/src/shared/component-defs.ts CHANGED
@@ -66,6 +66,7 @@ export const SHARED_COMPONENT_DEFS: SharedComponentDef[] = [
66
  { name: "desc", type: "string", default: "" },
67
  { name: "wide", type: "boolean", default: false },
68
  { name: "downloadable", type: "boolean", default: false },
 
69
  ],
70
  },
71
  {
@@ -88,7 +89,7 @@ export const SHARED_COMPONENT_DEFS: SharedComponentDef[] = [
88
  name: "mermaid",
89
  kind: "atomic",
90
  fields: [
91
- { name: "code", type: "string", default: "" },
92
  ],
93
  },
94
  ];
 
66
  { name: "desc", type: "string", default: "" },
67
  { name: "wide", type: "boolean", default: false },
68
  { name: "downloadable", type: "boolean", default: false },
69
+ { name: "height", type: "string", default: "400" },
70
  ],
71
  },
72
  {
 
89
  name: "mermaid",
90
  kind: "atomic",
91
  fields: [
92
+ { name: "code", type: "string", default: "graph LR\n A --> B" },
93
  ],
94
  },
95
  ];
backend/src/shared/embed-doc.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Shared embed document builder
3
+ //
4
+ // Single source of truth for the srcdoc injected into every embed iframe,
5
+ // used by both the editor (live preview) and the publisher (static HTML).
6
+ //
7
+ // Exports:
8
+ // - buildEmbedSrcdoc(html, opts) – assemble the full HTML doc
9
+ // - EMBED_MESSAGE_TYPES – { resize, setTheme, updatePrimaryColor }
10
+ // - DEFAULT_EMBED_HEIGHT – fallback pixel height when none stored
11
+ //
12
+ // Why one shared builder?
13
+ // Before, the editor and publisher each had their own COLOR_PALETTES / height
14
+ // reporter / theme listener, with subtle divergences (e.g. only the publisher
15
+ // supported setTheme hot-swap). Keeping them in sync by hand was a footgun.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const DEFAULT_EMBED_HEIGHT = 400;
19
+
20
+ export const EMBED_MESSAGE_TYPES = {
21
+ resize: "embedResize",
22
+ setTheme: "setTheme",
23
+ updatePrimaryColor: "updatePrimaryColor",
24
+ ready: "embedReady",
25
+ } as const;
26
+
27
+ /** ColorPalettes polyfill – mirrors the research-article-template API. */
28
+ const COLOR_PALETTES_POLYFILL = `
29
+ (function(){
30
+ var palettes = {
31
+ categorical: ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ac'],
32
+ sequential: ['#084594','#2171b5','#4292c6','#6baed6','#9ecae1','#c6dbef','#deebf7','#f7fbff'],
33
+ diverging: ['#d73027','#f46d43','#fdae61','#fee090','#ffffbf','#e0f3f8','#abd9e9','#74add1','#4575b4']
34
+ };
35
+ window.ColorPalettes = {
36
+ getColors: function(type, n) {
37
+ var p = palettes[type] || palettes.categorical;
38
+ return p.slice(0, n != null ? n : p.length);
39
+ },
40
+ getPrimary: function() {
41
+ return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7';
42
+ },
43
+ refresh: function() {},
44
+ getTextStyleForBackground: function() { return { fill: '#ffffff' }; }
45
+ };
46
+ })();
47
+ `.trim();
48
+
49
+ /**
50
+ * Height reporter:
51
+ * - Observes documentElement + body (ResizeObserver) to catch all reflows
52
+ * - Observes body's subtree (MutationObserver) to catch async chart mounts
53
+ * - Throttles via requestAnimationFrame and only posts when delta >= 1px
54
+ * - Safety retries at load, +300ms, +1000ms, +3000ms for slow async charts
55
+ * - Exposes window.__embedReady(optionalHeight) so charts can opt-in to a
56
+ * deterministic "I'm done" signal (useful for CSV fetches > 3s)
57
+ */
58
+ const HEIGHT_REPORTER = `
59
+ (function(){
60
+ var lastH = 0;
61
+ var scheduled = false;
62
+ // IMPORTANT: only measure <body>. document.documentElement.scrollHeight is
63
+ // clamped to at least the iframe's viewport height, so it would never allow
64
+ // the iframe to shrink (it would always report >= current iframe height).
65
+ // body.scrollHeight reflects the actual content height plus body padding.
66
+ function measure(){
67
+ var b = document.body;
68
+ if (!b) return 0;
69
+ // getBoundingClientRect().bottom accounts for padding/margin precisely;
70
+ // fall back to scrollHeight if the rect reports 0 (detached layout).
71
+ var rectBottom = Math.ceil(b.getBoundingClientRect().bottom);
72
+ var scroll = Math.ceil(b.scrollHeight);
73
+ // body margin may push the rect down; scrollHeight ignores it. Take the
74
+ // max so we never under-report.
75
+ return Math.max(rectBottom, scroll);
76
+ }
77
+ function post(h){
78
+ try {
79
+ window.parent.postMessage({ type: 'embedResize', height: h }, '*');
80
+ } catch(e){}
81
+ }
82
+ function schedule(){
83
+ if (scheduled) return;
84
+ scheduled = true;
85
+ requestAnimationFrame(function(){
86
+ scheduled = false;
87
+ var h = measure();
88
+ if (h > 0 && Math.abs(h - lastH) >= 1) {
89
+ lastH = h;
90
+ post(h);
91
+ }
92
+ });
93
+ }
94
+ if (typeof ResizeObserver !== 'undefined') {
95
+ var ro = new ResizeObserver(schedule);
96
+ ro.observe(document.documentElement);
97
+ if (document.body) ro.observe(document.body);
98
+ }
99
+ if (typeof MutationObserver !== 'undefined' && document.body) {
100
+ var mo = new MutationObserver(schedule);
101
+ mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
102
+ }
103
+ window.addEventListener('load', function(){
104
+ schedule();
105
+ setTimeout(schedule, 300);
106
+ setTimeout(schedule, 1000);
107
+ setTimeout(schedule, 3000);
108
+ });
109
+ // Opt-in explicit ready signal for async charts
110
+ window.__embedReady = function(){
111
+ schedule();
112
+ try { window.parent.postMessage({ type: 'embedReady' }, '*'); } catch(e){}
113
+ };
114
+ })();
115
+ `.trim();
116
+
117
+ /**
118
+ * Theme listener: swap CSS variables via [data-theme] without reloading.
119
+ * Also supports live primary-color updates and invokes window.__chartRerender
120
+ * if the embed defines one (same convention as research-article-template).
121
+ */
122
+ const THEME_LISTENER = `
123
+ (function(){
124
+ window.addEventListener('message', function(e){
125
+ if (!e.data) return;
126
+ if (e.data.type === 'setTheme') {
127
+ document.documentElement.setAttribute('data-theme', e.data.theme);
128
+ document.documentElement.style.colorScheme = e.data.theme === 'dark' ? 'dark' : 'light';
129
+ if (e.data.primaryColor) {
130
+ document.documentElement.style.setProperty('--primary-color', e.data.primaryColor);
131
+ }
132
+ if (window.__chartRerender) try { window.__chartRerender(); } catch(err){}
133
+ } else if (e.data.type === 'updatePrimaryColor') {
134
+ document.documentElement.style.setProperty('--primary-color', e.data.color);
135
+ if (window.__chartRerender) try { window.__chartRerender(); } catch(err){}
136
+ }
137
+ });
138
+ })();
139
+ `.trim();
140
+
141
+ const BASE_STYLES = `
142
+ :root, [data-theme='light'] {
143
+ color-scheme: light;
144
+ --text-color: rgba(0,0,0,.85);
145
+ --surface-bg: #f9f9f9;
146
+ --page-bg: #fff;
147
+ --border-color: rgba(0,0,0,.1);
148
+ --muted-color: rgba(0,0,0,.6);
149
+ --axis-color: rgba(0,0,0,.6);
150
+ --tick-color: rgba(0,0,0,.85);
151
+ --grid-color: rgba(0,0,0,.08);
152
+ }
153
+ [data-theme='dark'] {
154
+ color-scheme: dark;
155
+ --text-color: rgba(255,255,255,.9);
156
+ --surface-bg: #07080a;
157
+ --page-bg: #0f1115;
158
+ --border-color: rgba(255,255,255,.15);
159
+ --muted-color: rgba(255,255,255,.7);
160
+ --axis-color: rgba(255,255,255,.7);
161
+ --tick-color: rgba(255,255,255,.7);
162
+ --grid-color: rgba(255,255,255,.10);
163
+ }
164
+ *, *::before, *::after { box-sizing: border-box; }
165
+ /* IMPORTANT: do NOT set min-height:100% on <body>. Doing so creates a
166
+ lower bound on scrollHeight equal to the iframe's rendered height, so
167
+ the reporter can never signal a smaller height and the iframe can only
168
+ ever grow (never shrink when the chart re-renders narrower on resize). */
169
+ html, body { margin: 0; padding: 0; background: transparent; }
170
+ body {
171
+ color: var(--text-color);
172
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
173
+ font-size: 13px;
174
+ padding: 16px;
175
+ /* overflow: clip clips runaway horizontal content without creating a
176
+ scroll container that would trap scroll events or inflate scrollHeight. */
177
+ overflow: clip;
178
+ }
179
+ `.trim();
180
+
181
+ /**
182
+ * Escape `</script>` inside the embed HTML fragment so the outer parser does
183
+ * not terminate its own <script> block prematurely. Keeps the final closing
184
+ * tag intact in case the embed itself ends with `</script>`.
185
+ */
186
+ function escapeScriptTags(html: string): string {
187
+ const escaped = html.replace(/<\/script>/gi, "<\\/script>");
188
+ const lastIdx = escaped.lastIndexOf("<\\/script>");
189
+ if (lastIdx === -1) return html;
190
+ return escaped.slice(0, lastIdx) + "</script>" + escaped.slice(lastIdx + 10);
191
+ }
192
+
193
+ export interface BuildEmbedSrcdocOptions {
194
+ /** Initial theme, before any setTheme postMessage is received. */
195
+ isDark?: boolean;
196
+ /** Initial primary color (CSS color), defaults to #4e79a7. */
197
+ primaryColor?: string;
198
+ /**
199
+ * Edge-to-edge mode (used for article banners):
200
+ * - html/body fill 100% of the iframe viewport
201
+ * - no body padding (chart bleeds to the edges)
202
+ * - height reporter is skipped (iframe is size-driven by its parent's
203
+ * aspect-ratio, not by the chart's scrollHeight)
204
+ */
205
+ fullBleed?: boolean;
206
+ }
207
+
208
+ /**
209
+ * Stylesheet override applied when `fullBleed` is true. Replaces the
210
+ * default body padding and establishes a proper 100%-height chain so
211
+ * charts declaring `height: 100%` actually get a measurable height.
212
+ */
213
+ const FULL_BLEED_STYLES = `
214
+ html, body { height: 100%; width: 100%; }
215
+ body { padding: 0 !important; overflow: hidden; }
216
+ body > :first-child { width: 100%; height: 100%; }
217
+ `.trim();
218
+
219
+ /**
220
+ * Build a self-contained HTML document suitable for iframe `srcdoc=`.
221
+ * Theme changes after load should be driven via postMessage (`setTheme`)
222
+ * rather than rebuilding the srcdoc – otherwise the iframe reloads and loses
223
+ * chart state.
224
+ */
225
+ export function buildEmbedSrcdoc(htmlFragment: string, opts: BuildEmbedSrcdocOptions = {}): string {
226
+ const isDark = !!opts.isDark;
227
+ const primaryColor = opts.primaryColor || "#4e79a7";
228
+ const themeAttr = isDark ? "dark" : "light";
229
+ const inlinePrimary = `:root{--primary-color:${primaryColor};}`;
230
+ const fullBleed = !!opts.fullBleed;
231
+ const extraStyles = fullBleed ? FULL_BLEED_STYLES : "";
232
+
233
+ const parts: string[] = [
234
+ "<!DOCTYPE html>",
235
+ `<html data-theme="${themeAttr}">`,
236
+ "<head>",
237
+ '<meta charset="UTF-8">',
238
+ `<style>${BASE_STYLES}${inlinePrimary}${extraStyles}</style>`,
239
+ `<script>${COLOR_PALETTES_POLYFILL}<\/script>`,
240
+ "</head>",
241
+ "<body>",
242
+ escapeScriptTags(htmlFragment),
243
+ ];
244
+
245
+ // Skip the height reporter in fullBleed mode: the iframe is size-driven
246
+ // by its parent's aspect-ratio (e.g. the banner 5:2 box) and must not
247
+ // try to resize itself based on chart scrollHeight.
248
+ if (!fullBleed) {
249
+ parts.push(`<script>${HEIGHT_REPORTER}<\/script>`);
250
+ }
251
+ parts.push(`<script>${THEME_LISTENER}<\/script>`);
252
+ parts.push("</body></html>");
253
+
254
+ return parts.join("\n");
255
+ }
backend/src/shared/shiki-config.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared Shiki configuration.
3
+ *
4
+ * Single source of truth for syntax highlighting across the editor (live
5
+ * decorations) and the publisher (static SSR). Using the same config in both
6
+ * environments guarantees:
7
+ * - identical supported languages
8
+ * - identical themes / token colors
9
+ * - identical output structure (<span class="line"><span style=...>)
10
+ *
11
+ * Shiki is async-initialized (it loads an Oniguruma WASM engine + grammars).
12
+ * A singleton highlighter is created on first use and reused forever. The
13
+ * editor kicks off creation at mount; the publisher awaits it synchronously
14
+ * during render (publish is a one-shot pipeline).
15
+ */
16
+
17
+ import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki";
18
+ import { createHighlighter } from "shiki";
19
+
20
+ /**
21
+ * Languages bundled into the highlighter. Chosen for research/tech articles.
22
+ * Keep this list in sync with what the editor's language selector advertises.
23
+ * Adding a language = one line here + redeploy; the cost is ~50KB gzipped per
24
+ * grammar loaded lazily.
25
+ */
26
+ export const SHIKI_LANGS: BundledLanguage[] = [
27
+ "javascript",
28
+ "typescript",
29
+ "tsx",
30
+ "jsx",
31
+ "python",
32
+ "bash",
33
+ "shell",
34
+ "json",
35
+ "yaml",
36
+ "toml",
37
+ "css",
38
+ "html",
39
+ "xml",
40
+ "markdown",
41
+ "sql",
42
+ "rust",
43
+ "go",
44
+ "c",
45
+ "cpp",
46
+ "csharp",
47
+ "java",
48
+ "r",
49
+ "julia",
50
+ "ruby",
51
+ "php",
52
+ "swift",
53
+ "kotlin",
54
+ "scala",
55
+ "haskell",
56
+ "lua",
57
+ "dockerfile",
58
+ "diff",
59
+ "latex",
60
+ "ini",
61
+ "makefile",
62
+ "graphql",
63
+ ];
64
+
65
+ /**
66
+ * Dual theme: Shiki emits each token with inline `color` (light) plus a
67
+ * `--shiki-dark` CSS variable (dark). A small CSS rule toggles which wins
68
+ * based on the `data-theme` attribute on <html>. See `_code-blocks.css`.
69
+ *
70
+ * Pick well-balanced, muted themes that read well in an academic context.
71
+ */
72
+ export const SHIKI_THEMES = {
73
+ light: "github-light" as BundledTheme,
74
+ dark: "github-dark" as BundledTheme,
75
+ };
76
+
77
+ export type ShikiHighlighter = HighlighterGeneric<BundledLanguage, BundledTheme>;
78
+
79
+ let highlighterPromise: Promise<ShikiHighlighter> | null = null;
80
+
81
+ /**
82
+ * Returns the shared highlighter, creating it on first call. All subsequent
83
+ * callers wait on the same promise (deduped).
84
+ */
85
+ export function getSharedHighlighter(): Promise<ShikiHighlighter> {
86
+ if (!highlighterPromise) {
87
+ highlighterPromise = createHighlighter({
88
+ themes: [SHIKI_THEMES.light, SHIKI_THEMES.dark],
89
+ langs: SHIKI_LANGS,
90
+ });
91
+ }
92
+ return highlighterPromise;
93
+ }
94
+
95
+ /**
96
+ * Checks whether a language string is supported. Falls back to plain-text
97
+ * rendering if not, which is what both editor and publisher should do.
98
+ */
99
+ export function isSupportedLang(lang: string | null | undefined): lang is BundledLanguage {
100
+ if (!lang) return false;
101
+ return (SHIKI_LANGS as readonly string[]).includes(lang);
102
+ }
103
+
104
+ /**
105
+ * Normalize a language alias to the canonical name Shiki expects. Handles
106
+ * common aliases that users type (`js` → `javascript`, `py` → `python`, etc.).
107
+ */
108
+ export function normalizeLang(lang: string | null | undefined): string {
109
+ if (!lang) return "";
110
+ const l = lang.toLowerCase().trim();
111
+ const aliases: Record<string, string> = {
112
+ js: "javascript",
113
+ ts: "typescript",
114
+ py: "python",
115
+ rb: "ruby",
116
+ sh: "bash",
117
+ zsh: "bash",
118
+ yml: "yaml",
119
+ md: "markdown",
120
+ "c++": "cpp",
121
+ "c#": "csharp",
122
+ docker: "dockerfile",
123
+ tex: "latex",
124
+ };
125
+ return aliases[l] ?? l;
126
+ }
backend/src/shared/theme.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared theming constants used by both the editor (to preview primary color
3
+ * changes live) and the publisher (to bake them into the published HTML).
4
+ *
5
+ * Keep lightness/chroma here as the single source of truth. CSS `_variables.css`
6
+ * still owns the *default* primary hue; this module only defines the L/C pair
7
+ * shared by the HueSlider preview and the server-side override.
8
+ */
9
+
10
+ export const OKLCH_L = 0.75;
11
+ export const OKLCH_C = 0.12;
12
+
13
+ /** Format an OKLCH color string using the shared L/C and a given hue (0-360). */
14
+ export function oklchFromHue(hue: number): string {
15
+ const h = ((hue % 360) + 360) % 360;
16
+ return `oklch(${OKLCH_L} ${OKLCH_C} ${h})`;
17
+ }
backend/tests/__snapshots__/html-renderer-snapshot.test.ts.snap CHANGED
@@ -26,18 +26,41 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
26
 
27
  <!-- KaTeX CSS -->
28
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
29
- integrity="sha384-zh0CIslj3dQfMxK3pZnPJAb3bprHitCE2vBz9TyOTICAn3fso5GYa90qPNMBclov"
30
  crossorigin="anonymous">
31
 
32
- <!-- highlight.js for code syntax highlighting -->
33
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
34
- <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
35
- <script>hljs.highlightAll();</script>
36
 
37
- <!-- Mermaid (renders <pre class="mermaid"> blocks) -->
38
  <script type="module">
39
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
40
- mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </script>
42
 
43
  <style>
@@ -75,11 +98,21 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
75
  <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
76
  <div class="toc-mobile-sidebar__header">
77
  <span class="toc-mobile-sidebar__title">Table of Contents</span>
78
- <button class="toc-mobile-sidebar__close" aria-label="Close">
 
 
 
 
 
 
 
 
 
79
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
80
  <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
81
  </svg>
82
  </button>
 
83
  </div>
84
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
85
  </aside>
@@ -88,20 +121,21 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
88
  <section class="hero">
89
  <h1 class="hero-title">Test Article</h1>
90
 
 
91
  </section>
92
 
93
  <!-- Meta bar -->
94
  <header class="meta"><div class="meta-container"><div class="meta-container-cell"><h3>Authors</h3><ul class="authors"><li>Alice</li></ul></div><div class="meta-container-cell"><h3>Affiliations</h3><p>MIT</p></div><div class="meta-container-cell"><h3>Published</h3><p>January 1, 2025</p></div></div></header>
95
 
96
  <section class="content-grid">
97
- <nav class="table-of-contents" aria-label="Table of Contents">
98
  <div class="title">Table of Contents</div>
99
  <div id="toc-placeholder"></div>
100
  </nav>
101
 
102
  <main>
103
  <div class="tiptap">
104
- <h2>Introduction</h2><p>This is a test article with a <a title="ref1" id="cite-ref1-1" class="citation-inline" href="#ref-ref1">Ref (2024)</a> citation.</p><p>A footnote here<sup class="footnote-ref"><a id="fnref-1" href="#fn-1">[1]</a></sup></p><div data-type="bibliography" class="bibliography-block"><h2 class="bibliography-title">References</h2><div class="bibliography-content"><div id="ref-ref1" class="csl-entry">Reference One. 2024.</div></div></div><section class="footnotes"><h2>Footnotes</h2><ol><li id="fn-1"><p>Important detail <a href="#fnref-1" class="footnote-backref" aria-label="Back to text">↩</a></p></li></ol></section>
105
  </div>
106
  </main>
107
  </section>
@@ -207,6 +241,9 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
207
  });
208
 
209
  // Build nested nav with data-heading-idx
 
 
 
210
  var nav = document.createElement('nav');
211
  var ulStack = [document.createElement('ul')];
212
  nav.appendChild(ulStack[0]);
@@ -214,7 +251,16 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
214
  var prev = 2, hIdx = 0;
215
  headingsArr.forEach(function(h) {
216
  var lvl = levelOf(h.tagName);
217
- while (lvl > prev) { var ul = document.createElement('ul'); var last = ulStack[ulStack.length-1].lastElementChild; if (last) last.appendChild(ul); ulStack.push(ul); prev++; }
 
 
 
 
 
 
 
 
 
218
  while (lvl < prev) { ulStack.pop(); prev--; }
219
  var li = document.createElement('li');
220
  var a = document.createElement('a');
@@ -254,11 +300,13 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
254
 
255
  // ---- Auto-collapse logic ----
256
  var isMobile = window.matchMedia('(max-width: 1100px)');
 
 
257
 
258
  function getItemsWithChildren(navEl) {
259
  if (!navEl) return [];
260
  return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
261
- return li.querySelector(':scope > ul');
262
  });
263
  }
264
 
@@ -267,7 +315,9 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
267
  var activeLi = null;
268
  function find(li) {
269
  if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
270
- var ul = li.querySelector(':scope > ul');
 
 
271
  if (!ul) return null;
272
  var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
273
  for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
@@ -285,27 +335,21 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
285
  return toExpand;
286
  }
287
 
 
 
 
288
  function applyCollapseState(navEl, activeIdx) {
289
  if (!navEl) return;
290
  var items = getItemsWithChildren(navEl);
291
  var ancestors = getAncestorIndices(items, activeIdx);
292
 
293
  items.forEach(function(li) {
294
- var sub = li.querySelector(':scope > ul');
295
- if (!sub) return;
296
  var idx = Number(li.getAttribute('data-heading-idx'));
297
  var allDesc = li.querySelectorAll('li[data-heading-idx]');
298
  var related = [idx];
299
  allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
300
  var shouldExpand = related.some(function(i) { return ancestors[i]; });
301
-
302
- if (shouldExpand) {
303
- li.classList.remove('collapsed');
304
- sub.style.height = 'auto';
305
- } else {
306
- li.classList.add('collapsed');
307
- sub.style.height = '0px';
308
- }
309
  });
310
  }
311
 
@@ -313,13 +357,16 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
313
  if (!navEl) return;
314
  getItemsWithChildren(navEl).forEach(function(li) {
315
  li.classList.remove('collapsed');
316
- var sub = li.querySelector(':scope > ul');
317
- if (sub) sub.style.height = 'auto';
318
  });
319
  }
320
 
321
- // Initial state: collapse all except first section
 
 
322
  function initCollapse() {
 
 
 
323
  if (isMobile.matches) {
324
  expandAll(holder ? holder.querySelector('nav') : null);
325
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
@@ -327,10 +374,17 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
327
  applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
328
  applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
329
  }
 
 
 
 
 
 
330
  }
331
  initCollapse();
332
 
333
  isMobile.addEventListener('change', function() {
 
334
  if (isMobile.matches) {
335
  expandAll(holder ? holder.querySelector('nav') : null);
336
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
@@ -340,33 +394,70 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
340
  }
341
  });
342
 
343
- // ---- Scroll tracking ----
344
- var OFFSET = 60, lastScrollTime = 0, prevActiveIdx = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
- function onScroll() {
347
- var now = performance.now();
348
- if (now - lastScrollTime < 50) return;
349
- lastScrollTime = now;
350
  requestAnimationFrame(function() {
351
- var activeIdx = -1, activeId = null;
352
- for (var i = headingsArr.length - 1; i >= 0; i--) {
353
- if (headingsArr[i].getBoundingClientRect().top - OFFSET <= 0) {
354
- activeIdx = i; activeId = headingsArr[i].id; break;
 
 
 
 
 
 
 
 
 
 
355
  }
356
  }
357
- allLinks.forEach(function(l) {
358
- if (activeId && l.getAttribute('href') === '#'+activeId) l.classList.add('active');
359
- else l.classList.remove('active');
360
- });
361
- if (activeIdx !== prevActiveIdx && activeIdx >= 0 && !isMobile.matches) {
362
  prevActiveIdx = activeIdx;
363
- applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
364
- }
365
- if (activeIdx >= 0) prevActiveIdx = activeIdx;
366
  });
367
  }
368
- window.addEventListener('scroll', onScroll, { passive: true });
369
- onScroll();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  // ---- Mobile sidebar ----
372
  var sidebar = document.querySelector('.toc-mobile-sidebar');
@@ -397,6 +488,16 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
397
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
398
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
399
 
 
 
 
 
 
 
 
 
 
 
400
  // ---- Footer: move references & footnotes ----
401
  (function() {
402
  var footer = document.querySelector('footer.footer');
@@ -458,6 +559,41 @@ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
458
  setTimeout(run, 300);
459
  })();
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  // Hash navigation
462
  if (window.location.hash) {
463
  var target = document.querySelector(window.location.hash);
 
26
 
27
  <!-- KaTeX CSS -->
28
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
29
+ integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib"
30
  crossorigin="anonymous">
31
 
32
+ <!-- Code blocks are pre-highlighted at publish time with Shiki (dual-theme
33
+ via --shiki-dark CSS vars). Zero runtime JS, zero CDN dependency. -->
 
 
34
 
35
+ <!-- Mermaid (renders <pre class="mermaid"> blocks, auto-theme dark/light) -->
36
  <script type="module">
37
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
38
+ function getTheme() {
39
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'neutral';
40
+ }
41
+ mermaid.initialize({ startOnLoad: false, theme: getTheme() });
42
+
43
+ document.querySelectorAll('pre.mermaid').forEach(function(el) {
44
+ el.setAttribute('data-mermaid-src', el.textContent || '');
45
+ });
46
+ await mermaid.run();
47
+
48
+ new MutationObserver(function(ms) {
49
+ for (var i = 0; i < ms.length; i++) {
50
+ if (ms[i].attributeName === 'data-theme') {
51
+ mermaid.initialize({ startOnLoad: false, theme: getTheme() });
52
+ document.querySelectorAll('pre.mermaid').forEach(function(el) {
53
+ var src = el.getAttribute('data-mermaid-src');
54
+ if (src) {
55
+ el.removeAttribute('data-processed');
56
+ el.textContent = src;
57
+ }
58
+ });
59
+ mermaid.run();
60
+ break;
61
+ }
62
+ }
63
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
64
  </script>
65
 
66
  <style>
 
98
  <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
99
  <div class="toc-mobile-sidebar__header">
100
  <span class="toc-mobile-sidebar__title">Table of Contents</span>
101
+ <div class="toc-mobile-sidebar__actions">
102
+ <button class="toc-mobile-sidebar__theme" aria-label="Toggle color theme">
103
+ <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">
104
+ <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"/>
105
+ </svg>
106
+ <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">
107
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
108
+ </svg>
109
+ </button>
110
+ <button class="toc-mobile-sidebar__close" aria-label="Close table of contents">
111
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
112
  <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
113
  </svg>
114
  </button>
115
+ </div>
116
  </div>
117
  <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
118
  </aside>
 
121
  <section class="hero">
122
  <h1 class="hero-title">Test Article</h1>
123
 
124
+
125
  </section>
126
 
127
  <!-- Meta bar -->
128
  <header class="meta"><div class="meta-container"><div class="meta-container-cell"><h3>Authors</h3><ul class="authors"><li>Alice</li></ul></div><div class="meta-container-cell"><h3>Affiliations</h3><p>MIT</p></div><div class="meta-container-cell"><h3>Published</h3><p>January 1, 2025</p></div></div></header>
129
 
130
  <section class="content-grid">
131
+ <nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse="0">
132
  <div class="title">Table of Contents</div>
133
  <div id="toc-placeholder"></div>
134
  </nav>
135
 
136
  <main>
137
  <div class="tiptap">
138
+ <h2>Introduction</h2><p>This is a test article with a <a title="ref1" id="cite-ref1-1" class="citation-inline" href="#ref-ref1">Ref (2024)<span aria-hidden="true" class="pub-tooltip pub-tooltip--citation"><span class="pub-tooltip__title">Reference One</span></span></a> citation.</p><p>A footnote here<sup class="footnote-ref"><a id="fnref-1" href="#fn-1">[1]</a></sup></p><div data-type="bibliography" class="bibliography-block"><h2 class="bibliography-title">References</h2><div class="bibliography-content"><div id="ref-ref1" class="csl-entry">Reference One. 2024.</div></div></div><section class="footnotes"><h2>Footnotes</h2><ol><li id="fn-1"><p>Important detail <a href="#fnref-1" class="footnote-backref" aria-label="Back to text">↩</a></p></li></ol></section>
139
  </div>
140
  </main>
141
  </section>
 
241
  });
242
 
243
  // Build nested nav with data-heading-idx
244
+ // Each nested <ul> is wrapped in <div class="toc-children"> so we can
245
+ // animate expand/collapse via the grid-template-rows 0fr/1fr trick
246
+ // (CSS cannot interpolate height:auto).
247
  var nav = document.createElement('nav');
248
  var ulStack = [document.createElement('ul')];
249
  nav.appendChild(ulStack[0]);
 
251
  var prev = 2, hIdx = 0;
252
  headingsArr.forEach(function(h) {
253
  var lvl = levelOf(h.tagName);
254
+ while (lvl > prev) {
255
+ var wrap = document.createElement('div');
256
+ wrap.className = 'toc-children';
257
+ var ul = document.createElement('ul');
258
+ wrap.appendChild(ul);
259
+ var last = ulStack[ulStack.length-1].lastElementChild;
260
+ if (last) last.appendChild(wrap);
261
+ ulStack.push(ul);
262
+ prev++;
263
+ }
264
  while (lvl < prev) { ulStack.pop(); prev--; }
265
  var li = document.createElement('li');
266
  var a = document.createElement('a');
 
300
 
301
  // ---- Auto-collapse logic ----
302
  var isMobile = window.matchMedia('(max-width: 1100px)');
303
+ var tocNav = document.querySelector('.table-of-contents');
304
+ var autoCollapseEnabled = tocNav && tocNav.getAttribute('data-auto-collapse') === '1';
305
 
306
  function getItemsWithChildren(navEl) {
307
  if (!navEl) return [];
308
  return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
309
+ return li.querySelector(':scope > .toc-children');
310
  });
311
  }
312
 
 
315
  var activeLi = null;
316
  function find(li) {
317
  if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
318
+ var wrap = li.querySelector(':scope > .toc-children');
319
+ if (!wrap) return null;
320
+ var ul = wrap.querySelector(':scope > ul');
321
  if (!ul) return null;
322
  var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
323
  for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
 
335
  return toExpand;
336
  }
337
 
338
+ // Just toggle the .collapsed class on the li. The CSS rule
339
+ // .toc-collapsible li.collapsed > .toc-children { grid-template-rows: 0fr }
340
+ // drives the actual animation (no JS height math).
341
  function applyCollapseState(navEl, activeIdx) {
342
  if (!navEl) return;
343
  var items = getItemsWithChildren(navEl);
344
  var ancestors = getAncestorIndices(items, activeIdx);
345
 
346
  items.forEach(function(li) {
 
 
347
  var idx = Number(li.getAttribute('data-heading-idx'));
348
  var allDesc = li.querySelectorAll('li[data-heading-idx]');
349
  var related = [idx];
350
  allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
351
  var shouldExpand = related.some(function(i) { return ancestors[i]; });
352
+ li.classList.toggle('collapsed', !shouldExpand);
 
 
 
 
 
 
 
353
  });
354
  }
355
 
 
357
  if (!navEl) return;
358
  getItemsWithChildren(navEl).forEach(function(li) {
359
  li.classList.remove('collapsed');
 
 
360
  });
361
  }
362
 
363
+ // Initial state: collapse only if auto-collapse is enabled.
364
+ // We suppress transitions for the first paint so the user doesn't see
365
+ // a close-animation at page load when auto-collapse is on.
366
  function initCollapse() {
367
+ if (!autoCollapseEnabled) return;
368
+ if (nav) nav.classList.add('toc-no-transition');
369
+ if (navClone) navClone.classList.add('toc-no-transition');
370
  if (isMobile.matches) {
371
  expandAll(holder ? holder.querySelector('nav') : null);
372
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
 
374
  applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
375
  applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
376
  }
377
+ requestAnimationFrame(function(){
378
+ requestAnimationFrame(function(){
379
+ if (nav) nav.classList.remove('toc-no-transition');
380
+ if (navClone) navClone.classList.remove('toc-no-transition');
381
+ });
382
+ });
383
  }
384
  initCollapse();
385
 
386
  isMobile.addEventListener('change', function() {
387
+ if (!autoCollapseEnabled) return;
388
  if (isMobile.matches) {
389
  expandAll(holder ? holder.querySelector('nav') : null);
390
  expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
 
394
  }
395
  });
396
 
397
+ // ---- Scroll tracking (IntersectionObserver-driven) ----
398
+ // Semantics: active heading = the last one whose top has crossed the
399
+ // OFFSET line ("section I'm currently reading"). This matches typical
400
+ // documentation UX (MDN, Docusaurus, etc.) and is intentionally different
401
+ // from the editor-side TOC which picks the topmost visible heading in its
402
+ // scroll container (better suited to non-linear editing navigation).
403
+ //
404
+ // We use IntersectionObserver only as a trigger: it fires precisely when
405
+ // a heading crosses the OFFSET boundary, which are the only moments the
406
+ // active heading can change. No per-frame scroll handler, no throttling.
407
+ var OFFSET = 60, prevActiveIdx = -1, prevActiveId = null, rafPending = false;
408
+
409
+ function findActiveHeading() {
410
+ for (var i = headingsArr.length - 1; i >= 0; i--) {
411
+ var h = headingsArr[i];
412
+ // Skip headings removed from DOM or moved outside article (e.g. footnotes moved to footer)
413
+ if (!h.isConnected || !articleRoot.contains(h)) continue;
414
+ if (h.getBoundingClientRect().top - OFFSET <= 0) {
415
+ return { idx: i, id: h.id };
416
+ }
417
+ }
418
+ return { idx: -1, id: null };
419
+ }
420
 
421
+ function updateActive() {
422
+ if (rafPending) return;
423
+ rafPending = true;
 
424
  requestAnimationFrame(function() {
425
+ rafPending = false;
426
+ var result = findActiveHeading();
427
+ var activeIdx = result.idx, activeId = result.id;
428
+ if (activeId === prevActiveId && activeIdx === prevActiveIdx) return;
429
+ allLinks.forEach(function(l) { l.classList.remove('active'); });
430
+ if (activeId) {
431
+ var href = '#' + activeId;
432
+ allLinks.forEach(function(l) {
433
+ if (l.getAttribute('href') === href) l.classList.add('active');
434
+ });
435
+ }
436
+ if (activeIdx >= 0 && activeIdx !== prevActiveIdx) {
437
+ if (autoCollapseEnabled && !isMobile.matches) {
438
+ applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
439
  }
440
  }
 
 
 
 
 
441
  prevActiveIdx = activeIdx;
442
+ prevActiveId = activeId;
 
 
443
  });
444
  }
445
+
446
+ if (typeof IntersectionObserver !== 'undefined') {
447
+ var spyObserver = new IntersectionObserver(updateActive, {
448
+ rootMargin: '-' + OFFSET + 'px 0px 0px 0px',
449
+ threshold: 0,
450
+ });
451
+ headingsArr.forEach(function(h) {
452
+ if (articleRoot.contains(h)) spyObserver.observe(h);
453
+ });
454
+ // rootMargin changes on resize (viewport height affects the bottom edge
455
+ // of the root), but the top offset is fixed, so no need to recreate.
456
+ } else {
457
+ // Fallback for browsers without IntersectionObserver (very old).
458
+ window.addEventListener('scroll', updateActive, { passive: true });
459
+ }
460
+ updateActive();
461
 
462
  // ---- Mobile sidebar ----
463
  var sidebar = document.querySelector('.toc-mobile-sidebar');
 
488
  if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
489
  document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
490
 
491
+ // Sidebar theme toggle
492
+ var sidebarThemeBtn = document.querySelector('.toc-mobile-sidebar__theme');
493
+ if (sidebarThemeBtn) {
494
+ sidebarThemeBtn.addEventListener('click', function() {
495
+ var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
496
+ localStorage.setItem('theme', next);
497
+ applyTheme(next);
498
+ });
499
+ }
500
+
501
  // ---- Footer: move references & footnotes ----
502
  (function() {
503
  var footer = document.querySelector('footer.footer');
 
559
  setTimeout(run, 300);
560
  })();
561
 
562
+ // Sync theme with embed iframes (body embeds + hero banner)
563
+ function syncEmbedThemes() {
564
+ var theme = document.documentElement.dataset.theme || 'dark';
565
+ document.querySelectorAll('.html-embed-container iframe, .hero-banner iframe').forEach(function(iframe) {
566
+ try { iframe.contentWindow.postMessage({ type: 'setTheme', theme: theme }, '*'); } catch(e) {}
567
+ });
568
+ }
569
+ var obs = new MutationObserver(function(ms) {
570
+ for (var i = 0; i < ms.length; i++) {
571
+ if (ms[i].attributeName === 'data-theme') { syncEmbedThemes(); break; }
572
+ }
573
+ });
574
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
575
+
576
+ // Auto-size embed iframes from postMessage height reports.
577
+ // Dedupe: only apply when the new height differs from the last applied
578
+ // (prevents style thrash during tight ResizeObserver bursts).
579
+ var _lastEmbedHeight = new WeakMap();
580
+ window.addEventListener('message', function(e) {
581
+ if (!e.data || e.data.type !== 'embedResize') return;
582
+ var h = Math.max(0, Math.ceil(e.data.height));
583
+ if (!h) return;
584
+ document.querySelectorAll('.html-embed-container iframe').forEach(function(iframe) {
585
+ try {
586
+ if (iframe.contentWindow !== e.source) return;
587
+ if (_lastEmbedHeight.get(iframe) === h) return;
588
+ _lastEmbedHeight.set(iframe, h);
589
+ iframe.style.height = h + 'px';
590
+ } catch(ex) {}
591
+ });
592
+ });
593
+
594
+ // Initial theme sync after iframes load
595
+ window.addEventListener('load', function() { setTimeout(syncEmbedThemes, 200); });
596
+
597
  // Hash navigation
598
  if (window.location.hash) {
599
  var target = document.querySelector(window.location.hash);
backend/tests/css-resolution.test.ts CHANGED
@@ -2,25 +2,9 @@
2
  * CSS Resolution Tests (Section 1.2.2 of TESTS.md)
3
  *
4
  * Tests that @custom-media rules are properly resolved for published HTML.
5
- * We test the resolveCustomMedia function indirectly through the publish flow.
6
  */
7
  import { describe, it, expect } from "vitest";
8
-
9
- // resolveCustomMedia is not exported, so we re-implement the logic for testing.
10
- // This tests the algorithm, not the import path.
11
- function resolveCustomMedia(allCss: string): string {
12
- const mediaMap = new Map<string, string>();
13
- const declRegex = /@custom-media\s+(--[\w-]+)\s+\(([^)]+)\)\s*;/g;
14
- let match: RegExpExecArray | null;
15
- while ((match = declRegex.exec(allCss)) !== null) {
16
- mediaMap.set(match[1], `(${match[2]})`);
17
- }
18
- let resolved = allCss.replace(/@custom-media\s+--[\w-]+\s+\([^)]+\)\s*;/g, "");
19
- for (const [name, value] of mediaMap) {
20
- resolved = resolved.replaceAll(`(${name})`, value);
21
- }
22
- return resolved;
23
- }
24
 
25
  describe("1.2.2 CSS @custom-media resolution", () => {
26
  it("resolves custom media declarations", () => {
 
2
  * CSS Resolution Tests (Section 1.2.2 of TESTS.md)
3
  *
4
  * Tests that @custom-media rules are properly resolved for published HTML.
 
5
  */
6
  import { describe, it, expect } from "vitest";
7
+ import { resolveCustomMedia } from "../src/publisher/index.js";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  describe("1.2.2 CSS @custom-media resolution", () => {
10
  it("resolves custom media declarations", () => {
backend/tests/html-renderer-snapshot.test.ts CHANGED
@@ -27,40 +27,40 @@ function minimalDoc(content: any[]) {
27
  }
28
 
29
  describe("renderArticleHTML", () => {
30
- it("produces a complete HTML document", () => {
31
  const json = minimalDoc([{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }]);
32
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
33
  expect(html).toContain("<!DOCTYPE html>");
34
  expect(html).toContain("<title>Test Article</title>");
35
  expect(html).toContain("Hello world");
36
  });
37
 
38
- it("escapes HTML in meta fields", () => {
39
  const meta: PublishMeta = { ...BASIC_META, title: 'Title with "quotes" & <tags>' };
40
  const json = minimalDoc([{ type: "paragraph" }]);
41
- const html = renderArticleHTML(json, meta, EMPTY_CSS);
42
  expect(html).toContain("&amp;");
43
  expect(html).toContain("&lt;tags&gt;");
44
  expect(html).not.toContain('<tags>');
45
  });
46
 
47
- it("renders PDF download link when pdfUrl is set", () => {
48
  const meta: PublishMeta = { ...BASIC_META, pdfUrl: "/published/test/article.pdf" };
49
  const json = minimalDoc([{ type: "paragraph" }]);
50
- const html = renderArticleHTML(json, meta, EMPTY_CSS);
51
  expect(html).toContain("Download PDF");
52
  expect(html).toContain("/published/test/article.pdf");
53
  });
54
  });
55
 
56
  describe("postProcess - accordion", () => {
57
- it("transforms accordion div into details/summary", () => {
58
  const json = minimalDoc([{
59
  type: "accordion",
60
  attrs: { title: "My Section", open: false },
61
  content: [{ type: "paragraph", content: [{ type: "text", text: "Inner content" }] }],
62
  }]);
63
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
64
  expect(html).toContain("<details");
65
  expect(html).toContain("<summary>");
66
  expect(html).toContain("Inner content");
@@ -68,7 +68,7 @@ describe("postProcess - accordion", () => {
68
  });
69
 
70
  describe("postProcess - citations", () => {
71
- it("replaces citation spans with anchor links", () => {
72
  const json = minimalDoc([{
73
  type: "paragraph",
74
  content: [
@@ -81,12 +81,12 @@ describe("postProcess - citations", () => {
81
  orderedKeys: ["smith2024"],
82
  style: "apa",
83
  };
84
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
85
  expect(html).toContain('href="#ref-smith2024"');
86
  expect(html).toContain("citation-inline");
87
  });
88
 
89
- it("uses numeric labels for IEEE style", () => {
90
  const json = minimalDoc([{
91
  type: "paragraph",
92
  content: [
@@ -98,13 +98,13 @@ describe("postProcess - citations", () => {
98
  orderedKeys: ["doe2023"],
99
  style: "ieee",
100
  };
101
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
102
  expect(html).toContain("[1]");
103
  });
104
  });
105
 
106
  describe("postProcess - footnotes", () => {
107
- it("collects footnotes and appends a footnotes section", () => {
108
  const json = minimalDoc([{
109
  type: "paragraph",
110
  content: [
@@ -112,14 +112,14 @@ describe("postProcess - footnotes", () => {
112
  { type: "footnote", attrs: { content: "This is a footnote" } },
113
  ],
114
  }]);
115
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
116
  expect(html).toContain('class="footnote-ref"');
117
  expect(html).toContain('id="fn-1"');
118
  expect(html).toContain("This is a footnote");
119
  expect(html).toContain('class="footnotes"');
120
  });
121
 
122
- it("numbers multiple footnotes sequentially", () => {
123
  const json = minimalDoc([{
124
  type: "paragraph",
125
  content: [
@@ -128,7 +128,7 @@ describe("postProcess - footnotes", () => {
128
  { type: "footnote", attrs: { content: "Second note" } },
129
  ],
130
  }]);
131
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
132
  expect(html).toContain('id="fn-1"');
133
  expect(html).toContain('id="fn-2"');
134
  expect(html).toContain("First note");
@@ -137,32 +137,56 @@ describe("postProcess - footnotes", () => {
137
  });
138
 
139
  describe("postProcess - mermaid", () => {
140
- it("transforms mermaid div into pre.mermaid", () => {
141
  const json = minimalDoc([{
142
  type: "mermaid",
143
  attrs: { code: "graph TD\n A --> B" },
144
  }]);
145
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
146
  expect(html).toContain('class="mermaid"');
147
  expect(html).toContain("graph TD");
148
  });
149
  });
150
 
151
  describe("postProcess - htmlEmbed", () => {
152
- it("transforms htmlEmbed div into iframe", () => {
153
  const json = minimalDoc([{
154
  type: "htmlEmbed",
155
  attrs: { src: "d3-chart.html", title: "Chart", desc: "" },
156
  }]);
157
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
158
  expect(html).toContain("html-embed-container");
159
  expect(html).toContain('data-embed-src="d3-chart.html"');
160
  expect(html).toContain("<iframe");
161
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  });
163
 
164
  describe("postProcess - bibliography", () => {
165
- it("injects bibliography HTML into the placeholder", () => {
166
  const json = minimalDoc([
167
  { type: "paragraph", content: [{ type: "citation", attrs: { key: "test2024", label: "[1]" } }] },
168
  { type: "bibliography", attrs: { renderedHtml: "" } },
@@ -173,7 +197,7 @@ describe("postProcess - bibliography", () => {
173
  style: "apa",
174
  };
175
  const biblioHtml = '<div class="csl-entry">Test entry</div>';
176
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
177
  expect(html).toContain("bibliography-content");
178
  expect(html).toContain('id="ref-test2024"');
179
  expect(html).toContain("Test entry");
@@ -181,7 +205,7 @@ describe("postProcess - bibliography", () => {
181
  });
182
 
183
  describe("snapshot - full render", () => {
184
- it("matches snapshot for a typical article", () => {
185
  const json = minimalDoc([
186
  { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Introduction" }] },
187
  { type: "paragraph", content: [
@@ -203,7 +227,7 @@ describe("snapshot - full render", () => {
203
  };
204
  const biblioHtml = '<div class="csl-entry">Reference One. 2024.</div>';
205
 
206
- const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
207
  expect(html).toMatchSnapshot();
208
  });
209
  });
 
27
  }
28
 
29
  describe("renderArticleHTML", () => {
30
+ it("produces a complete HTML document", async () => {
31
  const json = minimalDoc([{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }]);
32
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
33
  expect(html).toContain("<!DOCTYPE html>");
34
  expect(html).toContain("<title>Test Article</title>");
35
  expect(html).toContain("Hello world");
36
  });
37
 
38
+ it("escapes HTML in meta fields", async () => {
39
  const meta: PublishMeta = { ...BASIC_META, title: 'Title with "quotes" & <tags>' };
40
  const json = minimalDoc([{ type: "paragraph" }]);
41
+ const html = await renderArticleHTML(json, meta, EMPTY_CSS);
42
  expect(html).toContain("&amp;");
43
  expect(html).toContain("&lt;tags&gt;");
44
  expect(html).not.toContain('<tags>');
45
  });
46
 
47
+ it("renders PDF download link when pdfUrl is set", async () => {
48
  const meta: PublishMeta = { ...BASIC_META, pdfUrl: "/published/test/article.pdf" };
49
  const json = minimalDoc([{ type: "paragraph" }]);
50
+ const html = await renderArticleHTML(json, meta, EMPTY_CSS);
51
  expect(html).toContain("Download PDF");
52
  expect(html).toContain("/published/test/article.pdf");
53
  });
54
  });
55
 
56
  describe("postProcess - accordion", () => {
57
+ it("transforms accordion div into details/summary", async () => {
58
  const json = minimalDoc([{
59
  type: "accordion",
60
  attrs: { title: "My Section", open: false },
61
  content: [{ type: "paragraph", content: [{ type: "text", text: "Inner content" }] }],
62
  }]);
63
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
64
  expect(html).toContain("<details");
65
  expect(html).toContain("<summary>");
66
  expect(html).toContain("Inner content");
 
68
  });
69
 
70
  describe("postProcess - citations", () => {
71
+ it("replaces citation spans with anchor links", async () => {
72
  const json = minimalDoc([{
73
  type: "paragraph",
74
  content: [
 
81
  orderedKeys: ["smith2024"],
82
  style: "apa",
83
  };
84
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
85
  expect(html).toContain('href="#ref-smith2024"');
86
  expect(html).toContain("citation-inline");
87
  });
88
 
89
+ it("uses numeric labels for IEEE style", async () => {
90
  const json = minimalDoc([{
91
  type: "paragraph",
92
  content: [
 
98
  orderedKeys: ["doe2023"],
99
  style: "ieee",
100
  };
101
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
102
  expect(html).toContain("[1]");
103
  });
104
  });
105
 
106
  describe("postProcess - footnotes", () => {
107
+ it("collects footnotes and appends a footnotes section", async () => {
108
  const json = minimalDoc([{
109
  type: "paragraph",
110
  content: [
 
112
  { type: "footnote", attrs: { content: "This is a footnote" } },
113
  ],
114
  }]);
115
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
116
  expect(html).toContain('class="footnote-ref"');
117
  expect(html).toContain('id="fn-1"');
118
  expect(html).toContain("This is a footnote");
119
  expect(html).toContain('class="footnotes"');
120
  });
121
 
122
+ it("numbers multiple footnotes sequentially", async () => {
123
  const json = minimalDoc([{
124
  type: "paragraph",
125
  content: [
 
128
  { type: "footnote", attrs: { content: "Second note" } },
129
  ],
130
  }]);
131
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
132
  expect(html).toContain('id="fn-1"');
133
  expect(html).toContain('id="fn-2"');
134
  expect(html).toContain("First note");
 
137
  });
138
 
139
  describe("postProcess - mermaid", () => {
140
+ it("transforms mermaid div into pre.mermaid", async () => {
141
  const json = minimalDoc([{
142
  type: "mermaid",
143
  attrs: { code: "graph TD\n A --> B" },
144
  }]);
145
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
146
  expect(html).toContain('class="mermaid"');
147
  expect(html).toContain("graph TD");
148
  });
149
  });
150
 
151
  describe("postProcess - htmlEmbed", () => {
152
+ it("transforms htmlEmbed div into iframe", async () => {
153
  const json = minimalDoc([{
154
  type: "htmlEmbed",
155
  attrs: { src: "d3-chart.html", title: "Chart", desc: "" },
156
  }]);
157
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS);
158
  expect(html).toContain("html-embed-container");
159
  expect(html).toContain('data-embed-src="d3-chart.html"');
160
  expect(html).toContain("<iframe");
161
  });
162
+
163
+ it("injects embed HTML from Y.Map into iframe srcdoc", async () => {
164
+ const json = minimalDoc([{
165
+ type: "htmlEmbed",
166
+ attrs: { src: "my-chart", title: "Chart", desc: "" },
167
+ }]);
168
+ const embeds = { "my-chart": '<div id="chart"><script>console.log("hello")<\/script></div>' };
169
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, undefined, undefined, embeds);
170
+ expect(html).toContain("html-embed-container");
171
+ expect(html).toContain('data-embed-src="my-chart"');
172
+ expect(html).toContain("srcdoc=");
173
+ expect(html).toContain("ColorPalettes");
174
+ expect(html).toContain("chart");
175
+ });
176
+
177
+ it("leaves srcdoc empty when embed key is missing", async () => {
178
+ const json = minimalDoc([{
179
+ type: "htmlEmbed",
180
+ attrs: { src: "missing-chart", title: "Chart", desc: "" },
181
+ }]);
182
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, undefined, undefined, {});
183
+ expect(html).toContain('data-embed-src="missing-chart"');
184
+ expect(html).toContain('srcdoc=""');
185
+ });
186
  });
187
 
188
  describe("postProcess - bibliography", () => {
189
+ it("injects bibliography HTML into the placeholder", async () => {
190
  const json = minimalDoc([
191
  { type: "paragraph", content: [{ type: "citation", attrs: { key: "test2024", label: "[1]" } }] },
192
  { type: "bibliography", attrs: { renderedHtml: "" } },
 
197
  style: "apa",
198
  };
199
  const biblioHtml = '<div class="csl-entry">Test entry</div>';
200
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
201
  expect(html).toContain("bibliography-content");
202
  expect(html).toContain('id="ref-test2024"');
203
  expect(html).toContain("Test entry");
 
205
  });
206
 
207
  describe("snapshot - full render", () => {
208
+ it("matches snapshot for a typical article", async () => {
209
  const json = minimalDoc([
210
  { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Introduction" }] },
211
  { type: "paragraph", content: [
 
227
  };
228
  const biblioHtml = '<div class="csl-entry">Reference One. 2024.</div>';
229
 
230
+ const html = await renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
231
  expect(html).toMatchSnapshot();
232
  });
233
  });
backend/tests/publisher.test.ts CHANGED
@@ -45,7 +45,7 @@ const EMPTY_CSS = {
45
  // ── 1.1 Y.Doc Extraction ──────────────────────────────────────────
46
 
47
  describe("1.1 Y.Doc extraction", () => {
48
- it("1.1.1 extracts frontmatter from Y.Doc", () => {
49
  const ydoc = createTestYDoc(FULL_DOC_OPTIONS);
50
  const metaMap = ydoc.getMap("frontmatter");
51
 
@@ -63,14 +63,14 @@ describe("1.1 Y.Doc extraction", () => {
63
  expect((affiliations[0] as any).name).toBe("University of Testing");
64
  });
65
 
66
- it("1.1.2 empty doc does not throw", () => {
67
  const ydoc = createTestYDoc({ title: "Empty" });
68
  const fragment = ydoc.getXmlFragment("default");
69
  expect(fragment.length).toBe(0);
70
  expect(ydoc.getMap("frontmatter").get("title")).toBe("Empty");
71
  });
72
 
73
- it("1.1.3 extracts citations from Y.Map", () => {
74
  const ydoc = createTestYDoc(FULL_DOC_OPTIONS);
75
  const citationsMap = ydoc.getMap("citations");
76
  const entries: Record<string, unknown> = {};
@@ -86,9 +86,9 @@ describe("1.1 Y.Doc extraction", () => {
86
  // ── 1.2 HTML Generation ───────────────────────────────────────────
87
 
88
  describe("1.2 HTML generation", () => {
89
- it("1.2.1 produces valid self-contained HTML", () => {
90
  const meta = buildMeta(FULL_DOC_OPTIONS);
91
- const html = renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
92
 
93
  expect(html).toContain("<!DOCTYPE html>");
94
  expect(html).toContain("<html");
@@ -96,22 +96,22 @@ describe("1.2 HTML generation", () => {
96
  expect(html).toContain("<style>");
97
  });
98
 
99
- it("1.2.3 generates TOC placeholder", () => {
100
  const meta = buildMeta(FULL_DOC_OPTIONS);
101
- const html = renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
102
  expect(html).toContain('aria-label="Table of Contents"');
103
  expect(html).toContain("toc-placeholder");
104
  });
105
 
106
- it("1.2.4 includes theme toggle", () => {
107
  const meta = buildMeta(FULL_DOC_OPTIONS);
108
- const html = renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
109
  expect(html).toContain("theme-toggle");
110
  expect(html).toContain("icon--sun");
111
  expect(html).toContain("icon--moon");
112
  });
113
 
114
- it("1.2.5 renders bibliography placeholder", () => {
115
  const docWithBiblio = {
116
  type: "doc",
117
  content: [
@@ -120,7 +120,7 @@ describe("1.2 HTML generation", () => {
120
  ],
121
  };
122
  const meta = buildMeta(FULL_DOC_OPTIONS);
123
- const html = renderArticleHTML(docWithBiblio, meta, EMPTY_CSS);
124
  expect(html).toContain("bibliography");
125
  expect(html).toContain("Smith 2024");
126
  });
@@ -134,7 +134,7 @@ describe("1.3 Post-processing", () => {
134
  date: "2025-01-01",
135
  };
136
 
137
- it("1.3.1 accordion rendered with data-component attribute", () => {
138
  const docJson = {
139
  type: "doc",
140
  content: [
@@ -147,7 +147,7 @@ describe("1.3 Post-processing", () => {
147
  },
148
  ],
149
  };
150
- const html = renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
151
  // The accordion is rendered with data-component="accordion" and data-title attribute
152
  // Post-processing converts shallow divs to <details>; nested content may prevent the
153
  // regex from matching. Either way, the accordion data is present in the output.
@@ -155,7 +155,7 @@ describe("1.3 Post-processing", () => {
155
  expect(html).toContain("Hidden content");
156
  });
157
 
158
- it("1.3.2 transforms htmlEmbed to iframe", () => {
159
  const docJson = {
160
  type: "doc",
161
  content: [
@@ -165,13 +165,13 @@ describe("1.3 Post-processing", () => {
165
  },
166
  ],
167
  };
168
- const html = renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
169
  // htmlEmbed is rendered as a div with data-component="htmlEmbed" and data-src
170
  // The post-processor converts it to an iframe
171
  expect(html).toContain("chart.html");
172
  });
173
 
174
- it("1.3.3 transforms mermaid to pre block", () => {
175
  const docJson = {
176
  type: "doc",
177
  content: [
@@ -181,7 +181,7 @@ describe("1.3 Post-processing", () => {
181
  },
182
  ],
183
  };
184
- const html = renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
185
  expect(html).toContain('<pre class="mermaid">');
186
  expect(html).toContain("graph TD");
187
  });
@@ -190,10 +190,10 @@ describe("1.3 Post-processing", () => {
190
  // ── 1.4 Publish idempotency ──────────────────────────────────────
191
 
192
  describe("1.4 Publish idempotency", () => {
193
- it("1.4.1 rendering twice gives same result", () => {
194
  const meta = buildMeta(FULL_DOC_OPTIONS);
195
- const html1 = renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
196
- const html2 = renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
197
  expect(html1).toBe(html2);
198
  });
199
  });
 
45
  // ── 1.1 Y.Doc Extraction ──────────────────────────────────────────
46
 
47
  describe("1.1 Y.Doc extraction", () => {
48
+ it("1.1.1 extracts frontmatter from Y.Doc", async () => {
49
  const ydoc = createTestYDoc(FULL_DOC_OPTIONS);
50
  const metaMap = ydoc.getMap("frontmatter");
51
 
 
63
  expect((affiliations[0] as any).name).toBe("University of Testing");
64
  });
65
 
66
+ it("1.1.2 empty doc does not throw", async () => {
67
  const ydoc = createTestYDoc({ title: "Empty" });
68
  const fragment = ydoc.getXmlFragment("default");
69
  expect(fragment.length).toBe(0);
70
  expect(ydoc.getMap("frontmatter").get("title")).toBe("Empty");
71
  });
72
 
73
+ it("1.1.3 extracts citations from Y.Map", async () => {
74
  const ydoc = createTestYDoc(FULL_DOC_OPTIONS);
75
  const citationsMap = ydoc.getMap("citations");
76
  const entries: Record<string, unknown> = {};
 
86
  // ── 1.2 HTML Generation ───────────────────────────────────────────
87
 
88
  describe("1.2 HTML generation", () => {
89
+ it("1.2.1 produces valid self-contained HTML", async () => {
90
  const meta = buildMeta(FULL_DOC_OPTIONS);
91
+ const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
92
 
93
  expect(html).toContain("<!DOCTYPE html>");
94
  expect(html).toContain("<html");
 
96
  expect(html).toContain("<style>");
97
  });
98
 
99
+ it("1.2.3 generates TOC placeholder", async () => {
100
  const meta = buildMeta(FULL_DOC_OPTIONS);
101
+ const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
102
  expect(html).toContain('aria-label="Table of Contents"');
103
  expect(html).toContain("toc-placeholder");
104
  });
105
 
106
+ it("1.2.4 includes theme toggle", async () => {
107
  const meta = buildMeta(FULL_DOC_OPTIONS);
108
+ const html = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
109
  expect(html).toContain("theme-toggle");
110
  expect(html).toContain("icon--sun");
111
  expect(html).toContain("icon--moon");
112
  });
113
 
114
+ it("1.2.5 renders bibliography placeholder", async () => {
115
  const docWithBiblio = {
116
  type: "doc",
117
  content: [
 
120
  ],
121
  };
122
  const meta = buildMeta(FULL_DOC_OPTIONS);
123
+ const html = await renderArticleHTML(docWithBiblio, meta, EMPTY_CSS);
124
  expect(html).toContain("bibliography");
125
  expect(html).toContain("Smith 2024");
126
  });
 
134
  date: "2025-01-01",
135
  };
136
 
137
+ it("1.3.1 accordion rendered with data-component attribute", async () => {
138
  const docJson = {
139
  type: "doc",
140
  content: [
 
147
  },
148
  ],
149
  };
150
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
151
  // The accordion is rendered with data-component="accordion" and data-title attribute
152
  // Post-processing converts shallow divs to <details>; nested content may prevent the
153
  // regex from matching. Either way, the accordion data is present in the output.
 
155
  expect(html).toContain("Hidden content");
156
  });
157
 
158
+ it("1.3.2 transforms htmlEmbed to iframe", async () => {
159
  const docJson = {
160
  type: "doc",
161
  content: [
 
165
  },
166
  ],
167
  };
168
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
169
  // htmlEmbed is rendered as a div with data-component="htmlEmbed" and data-src
170
  // The post-processor converts it to an iframe
171
  expect(html).toContain("chart.html");
172
  });
173
 
174
+ it("1.3.3 transforms mermaid to pre block", async () => {
175
  const docJson = {
176
  type: "doc",
177
  content: [
 
181
  },
182
  ],
183
  };
184
+ const html = await renderArticleHTML(docJson, simpleMeta, EMPTY_CSS);
185
  expect(html).toContain('<pre class="mermaid">');
186
  expect(html).toContain("graph TD");
187
  });
 
190
  // ── 1.4 Publish idempotency ──────────────────────────────────────
191
 
192
  describe("1.4 Publish idempotency", () => {
193
+ it("1.4.1 rendering twice gives same result", async () => {
194
  const meta = buildMeta(FULL_DOC_OPTIONS);
195
+ const html1 = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
196
+ const html2 = await renderArticleHTML(MINIMAL_DOC_JSON, meta, EMPTY_CSS);
197
  expect(html1).toBe(html2);
198
  });
199
  });
backend/tests/security.test.ts CHANGED
@@ -32,40 +32,40 @@ const MINIMAL_JSON = {
32
  };
33
 
34
  describe("4.4 XSS prevention", () => {
35
- it("4.4.1 licence field is escaped in published HTML", () => {
36
  const meta: PublishMeta = {
37
  ...BASE_META,
38
  licence: '<script>alert("xss")</script>',
39
  };
40
- const html = renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
41
 
42
  expect(html).not.toContain('<script>alert("xss")</script>');
43
  expect(html).toContain("&lt;script&gt;");
44
  });
45
 
46
- it("4.4.2 title is escaped in published HTML", () => {
47
  const meta: PublishMeta = {
48
  ...BASE_META,
49
  title: '<img src=x onerror="alert(1)">',
50
  };
51
- const html = renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
52
 
53
  expect(html).not.toContain('<img src=x onerror="alert(1)">');
54
  expect(html).toContain("&lt;img");
55
  });
56
 
57
- it("4.4.3 description is escaped in meta tags", () => {
58
  const meta: PublishMeta = {
59
  ...BASE_META,
60
  description: '"><script>alert(1)</script>',
61
  };
62
- const html = renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
63
 
64
  expect(html).not.toContain('"><script>');
65
  expect(html).toContain("&quot;&gt;&lt;script&gt;");
66
  });
67
 
68
- it("4.4.4 author names are escaped", () => {
69
  const meta: PublishMeta = {
70
  ...BASE_META,
71
  authors: [
@@ -76,18 +76,18 @@ describe("4.4 XSS prevention", () => {
76
  },
77
  ],
78
  };
79
- const html = renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
80
 
81
  expect(html).not.toContain('<script>alert(1)</script>');
82
  expect(html).toContain("&lt;script&gt;");
83
  });
84
 
85
- it("4.4.5 DOI is escaped in links", () => {
86
  const meta: PublishMeta = {
87
  ...BASE_META,
88
  doi: '"><script>alert(1)</script>',
89
  };
90
- const html = renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
91
 
92
  expect(html).not.toContain('"><script>');
93
  });
 
32
  };
33
 
34
  describe("4.4 XSS prevention", () => {
35
+ it("4.4.1 licence field is escaped in published HTML", async () => {
36
  const meta: PublishMeta = {
37
  ...BASE_META,
38
  licence: '<script>alert("xss")</script>',
39
  };
40
+ const html = await renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
41
 
42
  expect(html).not.toContain('<script>alert("xss")</script>');
43
  expect(html).toContain("&lt;script&gt;");
44
  });
45
 
46
+ it("4.4.2 title is escaped in published HTML", async () => {
47
  const meta: PublishMeta = {
48
  ...BASE_META,
49
  title: '<img src=x onerror="alert(1)">',
50
  };
51
+ const html = await renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
52
 
53
  expect(html).not.toContain('<img src=x onerror="alert(1)">');
54
  expect(html).toContain("&lt;img");
55
  });
56
 
57
+ it("4.4.3 description is escaped in meta tags", async () => {
58
  const meta: PublishMeta = {
59
  ...BASE_META,
60
  description: '"><script>alert(1)</script>',
61
  };
62
+ const html = await renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
63
 
64
  expect(html).not.toContain('"><script>');
65
  expect(html).toContain("&quot;&gt;&lt;script&gt;");
66
  });
67
 
68
+ it("4.4.4 author names are escaped", async () => {
69
  const meta: PublishMeta = {
70
  ...BASE_META,
71
  authors: [
 
76
  },
77
  ],
78
  };
79
+ const html = await renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
80
 
81
  expect(html).not.toContain('<script>alert(1)</script>');
82
  expect(html).toContain("&lt;script&gt;");
83
  });
84
 
85
+ it("4.4.5 DOI is escaped in links", async () => {
86
  const meta: PublishMeta = {
87
  ...BASE_META,
88
  doi: '"><script>alert(1)</script>',
89
  };
90
+ const html = await renderArticleHTML(MINIMAL_JSON, meta, EMPTY_CSS);
91
 
92
  expect(html).not.toContain('"><script>');
93
  });