Commit 2d8365a2 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch...

Merge branch '28601-have-gitlab-render-both-latex-and-images-correctly-in-jupyter-notebooks-2' into 'master'

Rendering images with relative path on Jupyter Notebooks

See merge request gitlab-org/gitlab!69075
parents 83f24355 b40466ca
...@@ -6,6 +6,9 @@ export default () => { ...@@ -6,6 +6,9 @@ export default () => {
return new Vue({ return new Vue({
el, el,
provide: {
relativeRawPath: el.dataset.relativeRawPath,
},
render(createElement) { render(createElement) {
return createElement(NotebookViewer, { return createElement(NotebookViewer, {
props: { props: {
......
...@@ -94,7 +94,16 @@ renderer.image = function image(href, title, text) { ...@@ -94,7 +94,16 @@ renderer.image = function image(href, title, text) {
const attachmentHeader = `attachment:`; // eslint-disable-line @gitlab/require-i18n-strings const attachmentHeader = `attachment:`; // eslint-disable-line @gitlab/require-i18n-strings
if (!this.attachments || !href.startsWith(attachmentHeader)) { if (!this.attachments || !href.startsWith(attachmentHeader)) {
return this.originalImage(href, title, text); let relativeHref = href;
// eslint-disable-next-line @gitlab/require-i18n-strings
if (!(href.startsWith('http') || href.startsWith('data:'))) {
// These are images within the repo. This will only work if the image
// is relative to the path where the file is located
relativeHref = this.relativeRawPath + href;
}
return this.originalImage(relativeHref, title, text);
} }
let img = ``; let img = ``;
...@@ -129,6 +138,7 @@ export default { ...@@ -129,6 +138,7 @@ export default {
components: { components: {
prompt: Prompt, prompt: Prompt,
}, },
inject: ['relativeRawPath'],
props: { props: {
cell: { cell: {
type: Object, type: Object,
...@@ -138,6 +148,7 @@ export default { ...@@ -138,6 +148,7 @@ export default {
computed: { computed: {
markdown() { markdown() {
renderer.attachments = this.cell.attachments; renderer.attachments = this.cell.attachments;
renderer.relativeRawPath = this.relativeRawPath;
return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig); return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig);
}, },
......
...@@ -183,6 +183,10 @@ module BlobHelper ...@@ -183,6 +183,10 @@ module BlobHelper
blob_raw_url(**kwargs, only_path: true) blob_raw_url(**kwargs, only_path: true)
end end
def parent_dir_raw_path
blob_raw_path.rpartition("/").first + "/"
end
# SVGs can contain malicious JavaScript; only include whitelisted # SVGs can contain malicious JavaScript; only include whitelisted
# elements and attributes. Note that this whitelist is by no means complete # elements and attributes. Note that this whitelist is by no means complete
# and may omit some elements. # and may omit some elements.
......
...@@ -17,6 +17,10 @@ class SnippetBlobPresenter < BlobPresenter ...@@ -17,6 +17,10 @@ class SnippetBlobPresenter < BlobPresenter
snippet_blob_raw_route snippet_blob_raw_route
end end
def raw_directory
raw_path.rpartition("/").first + "/"
end
def raw_plain_data def raw_plain_data
blob.data unless blob.binary? blob.data unless blob.binary?
end end
...@@ -33,7 +37,7 @@ class SnippetBlobPresenter < BlobPresenter ...@@ -33,7 +37,7 @@ class SnippetBlobPresenter < BlobPresenter
def render_rich_partial def render_rich_partial
renderer.render("projects/blob/viewers/_#{blob.rich_viewer.partial_name}", renderer.render("projects/blob/viewers/_#{blob.rich_viewer.partial_name}",
locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url }, locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url, parent_dir_raw_path: raw_directory },
layout: false) layout: false)
end end
......
.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path } } .file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path, relative_raw_path: parent_dir_raw_path } }
...@@ -11,6 +11,7 @@ describe('iPython notebook renderer', () => { ...@@ -11,6 +11,7 @@ describe('iPython notebook renderer', () => {
let mock; let mock;
const endpoint = 'test'; const endpoint = 'test';
const relativeRawPath = '';
const mockNotebook = { const mockNotebook = {
cells: [ cells: [
{ {
...@@ -27,7 +28,7 @@ describe('iPython notebook renderer', () => { ...@@ -27,7 +28,7 @@ describe('iPython notebook renderer', () => {
}; };
const mountComponent = () => { const mountComponent = () => {
wrapper = shallowMount(component, { propsData: { endpoint } }); wrapper = shallowMount(component, { propsData: { endpoint, relativeRawPath } });
}; };
const findLoading = () => wrapper.find(GlLoadingIcon); const findLoading = () => wrapper.find(GlLoadingIcon);
......
import { mount } from '@vue/test-utils';
import katex from 'katex'; import katex from 'katex';
import Vue from 'vue'; import Vue from 'vue';
import MarkdownComponent from '~/notebook/cells/markdown.vue'; import MarkdownComponent from '~/notebook/cells/markdown.vue';
...@@ -6,6 +7,28 @@ const Component = Vue.extend(MarkdownComponent); ...@@ -6,6 +7,28 @@ const Component = Vue.extend(MarkdownComponent);
window.katex = katex; window.katex = katex;
function buildCellComponent(cell, relativePath = '') {
return mount(Component, {
propsData: {
cell,
},
provide: {
relativeRawPath: relativePath,
},
}).vm;
}
function buildMarkdownComponent(markdownContent, relativePath = '') {
return buildCellComponent(
{
cell_type: 'markdown',
metadata: {},
source: markdownContent,
},
relativePath,
);
}
describe('Markdown component', () => { describe('Markdown component', () => {
let vm; let vm;
let cell; let cell;
...@@ -17,12 +40,7 @@ describe('Markdown component', () => { ...@@ -17,12 +40,7 @@ describe('Markdown component', () => {
// eslint-disable-next-line prefer-destructuring // eslint-disable-next-line prefer-destructuring
cell = json.cells[1]; cell = json.cells[1];
vm = new Component({ vm = buildCellComponent(cell);
propsData: {
cell,
},
});
vm.$mount();
return vm.$nextTick(); return vm.$nextTick();
}); });
...@@ -61,17 +79,36 @@ describe('Markdown component', () => { ...@@ -61,17 +79,36 @@ describe('Markdown component', () => {
expect(findLink().getAttribute('data-type')).toBe(null); expect(findLink().getAttribute('data-type')).toBe(null);
}); });
describe('When parsing images', () => {
it.each([
[
'for relative images in root folder, it does',
'![](local_image.png)\n',
'src="/raw/local_image',
],
[
'for relative images in child folders, it does',
'![](data/local_image.png)\n',
'src="/raw/data',
],
["for embedded images, it doesn't", '![](data:image/jpeg;base64)\n', 'src="data:'],
["for images urls, it doesn't", '![](http://image.png)\n', 'src="http:'],
])('%s', async ([testMd, mustContain]) => {
vm = buildMarkdownComponent([testMd], '/raw/');
await vm.$nextTick();
expect(vm.$el.innerHTML).toContain(mustContain);
});
});
describe('tables', () => { describe('tables', () => {
beforeEach(() => { beforeEach(() => {
json = getJSONFixture('blob/notebook/markdown-table.json'); json = getJSONFixture('blob/notebook/markdown-table.json');
}); });
it('renders images and text', () => { it('renders images and text', () => {
vm = new Component({ vm = buildCellComponent(json.cells[0]);
propsData: {
cell: json.cells[0],
},
}).$mount();
return vm.$nextTick().then(() => { return vm.$nextTick().then(() => {
const images = vm.$el.querySelectorAll('img'); const images = vm.$el.querySelectorAll('img');
...@@ -102,48 +139,28 @@ describe('Markdown component', () => { ...@@ -102,48 +139,28 @@ describe('Markdown component', () => {
}); });
it('renders multi-line katex', async () => { it('renders multi-line katex', async () => {
vm = new Component({ vm = buildCellComponent(json.cells[0]);
propsData: {
cell: json.cells[0],
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
expect(vm.$el.querySelector('.katex')).not.toBeNull(); expect(vm.$el.querySelector('.katex')).not.toBeNull();
}); });
it('renders inline katex', async () => { it('renders inline katex', async () => {
vm = new Component({ vm = buildCellComponent(json.cells[1]);
propsData: {
cell: json.cells[1],
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull(); expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull();
}); });
it('renders multiple inline katex', async () => { it('renders multiple inline katex', async () => {
vm = new Component({ vm = buildCellComponent(json.cells[1]);
propsData: {
cell: json.cells[1],
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4); expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4);
}); });
it('output cell in case of katex error', async () => { it('output cell in case of katex error', async () => {
vm = new Component({ vm = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']);
propsData: {
cell: {
cell_type: 'markdown',
metadata: {},
source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one paragraph with no katex formula in it // expect one paragraph with no katex formula in it
...@@ -152,15 +169,10 @@ describe('Markdown component', () => { ...@@ -152,15 +169,10 @@ describe('Markdown component', () => {
}); });
it('output cell and render remaining formula in case of katex error', async () => { it('output cell and render remaining formula in case of katex error', async () => {
vm = new Component({ vm = buildMarkdownComponent([
propsData: { 'An invalid $a & b$ inline formula and a vaild one $b = c$\n',
cell: { '\n',
cell_type: 'markdown', ]);
metadata: {},
source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one paragraph with no katex formula in it // expect one paragraph with no katex formula in it
...@@ -169,15 +181,7 @@ describe('Markdown component', () => { ...@@ -169,15 +181,7 @@ describe('Markdown component', () => {
}); });
it('renders math formula in list object', async () => { it('renders math formula in list object', async () => {
vm = new Component({ vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']);
propsData: {
cell: {
cell_type: 'markdown',
metadata: {},
source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one list with a katex formula in it // expect one list with a katex formula in it
...@@ -186,15 +190,7 @@ describe('Markdown component', () => { ...@@ -186,15 +190,7 @@ describe('Markdown component', () => {
}); });
it("renders math formula with tick ' in it", async () => { it("renders math formula with tick ' in it", async () => {
vm = new Component({ vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']);
propsData: {
cell: {
cell_type: 'markdown',
metadata: {},
source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one list with a katex formula in it // expect one list with a katex formula in it
...@@ -203,15 +199,7 @@ describe('Markdown component', () => { ...@@ -203,15 +199,7 @@ describe('Markdown component', () => {
}); });
it('renders math formula with less-than-operator < in it', async () => { it('renders math formula with less-than-operator < in it', async () => {
vm = new Component({ vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b < c$\n', '\n']);
propsData: {
cell: {
cell_type: 'markdown',
metadata: {},
source: ['- list with inline $a=2$ inline formula $a + b < c$\n', '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one list with a katex formula in it // expect one list with a katex formula in it
...@@ -220,15 +208,7 @@ describe('Markdown component', () => { ...@@ -220,15 +208,7 @@ describe('Markdown component', () => {
}); });
it('renders math formula with greater-than-operator > in it', async () => { it('renders math formula with greater-than-operator > in it', async () => {
vm = new Component({ vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b > c$\n', '\n']);
propsData: {
cell: {
cell_type: 'markdown',
metadata: {},
source: ['- list with inline $a=2$ inline formula $a + b > c$\n', '\n'],
},
},
}).$mount();
await vm.$nextTick(); await vm.$nextTick();
// expect one list with a katex formula in it // expect one list with a katex formula in it
......
import { mount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import Notebook from '~/notebook/index.vue'; import Notebook from '~/notebook/index.vue';
...@@ -13,14 +14,16 @@ describe('Notebook component', () => { ...@@ -13,14 +14,16 @@ describe('Notebook component', () => {
jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json');
}); });
function buildComponent(notebook) {
return mount(Component, {
propsData: { notebook, codeCssClass: 'js-code-class' },
provide: { relativeRawPath: '' },
}).vm;
}
describe('without JSON', () => { describe('without JSON', () => {
beforeEach((done) => { beforeEach((done) => {
vm = new Component({ vm = buildComponent({});
propsData: {
notebook: {},
},
});
vm.$mount();
setImmediate(() => { setImmediate(() => {
done(); done();
...@@ -34,13 +37,7 @@ describe('Notebook component', () => { ...@@ -34,13 +37,7 @@ describe('Notebook component', () => {
describe('with JSON', () => { describe('with JSON', () => {
beforeEach((done) => { beforeEach((done) => {
vm = new Component({ vm = buildComponent(json);
propsData: {
notebook: json,
codeCssClass: 'js-code-class',
},
});
vm.$mount();
setImmediate(() => { setImmediate(() => {
done(); done();
...@@ -66,13 +63,7 @@ describe('Notebook component', () => { ...@@ -66,13 +63,7 @@ describe('Notebook component', () => {
describe('with worksheets', () => { describe('with worksheets', () => {
beforeEach((done) => { beforeEach((done) => {
vm = new Component({ vm = buildComponent(jsonWithWorksheet);
propsData: {
notebook: jsonWithWorksheet,
codeCssClass: 'js-code-class',
},
});
vm.$mount();
setImmediate(() => { setImmediate(() => {
done(); done();
......
...@@ -92,6 +92,30 @@ RSpec.describe BlobHelper do ...@@ -92,6 +92,30 @@ RSpec.describe BlobHelper do
end end
end end
describe "#relative_raw_path" do
include FakeBlobHelpers
let_it_be(:project) { create(:project) }
before do
assign(:project, project)
end
[
%w[/file.md /-/raw/main/],
%w[/test/file.md /-/raw/main/test/],
%w[/another/test/file.md /-/raw/main/another/test/]
].each do |file_path, expected_path|
it "pointing from '#{file_path}' to '#{expected_path}'" do
blob = fake_blob(path: file_path)
assign(:blob, blob)
assign(:id, "main#{blob.path}")
assign(:path, blob.path)
expect(helper.parent_dir_raw_path).to eq "/#{project.full_path}#{expected_path}"
end
end
end
context 'viewer related' do context 'viewer related' do
include FakeBlobHelpers include FakeBlobHelpers
......
...@@ -10,6 +10,7 @@ RSpec.describe SnippetBlobPresenter do ...@@ -10,6 +10,7 @@ RSpec.describe SnippetBlobPresenter do
describe '#rich_data' do describe '#rich_data' do
let(:data_endpoint_url) { "/-/snippets/#{snippet.id}/raw/#{branch}/#{file}" } let(:data_endpoint_url) { "/-/snippets/#{snippet.id}/raw/#{branch}/#{file}" }
let(:data_raw_dir) { "/-/snippets/#{snippet.id}/raw/#{branch}/" }
before do before do
allow_next_instance_of(described_class) do |instance| allow_next_instance_of(described_class) do |instance|
...@@ -45,7 +46,7 @@ RSpec.describe SnippetBlobPresenter do ...@@ -45,7 +46,7 @@ RSpec.describe SnippetBlobPresenter do
let(:file) { 'test.ipynb' } let(:file) { 'test.ipynb' }
it 'returns rich notebook content' do it 'returns rich notebook content' do
expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" id="js-notebook-viewer"></div>) expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" data-relative-raw-path="#{data_raw_dir}" id="js-notebook-viewer"></div>)
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment