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;
+ }
+};