Skip to content

Commit 0693ae6

Browse files
committed
fix(cleaner,parser): fix KaTeX inline math preview and preserve emphasis whitespace in lists
1 parent 683707b commit 0693ae6

7 files changed

Lines changed: 186 additions & 29 deletions

File tree

‎package-lock.json‎

Lines changed: 41 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"vitest": "^3.2.7"
2424
},
2525
"dependencies": {
26+
"@types/katex": "^0.16.8",
2627
"@types/prismjs": "^1.26.6",
28+
"katex": "^0.18.5",
2729
"mermaid": "^11.17.2",
2830
"prismjs": "^1.30.0"
2931
}

‎src/lib/cleaner.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -562,47 +562,52 @@ export function fixEmphasisSpacing(text: string): { result: string; count: numbe
562562
let totalCount = 0;
563563
let result = text;
564564

565+
// We ensure that delimiters are flanking content and bounded by start of line, whitespace, or punctuation/brackets.
566+
// This prevents stripping valid inter-phrase whitespace (e.g. `*Sequence Flows* model ... strictly *within*`).
567+
const startPunct = '(?:^|[\\s(\\[{<"\'\u201c\u2018])';
568+
const endPunct = '(?=[\\s)\\]}>"\'\u201d\u2019,.;:!?]|$)';
569+
565570
// 1. Triple asterisks *** text ***
566-
result = result.replace(/\*\*\*([ \t]*)([^\n*]+?)([ \t]*)\*\*\*/g, (match, leading, content, trailing) => {
571+
result = result.replace(new RegExp(`(${startPunct})\\*\\*\\*([ \\t]*)([^\\n*]+?)([ \\t]*)\\*\\*\\*${endPunct}`, 'g'), (match, prefix, leading, content, trailing) => {
567572
if (leading.length > 0 || trailing.length > 0) {
568573
totalCount++;
569-
return `***${content.trim()}***`;
574+
return `${prefix}***${content.trim()}***`;
570575
}
571576
return match;
572577
});
573578

574579
// 2. Double asterisks ** text **
575-
result = result.replace(/\*\*([ \t]*)([^\n*]+?)([ \t]*)\*\*/g, (match, leading, content, trailing) => {
580+
result = result.replace(new RegExp(`(${startPunct})\\*\\*([ \\t]*)([^\\n*]+?)([ \\t]*)\\*\\*${endPunct}`, 'g'), (match, prefix, leading, content, trailing) => {
576581
if (leading.length > 0 || trailing.length > 0) {
577582
totalCount++;
578-
return `**${content.trim()}**`;
583+
return `${prefix}**${content.trim()}**`;
579584
}
580585
return match;
581586
});
582587

583588
// 3. Double underscores __ text __
584-
result = result.replace(/__([ \t]*)([^\n_]+?)([ \t]*)__/g, (match, leading, content, trailing) => {
589+
result = result.replace(new RegExp(`(${startPunct})__([ \\t]*)([^\\n_]+?)([ \\t]*)__${endPunct}`, 'g'), (match, prefix, leading, content, trailing) => {
585590
if (leading.length > 0 || trailing.length > 0) {
586591
totalCount++;
587-
return `__${content.trim()}__`;
592+
return `${prefix}__${content.trim()}__`;
588593
}
589594
return match;
590595
});
591596

592597
// 4. Single asterisks * text *
593-
result = result.replace(/(?<!\*)\*([ \t]*)([^\n*]+?)([ \t]*)\*(?!\*)/g, (match, leading, content, trailing) => {
598+
result = result.replace(new RegExp(`(${startPunct})\\*(?!\\*)([ \\t]*)([^\\n*]+?)([ \\t]*)\\*(?!\\*)${endPunct}`, 'g'), (match, prefix, leading, content, trailing) => {
594599
if (leading.length > 0 || trailing.length > 0) {
595600
totalCount++;
596-
return `*${content.trim()}*`;
601+
return `${prefix}*${content.trim()}*`;
597602
}
598603
return match;
599604
});
600605

601606
// 5. Single underscores _ text _
602-
result = result.replace(/(?<!_)_([ \t]*)([^\n_]+?)([ \t]*)_(?!_)/g, (match, leading, content, trailing) => {
607+
result = result.replace(new RegExp(`(${startPunct})_(?!_)([ \\t]*)([^\\n_]+?)([ \\t]*)_(?!_)${endPunct}`, 'g'), (match, prefix, leading, content, trailing) => {
603608
if (leading.length > 0 || trailing.length > 0) {
604609
totalCount++;
605-
return `_${content.trim()}_`;
610+
return `${prefix}_${content.trim()}_`;
606611
}
607612
return match;
608613
});

‎src/lib/parser.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Prism from 'prismjs';
2+
import katex from 'katex';
23

34
// Core dependencies and language grammars
45
import 'prismjs/components/prism-clike';
@@ -51,6 +52,25 @@ function escapeHtml(str: string): string {
5152
.replace(/'/g, '&#039;');
5253
}
5354

55+
/**
56+
* Renders a LaTeX math expression using KaTeX with graceful fallback.
57+
*/
58+
export function renderKaTeX(formula: string, displayMode: boolean): string {
59+
try {
60+
return katex.renderToString(formula.trim(), {
61+
displayMode,
62+
throwOnError: false,
63+
output: 'htmlAndMathml',
64+
});
65+
} catch {
66+
const escapedFormula = escapeHtml(formula.trim());
67+
if (displayMode) {
68+
return `<div class="obsidian-math-block">$$ ${escapedFormula} $$</div>`;
69+
}
70+
return `<span class="obsidian-inline-math">$${escapedFormula}$</span>`;
71+
}
72+
}
73+
5474
const LANGUAGE_ALIASES: Record<string, string> = {
5575
js: 'javascript',
5676
ts: 'typescript',
@@ -190,7 +210,8 @@ export function parseMarkdownToHtml(markdown: string): string {
190210
if (line.trim() === '$$' || /^\$\$(.*)\$\$$/.test(line.trim())) {
191211
if (/^\$\$(.+)\$\$$/.test(line.trim())) {
192212
const formula = line.trim().slice(2, -2).trim();
193-
blocks.push(`<div class="obsidian-math-block">$$ ${escapeHtml(formula)} $$</div>`);
213+
const rendered = renderKaTeX(formula, true);
214+
blocks.push(`<div class="obsidian-math-block">${rendered}</div>`);
194215
i++;
195216
continue;
196217
} else {
@@ -201,7 +222,9 @@ export function parseMarkdownToHtml(markdown: string): string {
201222
i++;
202223
}
203224
i++; // Skip closing $$
204-
blocks.push(`<div class="obsidian-math-block">$$\n${escapeHtml(mathLines.join('\n'))}\n$$</div>`);
225+
const formula = mathLines.join('\n');
226+
const rendered = renderKaTeX(formula, true);
227+
blocks.push(`<div class="obsidian-math-block">${rendered}</div>`);
205228
continue;
206229
}
207230
}
@@ -408,13 +431,25 @@ function renderListBlock(lines: string[]): string {
408431
export function renderInline(text: string): string {
409432
if (!text) return '';
410433

411-
let escaped = escapeHtml(text);
434+
// 1. Extract and isolate inline code tokens: `code`
435+
const codeTokens: string[] = [];
436+
let masked = text.replace(/`([^`]+)`/g, (_match, code) => {
437+
const token = `%%OBS-INLINE-CODE-${codeTokens.length}%%`;
438+
codeTokens.push(`<code class="obsidian-inline-code">${escapeHtml(code)}</code>`);
439+
return token;
440+
});
412441

413-
// 1. Inline Code: `code`
414-
escaped = escaped.replace(/`([^`]+)`/g, '<code class="obsidian-inline-code">$1</code>');
442+
// 2. Extract and render inline math tokens with KaTeX: $math$ (e.g. $A$, $x + y$)
443+
const mathTokens: string[] = [];
444+
const inlineMathRegex = /\$([^\s\$\n](?:[^\$\n]*?[^\s\$\n])?)\$/g;
445+
masked = masked.replace(inlineMathRegex, (_match, formula) => {
446+
const token = `%%OBS-INLINE-MATH-${mathTokens.length}%%`;
447+
const rendered = renderKaTeX(formula, false);
448+
mathTokens.push(rendered);
449+
return token;
450+
});
415451

416-
// 2. Inline Math: $math$
417-
escaped = escaped.replace(/\$([^\$\n]+)\$/g, '<span class="obsidian-inline-math">$$1</span>');
452+
let escaped = escapeHtml(masked);
418453

419454
// 3. Bold + Italic: ***text*** or ___text___
420455
escaped = escaped.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
@@ -442,5 +477,13 @@ export function renderInline(text: string): string {
442477
// 9. Obsidian Tags: #tag (must have whitespace or start before it)
443478
escaped = escaped.replace(/(^|\s)#([a-zA-Z0-9_\-\/]+)(?=\s|$)/g, '$1<span class="obsidian-tag">#$2</span>');
444479

480+
// 10. Restore code and math tokens safely
481+
for (let i = 0; i < codeTokens.length; i++) {
482+
escaped = escaped.replace(`%%OBS-INLINE-CODE-${i}%%`, () => codeTokens[i]);
483+
}
484+
for (let i = 0; i < mathTokens.length; i++) {
485+
escaped = escaped.replace(`%%OBS-INLINE-MATH-${i}%%`, () => mathTokens[i]);
486+
}
487+
445488
return escaped;
446489
}

‎src/main.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { mount } from 'svelte'
2+
import 'katex/dist/katex.min.css'
23
import './app.css'
34
import App from './App.svelte'
45

‎tests/cleaner.test.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,30 @@ describe('Markdown Cleaner - Bold & Italic Whitespace', () => {
192192
expect(result).toBe('This is *italic text* here.');
193193
});
194194

195-
it('fixes spaces inside triple asterisks *** text *** -> ***text***', () => {
196-
const input = 'This is *** bold italic *** text.';
197-
const { result } = fixEmphasisSpacing(input);
198-
expect(result).toBe('This is ***bold italic*** text.');
195+
it('preserves list item bullet with italic text and inter-word spaces', () => {
196+
const input = [
197+
'# Foundational Elements of the System View',
198+
'',
199+
'Wazlawick uses BPMN as the primary notation to map business reality prior to software requirements engineering:',
200+
'',
201+
'* **Pools and Lanes:** A *Pool* represents an autonomous business entity or external participant (e.g., an external enterprise, partner, or bank). A *Lane* partitions a single pool into internal roles, departments, or subsystem responsibilities.',
202+
'* **Flow Types:**',
203+
'* *Sequence Flows* model execution order and control strictly *within* a single pool.',
204+
'* *Message Flows* (dashed lines with open circles/arrows) model asynchronous communication *between* independent pools. Sequence flows are forbidden from crossing pool boundaries.',
205+
].join('\n');
206+
207+
const { cleaned, stats } = cleanMarkdown(input);
208+
expect(cleaned).toContain('* *Sequence Flows* model execution order and control strictly *within* a single pool.');
209+
expect(cleaned).toContain('* *Message Flows* (dashed lines with open circles/arrows) model asynchronous communication *between* independent pools.');
210+
expect(cleaned).toContain('* **Pools and Lanes:** A *Pool* represents an autonomous business entity');
211+
expect(stats.emphasisFixed).toBe(0);
212+
});
213+
214+
it('corrects malformed emphasis while preserving punctuation and delimiters', () => {
215+
const input = '"** bold in quotes **" and (* italic in parens *) and ** bold **, ** bold 2 **! ** bold 3 **?';
216+
const { result, count } = fixEmphasisSpacing(input);
217+
expect(result).toBe('"**bold in quotes**" and (*italic in parens*) and **bold**, **bold 2**! **bold 3**?');
218+
expect(count).toBe(5);
199219
});
200220
});
201221

‎tests/parser.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// @vitest-environment happy-dom
2+
import { describe, it, expect } from 'vitest';
3+
import { parseMarkdownToHtml, renderInline } from '../src/lib/parser';
4+
5+
describe('Obsidian Previewer Parser & Math Rendering', () => {
6+
it('correctly renders inline math variables without corrupting them into $1 or $Is', () => {
7+
const text = 'Glitch-Free Reactive Propagation: Fine-grained reactive graphs track dynamic dependencies at runtime, ensuring that if Signal $A$ updates both Signal $B$ and $C$, and $D$ depends on both, $D$ recalculates exactly once without intermediate stale reads ("glitches").';
8+
const html = parseMarkdownToHtml(text);
9+
10+
// Verify it contains KaTeX math rendering for A, B, C, and D
11+
expect(html).toContain('katex');
12+
expect(html).toContain('A</annotation>');
13+
expect(html).toContain('B</annotation>');
14+
expect(html).toContain('C</annotation>');
15+
expect(html).toContain('D</annotation>');
16+
17+
// Must NOT contain corrupted $1 or $Is
18+
expect(html).not.toContain('$1');
19+
expect(html).not.toContain('&gt;1&lt;');
20+
expect(html).not.toContain('>$1<');
21+
});
22+
23+
it('renders single character and formula inline math', () => {
24+
const single = renderInline('Formula $x$ and $y = mx + b$');
25+
expect(single).toContain('katex');
26+
expect(single).toContain('x</annotation>');
27+
expect(single).toContain('y = mx + b</annotation>');
28+
});
29+
30+
it('does not confuse currency values with inline math', () => {
31+
const currency = renderInline('Item costs $100 and shipping is $20 for all users.');
32+
expect(currency).not.toContain('katex');
33+
expect(currency).toContain('$100');
34+
expect(currency).toContain('$20');
35+
});
36+
37+
it('renders display math blocks with KaTeX', () => {
38+
const md = '$$\n\\frac{a}{b} = c\n$$';
39+
const html = parseMarkdownToHtml(md);
40+
expect(html).toContain('obsidian-math-block');
41+
expect(html).toContain('katex-display');
42+
expect(html).toContain('\\frac{a}{b} = c</annotation>');
43+
});
44+
45+
it('renders inline code, bold, and italics cleanly alongside math', () => {
46+
const md = 'Use `Signal<T>` with invariant $A$ and **bold** *italic* formatting.';
47+
const html = renderInline(md);
48+
expect(html).toContain('<code class="obsidian-inline-code">Signal&lt;T&gt;</code>');
49+
expect(html).toContain('<strong>bold</strong>');
50+
expect(html).toContain('<em>italic</em>');
51+
expect(html).toContain('A</annotation>');
52+
});
53+
});

0 commit comments

Comments
 (0)