0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-10 09:41:52 +02:00

Merge 3ea826abf15491444482afa39643f1cc30d38f58 into ce089f498bce32305b2d9e8c6adfd8cb7c82f88f

This commit is contained in:
Karthik Bhandary 2026-05-09 10:10:40 +02:00 committed by GitHub
commit d78490724b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 498 additions and 0 deletions

View File

@ -48,6 +48,11 @@ func RegisterRenderers() {
},
})
markup.RegisterRenderer(&frontendRenderer{
name: "jupyter-notebook",
patterns: []string{"*.ipynb"},
})
for _, renderer := range setting.ExternalMarkupRenderers {
markup.RegisterRenderer(&Renderer{renderer})
}

View File

@ -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",

10
pnpm-lock.yaml generated
View File

@ -168,6 +168,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
@ -3297,6 +3300,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'}
@ -7603,6 +7611,8 @@ snapshots:
marked@16.4.2: {}
marked@18.0.2: {}
marked@4.3.0: {}
material-icon-theme@5.34.0:

View File

@ -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': ['<b>HTML</b>']}}]},
],
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();
});
});

View File

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

View File

@ -8,6 +8,7 @@ type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>;
const frontendPlugins: Record<string, LazyLoadFunc> = {
'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 {

View File

@ -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<K extends keyof HTMLElementTagNameMap>(
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;
}
};