feat(frontend): render markdown
vi did:web:vt3e.cat
Sun, 14 Jun 2026 16:02:30 +0100
2 files changed,
64 insertions(+),
34 deletions(-)
M
pkgs/frontend/package.json
→
pkgs/frontend/package.json
@@ -15,6 +15,8 @@ "lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/" }, "dependencies": { + "dompurify": "^3.4.10", + "marked": "^18.0.5", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^5.0.4"@@ -22,6 +24,7 @@ },
"devDependencies": { "@stealth-developers/api": "workspace:*", "@tsconfig/node24": "^24.0.4", + "@types/dompurify": "^3.2.0", "@types/node": "^24.12.2", "@vitejs/plugin-vue": "^6.0.6", "@vue/eslint-config-typescript": "^14.7.0",
M
pkgs/frontend/src/components/UserMessage.vue
→
pkgs/frontend/src/components/UserMessage.vue
@@ -1,6 +1,9 @@
<script lang="ts" setup> import { computed } from 'vue' -import type { Message, User } from '@/types' +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +import type { Message } from '@/types' const props = defineProps<{ messages: Message[] }>()@@ -19,35 +22,26 @@ const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } -type ParsedContent = { type: 'text'; content: string } | { type: 'mention'; user: User } - -const parseContent = (msg: Message): ParsedContent[] => { - if (!msg.content) return [] +const renderMarkdown = (msg: Message): string => { + if (!msg.content) return '' const regex = /<@!?(\d+)>/g - const parts: ParsedContent[] = [] - let lastIndex = 0 - let match - - while ((match = regex.exec(msg.content)) !== null) { - if (match.index > lastIndex) { - parts.push({ type: 'text', content: msg.content.substring(lastIndex, match.index) }) - } - - const id = match[1] + const processedContent = msg.content.replace(regex, (match, id) => { const user = msg.mentions.find(u => u.id === id) - - if (user) parts.push({ type: 'mention', user }) - else parts.push({ type: 'text', content: match[0] }) - - lastIndex = regex.lastIndex - } + return user + ? `<span class="inline-mention">@${user.name}</span>` + : match + }) - if (lastIndex < msg.content.length) { - parts.push({ type: 'text', content: msg.content.substring(lastIndex) }) - } + const rawHtml = marked.parse(processedContent, { async: false }) as string - return parts + return DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: [ + 'p', 'span', 'strong', 'em', 'code', 'pre', 'br', + 'ul', 'ol', 'li', 'a', 'blockquote', 'h1', 'h2', 'h3' + ], + ALLOWED_ATTR: ['class', 'href', 'target'] + }) } </script>@@ -63,14 +57,12 @@ <span v-else-if="firstMessage.authorType === 'staff'" class="badge staff">Staff</span>
</div> <div class="bodies"> <div v-for="msg in messages" :key="msg.id" class="message-part"> - <div class="body" v-if="msg.content"> - <template v-for="(part, idx) in parseContent(msg)" :key="idx"> - <span v-if="part.type === 'text'">{{ part.content }}</span> - <span v-else-if="part.type === 'mention'" class="inline-mention"> - @{{ part.user.name }} - </span> - </template> - </div> + <div + v-if="msg.content" + class="body" + v-html="renderMarkdown(msg)" + ></div> + <div class="attachments" v-if="msg.attachments && msg.attachments.length > 0"> <template v-for="att in msg.attachments" :key="att.id"> <div v-if="att.contentType.startsWith('video/')" class="attachment">@@ -173,7 +165,42 @@ color: hsl(var(--text));
white-space: pre-wrap; word-break: break-word; - .inline-mention { + :deep(p) { + margin: 0 0 0.5rem 0; + &:last-child { + margin-bottom: 0; + } + } + + :deep(strong) { + font-weight: bold; + } + + :deep(em) { + font-style: italic; + } + + :deep(code) { + font-family: monospace; + background-color: hsla(var(--surface1) / 0.5); + padding: 0.1rem 0.3rem; + border-radius: 0.25rem; + } + + :deep(pre) { + background-color: hsla(var(--surface1) / 0.5); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 0.5rem 0; + + code { + padding: 0; + background: transparent; + } + } + + :deep(.inline-mention) { display: inline-flex; align-items: center; background-color: hsla(var(--accent) / 0.15);