1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
<script>
/*
This component is designed to render the markdown, which is **not** the GitLab Flavored Markdown.
It renders the code snippets the same way GitLab Flavored Markdown code snippets are rendered
respecting the user's preferred color scheme and featuring a copy-code button.
This component can be used to render client-side markdown that doesn't have GitLab-specific markdown elements such as issue links.
*/
import { marked } from 'marked';
import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
components: {
CodeBlockHighlighted,
ModalCopyButton,
},
directives: {
SafeHtml,
},
props: {
markdown: {
type: String,
required: true,
},
},
data() {
return {
hoverMap: {},
};
},
computed: {
markdownBlocks() {
// we use lexer https://marked.js.org/using_pro#lexer
// to get an array of tokens that marked npm module uses.
// We will use these tokens to override rendering of some of them
// with our vue components
const tokens = marked.lexer(this.markdown);
// since we only want to differentiate between code and non-code blocks
// we want non-code blocks merged together so that the markdown parser could render
// them according to the markdown rules.
// This way we introduce minimum extra wrapper mark-up
const flattenedTokens = [];
for (const token of tokens) {
const lastFlattenedToken = flattenedTokens[flattenedTokens.length - 1];
if (token.type === 'code') {
flattenedTokens.push(token);
} else if (lastFlattenedToken?.type === 'markdown') {
lastFlattenedToken.raw += token.raw;
} else {
flattenedTokens.push({ type: 'markdown', raw: token.raw });
}
}
return flattenedTokens;
},
},
methods: {
getSafeHtml(markdown) {
return sanitize(marked.parse(markdown), markdownConfig);
},
setHoverOn(key) {
this.hoverMap = { ...this.hoverMap, [key]: true };
},
setHoverOff(key) {
this.hoverMap = { ...this.hoverMap, [key]: false };
},
isLastElement(index) {
return index === this.markdownBlocks.length - 1;
},
},
safeHtmlConfig: {
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
},
i18n: {
copyCodeTitle: __('Copy code'),
},
fallbackLanguage: 'text',
};
</script>
<template>
<div>
<template v-for="(block, index) in markdownBlocks">
<div
v-if="block.type === 'code'"
:key="`code-${index}`"
:class="{ 'gl-relative': true, 'gl-mb-4': !isLastElement(index) }"
data-testid="code-block-wrapper"
@mouseenter="setHoverOn(`code-${index}`)"
@mouseleave="setHoverOff(`code-${index}`)"
>
<modal-copy-button
v-if="hoverMap[`code-${index}`]"
:title="$options.i18n.copyCodeTitle"
:text="block.text"
class="gl-absolute gl-top-3 gl-right-3 gl-z-index-1 gl-transition-duration-medium"
/>
<code-block-highlighted
class="gl-border gl-rounded-0! gl-p-4 gl-mb-0 gl-overflow-y-auto"
:language="block.lang || $options.fallbackLanguage"
:code="block.text"
/>
</div>
<div
v-else
:key="`text-${index}`"
v-safe-html:[$options.safeHtmlConfig]="getSafeHtml(block.raw)"
:class="{ 'non-gfm-markdown-block': true, 'gl-mb-4': !isLastElement(index) }"
data-testid="non-code-markdown"
></div>
</template>
</div>
</template>
|