Last active
August 29, 2025 18:33
-
-
Save Avi-E-Koenig/3ed28b221e71174f5edaadb98073e31b to your computer and use it in GitHub Desktop.
MarkdownDisplay.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- eslint-disable vue/no-v-html --> | |
<template> | |
<div | |
ref="root" | |
:class="['markdown-display', containerClass]" | |
v-bind="$attrs" | |
v-html="safeHtml" | |
/> | |
</template> | |
<script setup> | |
import { computed, ref, watch, onMounted, nextTick } from "vue"; | |
import { marked } from "marked"; | |
import DOMPurify from "dompurify"; | |
// Use highlight.js CORE and register only the languages you need | |
import hljs from "highlight.js/lib/core"; | |
import javascript from "highlight.js/lib/languages/javascript"; | |
import typescript from "highlight.js/lib/languages/typescript"; | |
import json from "highlight.js/lib/languages/json"; | |
import bash from "highlight.js/lib/languages/bash"; | |
import xml from "highlight.js/lib/languages/xml"; // html/xml | |
import css from "highlight.js/lib/languages/css"; | |
import sql from "highlight.js/lib/languages/sql"; | |
import "highlight.js/styles/github.css"; | |
hljs.registerLanguage("javascript", javascript); | |
hljs.registerLanguage("typescript", typescript); | |
hljs.registerLanguage("json", json); | |
hljs.registerLanguage("bash", bash); | |
hljs.registerLanguage("html", xml); | |
hljs.registerLanguage("xml", xml); | |
hljs.registerLanguage("css", css); | |
hljs.registerLanguage("sql", sql); | |
defineOptions({ name: "MarkdownDisplay" }); | |
const props = defineProps({ | |
/** Markdown string to render */ | |
content: { type: String, required: true }, | |
/** Allow raw HTML in markdown (still sanitized) */ | |
allowHtml: { type: Boolean, default: false }, | |
/** GitHub-flavored markdown */ | |
gfm: { type: Boolean, default: true }, | |
/** Convert newlines to <br> */ | |
breaks: { type: Boolean, default: true }, | |
/** Extra CSS classes on the container */ | |
containerClass: { type: String, default: "" }, | |
}); | |
// Configure marked with basic options | |
marked.setOptions({ | |
gfm: props.gfm, | |
breaks: props.breaks, | |
}); | |
const root = ref(null); | |
const rawHtml = computed(() => { | |
try { | |
return marked.parse(props.content || ""); | |
} catch (_e) { | |
// very small HTML escape fallback | |
const esc = s => | |
s.replace( | |
/[&<>"']/g, | |
m => | |
({ | |
"&": "&", | |
"<": "<", | |
">": ">", | |
'"': """, | |
"'": "'", | |
})[m] | |
); | |
return `<pre>${esc(props.content || "")}</pre>`; | |
} | |
}); | |
const safeHtml = computed(() => { | |
const html = String(rawHtml.value); | |
// Sanitize the HTML | |
return DOMPurify.sanitize(html, { | |
ALLOWED_TAGS: [ | |
"h1", | |
"h2", | |
"h3", | |
"h4", | |
"h5", | |
"h6", | |
"p", | |
"br", | |
"hr", | |
"ul", | |
"ol", | |
"li", | |
"strong", | |
"em", | |
"code", | |
"pre", | |
"blockquote", | |
"table", | |
"thead", | |
"tbody", | |
"tr", | |
"th", | |
"td", | |
"a", | |
"img", | |
], | |
ALLOWED_ATTR: ["href", "src", "alt", "title", "class", "id"], | |
ADD_ATTR: ["target", "rel", "loading"], | |
}); | |
}); | |
function highlightInContainer() { | |
if (!root.value) return; | |
root.value.querySelectorAll("pre code").forEach(block => { | |
hljs.highlightElement(block); | |
}); | |
} | |
onMounted(async () => { | |
highlightInContainer(); | |
}); | |
watch(safeHtml, async () => { | |
await nextTick(); | |
highlightInContainer(); | |
}); | |
</script> | |
<style scoped lang="scss"> | |
.markdown-display { | |
background: #fff; | |
padding: 30px; | |
border-radius: 8px; | |
border: 1px solid #e9ecef; | |
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
line-height: 1.8; | |
direction: rtl; | |
text-align: right; | |
word-wrap: anywhere; | |
h1, | |
h2, | |
h3, | |
h4, | |
h5, | |
h6 { | |
color: #2c3e50; | |
margin-top: 1.5em; | |
margin-bottom: 0.5em; | |
font-weight: 600; | |
} | |
h1 { | |
font-size: 2.2em; | |
border-bottom: 2px solid #3498db; | |
padding-bottom: 10px; | |
margin-bottom: 1em; | |
} | |
h2 { | |
font-size: 1.6em; | |
border-bottom: 2px solid #3498db; | |
padding-bottom: 8px; | |
margin-top: 2em; | |
} | |
p { | |
margin-bottom: 1em; | |
line-height: 1.6; | |
} | |
ul, | |
ol { | |
padding-right: 2em; | |
margin: 15px 0; | |
} | |
li { | |
margin-bottom: 8px; | |
line-height: 1.6; | |
} | |
pre { | |
direction: ltr !important; | |
text-align: left !important; | |
background-color: #f8f9fa; | |
border: 1px solid #e9ecef; | |
border-radius: 6px; | |
padding: 16px; | |
overflow-x: auto; | |
margin: 20px 0; | |
font-family: | |
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, | |
"Liberation Mono", monospace; | |
font-size: 14px; | |
line-height: 1.5; | |
} | |
code { | |
direction: ltr !important; | |
text-align: left !important; | |
background-color: #f1f3f4; | |
padding: 2px 6px; | |
border-radius: 4px; | |
font-family: | |
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, | |
"Liberation Mono", monospace; | |
font-size: 0.9em; | |
} | |
pre code { | |
background: transparent; | |
padding: 0; | |
border-radius: 0; | |
} | |
table { | |
border-collapse: collapse; | |
width: 100%; | |
margin: 20px 0; | |
background: #fff; | |
border-radius: 6px; | |
overflow: hidden; | |
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | |
th, | |
td { | |
padding: 12px 15px; | |
text-align: right; | |
border-bottom: 1px solid #e9ecef; | |
} | |
th { | |
background-color: #3498db; | |
color: #fff; | |
font-weight: 600; | |
} | |
tr:hover { | |
background-color: #f8f9fa; | |
} | |
tr:nth-child(even) td { | |
background-color: #f8f9fa; | |
} | |
} | |
blockquote { | |
border-right: 4px solid #3498db; | |
margin: 20px 0; | |
padding: 10px 20px; | |
background-color: #f8f9fa; | |
border-radius: 4px; | |
font-style: italic; | |
} | |
a { | |
color: #3498db; | |
text-decoration: none; | |
&:hover { | |
text-decoration: underline; | |
} | |
} | |
img { | |
max-width: 100%; | |
height: auto; | |
border-radius: 6px; | |
margin: 10px 0; | |
} | |
hr { | |
border: none; | |
border-top: 2px solid #e9ecef; | |
margin: 30px 0; | |
} | |
strong { | |
font-weight: 600; | |
color: #2c3e50; | |
} | |
em { | |
font-style: italic; | |
} | |
/* highlight.js tweaks */ | |
:deep(.hljs) { | |
background-color: #f8f9fa !important; | |
border: 1px solid #e9ecef; | |
border-radius: 6px; | |
padding: 16px; | |
direction: ltr !important; | |
text-align: left !important; | |
} | |
} | |
@media (max-width: 768px) { | |
.markdown-display { | |
padding: 15px; | |
h1 { | |
font-size: 2em; | |
} | |
h2 { | |
font-size: 1.5em; | |
} | |
pre { | |
padding: 12px; | |
font-size: 12px; | |
} | |
} | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment