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 +11 -7
- backend/src/publisher/html-renderer.ts +270 -198
- backend/src/publisher/index.ts +184 -88
- backend/src/publisher/pdf-generator.ts +389 -12
- backend/src/publisher/transformers/accordion.ts +30 -0
- backend/src/publisher/transformers/bibliography.ts +41 -0
- backend/src/publisher/transformers/citation.ts +81 -0
- backend/src/publisher/transformers/footnote.ts +32 -0
- backend/src/publisher/transformers/highlight-code.ts +166 -0
- backend/src/publisher/transformers/html-embed.ts +83 -0
- backend/src/publisher/transformers/index.ts +40 -0
- backend/src/publisher/transformers/mermaid.ts +18 -0
- backend/src/publisher/transformers/types.ts +38 -0
- backend/src/shared/component-defs.ts +2 -1
- backend/src/shared/embed-doc.ts +255 -0
- backend/src/shared/shiki-config.ts +126 -0
- backend/src/shared/theme.ts +17 -0
- backend/tests/__snapshots__/html-renderer-snapshot.test.ts.snap +182 -46
- backend/tests/css-resolution.test.ts +1 -17
- backend/tests/html-renderer-snapshot.test.ts +48 -24
- backend/tests/publisher.test.ts +20 -20
- backend/tests/security.test.ts +10 -10
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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
];
|
| 103 |
},
|
| 104 |
});
|
|
@@ -233,7 +237,7 @@ export function getServerExtensions(): Extensions {
|
|
| 233 |
undoRedo: false,
|
| 234 |
link: { openOnClick: false },
|
| 235 |
} as any),
|
| 236 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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-
|
| 95 |
crossorigin="anonymous">
|
| 96 |
|
| 97 |
-
<!--
|
| 98 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 >
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
-
function
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
lastScrollTime = now;
|
| 400 |
requestAnimationFrame(function() {
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 414 |
-
}
|
| 415 |
-
if (activeIdx >= 0) prevActiveIdx = activeIdx;
|
| 416 |
});
|
| 417 |
}
|
| 418 |
-
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
if (Array.isArray(node.content)) {
|
| 541 |
const found = walk(node.content);
|
| 542 |
if (found) return found;
|
| 543 |
}
|
| 544 |
-
|
| 545 |
-
|
| 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
|
| 554 |
-
*
|
| 555 |
-
*
|
| 556 |
-
*
|
| 557 |
-
*
|
| 558 |
-
* - Transform htmlEmbed divs into iframes
|
| 559 |
-
* - Collect footnotes and append a footnotes section
|
| 560 |
*/
|
| 561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
|
| 563 |
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
details.setAttribute("data-component", "accordion");
|
| 571 |
-
if (isOpen) details.setAttribute("open", "");
|
| 572 |
|
| 573 |
-
|
| 574 |
-
|
| 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, "&")
|
|
@@ -706,7 +769,13 @@ function escapeHtml(str: string): string {
|
|
| 706 |
}
|
| 707 |
|
| 708 |
function renderMetaBar(meta: PublishMeta): string {
|
| 709 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 719 |
-
|
| 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 |
-
//
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, "&")
|
|
|
|
| 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 {
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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, "
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 298 |
const ydoc = new Y.Doc();
|
| 299 |
const data = readFileSync(p);
|
| 300 |
Y.applyUpdate(ydoc, new Uint8Array(data));
|
| 301 |
|
| 302 |
-
|
| 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 |
-
|
| 330 |
-
|
| 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 |
-
//
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
-
//
|
| 354 |
let pdf: Buffer | null = null;
|
| 355 |
let thumbnail: Buffer | null = null;
|
| 356 |
|
| 357 |
-
if (wantPdf &&
|
|
|
|
|
|
|
| 358 |
try {
|
| 359 |
-
|
|
|
|
| 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 |
-
//
|
| 372 |
-
|
| 373 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
if (useHf) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
try {
|
| 394 |
const urls = await uploadPublishedAssets(docName, {
|
| 395 |
-
html,
|
| 396 |
pdf,
|
| 397 |
thumbnail,
|
| 398 |
-
meta:
|
| 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 |
-
*
|
| 5 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
const pdf = await page.pdf({
|
| 62 |
-
format
|
| 63 |
printBackground: true,
|
| 64 |
-
|
|
|
|
|
|
|
| 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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function escapeAttr(s: string): string {
|
| 70 |
+
return s.replace(/[&"]/g, (c) => (c === "&" ? "&" : """));
|
| 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-
|
| 30 |
crossorigin="anonymous">
|
| 31 |
|
| 32 |
-
<!--
|
| 33 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 >
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
-
function
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
lastScrollTime = now;
|
| 350 |
requestAnimationFrame(function() {
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 364 |
-
}
|
| 365 |
-
if (activeIdx >= 0) prevActiveIdx = activeIdx;
|
| 366 |
});
|
| 367 |
}
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("&");
|
| 43 |
expect(html).toContain("<tags>");
|
| 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("&");
|
| 43 |
expect(html).toContain("<tags>");
|
| 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("<script>");
|
| 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("<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(""><script>");
|
| 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("<script>");
|
| 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("<script>");
|
| 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("<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(""><script>");
|
| 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("<script>");
|
| 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 |
});
|