Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import footnote from 'markdown-it-footnote';
import tasklist from 'markdown-it-task-lists';
import githubAlerts from 'markdown-it-github-alerts';

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

/**
* Render raw Mermaid content as a standalone diagram, used for `.mmd` and `.mermaid` files.
*
* @param lineInfo Whether to include line info like `data-line-from` and `data-line-to`.
*/
export async function renderMermaid(content: string, lineInfo = false) {
const html = mdit.utils.escapeHtml(content.trim());
return renderStandalone('mermaid', html, lineInfo);
}

/**
* Render raw LaTeX content as standalone KaTeX math, used for `.tex` files.
*
* @param lineInfo Whether to include line info like `data-line-from` and `data-line-to`.
*/
export async function renderKatex(content: string, lineInfo = false) {
const katex = (await import('katex')).default;
const html = katex.renderToString(content.trim(), { displayMode: true, throwOnError: false });
return renderStandalone('katex', html, lineInfo);
}

export function handlePostRender(process: () => void) {
if (__FULL_BUILD__) {
import('mermaid').then(({ default: mermaid }) => {
Expand Down Expand Up @@ -76,6 +98,14 @@ export async function applyStyles(html: string) {
return components.join('\n');
}

// Render the entire content as a standalone block
const renderStandalone = async (className: string, innerHtml: string, lineInfo: boolean) => {
await pluginsReady;
const lineTo = () => MarkEdit.editorView.state.doc.lines - 1;
const lineAttrs = lineInfo ? ` data-line-from="0" data-line-to="${lineTo()}"` : '';
return `<div class="${className}"${lineAttrs}>${innerHtml}</div>`;
};

// Create the markdown-it instance
const mdit = markdownit(markdownItPreset, {
html: true,
Expand Down
5 changes: 5 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export function getFileName(filePath: string) {
return fileName.split('.').slice(0, -1).join('.');
}

export function getFileExtension(filePath?: string) {
const index = filePath?.lastIndexOf('.');
return index === -1 ? '' : filePath?.slice(index).toLowerCase();
}

export function getClosestLine(node: Node) {
return (node instanceof HTMLElement ? node : node.parentElement)?.closest('.cm-line') as HTMLElement | null;
}
Expand Down
26 changes: 24 additions & 2 deletions src/view.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MarkEdit } from 'markedit-api';
import { appendStyle, getFileName, selectFullRange } from './utils';
import { renderMarkdown, handlePostRender, applyStyles } from './render';
import { appendStyle, getFileExtension, getFileName, selectFullRange } from './utils';
import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles } from './render';
import { replaceImageURLs } from './image';
import { hidePreviewButtons, previewModes } from './settings';
import { localized } from './strings';
Expand Down Expand Up @@ -247,6 +247,28 @@ export async function generateStaticHtml(styled: boolean) {

async function getRenderedHtml(lineInfo = true) {
const markdown = MarkEdit.editorAPI.getText();

if (__FULL_BUILD__) {
const fileType = await (async () => {
if (typeof MarkEdit.getFileInfo !== 'function') {
return undefined;
}

Comment thread
cyanzhong marked this conversation as resolved.
const fileInfo = await MarkEdit.getFileInfo();
return getFileExtension(fileInfo?.filePath);
})();

// The entire file is mermaid
if (fileType === '.mmd' || fileType === '.mermaid') {
return await renderMermaid(markdown, lineInfo);
}

// The entire file is KaTeX
if (fileType === '.tex') {
return await renderKatex(markdown, lineInfo);
}
}

return await renderMarkdown(markdown, lineInfo);
}

Expand Down
134 changes: 132 additions & 2 deletions tests/render.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../src/render';
import { describe, it, expect, vi } from 'vitest';
import { renderMarkdown, renderMermaid, renderKatex } from '../src/render';

vi.mock('markedit-api', () => {
const markEdit: Record<string, unknown> = {};
return { MarkEdit: markEdit };
});
Comment on lines +1 to +7
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vi.mock('markedit-api', ...) is declared after the static import of ../src/render. In ESM, that can cause render.ts to capture the real MarkEdit export before the mock is applied, making mockDocLines() ineffective and potentially causing flaky failures when renderMermaid/renderKatex read MarkEdit.editorView. To make the mock application unambiguous, set up the mock before importing the module under test (e.g., use a dynamic await import('../src/render') after vi.mock, or restructure to avoid static import here).

Copilot uses AI. Check for mistakes.

// Access the mocked MarkEdit to configure editorView per test
async function mockDocLines(lines: number) {
const { MarkEdit } = await import('markedit-api');
(MarkEdit as Record<string, unknown>).editorView = { state: { doc: { lines } } };
}

describe('renderMarkdown', () => {
describe('code blocks without language specifier', () => {
Expand Down Expand Up @@ -57,3 +68,122 @@ describe('renderMarkdown', () => {
});
});
});

describe('renderMermaid', () => {
it('should wrap content in a mermaid div', async () => {
await mockDocLines(2);
const content = 'graph TD\n A --> B';
const html = await renderMermaid(content);
expect(html).toContain('<div class="mermaid">');
expect(html).toContain('</div>');
expect(html).toContain('graph TD');
});

it('should escape HTML in mermaid content', async () => {
await mockDocLines(1);
const content = '<script>alert("xss")</script>';
const html = await renderMermaid(content);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});

it('should trim whitespace from content', async () => {
await mockDocLines(2);
const content = ' graph TD\n A --> B \n';
const html = await renderMermaid(content);
expect(html).toBe('<div class="mermaid">graph TD\n A --&gt; B</div>');
});

it('should include line info attributes when lineInfo is true', async () => {
await mockDocLines(3);
const content = 'graph TD\n A --> B\n B --> C';
const html = await renderMermaid(content, true);
expect(html).toContain('data-line-from="0"');
expect(html).toContain('data-line-to="2"');
});

it('should not include line info attributes by default', async () => {
await mockDocLines(2);
const content = 'graph TD\n A --> B';
const html = await renderMermaid(content);
expect(html).not.toContain('data-line-from');
expect(html).not.toContain('data-line-to');
});

it('should handle single-line content with lineInfo', async () => {
await mockDocLines(1);
const content = 'graph TD';
const html = await renderMermaid(content, true);
expect(html).toContain('data-line-from="0"');
expect(html).toContain('data-line-to="0"');
});

it('should not affect markdown mermaid rendering', async () => {
const md = '```mermaid\ngraph TD\n```';
const html = await renderMarkdown(md);
expect(html).toContain('<div class="mermaid"');
});
});

describe('renderKatex', () => {
it('should wrap content in a katex div', async () => {
await mockDocLines(1);
const content = 'E = mc^2';
const html = await renderKatex(content);
expect(html).toContain('<div class="katex">');
expect(html).toContain('</div>');
});

it('should render KaTeX HTML output', async () => {
await mockDocLines(1);
const content = 'x^2 + y^2 = z^2';
const html = await renderKatex(content);
expect(html).toContain('class="katex');
});

it('should handle invalid LaTeX gracefully', async () => {
await mockDocLines(1);
const content = '\\invalid{command}';
const html = await renderKatex(content);
expect(html).toContain('<div class="katex">');
// With throwOnError: false, KaTeX renders error spans instead of throwing
expect(html).toBeDefined();
});

it('should trim whitespace from content', async () => {
await mockDocLines(1);
const content = ' E = mc^2 \n';
const html = await renderKatex(content);
expect(html).toContain('class="katex');
});

it('should include line info attributes when lineInfo is true', async () => {
await mockDocLines(3);
const content = 'a + b = c';
const html = await renderKatex(content, true);
expect(html).toContain('data-line-from="0"');
expect(html).toContain('data-line-to="2"');
});

it('should not include line info attributes by default', async () => {
await mockDocLines(1);
const content = 'E = mc^2';
const html = await renderKatex(content);
expect(html).not.toContain('data-line-from');
expect(html).not.toContain('data-line-to');
});

it('should handle single-line content with lineInfo', async () => {
await mockDocLines(1);
const content = 'E = mc^2';
const html = await renderKatex(content, true);
expect(html).toContain('data-line-from="0"');
expect(html).toContain('data-line-to="0"');
});

it('should not affect markdown katex rendering', async () => {
const md = '$E = mc^2$';
const html = await renderMarkdown(md);
expect(html).toContain('class="katex');
});
});
Loading