Skip to content

Commit 9574cec

Browse files
Copilotcyanzhong
andauthored
Render mermaid and katex files (#81)
* Initial plan * Render .mmd files as standalone Mermaid diagrams - Add renderMermaid() in src/render.ts to wrap raw content in a mermaid div - Add isMermaidFile() in src/view.ts using MarkEdit.getFileInfo() - Route .mmd files to renderMermaid() in getRenderedHtml() (__FULL_BUILD__ only) - Add tests for renderMermaid() function Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Make .mmd file extension check case-insensitive Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Add renderMermaidDiagram setting and .mermaid extension support Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Add scroll sync line info to standalone Mermaid rendering Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * fix: use CodeMirror state.doc.lines for mermaid scroll sync line count Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: move line counting back inside renderMermaid() using state.doc.lines Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * fix: only access editorView.state.doc.lines when lineInfo is true Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * nit-picking * Rename setting to renderFileAsMermaid, await pluginsReady, add README docs, fix test mocks Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: separate config check from isMermaidFile() into call site Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * remove renderFileAsMermaid option; add standalone .tex KaTeX rendering Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: extract shared renderStandaloneBlock helper to eliminate duplication Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: rename renderStandaloneBlock to renderStandalone, use katex class, inline lineAttrs, expand ext to extension Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * nit-picking --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> Co-authored-by: Ying Zhong <0x00eeee@gmail.com>
1 parent db9cebd commit 9574cec

4 files changed

Lines changed: 191 additions & 4 deletions

File tree

src/render.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import footnote from 'markdown-it-footnote';
55
import tasklist from 'markdown-it-task-lists';
66
import githubAlerts from 'markdown-it-github-alerts';
77

8+
import { MarkEdit } from 'markedit-api';
89
import { createFrontMatterPlugin } from './frontMatter';
910
import { coreCss, previewThemeCss, alertsCss, hljsCss, codeCopyCss } from './styling';
1011
import { localized } from './strings';
@@ -18,6 +19,27 @@ export async function renderMarkdown(markdown: string, lineInfo = true) {
1819
return mdit.render(markdown, { lineInfo });
1920
}
2021

22+
/**
23+
* Render raw Mermaid content as a standalone diagram, used for `.mmd` and `.mermaid` files.
24+
*
25+
* @param lineInfo Whether to include line info like `data-line-from` and `data-line-to`.
26+
*/
27+
export async function renderMermaid(content: string, lineInfo = false) {
28+
const html = mdit.utils.escapeHtml(content.trim());
29+
return renderStandalone('mermaid', html, lineInfo);
30+
}
31+
32+
/**
33+
* Render raw LaTeX content as standalone KaTeX math, used for `.tex` files.
34+
*
35+
* @param lineInfo Whether to include line info like `data-line-from` and `data-line-to`.
36+
*/
37+
export async function renderKatex(content: string, lineInfo = false) {
38+
const katex = (await import('katex')).default;
39+
const html = katex.renderToString(content.trim(), { displayMode: true, throwOnError: false });
40+
return renderStandalone('katex', html, lineInfo);
41+
}
42+
2143
export function handlePostRender(process: () => void) {
2244
if (__FULL_BUILD__) {
2345
import('mermaid').then(({ default: mermaid }) => {
@@ -76,6 +98,14 @@ export async function applyStyles(html: string) {
7698
return components.join('\n');
7799
}
78100

101+
// Render the entire content as a standalone block
102+
const renderStandalone = async (className: string, innerHtml: string, lineInfo: boolean) => {
103+
await pluginsReady;
104+
const lineTo = () => MarkEdit.editorView.state.doc.lines - 1;
105+
const lineAttrs = lineInfo ? ` data-line-from="0" data-line-to="${lineTo()}"` : '';
106+
return `<div class="${className}"${lineAttrs}>${innerHtml}</div>`;
107+
};
108+
79109
// Create the markdown-it instance
80110
const mdit = markdownit(markdownItPreset, {
81111
html: true,

src/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function getFileName(filePath: string) {
2525
return fileName.split('.').slice(0, -1).join('.');
2626
}
2727

28+
export function getFileExtension(filePath?: string) {
29+
const index = filePath?.lastIndexOf('.');
30+
return index === -1 ? '' : filePath?.slice(index).toLowerCase();
31+
}
32+
2833
export function getClosestLine(node: Node) {
2934
return (node instanceof HTMLElement ? node : node.parentElement)?.closest('.cm-line') as HTMLElement | null;
3035
}

src/view.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { MarkEdit } from 'markedit-api';
2-
import { appendStyle, getFileName, selectFullRange } from './utils';
3-
import { renderMarkdown, handlePostRender, applyStyles } from './render';
2+
import { appendStyle, getFileExtension, getFileName, selectFullRange } from './utils';
3+
import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles } from './render';
44
import { replaceImageURLs } from './image';
55
import { hidePreviewButtons, previewModes } from './settings';
66
import { localized } from './strings';
@@ -247,6 +247,28 @@ export async function generateStaticHtml(styled: boolean) {
247247

248248
async function getRenderedHtml(lineInfo = true) {
249249
const markdown = MarkEdit.editorAPI.getText();
250+
251+
if (__FULL_BUILD__) {
252+
const fileType = await (async () => {
253+
if (typeof MarkEdit.getFileInfo !== 'function') {
254+
return undefined;
255+
}
256+
257+
const fileInfo = await MarkEdit.getFileInfo();
258+
return getFileExtension(fileInfo?.filePath);
259+
})();
260+
261+
// The entire file is mermaid
262+
if (fileType === '.mmd' || fileType === '.mermaid') {
263+
return await renderMermaid(markdown, lineInfo);
264+
}
265+
266+
// The entire file is KaTeX
267+
if (fileType === '.tex') {
268+
return await renderKatex(markdown, lineInfo);
269+
}
270+
}
271+
250272
return await renderMarkdown(markdown, lineInfo);
251273
}
252274

tests/render.test.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { describe, it, expect } from 'vitest';
2-
import { renderMarkdown } from '../src/render';
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { renderMarkdown, renderMermaid, renderKatex } from '../src/render';
3+
4+
vi.mock('markedit-api', () => {
5+
const markEdit: Record<string, unknown> = {};
6+
return { MarkEdit: markEdit };
7+
});
8+
9+
// Access the mocked MarkEdit to configure editorView per test
10+
async function mockDocLines(lines: number) {
11+
const { MarkEdit } = await import('markedit-api');
12+
(MarkEdit as Record<string, unknown>).editorView = { state: { doc: { lines } } };
13+
}
314

415
describe('renderMarkdown', () => {
516
describe('code blocks without language specifier', () => {
@@ -57,3 +68,122 @@ describe('renderMarkdown', () => {
5768
});
5869
});
5970
});
71+
72+
describe('renderMermaid', () => {
73+
it('should wrap content in a mermaid div', async () => {
74+
await mockDocLines(2);
75+
const content = 'graph TD\n A --> B';
76+
const html = await renderMermaid(content);
77+
expect(html).toContain('<div class="mermaid">');
78+
expect(html).toContain('</div>');
79+
expect(html).toContain('graph TD');
80+
});
81+
82+
it('should escape HTML in mermaid content', async () => {
83+
await mockDocLines(1);
84+
const content = '<script>alert("xss")</script>';
85+
const html = await renderMermaid(content);
86+
expect(html).not.toContain('<script>');
87+
expect(html).toContain('&lt;script&gt;');
88+
});
89+
90+
it('should trim whitespace from content', async () => {
91+
await mockDocLines(2);
92+
const content = ' graph TD\n A --> B \n';
93+
const html = await renderMermaid(content);
94+
expect(html).toBe('<div class="mermaid">graph TD\n A --&gt; B</div>');
95+
});
96+
97+
it('should include line info attributes when lineInfo is true', async () => {
98+
await mockDocLines(3);
99+
const content = 'graph TD\n A --> B\n B --> C';
100+
const html = await renderMermaid(content, true);
101+
expect(html).toContain('data-line-from="0"');
102+
expect(html).toContain('data-line-to="2"');
103+
});
104+
105+
it('should not include line info attributes by default', async () => {
106+
await mockDocLines(2);
107+
const content = 'graph TD\n A --> B';
108+
const html = await renderMermaid(content);
109+
expect(html).not.toContain('data-line-from');
110+
expect(html).not.toContain('data-line-to');
111+
});
112+
113+
it('should handle single-line content with lineInfo', async () => {
114+
await mockDocLines(1);
115+
const content = 'graph TD';
116+
const html = await renderMermaid(content, true);
117+
expect(html).toContain('data-line-from="0"');
118+
expect(html).toContain('data-line-to="0"');
119+
});
120+
121+
it('should not affect markdown mermaid rendering', async () => {
122+
const md = '```mermaid\ngraph TD\n```';
123+
const html = await renderMarkdown(md);
124+
expect(html).toContain('<div class="mermaid"');
125+
});
126+
});
127+
128+
describe('renderKatex', () => {
129+
it('should wrap content in a katex div', async () => {
130+
await mockDocLines(1);
131+
const content = 'E = mc^2';
132+
const html = await renderKatex(content);
133+
expect(html).toContain('<div class="katex">');
134+
expect(html).toContain('</div>');
135+
});
136+
137+
it('should render KaTeX HTML output', async () => {
138+
await mockDocLines(1);
139+
const content = 'x^2 + y^2 = z^2';
140+
const html = await renderKatex(content);
141+
expect(html).toContain('class="katex');
142+
});
143+
144+
it('should handle invalid LaTeX gracefully', async () => {
145+
await mockDocLines(1);
146+
const content = '\\invalid{command}';
147+
const html = await renderKatex(content);
148+
expect(html).toContain('<div class="katex">');
149+
// With throwOnError: false, KaTeX renders error spans instead of throwing
150+
expect(html).toBeDefined();
151+
});
152+
153+
it('should trim whitespace from content', async () => {
154+
await mockDocLines(1);
155+
const content = ' E = mc^2 \n';
156+
const html = await renderKatex(content);
157+
expect(html).toContain('class="katex');
158+
});
159+
160+
it('should include line info attributes when lineInfo is true', async () => {
161+
await mockDocLines(3);
162+
const content = 'a + b = c';
163+
const html = await renderKatex(content, true);
164+
expect(html).toContain('data-line-from="0"');
165+
expect(html).toContain('data-line-to="2"');
166+
});
167+
168+
it('should not include line info attributes by default', async () => {
169+
await mockDocLines(1);
170+
const content = 'E = mc^2';
171+
const html = await renderKatex(content);
172+
expect(html).not.toContain('data-line-from');
173+
expect(html).not.toContain('data-line-to');
174+
});
175+
176+
it('should handle single-line content with lineInfo', async () => {
177+
await mockDocLines(1);
178+
const content = 'E = mc^2';
179+
const html = await renderKatex(content, true);
180+
expect(html).toContain('data-line-from="0"');
181+
expect(html).toContain('data-line-to="0"');
182+
});
183+
184+
it('should not affect markdown katex rendering', async () => {
185+
const md = '$E = mc^2$';
186+
const html = await renderMarkdown(md);
187+
expect(html).toContain('class="katex');
188+
});
189+
});

0 commit comments

Comments
 (0)