feat(announcements): add markdown support (#1371)

* feat(announcements): add markdown support

* feat(announcements): add information about announcement formatting in readme

* feat(announcements): bump packages versions for marked and dompurify

* feat(announcements): bump versions for marked and dompurify in package-lock.json

* fix(announcements): md to link was not working since the conflict merge

* fix(announcements): fix time before message and not after

* feat(announcements): past announcements add markdown support

* feat(announcements): static files
This commit is contained in:
Sworyz
2025-11-08 19:28:57 +01:00
committed by GitHub
parent 7c6b5539c1
commit 907716289c
8 changed files with 284 additions and 57 deletions

View File

@@ -111,7 +111,10 @@
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
@@ -127,6 +130,8 @@
<script setup>
import { computed, ref } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
// Props
@@ -232,6 +237,55 @@ const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return ''
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const renderer = new marked.Renderer()
renderer.link = (tokenOrHref, title, text) => {
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
? tokenOrHref
: null
const href = tokenObject ? tokenObject.href : tokenOrHref
const resolvedTitle = tokenObject ? tokenObject.title : title
const resolvedText = tokenObject ? tokenObject.text : text
const url = escapeHtml(href || '')
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
const linkText = resolvedText || ''
return `<a href="${url}" target="_blank" rel="noopener noreferrer"${titleAttribute}>${linkText}</a>`
}
marked.use({
renderer,
breaks: true,
gfm: true,
headerIds: false,
mangle: false
})
const formatAnnouncementMessage = (message) => {
if (!message) {
return ''
}
const markdown = String(message)
const html = marked.parse(markdown)
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
@@ -295,4 +349,22 @@ const formatFullTimestamp = (timestamp) => {
margin-left: 1.5rem;
}
}
</style>
.announcement-content :deep(a) {
color: #1d4ed8;
text-decoration: underline;
font-weight: 500;
}
.announcement-content :deep(a:hover) {
color: #1e40af;
}
.dark .announcement-content :deep(a) {
color: #60a5fa;
}
.dark .announcement-content :deep(a:hover) {
color: #93c5fd;
}
</style>

View File

@@ -40,9 +40,10 @@
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">
{{ announcement.message }}
</p>
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
@@ -69,6 +70,8 @@
<script setup>
import { ref, computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
// Props
@@ -178,6 +181,55 @@ const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return ''
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const renderer = new marked.Renderer()
renderer.link = (tokenOrHref, title, text) => {
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
? tokenOrHref
: null
const href = tokenObject ? tokenObject.href : tokenOrHref
const resolvedTitle = tokenObject ? tokenObject.title : title
const resolvedText = tokenObject ? tokenObject.text : text
const url = escapeHtml(href || '')
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
const linkText = resolvedText || ''
return `<a href="${url}" target="_blank" rel="noopener noreferrer"${titleAttribute}>${linkText}</a>`
}
marked.use({
renderer,
breaks: true,
gfm: true,
headerIds: false,
mangle: false
})
const formatAnnouncementMessage = (message) => {
if (!message) {
return ''
}
const markdown = String(message)
const html = marked.parse(markdown)
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -207,4 +259,24 @@ const formatFullTimestamp = (timestamp) => {
timeZoneName: 'short'
})
}
</script>
</script>
<style scoped>
.past-announcements :deep(a) {
color: #1d4ed8;
text-decoration: underline;
font-weight: 500;
}
.past-announcements :deep(a:hover) {
color: #1e40af;
}
.dark .past-announcements :deep(a) {
color: #60a5fa;
}
.dark .past-announcements :deep(a:hover) {
color: #93c5fd;
}
</style>