diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 4b3c96fd33d2c..1926c46f4af5e 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -48,6 +48,11 @@ func RegisterRenderers() { }, }) + markup.RegisterRenderer(&frontendRenderer{ + name: "jupyter-notebook", + patterns: []string{"*.ipynb"}, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/package.json b/package.json index 04f27fec82d1c..fff2ad1e2d951 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 cca57d3b1958a..147dcccb29e5e 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 new file mode 100644 index 0000000000000..9528fbd85da95 --- /dev/null +++ b/tests/e2e/jupyter-render.test.ts @@ -0,0 +1,62 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateFile, assertNoJsError, randomString} from './utils.ts'; + +test.describe('jupyter notebook rendering', () => { + let repoName: string; + let owner: string; + + test.beforeAll(async ({request}) => { + repoName = `e2e-jupyter-${randomString(8)}`; + owner = env.GITEA_TEST_E2E_USER; + + await apiCreateRepo(request, {name: repoName}); + + // Single comprehensive test notebook + const notebook = JSON.stringify({ + cells: [ + {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: []}, + {cell_type: 'code', source: ['err'], outputs: [{output_type: 'error', ename: 'ValueError', evalue: 'Test', traceback: ['ValueError: Test']}]}, + {cell_type: 'code', source: ['mixed'], outputs: [{output_type: 'stream', name: 'stdout', text: ['text\n']}, {output_type: 'execute_result', data: {'text/html': ['HTML']}}]}, + ], + metadata: {}, nbformat: 4, nbformat_minor: 5, + }); + + await apiCreateFile(request, owner, repoName, 'test.ipynb', notebook); + }); + + test('renders markdown cells', async ({page}) => { + await login(page); + await page.goto(`/${owner}/${repoName}/src/branch/main/test.ipynb`); + await assertNoJsError(page); + 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}) => { + 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.code .output pre').first()).toBeVisible(); + }); + + test('renders image outputs', async ({page}) => { + 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.code .output img')).toBeVisible(); + }); + + test('renders error outputs', async ({page}) => { + 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('.error-output')).toBeVisible(); + }); +}); diff --git a/web_src/css/features/jupyter.css b/web_src/css/features/jupyter.css new file mode 100644 index 0000000000000..5d366eb237c06 --- /dev/null +++ b/web_src/css/features/jupyter.css @@ -0,0 +1,192 @@ +/* Gitea styles for Jupyter notebook content */ +.jupyter-notebook { + padding: 20px; + background: var(--color-body); + color: var(--color-text); + font-family: inherit; +} + +/* Cell containers */ +.jupyter-notebook .cell { + margin-bottom: 20px; +} + +/* Markdown cells */ +.jupyter-notebook .cell.markdown { + background: var(--color-body); + border: none; +} + +.jupyter-notebook .cell.markdown .input { + padding: 6px 12px; + line-height: 1.6; + background: var(--color-body); + color: var(--color-text); + margin-left: 110px; +} + +.jupyter-notebook .cell.markdown h1, +.jupyter-notebook .cell.markdown h2, +.jupyter-notebook .cell.markdown h3 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--font-weight-semibold); + line-height: 1.25; + color: var(--color-text); +} + +.jupyter-notebook .cell.markdown h1 { + font-size: 1.875em; + border-bottom: 1px solid var(--color-secondary-alpha-20); + padding-bottom: 0.3em; +} + +.jupyter-notebook .cell.markdown h2 { + font-size: 1.5em; +} + +.jupyter-notebook .cell.markdown h3 { + font-size: 1.25em; +} + +.jupyter-notebook .cell.markdown p { + margin-top: 0; + margin-bottom: 16px; +} + +.jupyter-notebook .cell.markdown code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: var(--color-secondary-alpha-20); + border-radius: 3px; + font-family: var(--fonts-monospace); +} + +.jupyter-notebook .cell.markdown table { + border-collapse: collapse; + width: 100%; + margin: 16px 0; + font-size: 13px; +} + +.jupyter-notebook .cell.markdown table th, +.jupyter-notebook .cell.markdown table td { + border: 1px solid var(--color-secondary); + padding: 6px 13px; + text-align: left; +} + +.jupyter-notebook .cell.markdown table th { + background: var(--color-secondary-alpha-20); + font-weight: var(--font-weight-semibold); +} + +.jupyter-notebook .cell.markdown table tr:nth-child(even) { + background: var(--color-secondary-alpha-10); +} + +/* Code cells */ +.jupyter-notebook .cell.code { + background: transparent; + border: none; +} + +.jupyter-notebook .cell.code .input-wrapper, +.jupyter-notebook .cell.code .output-wrapper { + display: flex; + align-items: flex-start; +} + +.jupyter-notebook .cell.code .prompt { + padding: 10px 10px 10px 0; + color: var(--color-text-light-2); + font-family: var(--fonts-monospace); + font-size: 13px; + white-space: nowrap; + user-select: none; + text-align: right; + width: 100px; + flex-shrink: 0; +} + +.jupyter-notebook .cell.code .input { + flex: 1; + background-color: var(--color-code-bg, #f6f8fa); + border: 1px solid var(--color-secondary-alpha-20, #d0d7de); + border-radius: 4px; + min-height: 40px; +} + +.jupyter-notebook .cell.code .input pre { + margin: 0; + padding: 10px 16px; + font-family: var(--fonts-monospace, monospace); + font-size: 13px; + line-height: 1.5; + overflow-x: auto; + color: var(--color-text); + background: transparent; +} + +.jupyter-notebook .cell.code .input code { + display: block; + font-family: inherit; +} + +/* Code outputs */ +.jupyter-notebook .cell.code .output { + flex: 1; + background: var(--color-body); + color: var(--color-text); + overflow-x: auto; + min-width: 0; +} + +.jupyter-notebook .cell.code .output pre { + margin: 0; + padding: 10px 16px; + font-family: var(--fonts-monospace); + font-size: 13px; + line-height: 1.5; + overflow-x: auto; + color: var(--color-text); + background: var(--color-body); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.jupyter-notebook .cell.code .output table { + border-collapse: collapse; + margin: 10px 16px; + font-size: 13px; + max-width: 100%; +} + +.jupyter-notebook .cell.code .output table th, +.jupyter-notebook .cell.code .output table td { + border: 1px solid var(--color-secondary); + padding: 6px 13px; + text-align: left; +} + +.jupyter-notebook .cell.code .output table th { + background: var(--color-secondary-alpha-20); + font-weight: var(--font-weight-semibold); +} + +.jupyter-notebook .cell.code .output table tr:nth-child(even) { + background: var(--color-secondary-alpha-10); +} + +.jupyter-notebook .cell.code .output img { + max-width: 90%; + height: auto; + display: block; + margin: 10px 0; +} + +.jupyter-notebook .cell.code .output table img { + margin: 0; + width: auto; +} diff --git a/web_src/js/external-render-frontend.ts b/web_src/js/external-render-frontend.ts index 9d969bcf90004..bd2c548741297 100644 --- a/web_src/js/external-render-frontend.ts +++ b/web_src/js/external-render-frontend.ts @@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>; const frontendPlugins: Record = { 'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'), 'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'), + 'jupyter-notebook': () => import('./render/plugins/frontend-jupyter-notebook.ts'), }; class Options implements FrontendRenderOptions { diff --git a/web_src/js/render/plugins/frontend-jupyter-notebook.ts b/web_src/js/render/plugins/frontend-jupyter-notebook.ts new file mode 100644 index 0000000000000..365b41ce51ada --- /dev/null +++ b/web_src/js/render/plugins/frontend-jupyter-notebook.ts @@ -0,0 +1,227 @@ +import type {FrontendRenderFunc} from '../plugin.ts'; +import {marked} from 'marked'; +import '../../../css/features/jupyter.css'; + +// Helper to create elements with properties +function createElement( + tag: K, + props?: {className?: string; textContent?: string; innerHTML?: string}, +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + if (props?.className) el.className = props.className; + if (props?.textContent) el.textContent = props.textContent; + if (props?.innerHTML) el.innerHTML = props.innerHTML; + return el; +} + +// Render markdown using marked library +function renderMarkdown(markdown: string): HTMLElement { + const container = document.createElement('div'); + container.className = 'markup'; + container.innerHTML = marked.parse(markdown) as string; + return container; +} + +export const frontendRender: FrontendRenderFunc = async (opts) => { + try { + const notebook = JSON.parse(opts.contentString()); + + if (!notebook.cells || !Array.isArray(notebook.cells)) { + throw new Error('Invalid notebook format: missing or invalid cells array'); + } + + // Detect language from notebook metadata + const language = notebook.metadata?.language_info?.name || + notebook.metadata?.kernelspec?.language || + 'text'; + + const container = createElement('div', {className: 'jupyter-notebook'}); + + let executionCount = 1; + + for (const cell of notebook.cells) { + if (!cell.cell_type) continue; + + const cellDiv = createElement('div', {className: `cell ${cell.cell_type}`}); + + if (cell.cell_type === 'markdown') { + const inputDiv = createElement('div', {className: 'input markup'}); + const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); + inputDiv.append(renderMarkdown(source)); + cellDiv.append(inputDiv); + } else if (cell.cell_type === 'code') { + const inputWrapper = createElement('div', {className: 'input-wrapper'}); + + const prompt = createElement('div', { + className: 'prompt input-prompt', + textContent: `In [${cell.execution_count ?? executionCount}]:`, + }); + inputWrapper.append(prompt); + + const inputDiv = createElement('div', {className: 'input'}); + + const pre = document.createElement('pre'); + const code = createElement('code', {className: `language-${language}`}); + const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); + code.textContent = source; + pre.append(code); + inputDiv.append(pre); + inputWrapper.append(inputDiv); + cellDiv.append(inputWrapper); + + if (cell.outputs && Array.isArray(cell.outputs) && cell.outputs.length > 0) { + const outputWrapper = createElement('div', {className: 'output-wrapper'}); + + const hasExecutionResult = cell.outputs.some((o: any) => o.output_type === 'execute_result'); + + const outPrompt = createElement('div', {className: 'prompt output-prompt'}); + if (hasExecutionResult) { + outPrompt.textContent = `Out[${cell.execution_count ?? executionCount}]:`; + } + outputWrapper.append(outPrompt); + + const outputDiv = createElement('div', {className: 'output'}); + + for (const output of cell.outputs) { + try { + if (output.data) { + if (output.data['image/png']) { + const img = document.createElement('img'); + const imgData = Array.isArray(output.data['image/png']) ? + output.data['image/png'].join('') : output.data['image/png']; + img.src = `data:image/png;base64,${imgData}`; + img.style.maxWidth = '100%'; + outputDiv.append(img); + } else if (output.data['image/jpeg']) { + const img = document.createElement('img'); + const imgData = Array.isArray(output.data['image/jpeg']) ? + output.data['image/jpeg'].join('') : output.data['image/jpeg']; + img.src = `data:image/jpeg;base64,${imgData}`; + img.style.maxWidth = '100%'; + outputDiv.append(img); + } else if (output.data['image/svg+xml']) { + const svgDiv = document.createElement('div'); + const svgData = Array.isArray(output.data['image/svg+xml']) ? + output.data['image/svg+xml'].join('') : output.data['image/svg+xml']; + svgDiv.innerHTML = svgData; + outputDiv.append(svgDiv); + } else if (output.data['text/html']) { + const wrapperDiv = document.createElement('div'); + wrapperDiv.style.overflowX = 'auto'; + wrapperDiv.style.maxWidth = '100%'; + const htmlDiv = document.createElement('div'); + const htmlData = Array.isArray(output.data['text/html']) ? + output.data['text/html'].join('') : output.data['text/html']; + htmlDiv.innerHTML = htmlData; + // Ensure images inside HTML outputs are constrained + for (const img of htmlDiv.querySelectorAll('img')) { + img.style.maxWidth = '100%'; + img.style.height = 'auto'; + } + wrapperDiv.append(htmlDiv); + outputDiv.append(wrapperDiv); + } else if (output.data['application/javascript']) { + const jsDiv = createElement('div', { + className: 'js-output-warning', + textContent: '[JavaScript output - execution disabled for security]', + }); + jsDiv.style.color = 'var(--color-text-light-2)'; + jsDiv.style.fontStyle = 'italic'; + outputDiv.append(jsDiv); + } else if (output.data['application/vnd.plotly.v1+json']) { + const plotlyDiv = createElement('div', { + className: 'plotly-output-warning', + textContent: '[Plotly output - interactive plots not supported]', + }); + plotlyDiv.style.color = 'var(--color-text-light-2)'; + plotlyDiv.style.fontStyle = 'italic'; + outputDiv.append(plotlyDiv); + } else if (output.data['application/vnd.jupyter.widget-view+json']) { + const widgetDiv = createElement('div', { + className: 'widget-output-warning', + textContent: '[Jupyter widget - interactive widgets not supported]', + }); + widgetDiv.style.color = 'var(--color-text-light-2)'; + widgetDiv.style.fontStyle = 'italic'; + outputDiv.append(widgetDiv); + } else if (output.data['text/latex']) { + const latex = Array.isArray(output.data['text/latex']) ? + output.data['text/latex'].join('') : output.data['text/latex']; + const pre = document.createElement('pre'); + const mathCode = createElement('code', { + className: 'language-math display', + textContent: latex.replace(/^\$\$|\$\$$/g, ''), + }); + pre.append(mathCode); + outputDiv.append(pre); + } else if (output.data['text/plain']) { + const plainText = Array.isArray(output.data['text/plain']) ? + output.data['text/plain'].join('') : output.data['text/plain']; + const textPre = createElement('pre', {textContent: plainText}); + outputDiv.append(textPre); + } + } else if (output.output_type === 'stream' && output.name) { + const streamText = Array.isArray(output.text) ? output.text.join('') : (output.text || ''); + const streamPre = createElement('pre', { + className: `stream-${output.name}`, + textContent: streamText, + }); + outputDiv.append(streamPre); + } else if (output.output_type === 'error') { + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : + (output.ename && output.evalue ? `${output.ename}: ${output.evalue}` : 'Error'); + const errorPre = createElement('pre', { + className: 'error-output', + textContent: traceback, + }); + errorPre.style.color = 'var(--color-red)'; + outputDiv.append(errorPre); + } else if (output.text) { + const text = Array.isArray(output.text) ? output.text.join('') : output.text; + const textPre = createElement('pre', {textContent: text}); + outputDiv.append(textPre); + } + } catch (outputError) { + console.warn('Failed to render output:', outputError); + const errorDiv = createElement('div', { + textContent: '[Output rendering failed]', + }); + errorDiv.style.color = 'var(--color-text-light-2)'; + errorDiv.style.fontStyle = 'italic'; + outputDiv.append(errorDiv); + } + } + + if (outputDiv.children.length > 0) { + outputWrapper.append(outputDiv); + cellDiv.append(outputWrapper); + } + } + + executionCount++; + } + + container.append(cellDiv); + } + + opts.container.append(container); + + const {initMarkupCodeMath} = await import('../../markup/math.ts'); + await initMarkupCodeMath(container); + + return true; + } catch (error) { + console.error('Jupyter notebook rendering failed:', error); + const errorDiv = document.createElement('div'); + errorDiv.style.padding = '20px'; + errorDiv.style.color = 'var(--color-red)'; + const errorTitle = document.createElement('strong'); + errorTitle.textContent = 'Failed to render notebook:'; + errorDiv.append(errorTitle); + errorDiv.append(document.createElement('br')); + const errorMessage = error instanceof Error ? error.message : String(error); + errorDiv.append(document.createTextNode(errorMessage)); + opts.container.append(errorDiv); + return false; + } +};