Commit b40466ca authored by Eduardo Bonet's avatar Eduardo Bonet

Renders images on the repository of .ipynb files

Images that were not embedded into Jupyter Notebooks were not being
properly rendered because the path would point to the blob url, not the
raw URL.

Changelog: changed
Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/28601
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69075
parent bf7c7611
...@@ -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