feat: KaTeX math rendering for LaTeX in chat + workspace previews (#352)
* feat: KaTeX math rendering for $..$ and $$..$$ in chat and previews (fixes #347) - Stash math delimiters before markdown pipeline, restore as .katex-block/.katex-inline elements - KaTeX JS lazy-loaded from CDN on first math block (mirrors mermaid pattern) - KaTeX CSS loaded eagerly in <head> to prevent layout shift - SRI hashes on both CDN tags - throwOnError:false — bad LaTeX degrades to code span - Supports $$, $, \\(...\\), \\[...\\] delimiters - 18 new tests, 831/831 passing * fix: remove invalid \' escape sequences in math stash lines Lines 311, 314, 316, 317 had \' (backslash-quote) instead of plain ' in the arrow function bodies. This is a JS syntax error — node --check fails with 'Invalid or unexpected token'. Likely caused by a serialization artifact during code generation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: swap stash order (fence before math) to protect code spans; add renderKatexBlocks to workspace preview - static/ui.js: fence_stash now runs BEFORE math_stash so dollar signs inside backtick code spans are not extracted as math. Previously `$x$` would render as KaTeX inside a <code> tag instead of showing the literal string $x$. - static/workspace.js: add requestAnimationFrame(renderKatexBlocks) after markdown preview renders so math works in workspace file previews, not only in chat messages. * feat: KaTeX math rendering + stash order fix + workspace wiring (#352) - tests/test_issue347.py: 11 new tests (29 total) covering fence-before-math ordering, workspace.js renderKatexBlocks call, stash token distinctness, false-positive prevention, safe-tags boundary check - CHANGELOG.md: v0.50.15 entry; 870 tests total (up from 841) * fix: use literal null byte (\x00M) in math stash token — matches restore regex The original PR's second commit (fix: remove invalid \' escapes) accidentally doubled the backslash in the math stash tokens: '\\x00M' is a 5-char string (backslash + x + 0 + 0 + M) but the restore regex /\x00M/ expects a null byte. Result: $...$ in messages produced visible \x00M0\x00 tokens instead of KaTeX spans. Changed all 4 math stash return statements to use '\x00M' (single backslash = null byte, same convention as fence_stash's '\x00F'). Also updates test_stash_tokens_distinct to check for the correct pattern. * fix: add null-byte token test; update CHANGELOG to v0.50.15 with fixes - tests/test_issue347.py: add test_math_stash_token_uses_single_backslash_null_byte to catch the \\x00M double-backslash regression; 30 tests total (up from 29) - CHANGELOG.md: v0.50.15 entry documents all fixes including the token bug and workspace preview wiring; 871 tests total --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<title>Hermes</title>
|
||||
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||
|
||||
@@ -375,6 +375,12 @@
|
||||
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
|
||||
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
|
||||
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
|
||||
/* KaTeX math rendering */
|
||||
.katex-block{display:block;text-align:center;margin:12px 0;overflow-x:auto;}
|
||||
.katex-inline{display:inline;}
|
||||
.katex-block .katex-html{text-align:center;}
|
||||
.msg-body .katex{font-size:1.1em;}
|
||||
.msg-body .katex-display{margin:8px 0;}
|
||||
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
|
||||
.msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);}
|
||||
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
|
||||
|
||||
67
static/ui.js
67
static/ui.js
@@ -304,8 +304,21 @@ function renderMd(raw){
|
||||
// Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore).
|
||||
// Unsafe tags (anything not in the allowlist) are left as-is and will be
|
||||
// HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk.
|
||||
// Fence stash: protect code blocks and backtick spans from all further processing
|
||||
// Must run BEFORE math_stash so $..$ inside code spans is not extracted as math
|
||||
const fence_stash=[];
|
||||
s=s.replace(/(```[\s\S]*?```|`[^`\n]+`)/g,m=>{fence_stash.push(m);return '\x00F'+(fence_stash.length-1)+'\x00';});
|
||||
// Math stash: protect $$..$$ and $..$ from markdown processing
|
||||
// Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
|
||||
const math_stash=[];
|
||||
// Display math: $$...$$ (must come before inline to avoid mis-parsing)
|
||||
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Inline math: $...$ — require non-space at boundaries to avoid false positives
|
||||
// e.g. "costs $5 and $10" should not trigger (space after opening $)
|
||||
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Also stash \(...\) and \[...\] LaTeX delimiters
|
||||
s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
|
||||
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
|
||||
@@ -382,7 +395,7 @@ function renderMd(raw){
|
||||
// Our pipeline only emits: <strong>,<em>,<code>,<pre>,<h1-6>,<ul>,<ol>,<li>,
|
||||
// <table>,<thead>,<tbody>,<tr>,<th>,<td>,<hr>,<blockquote>,<p>,<br>,<a>,
|
||||
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
|
||||
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div)([\s>]|$)/i;
|
||||
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span)([\s>]|$)/i;
|
||||
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
|
||||
// Autolink: convert plain URLs to clickable links (not inside existing <a> tags, not in code)
|
||||
s=s.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{
|
||||
@@ -391,6 +404,15 @@ function renderMd(raw){
|
||||
const clean=trail?url.slice(0,-1):url;
|
||||
return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;
|
||||
});
|
||||
// Restore math stash → katex placeholder spans/divs
|
||||
// These will be rendered by renderKatexBlocks() after DOM insertion
|
||||
s=s.replace(/\x00M(\d+)\x00/g,(_,i)=>{
|
||||
const item=math_stash[+i];
|
||||
if(item.type==='display'){
|
||||
return `<div class="katex-block" data-katex="display">${esc(item.src)}</div>`;
|
||||
}
|
||||
return `<span class="katex-inline" data-katex="inline">${esc(item.src)}</span>`;
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
return s;
|
||||
@@ -963,7 +985,7 @@ function renderMessages(){
|
||||
}
|
||||
scrollToBottom();
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();});
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
@@ -1237,6 +1259,47 @@ function renderMermaidBlocks(){
|
||||
});
|
||||
}
|
||||
|
||||
let _katexLoading=false;
|
||||
let _katexReady=false;
|
||||
|
||||
function renderKatexBlocks(){
|
||||
const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
|
||||
if(!blocks.length) return;
|
||||
if(!_katexReady){
|
||||
if(!_katexLoading){
|
||||
_katexLoading=true;
|
||||
const script=document.createElement('script');
|
||||
script.src='https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js';
|
||||
script.integrity='sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6';
|
||||
script.crossOrigin='anonymous';
|
||||
script.onload=()=>{
|
||||
if(typeof katex!=='undefined'){
|
||||
_katexReady=true;
|
||||
renderKatexBlocks();
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
return;
|
||||
}
|
||||
blocks.forEach(el=>{
|
||||
el.dataset.rendered='true';
|
||||
const src=el.textContent||'';
|
||||
const displayMode=el.dataset.katex==='display';
|
||||
try{
|
||||
katex.render(src,el,{
|
||||
displayMode,
|
||||
throwOnError:false,
|
||||
trust:false,
|
||||
strict:'ignore',
|
||||
});
|
||||
}catch(e){
|
||||
// Leave as raw text in a code span on failure
|
||||
el.outerHTML=`<code>${esc(src)}</code>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendThinking(){
|
||||
$('emptyState').style.display='none';
|
||||
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
|
||||
|
||||
@@ -150,7 +150,7 @@ async function toggleEditMode(){
|
||||
_previewDirty=false;
|
||||
// Update read-only views
|
||||
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
|
||||
else $('previewMd').innerHTML=renderMd(content);
|
||||
else { $('previewMd').innerHTML=renderMd(content); requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();}); }
|
||||
$('previewEditArea').style.display='none';
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
||||
else $('previewMd').style.display='';
|
||||
@@ -215,6 +215,7 @@ async function openFile(path){
|
||||
showPreview('md');
|
||||
_previewRawContent = data.content;
|
||||
$('previewMd').innerHTML=renderMd(data.content);
|
||||
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
|
||||
}catch(e){setStatus(t('file_open_failed'));}
|
||||
} else {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
|
||||
Reference in New Issue
Block a user