perf(static): vendor KaTeX and Mermaid, and load them on first use (#5994)

* fix(static): vendor KaTeX and Mermaid instead of loading them from a CDN

index.html pulled katex.min.{js,css} and mermaid.min.js from cdn.jsdelivr.net on
every page load. For self-hosted software that is three problems at once: an
air-gapped or offline install renders no math and no diagrams at all, every
session announces its IP, User-Agent and Referer to a third party, and the "runs
on your own hardware" promise quietly isn't true.

static/lib/ already vendors highlight.js, docx, xlsx, mammoth, html2pdf and
qrcode, so the CDN usage was an inconsistency rather than a policy. Vendoring
also pins Mermaid, which was floating on the `11` tag, to 11.16.1.

Behaviour is unchanged: both libraries still load eagerly from <head>, just from
this machine.

- KaTeX goes in its own directory because its stylesheet resolves fonts with a
  relative url(fonts/...), so the vendored CSS needs no rewrite. Only the .woff2
  variants ship, matching static/fonts/, since a browser that supports woff2
  never requests the .woff/.ttf alternatives the stylesheet also lists.
- The service worker precaches KaTeX and its fonts so offline math is typeset
  rather than falling back to system glyphs, and CACHE_NAME is bumped. Mermaid
  is left to the existing cache-first rule: at 3.5 MB, precaching it would mean
  re-downloading it on every cache bump for a library most sessions never touch.
- Licence texts travel with the bundles in licenses/, following the convention
  the repo already uses for OpenDyslexic and DeepResearch.
- .gitattributes turns the whitespace check off for static/lib/ so `git diff
  --check` passes without stripping bytes from the published npm artifacts,
  which would desync them from upstream.

* perf(markdown): load KaTeX and Mermaid on first use, not on every page load

Both libraries loaded eagerly from <head>, costing every session ~985 KB on the
wire (929 KB of that Mermaid) even though most chats contain neither a formula
nor a diagram. Measured on a cold profile via the Resource Timing API: JS bytes
per page load drop from 3,102,141 to 2,098,634, a saving of 1,003,507 bytes, and
third-party requests per load go from 3 to 0.

markdown.js now fetches each library the first time one is actually needed:

- renderMermaid() checks for an unprocessed mermaid fence before touching the
  network, and re-queries the DOM after the load so a diagram replaced mid-stream
  still renders.
- mdToHtml() is synchronous, so when KaTeX is not in yet it banks the math source
  in an inert placeholder and schedules a flush that loads the library and swaps
  the placeholders in. Once KaTeX is loaded it typesets inline exactly as before,
  so callers that never call a render helper still get their math.

Both loaders memoise the promise rather than the module, so concurrent callers
share one fetch and a double trigger cannot start two loads; a failed load clears
the memo so the next formula retries instead of being poisoned for the session.
The flush is scheduled with setTimeout rather than requestAnimationFrame, which
is throttled to a stop in a background tab and never fires at all in a headless
browser, so math would have sat as plain source text until the tab was focused.

If neither library ever loads, math degrades to readable source text and diagrams
to their fence contents, rather than to nothing.

* fix(markdown): unescape &amp; last so math entities survive intact

The math pass unescaped &amp; before &lt; and &gt;. mdToHtml escapes the source
first, so a literal "&lt;" typed inside a formula arrives here as "&amp;lt;",
turns back into "&lt;" on the ampersand pass, and is then eaten by the very next
one. Typing $a &lt; b$ rendered as "a < b" instead of the literal text.

The code-block pass in the same function already unescapes &amp; last; only the
math paths were the outlier, in all four of the copies this branch consolidated
into pushMath(). Reordering to match makes them consistent and clears the
js/double-escaping alert CodeQL raised on this PR.

Math containing a genuinely typed "<" is unaffected, which is why this went
unnoticed for so long. Covered by a regression test asserting both cases.

* fix(markdown): decode entity-spelled math in one pass

mdToHtml escapes the source before the math pass, so a typed "<" reaches
the delimiters as "&lt;" and a typed "&lt;" reaches them as "&amp;lt;".
KaTeX has no entity syntax and reads the leftover "&" as an alignment
marker, so "$a &lt; b$" rendered as a red .katex-error instead of a
formula, on both the inline and the deferred path.

Chained replaces cannot fix it in either order: unescaping "&amp;" first
lets the next pass eat the "&lt;" it just wrote, and unescaping it last
leaves the entity spelling for KaTeX to choke on. One alternation,
longest form first, decodes every spelling and never rescans its own
output.

The tests now drive the vendored KaTeX build rather than a renderer that
echoes its input, which is why the old assertion looked correct.

* fix(document): typeset deferred math before the PDF export

exportAsPdf() renders the document into a detached container and hands
it straight to html2pdf. On a page where KaTeX has not loaded yet,
mdToHtml() returns pending placeholders and schedules a flush scoped to
document, which never reaches a node that was never attached, so the
PDF printed raw formula source.

Render the container's own math first. renderMath() returns immediately
without fetching anything when there is nothing pending, so a document
with no formulas still exports without pulling KaTeX.
This commit is contained in:
Léo
2026-08-16 22:43:12 +01:00
committed by GitHub
parent 04b8829fb2
commit d0bf771f9d
32 changed files with 4394 additions and 83 deletions
+7
View File
@@ -15,6 +15,13 @@ docker/entrypoint.sh text eol=lf
*.cmd text eol=crlf *.cmd text eol=crlf
*.bat text eol=crlf *.bat text eol=crlf
# Vendored third-party bundles in static/lib/ are published minified artifacts
# and must stay byte-identical to what npm ships — stripping trailing whitespace
# to satisfy `git diff --check` would desync them from the upstream release. Turn
# the whitespace check off for that tree instead, and keep the bundles out of
# GitHub's language statistics.
static/lib/** -whitespace linguist-vendored
# Binary assets — never normalize. # Binary assets — never normalize.
*.png binary *.png binary
*.jpg binary *.jpg binary
+10 -2
View File
@@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly:
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | | [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | | [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | | [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
from `index.html`, so a session that renders no math and no diagram never fetches
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
browser that supports `woff2`. The bundles are the published npm artifacts,
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
so they can stay byte-identical to upstream.
## Front-end libraries loaded at runtime (CDN) ## Front-end libraries loaded at runtime (CDN)
@@ -72,8 +82,6 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
| Library | Purpose | License | | Library | Purpose | License |
|---|---|---| |---|---|---|
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 | | [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT | | [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -17
View File
@@ -231,23 +231,11 @@
} }
} }
</style> </style>
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render, <!-- KaTeX and Mermaid are vendored in /static/lib and pulled in by
then flipped to "all" via JS after load. Mermaid init runs once the static/js/markdown.js the first time a page actually renders math or a
library finishes loading. Both hooks are wired via addEventListener ```mermaid fence. They used to load here from cdn.jsdelivr.net on every
below (inline onload= attrs are blocked by CSP script-src-attr). --> page load, which cost ~985 KB on the wire, broke offline installs, and
<link id="katex-css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" media="print"> announced every session to a third party. -->
<script async src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
<script id="mermaid-script" async src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script nonce="{{CSP_NONCE}}">
(function(){
var k = document.getElementById('katex-css');
if (k) k.addEventListener('load', function(){ k.media = 'all'; }, { once: true });
var m = document.getElementById('mermaid-script');
if (m) m.addEventListener('load', function(){
if (window.odysseusInitMermaid) window.odysseusInitMermaid();
}, { once: true });
})();
</script>
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600, <!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
the app font and the weight the sidebar and header text render at. They the app font and the weight the sidebar and header text render at. They
are declared in style.css, so without a hint they are only discovered are declared in style.css, so without a hint they are only discovered
+5
View File
@@ -9738,6 +9738,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const container = document.createElement('div'); const container = document.createElement('div');
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;'; container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
container.innerHTML = html; container.innerHTML = html;
// This container is detached, so the document-scoped flush mdToHtml
// schedules never sees it. Typeset the deferred math before html2pdf
// rasterises, or the PDF gets raw formula source. renderMath() returns
// immediately, without loading KaTeX, when there is nothing pending.
await markdownModule.renderMath(container);
const baseName = _getExportBaseName(); const baseName = _getExportBaseName();
window.html2pdf().set({ window.html2pdf().set({
margin: 10, margin: 10,
+202 -63
View File
@@ -10,6 +10,127 @@ import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js'
var escapeHtml = uiModule.esc; var escapeHtml = uiModule.esc;
// Mermaid and KaTeX are vendored under /static/lib and fetched on first use.
// Loading them from <head> cost every session ~985 KB on the wire even though
// most chats never contain a diagram or a formula. Both loaders memoise the
// *promise* rather than the resolved library, so concurrent callers share one
// fetch and a double trigger cannot start two loads. A failed load clears the
// memo so the next diagram/formula retries instead of being poisoned forever.
const MERMAID_SRC = '/static/lib/mermaid.min.js';
const KATEX_SRC = '/static/lib/katex/katex.min.js';
const KATEX_CSS = '/static/lib/katex/katex.min.css';
// Marks math emitted before KaTeX finished loading; renderMath() swaps these
// for typeset output. The source stays as readable text inside the span, so a
// load that never completes degrades to plain text rather than to nothing.
const MATH_PENDING_CLASS = 'ody-math-pending';
// KaTeX has no entity syntax: it reads a bare "&" as an alignment marker and
// errors out on anything that is not a valid column break, so "a &lt; b" comes
// back as a red .katex-error instead of a formula. mdToHtml escapes the whole
// string before the math pass, which leaves two spellings of the same
// character at the delimiters — a typed "<" arrives as "&lt;", while a typed
// "&lt;" arrives as "&amp;lt;" — and both have to reach KaTeX as "<".
//
// One alternation, longest form first, so nothing this writes is scanned
// again. Chained .replace() calls cannot do it: unescaping "&amp;" first lets
// the next pass eat the "&lt;" it just produced (the double-unescape CodeQL
// flags), and unescaping it last leaves the entity spelling intact and breaks
// the render. The code-block pass upstream keeps its chained order on purpose
// — Markdown does not decode entities inside code, so "&lt;" there is meant to
// stay visible.
const MATH_SOURCE_ENTITY_RE = /&amp;(?:lt|gt|amp|quot|#39);|&lt;|&gt;|&amp;/g;
const MATH_SOURCE_ENTITIES = {
'&amp;lt;': '<',
'&amp;gt;': '>',
'&amp;amp;': '&',
'&amp;quot;': '"',
'&amp;#39;': "'",
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
};
function decodeMathSource(text) {
return String(text).replace(MATH_SOURCE_ENTITY_RE, (entity) => MATH_SOURCE_ENTITIES[entity]);
}
let _mermaidPromise = null;
let _katexPromise = null;
let _mathFlushScheduled = false;
function _loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener('error', () => reject(new Error('Failed to load ' + src)), { once: true });
document.head.appendChild(script);
});
}
function _loadStylesheet(href) {
// Resolves either way: without the stylesheet KaTeX still produces correct
// markup, just unstyled, which beats failing the whole math render.
return new Promise((resolve) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.addEventListener('load', () => resolve(), { once: true });
link.addEventListener('error', () => resolve(), { once: true });
document.head.appendChild(link);
});
}
/**
* Load Mermaid on first use and initialize it once.
*/
export function ensureMermaid() {
return (_mermaidPromise ??= _loadScript(MERMAID_SRC)
.then(() => {
if (!window.mermaid) throw new Error('mermaid global missing after load');
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
return window.mermaid;
})
.catch((err) => {
_mermaidPromise = null;
throw err;
}));
}
/**
* Load KaTeX (script + stylesheet) on first use.
*/
export function ensureKatex() {
return (_katexPromise ??= Promise.all([_loadScript(KATEX_SRC), _loadStylesheet(KATEX_CSS)])
.then(() => {
if (!window.katex) throw new Error('katex global missing after load');
return window.katex;
})
.catch((err) => {
_katexPromise = null;
throw err;
}));
}
// mdToHtml() is synchronous and its callers insert the returned string into the
// DOM themselves, so the placeholders are usually not attached yet when this
// fires. Loading first and scanning afterwards covers that gap: by the time
// KaTeX is in, the caller's innerHTML assignment has long since happened.
//
// setTimeout, not requestAnimationFrame: this has nothing to do with paint, and
// rAF is throttled to a stop in a background tab (and never fires at all in a
// headless browser), which would leave math untypeset until the tab is focused.
function _scheduleMathFlush() {
if (_mathFlushScheduled) return;
_mathFlushScheduled = true;
setTimeout(() => {
_mathFlushScheduled = false;
ensureKatex()
.then(() => renderMath(document))
.catch((e) => console.warn('KaTeX load error:', e));
}, 0);
}
function safeLinkUrl(rawUrl) { function safeLinkUrl(rawUrl) {
const url = String(rawUrl || '').trim(); const url = String(rawUrl || '').trim();
if (url.startsWith('#')) { if (url.startsWith('#')) {
@@ -631,49 +752,45 @@ export function mdToHtml(src, opts) {
// KaTeX math rendering (after code blocks are extracted, so math in code is safe) // KaTeX math rendering (after code blocks are extracted, so math in code is safe)
const mathBlocks = []; const mathBlocks = [];
if (window.katex) { let sawPendingMath = false;
// Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
// Handle before $$/$ so all common delimiters render. // Typeset straight away when KaTeX is already in, otherwise bank the source in
s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => { // an inert placeholder for renderMath() to swap once the library lands.
try { const pushMath = (math, displayMode) => {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'); const raw = decodeMathSource(math).trim();
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); if (window.katex) {
return placeholder; mathBlocks.push(katex.renderToString(raw, { displayMode, throwOnError: false }));
} catch (e) { return match; } } else {
}); sawPendingMath = true;
// Inline math: \( ... \) — GPT-style inline delimiter. Single-line only mathBlocks.push(`<span class="${MATH_PENDING_CLASS}" data-display="${displayMode}">${escapeHtml(raw)}</span>`);
// ([^\n]) so a stray escaped paren in prose can't swallow across lines. }
s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => { return placeholder;
try { };
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; // Display math: \[ ... \] — GPT-style delimiter (gpt-5.x, Claude, etc.).
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); // Handle before $$/$ so all common delimiters render.
return placeholder; s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
} catch (e) { return match; } try { return pushMath(math, true); } catch (e) { return match; }
}); });
// Display math: $$...$$ // Inline math: \( ... \) — GPT-style inline delimiter. Single-line only
s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => { // ([^\n]) so a stray escaped paren in prose can't swallow across lines.
try { s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'); try { return pushMath(math, false); } catch (e) { return match; }
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`; });
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false })); // Display math: $$...$$
return placeholder; s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
} catch (e) { return match; } try { return pushMath(math, true); } catch (e) { return match; }
}); });
// Inline math: $...$ — single line only, and Pandoc-style delimiter rules so // Inline math: $...$ — single line only, and Pandoc-style delimiter rules so
// currency doesn't render as math ("$5 to $10"): the opening $ must be // currency doesn't render as math ("$5 to $10"): the opening $ must be
// immediately followed by a non-space, the closing $ must be immediately // immediately followed by a non-space, the closing $ must be immediately
// preceded by a non-space and not followed by a digit. // preceded by a non-space and not followed by a digit.
s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => { s = s.replace(/(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)/g, (match, math) => {
try { try { return pushMath(math, false); } catch (e) { return match; }
const raw = math.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'); });
const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false })); if (sawPendingMath) _scheduleMathFlush();
return placeholder;
} catch (e) { return match; }
});
}
// Handle pipe tables // Handle pipe tables
s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => { s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
@@ -826,19 +943,47 @@ export function renderContent(content) {
} }
/** /**
* Initialize any unprocessed Mermaid diagrams in a container (or whole document) * Initialize any unprocessed Mermaid diagrams in a container (or whole document).
* Returns a promise so callers can await the (lazy) library load if they need to.
*/ */
export function renderMermaid(container) { export function renderMermaid(container) {
if (!window.mermaid) return;
initMermaid();
const target = container || document; const target = container || document;
const pending = target.querySelectorAll('pre.mermaid:not([data-processed])'); if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
if (pending.length === 0) return; // Cheap pre-check: no fence on the page means Mermaid is never fetched.
try { if (target.querySelectorAll('pre.mermaid:not([data-processed])').length === 0) return Promise.resolve();
window.mermaid.run({ nodes: pending }); return ensureMermaid()
} catch (e) { .then((mermaid) => {
console.warn('Mermaid render error:', e); // Re-query after the load: during streaming the renderer replaces the
} // message body repeatedly, so the nodes seen before the fetch are stale.
const nodes = [...target.querySelectorAll('pre.mermaid:not([data-processed])')]
.filter((node) => node.isConnected);
if (nodes.length === 0) return;
return mermaid.run({ nodes });
})
.catch((e) => { console.warn('Mermaid render error:', e); });
}
/**
* Typeset any math that mdToHtml() had to defer because KaTeX was not loaded
* yet. Once KaTeX is in, mdToHtml() renders inline and this finds nothing.
*/
export function renderMath(container) {
const target = container || document;
if (!target || typeof target.querySelectorAll !== 'function') return Promise.resolve();
if (target.querySelectorAll('.' + MATH_PENDING_CLASS).length === 0) return Promise.resolve();
return ensureKatex()
.then((katex) => {
target.querySelectorAll('.' + MATH_PENDING_CLASS).forEach((el) => {
const displayMode = el.getAttribute('data-display') === 'true';
try {
el.outerHTML = katex.renderToString(el.textContent || '', { displayMode, throwOnError: false });
} catch (e) {
// Leave the source visible — readable, just not typeset.
el.classList.remove(MATH_PENDING_CLASS);
}
});
})
.catch((e) => { console.warn('KaTeX render error:', e); });
} }
const markdownModule = { const markdownModule = {
@@ -853,20 +998,14 @@ const markdownModule = {
extractThinkingBlocks, extractThinkingBlocks,
normalizeThinkingMarkup, normalizeThinkingMarkup,
startsWithReasoningPrefix, startsWithReasoningPrefix,
renderMermaid renderMermaid,
renderMath,
ensureMermaid,
ensureKatex
}; };
export default markdownModule; export default markdownModule;
// Mermaid is loaded async so it cannot delay the app shell.
function initMermaid() {
if (!window.mermaid || window.__odysseusMermaidReady) return;
window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
window.__odysseusMermaidReady = true;
}
window.odysseusInitMermaid = initMermaid;
initMermaid();
// Persist which thinking sections were expanded across page refreshes. // Persist which thinking sections were expanded across page refreshes.
// IDs are render-generated (Date.now-based) so we key by a stable hash of // IDs are render-generated (Date.now-based) so we key by a stable hash of
// the inner text content instead — same content reproduces the same hash on // the inner text content instead — same content reproduces the same hash on
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+3587
View File
File diff suppressed because one or more lines are too long
+24 -1
View File
@@ -7,7 +7,21 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v378-shared-config-image-editor'; const CACHE_NAME = 'odysseus-v380-shared-config-image-editor-lazy-katex-mermaid';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
const KATEX_FONTS = [
'AMS-Regular', 'Caligraphic-Bold', 'Caligraphic-Regular',
'Fraktur-Bold', 'Fraktur-Regular',
'Main-Bold', 'Main-BoldItalic', 'Main-Italic', 'Main-Regular',
'Math-BoldItalic', 'Math-Italic',
'SansSerif-Bold', 'SansSerif-Italic', 'SansSerif-Regular',
'Script-Regular',
'Size1-Regular', 'Size2-Regular', 'Size3-Regular', 'Size4-Regular',
'Typewriter-Regular',
].map(name => `/static/lib/katex/fonts/KaTeX_${name}.woff2`);
// Two lists, two jobs — they are no longer the same set and must not be // Two lists, two jobs — they are no longer the same set and must not be
// "resynced" back into one: // "resynced" back into one:
@@ -73,6 +87,15 @@ const PRECACHE = [
'/static/js/sidebar-layout.js', '/static/js/sidebar-layout.js',
'/static/js/section-management.js', '/static/js/section-management.js',
'/static/lib/highlight.min.js', '/static/lib/highlight.min.js',
// Math turns up in ordinary answers and KaTeX is small, so precaching it and
// its fonts keeps formulas typeset offline. Mermaid is deliberately NOT
// precached: at 3.5 MB it would re-download on every CACHE_NAME bump, a poor
// trade for a library most sessions never touch. The cache-first rule below
// picks it up the first time a diagram renders, which is also when it starts
// mattering offline.
'/static/lib/katex/katex.min.js',
'/static/lib/katex/katex.min.css',
...KATEX_FONTS,
]; ];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design; // Lazily-imported panel modules (js/panels.js). Not in index.html by design;
+510
View File
@@ -0,0 +1,510 @@
"""KaTeX and Mermaid must be vendored and fetched only on first real use.
They used to load from cdn.jsdelivr.net in every <head>, costing ~985 KB on the
wire per page load, breaking offline installs and announcing each session to a
third party. These tests pin the replacement contract: one fetch per library,
never before a formula or a ```mermaid fence actually shows up, and math still
renders once the library lands.
"""
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HAS_NODE = shutil.which("node") is not None
MERMAID_SRC = "/static/lib/mermaid.min.js"
KATEX_SRC = "/static/lib/katex/katex.min.js"
KATEX_CSS = "/static/lib/katex/katex.min.css"
@pytest.fixture(scope="module")
def node_available():
if not _HAS_NODE:
pytest.skip("node binary not on PATH")
def _katex_fonts_block(sw_source: str) -> str:
"""The literal body of sw.js's KATEX_FONTS array."""
match = re.search(r"const KATEX_FONTS = \[(.*?)\]", sw_source, re.S)
assert match, "sw.js no longer defines a KATEX_FONTS array"
return match.group(1)
# A DOM stub small enough to reason about: it records every <script>/<link> the
# module injects and lets the test decide when each one "loads", which is the
# only way to observe that a second call reuses the first fetch.
_HARNESS = r"""
import fs from 'node:fs';
import vm from 'node:vm';
// The vendored KaTeX build itself, not a stand-in. A fake renderer that echoes
// its input cannot tell "a < b" from "a &lt; b" — real KaTeX reads the "&" as
// an alignment marker and returns a .katex-error span, which is the whole
// point of the entity tests below. renderToString needs no DOM, so a bare vm
// context is enough and keeps the library off the harness globals until a test
// installs it deliberately.
function loadRealKatex() {
const context = { console };
context.window = context;
context.self = context;
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync('./static/lib/katex/katex.min.js', 'utf8'), context);
if (!context.katex) throw new Error('vendored katex.min.js did not define a katex global');
return context.katex;
}
const injected = { scripts: [], links: [] };
function makeEl(tag) {
return {
tagName: String(tag).toUpperCase(),
_listeners: {},
classList: { remove() {} },
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); },
fire(type) { (this._listeners[type] || []).forEach((fn) => fn()); },
};
}
function makeTemplate() {
return {
_html: '',
content: { querySelectorAll() { return []; } },
set innerHTML(value) { this._html = value; },
get innerHTML() { return this._html; },
};
}
globalThis.window = { location: { origin: 'http://localhost' }, katex: null, mermaid: null };
globalThis.document = {
readyState: 'complete',
addEventListener() {},
head: {
appendChild(el) {
if (el.tagName === 'SCRIPT') injected.scripts.push(el);
else if (el.tagName === 'LINK') injected.links.push(el);
return el;
},
},
createElement(tag) {
if (tag === 'template') return makeTemplate();
return makeEl(tag);
},
querySelectorAll() { return []; },
};
globalThis.MutationObserver = class { observe() {} };
let source = fs.readFileSync('./static/js/markdown.js', 'utf8');
source = source.replace(/import uiModule from ['"]\.\/ui\.js['"];/, '');
source = source.replace(
/import \{ splitTableRow \} from ['"]\.\/markdown\/tableRow\.js['"];/,
`function splitTableRow(row) {
return (row || '').replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
}`
);
const emojiSource = fs.readFileSync('./static/js/emojiShortcodes.js', 'utf8')
.replace(/^export default .*$/m, '')
.replace(/export const /g, 'const ')
.replace(/export function /g, 'function ');
source = source.replace(
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from ['"]\.\/emojiShortcodes\.js['"];/,
() => emojiSource
);
source = source.replace(
/var escapeHtml = uiModule\.esc;/,
`var escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');`
);
const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64');
const mod = await import(moduleUrl);
// A container whose querySelectorAll answers from a fixed element list, so a
// test can hand the renderer exactly the nodes it wants it to see.
function makeContainer(elements) {
return {
querySelectorAll(selector) {
return (elements[selector] || []).slice();
},
};
}
const emit = (value) => console.log(JSON.stringify(value));
"""
def _run_node(body: str, timeout: int = 20):
script = _HARNESS + textwrap.dedent(body)
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=_REPO,
capture_output=True,
timeout=timeout,
text=True,
)
if result.returncode != 0:
raise AssertionError(f"node failed:\nSTDERR:\n{result.stderr}\nSTDOUT:\n{result.stdout}")
return json.loads(result.stdout.splitlines()[-1])
def test_ensure_mermaid_shares_one_load_between_concurrent_callers(node_available):
"""Two callers before the library lands must not trigger two fetches."""
out = _run_node(
"""
const p1 = mod.ensureMermaid();
const p2 = mod.ensureMermaid();
const samePromise = p1 === p2;
const srcs = injected.scripts.map((s) => s.src);
let initializeCalls = 0;
globalThis.window.mermaid = {
initialize() { initializeCalls++; },
run() {},
};
injected.scripts[0].fire('load');
const [a, b] = await Promise.all([p1, p2]);
emit({
samePromise,
scriptCount: injected.scripts.length,
srcs,
sameLibrary: a === b && a === globalThis.window.mermaid,
initializeCalls,
});
"""
)
assert out["samePromise"] is True
assert out["scriptCount"] == 1
assert out["srcs"] == [MERMAID_SRC]
assert out["sameLibrary"] is True
assert out["initializeCalls"] == 1
def test_ensure_mermaid_retries_after_a_failed_load(node_available):
"""A blocked first fetch must not poison every later diagram."""
out = _run_node(
"""
const first = mod.ensureMermaid();
injected.scripts[0].fire('error');
let firstError = null;
try { await first; } catch (e) { firstError = e.message; }
const second = mod.ensureMermaid();
const retried = injected.scripts.length === 2;
globalThis.window.mermaid = { initialize() {}, run() {} };
injected.scripts[1].fire('load');
await second;
emit({ firstError, retried, differentPromise: first !== second });
"""
)
assert MERMAID_SRC in out["firstError"]
assert out["retried"] is True
assert out["differentPromise"] is True
def test_render_mermaid_does_not_fetch_when_no_diagram_is_present(node_available):
"""The whole point of the lazy load: no fence, no 3.5 MB download."""
out = _run_node(
"""
const container = makeContainer({});
await mod.renderMermaid(container);
emit({ scriptCount: injected.scripts.length });
"""
)
assert out["scriptCount"] == 0
def test_render_mermaid_fetches_once_a_diagram_is_present(node_available):
out = _run_node(
"""
const node = makeEl('pre');
node.isConnected = true;
const container = makeContainer({ 'pre.mermaid:not([data-processed])': [node] });
const pending = mod.renderMermaid(container);
const srcs = injected.scripts.map((s) => s.src);
let ranWith = null;
globalThis.window.mermaid = {
initialize() {},
run(opts) { ranWith = opts.nodes.length; },
};
injected.scripts[0].fire('load');
await pending;
emit({ srcs, ranWith });
"""
)
assert out["srcs"] == [MERMAID_SRC]
assert out["ranWith"] == 1
def test_ensure_katex_loads_script_and_stylesheet_once(node_available):
out = _run_node(
"""
const p1 = mod.ensureKatex();
const p2 = mod.ensureKatex();
const samePromise = p1 === p2;
const scriptSrcs = injected.scripts.map((s) => s.src);
const linkHrefs = injected.links.map((l) => l.href);
globalThis.window.katex = { renderToString: (src) => src };
injected.scripts[0].fire('load');
injected.links[0].fire('load');
await Promise.all([p1, p2]);
emit({ samePromise, scriptSrcs, linkHrefs });
"""
)
assert out["samePromise"] is True
assert out["scriptSrcs"] == [KATEX_SRC]
assert out["linkHrefs"] == [KATEX_CSS]
def test_md_to_html_defers_math_when_katex_is_not_loaded_yet(node_available):
"""Without KaTeX the source is banked verbatim, not dropped or mangled."""
out = _run_node(
"""
const html = mod.mdToHtml('Inline $x^2 + y_1$ and\\n\\n$$\\\\frac{a}{b}$$\\n');
emit({ html, scriptCount: injected.scripts.length });
"""
)
html = out["html"]
assert 'class="ody-math-pending" data-display="false"' in html
assert 'class="ody-math-pending" data-display="true"' in html
# The raw source survives the escaping passes — `y_1` must not become <em>.
assert "x^2 + y_1" in html
assert "<em>" not in html
# Nothing is fetched during the synchronous render itself.
assert out["scriptCount"] == 0
def test_deferred_math_schedules_a_katex_load(node_available):
"""Deferring is only safe if the follow-up actually fires.
An earlier version scheduled this on requestAnimationFrame, which never runs
in a headless browser and is throttled to a stop in a background tab — math
then sat as plain source text until the tab was focused.
"""
out = _run_node(
"""
mod.mdToHtml('Inline $x^2$ here.');
const duringRender = injected.scripts.length;
await new Promise((r) => setTimeout(r, 0));
emit({ duringRender, scriptSrcs: injected.scripts.map((s) => s.src) });
"""
)
assert out["duringRender"] == 0, "the synchronous render must not block on a fetch"
assert out["scriptSrcs"] == [KATEX_SRC]
def test_entity_math_reaches_katex_as_characters_not_entities(node_available):
""""$a &lt; b$" and "$a < b$" must typeset the same, with no parse error.
mdToHtml escapes the source before the math pass, so a typed "<" arrives at
the delimiters as "&lt;" and a typed "&lt;" arrives as "&amp;lt;". KaTeX
has no entity syntax and treats the "&" as an alignment marker, so anything
still spelled as an entity comes back as a red .katex-error instead of a
formula. Both spellings have to be decoded to the character itself, in one
pass — decoding "&amp;" first and "&lt;" after would let the second pass eat
what the first produced, which is the double-unescape CodeQL flags.
"""
out = _run_node(
"""
const katex = loadRealKatex();
globalThis.window.katex = katex;
globalThis.katex = katex;
emit({
entity: mod.mdToHtml('Math: $a &lt; b$ done.'),
typed: mod.mdToHtml('Math: $a < b$ done.'),
ampersandEntity: mod.mdToHtml('Math: $x &gt; y$ done.'),
});
"""
)
assert "katex-error" not in out["entity"]
assert "katex-error" not in out["typed"]
assert "katex-error" not in out["ampersandEntity"]
# Same formula, same markup, whichever way the author spelled the operator.
assert out["entity"] == out["typed"]
assert 'class="katex"' in out["entity"]
def test_deferred_entity_math_banks_the_decoded_source(node_available):
"""The placeholder has to hold the same source the inline path would use.
renderMath() feeds the span's textContent straight to KaTeX, so an entity
left in the bank is a .katex-error that only appears on a cold page — the
exact case the lazy load made common.
"""
out = _run_node(
"""
emit({
entity: mod.mdToHtml('Math: $a &lt; b$ done.'),
typed: mod.mdToHtml('Math: $a < b$ done.'),
});
"""
)
assert 'class="ody-math-pending"' in out["entity"]
assert out["entity"] == out["typed"]
# Escaped once for transport, so the span's textContent is "a < b".
assert "a &lt; b</span>" in out["entity"]
def test_detached_container_math_typesets_with_the_real_renderer(node_available):
"""The PDF export renders into a container it never attaches to the page.
mdToHtml defers math to a document-scoped flush, which cannot reach a
detached node, so the export has to typeset its own container before
handing it to html2pdf. This is that container: pending spans in, real
KaTeX markup out, no .katex-error and nothing left pending.
"""
out = _run_node(
"""
const katex = loadRealKatex();
const html = mod.mdToHtml('Formula $E = mc^2$ here.');
const el = makeEl('span');
el.textContent = 'E = mc^2';
el.getAttribute = (name) => (name === 'data-display' ? 'false' : null);
let written = null;
Object.defineProperty(el, 'outerHTML', { set(v) { written = v; } });
const container = makeContainer({ '.ody-math-pending': [el] });
const pending = mod.renderMath(container);
globalThis.window.katex = katex;
injected.scripts[0].fire('load');
injected.links[0].fire('load');
await pending;
emit({ html, written });
"""
)
# Cold page: mdToHtml could not typeset, so the export HTML starts pending.
assert 'class="ody-math-pending"' in out["html"]
# After the export's own render pass it is real KaTeX markup.
assert 'class="katex"' in out["written"]
assert "katex-error" not in out["written"]
assert "ody-math-pending" not in out["written"]
def test_pdf_export_typesets_its_container_before_html2pdf():
"""Ordering in a call site, so pin the call site. No node needed."""
source = (_REPO / "static/js/document.js").read_text(encoding="utf-8")
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
assert match, "exportAsPdf not found"
body = match.group(1)
render = "await markdownModule.renderMath(container);"
assert render in body, "the export never typesets its detached container"
assert body.index("container.innerHTML = html;") < body.index(render)
assert body.index(render) < body.index("window.html2pdf()")
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
"""After the first load mdToHtml goes back to typesetting synchronously."""
out = _run_node(
"""
globalThis.window.katex = {
renderToString: (src, opts) => `<span class="katex" data-display="${!!(opts && opts.displayMode)}">${src}</span>`,
};
globalThis.katex = globalThis.window.katex;
const html = mod.mdToHtml('Inline $x^2$ here.');
emit({ html });
"""
)
assert '<span class="katex" data-display="false">x^2</span>' in out["html"]
assert "ody-math-pending" not in out["html"]
def test_render_math_typesets_deferred_placeholders(node_available):
out = _run_node(
"""
const el = makeEl('span');
el.textContent = 'x^2';
el.getAttribute = (name) => (name === 'data-display' ? 'false' : null);
let written = null;
Object.defineProperty(el, 'outerHTML', { set(v) { written = v; } });
const container = makeContainer({ '.ody-math-pending': [el] });
const pending = mod.renderMath(container);
const scriptSrcs = injected.scripts.map((s) => s.src);
globalThis.window.katex = {
renderToString: (src, opts) => `<span class="katex" data-display="${!!(opts && opts.displayMode)}">${src}</span>`,
};
injected.scripts[0].fire('load');
injected.links[0].fire('load');
await pending;
emit({ scriptSrcs, written });
"""
)
assert out["scriptSrcs"] == [KATEX_SRC]
assert out["written"] == '<span class="katex" data-display="false">x^2</span>'
def test_render_math_does_not_fetch_without_placeholders(node_available):
out = _run_node(
"""
await mod.renderMath(makeContainer({}));
emit({ scriptCount: injected.scripts.length, linkCount: injected.links.length });
"""
)
assert out["scriptCount"] == 0
assert out["linkCount"] == 0
def test_vendored_assets_exist_and_index_html_has_no_cdn_reference():
"""Guards the offline/privacy half: no node needed, so it always runs."""
for rel in (
"static/lib/mermaid.min.js",
"static/lib/katex/katex.min.js",
"static/lib/katex/katex.min.css",
):
path = _REPO / rel
assert path.is_file(), f"{rel} is not vendored"
assert path.stat().st_size > 1024, f"{rel} looks truncated"
# KaTeX's stylesheet resolves fonts relative to itself; a missing font
# degrades silently to fallback glyphs, so resolve every woff2 the vendored
# CSS actually asks for. (.woff/.ttf are listed too but never requested by a
# browser that supports woff2, which is what static/fonts/ already assumes.)
css_dir = _REPO / "static/lib/katex"
css = (css_dir / "katex.min.css").read_text(encoding="utf-8")
wanted = sorted(set(re.findall(r"url\((fonts/KaTeX_[\w-]+\.woff2)\)", css)))
assert len(wanted) == 20, f"expected 20 woff2 references in the CSS, found {len(wanted)}"
missing = [ref for ref in wanted if not (css_dir / ref).is_file()]
assert missing == [], f"KaTeX stylesheet references fonts that are not vendored: {missing}"
# Everything the CSS needs must also survive an offline install: the font
# names have to be in KATEX_FONTS and that array has to reach PRECACHE.
sw = (_REPO / "static/sw.js").read_text(encoding="utf-8")
assert "...KATEX_FONTS," in sw, "KATEX_FONTS is defined but never spread into PRECACHE"
precached = {
f"fonts/KaTeX_{name}.woff2"
for name in re.findall(r"'([\w-]+)',", _katex_fonts_block(sw))
}
assert precached >= set(wanted), f"not precached: {sorted(set(wanted) - precached)}"
# The shell must fetch no resource from a third party. Scoped to the tags
# that actually load something — an <a href> to an external page is fine,
# and the comment explaining the move can keep naming the CDN it left.
index = (_REPO / "static/index.html").read_text(encoding="utf-8")
remote_loads = re.findall(r"<(?:script|link)\b[^>]*\b(?:src|href)=\"https?://[^\"]+", index)
assert remote_loads == [], f"index.html loads remote resources: {remote_loads}"
sw = (_REPO / "static/sw.js").read_text(encoding="utf-8")
assert KATEX_SRC in sw
assert KATEX_CSS in sw