diff --git a/package.json b/package.json index 04f27fec82..fff2ad1e2d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "jquery": "4.0.0", "js-yaml": "4.1.1", "katex": "0.16.45", + "marked": "18.0.2", "mermaid": "11.14.0", "online-3d-viewer": "0.18.0", "pdfobject": "2.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cca57d3b19..147dcccb29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: katex: specifier: 0.16.45 version: 0.16.45 + marked: + specifier: 18.0.2 + version: 18.0.2 mermaid: specifier: 11.14.0 version: 11.14.0 @@ -3113,6 +3116,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@18.0.2: + resolution: {integrity: sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==} + engines: {node: '>= 20'} + hasBin: true + marked@4.3.0: resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} engines: {node: '>= 12'} @@ -7084,6 +7092,8 @@ snapshots: marked@16.4.2: {} + marked@18.0.2: {} + marked@4.3.0: {} material-icon-theme@5.33.1: diff --git a/tests/e2e/jupyter-render.test.ts b/tests/e2e/jupyter-render.test.ts index 20fe6797fe..9528fbd85d 100644 --- a/tests/e2e/jupyter-render.test.ts +++ b/tests/e2e/jupyter-render.test.ts @@ -15,7 +15,7 @@ test.describe('jupyter notebook rendering', () => { // Single comprehensive test notebook const notebook = JSON.stringify({ cells: [ - {cell_type: 'markdown', source: ['# Test\n', '**bold**']}, + {cell_type: 'markdown', source: ['# Header 1\n', '## Header 2\n', '**bold** *italic* `code`\n', '- List item 1\n', '- List item 2\n', '[link](https://example.com)\n', '| Col1 | Col2 |\n', '|------|------|\n', '| A | B |\n', '```python\ncode block\n```\n', '> blockquote\n', '~~strikethrough~~']}, {cell_type: 'code', execution_count: 1, source: ['print("Hello")'], outputs: [{output_type: 'stream', name: 'stdout', text: ['Hello\n']}]}, {cell_type: 'code', execution_count: 2, source: ['x'], outputs: [{output_type: 'execute_result', data: {'image/png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='}}]}, {cell_type: 'code', source: ['# No output'], outputs: []}, @@ -32,7 +32,11 @@ test.describe('jupyter notebook rendering', () => { await login(page); await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); await assertNoJsError(page); - await expect(page.frameLocator('iframe.external-render-iframe').locator('.cell.markdown strong')).toBeVisible(); + const frame = page.frameLocator('iframe.external-render-iframe'); + await expect(frame.locator('.cell.markdown h1')).toBeVisible(); + await expect(frame.locator('.cell.markdown strong')).toBeVisible(); + await expect(frame.locator('.cell.markdown ul li').first()).toBeVisible(); + await expect(frame.locator('.cell.markdown table')).toBeVisible(); }); test('renders code cells with outputs', async ({page}) => { diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts index 7c30f4063c..dfaf87aa94 100644 --- a/web_src/js/render/plugins/frontend-jupyter-notebook.ts +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -1,4 +1,5 @@ import type {FrontendRenderFunc} from '../plugin.ts'; +import {marked} from 'marked'; import '../../../css/features/jupyter.css'; // Sanitize HTML by removing dangerous attributes and elements @@ -38,60 +39,14 @@ function sanitizeHtml(element: HTMLElement) { } } -// Simple markdown to HTML converter for notebook cells using DOM methods +// Render markdown using marked library function renderMarkdown(markdown: string): HTMLElement { const container = document.createElement('div'); - - // Split by lines and process - const lines = markdown.split('\n'); - for (const line of lines) { - let element: HTMLElement; - - // Headers - if (line.startsWith('### ')) { - element = document.createElement('h3'); - element.textContent = line.substring(4); - } else if (line.startsWith('## ')) { - element = document.createElement('h2'); - element.textContent = line.substring(3); - } else if (line.startsWith('# ')) { - element = document.createElement('h1'); - element.textContent = line.substring(2); - } else { - element = document.createElement('p'); - // Process inline formatting - processInlineFormatting(element, line); - } - - container.append(element); - } - + container.innerHTML = marked.parse(markdown) as string; + sanitizeHtml(container); return container; } -// Process bold, italic, and inline code -function processInlineFormatting(element: HTMLElement, text: string) { - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/); - - for (const part of parts) { - if (part.startsWith('**') && part.endsWith('**')) { - const strong = document.createElement('strong'); - strong.textContent = part.slice(2, -2); - element.append(strong); - } else if (part.startsWith('*') && part.endsWith('*')) { - const em = document.createElement('em'); - em.textContent = part.slice(1, -1); - element.append(em); - } else if (part.startsWith('`') && part.endsWith('`')) { - const code = document.createElement('code'); - code.textContent = part.slice(1, -1); - element.append(code); - } else if (part) { - element.append(document.createTextNode(part)); - } - } -} - export const frontendRender: FrontendRenderFunc = async (opts) => { try { const notebook = JSON.parse(opts.contentString());