Commit 1059e7da authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '31810-markdown-images' into 'master'

Allow Web IDE Markdown Preview to display uncommitted images

See merge request gitlab-org/gitlab!31540
parents d3eba34d e265b006
......@@ -13,6 +13,7 @@ import {
import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
export default {
components: {
......@@ -26,6 +27,12 @@ export default {
required: true,
},
},
data() {
return {
content: '',
images: {},
};
},
computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
......@@ -36,6 +43,7 @@ export default {
'currentActivityView',
'renderWhitespaceInCode',
'editorTheme',
'entries',
]),
...mapGetters([
'currentMergeRequest',
......@@ -136,6 +144,18 @@ export default {
this.$nextTick(() => this.refreshEditorDimensions());
}
},
showContentViewer(val) {
if (!val) return;
if (this.fileType === 'markdown') {
const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
this.content = content;
this.images = images;
} else {
this.content = this.file.content || this.file.raw;
this.images = {};
}
},
},
beforeDestroy() {
this.editor.dispose();
......@@ -310,7 +330,8 @@ export default {
></div>
<content-viewer
v-if="showContentViewer"
:content="file.content || file.raw"
:content="content"
:images="images"
:path="file.rawPath || file.path"
:file-path="file.path"
:file-size="file.size"
......
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
......@@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => {
// if the contents of a file dont end with a newline, this function adds a newline
export const addFinalNewlineIfNeeded = content =>
content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content;
export function extractMarkdownImagesFromEntries(mdFile, entries) {
/**
* Regex to identify an image tag in markdown, like:
*
* ![img alt goes here](/img.png)
* ![img alt](../img 1/img.png "my image title")
* ![img alt](https://gitlab.com/assets/logo.svg "title here")
*
*/
const reMdImage = /!\[([^\]]*)\]\((.*?)(?:(?="|\))"([^"]*)")?\)/gi;
const prefix = 'gl_md_img_';
const images = {};
let content = mdFile.content || mdFile.raw;
let i = 0;
content = content.replace(reMdImage, (_, alt, path, title) => {
const imagePath = (isRootRelative(path) ? path : relativePathToAbsolute(path, mdFile.path))
.substr(1)
.trim();
const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw;
if (!isAbsolute(path) && imageContent) {
const ext = path.includes('.')
? path
.split('.')
.pop()
.trim()
: 'jpeg';
const src = `data:image/${ext};base64,${imageContent}`;
i += 1;
const key = `{{${prefix}${i}}}`;
images[key] = { alt, src, title };
return key;
}
return title ? `![${alt}](${path}"${title}")` : `![${alt}](${path})`;
});
return { content, images };
}
......@@ -39,6 +39,11 @@ export default {
required: false,
default: '',
},
images: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
viewer() {
......@@ -67,6 +72,7 @@ export default {
:file-size="fileSize"
:project-path="projectPath"
:content="content"
:images="images"
:commit-sha="commitSha"
/>
</div>
......
......@@ -5,6 +5,7 @@ import '~/behaviors/markdown/render_gfm';
import { GlSkeletonLoading } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { forEach, escape } from 'lodash';
const { CancelToken } = axios;
let axiosSource;
......@@ -32,6 +33,11 @@ export default {
type: String,
required: true,
},
images: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
......@@ -76,7 +82,15 @@ export default {
postOptions,
)
.then(({ data }) => {
this.previewContent = data.body;
let previewContent = data.body;
forEach(this.images, ({ src, title = '', alt }, key) => {
previewContent = previewContent.replace(
key,
`<img src="${escape(src)}" title="${escape(title)}" alt="${escape(alt)}">`,
);
});
this.previewContent = previewContent;
this.isLoading = false;
this.$nextTick(() => {
......
---
title: Allow Web IDE markdown to preview uncommitted images
merge_request: 31540
author:
type: added
......@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => {
});
});
});
describe('extractMarkdownImagesFromEntries', () => {
let mdFile;
let entries;
beforeEach(() => {
const img = { content: '/base64/encoded/image+' };
mdFile = { path: 'path/to/some/directory/myfile.md' };
entries = {
// invalid (or lack of) extensions are also supported as long as there's
// a real image inside and can go into an <img> tag's `src` and the browser
// can render it
img,
'img.js': img,
'img.png': img,
'img.with.many.dots.png': img,
'path/to/img.gif': img,
'path/to/some/img.jpg': img,
'path/to/some/img 1/img.png': img,
'path/to/some/directory/img.png': img,
'path/to/some/directory/img 1.png': img,
};
});
it.each`
markdownBefore | ext | imgAlt | imgTitle
${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined}
${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined}
${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined}
${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '}
${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined}
${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'}
${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined}
${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'}
`(
'correctly transforms markdown with uncommitted images: $markdownBefore',
({ markdownBefore, ext, imgAlt, imgTitle }) => {
mdFile.content = markdownBefore;
expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
content: '* {{gl_md_img_1}}',
images: {
'{{gl_md_img_1}}': {
src: `data:image/${ext};base64,/base64/encoded/image+`,
alt: imgAlt,
title: imgTitle,
},
},
});
},
);
it.each`
markdown
${'* ![img](i.png)'}
${'* ![img](img.png invalid title)'}
${'* ![img](img.png "incorrect" "markdown")'}
${'* ![img](https://gitlab.com/logo.png)'}
${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'}
`("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => {
mdFile.content = markdown;
expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
content: markdown,
images: {},
});
});
});
});
......@@ -35,7 +35,7 @@ describe('MarkdownViewer', () => {
describe('success', () => {
beforeEach(() => {
mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, {
body: '<b>testing</b>',
body: '<b>testing</b> {{gl_md_img_1}}',
});
});
......@@ -53,15 +53,48 @@ describe('MarkdownViewer', () => {
});
});
it('receives the filePath as a parameter and passes it on to the server', () => {
createComponent({ filePath: 'foo/test.md' });
it('receives the filePath and commitSha as a parameters and passes them on to the server', () => {
createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' });
expect(axios.post).toHaveBeenCalledWith(
`${gon.relative_url_root}/testproject/preview_markdown`,
{ path: 'foo/test.md', text: '* Test' },
{ path: 'foo/test.md', text: '* Test', ref: 'abcdef' },
expect.any(Object),
);
});
it.each`
imgSrc | imgAlt
${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'}
${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'}
${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'}
${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'}
${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"}
${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"}
`(
'transforms template tags with base64 encoded images available locally',
({ imgSrc, imgAlt }) => {
createComponent({
images: {
'{{gl_md_img_1}}': {
src: imgSrc,
alt: imgAlt,
title: imgAlt,
},
},
});
return waitForPromises().then(() => {
const img = wrapper.find('.md-previewer img').element;
// if the values are the same as the input, it means
// they were escaped correctly
expect(img).toHaveAttr('src', imgSrc);
expect(img).toHaveAttr('alt', imgAlt);
expect(img).toHaveAttr('title', imgAlt);
});
},
);
});
describe('error', () => {
......
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