1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-04 15:14:43 +00:00

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

@@ -11,11 +11,13 @@
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"date-fns": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
@@ -88,6 +90,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.10.tgz",
"integrity": "sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@@ -2366,6 +2369,13 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@@ -2690,6 +2700,7 @@
"integrity": "sha512-nV7tYQLe7YsTtzFrfOMIHc5N2hp5lHG2rpYr0aNja9rNljdgcPZLyQRb2YRivTHqTv7lI962UXFURcpStHgyFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -3286,6 +3297,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3367,6 +3379,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3847,6 +3860,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
@@ -4597,6 +4611,7 @@
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz",
"integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==",
"dev": true,
"peer": true,
"dependencies": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.7",
@@ -4676,6 +4691,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -5171,6 +5187,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -5349,6 +5374,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5506,6 +5532,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -7672,6 +7699,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -7817,6 +7856,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -8642,6 +8682,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10803,6 +10844,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
@@ -11090,6 +11132,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
"integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
@@ -11275,6 +11318,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -11383,6 +11427,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -11889,6 +11934,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.10.tgz",
"integrity": "sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==",
"dev": true,
"peer": true,
"requires": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@@ -13539,6 +13585,12 @@
"@types/node": "*"
}
},
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"@types/ws": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz",
@@ -13793,6 +13845,7 @@
"resolved": "https://registry.npmjs.org/@vue/cli-service/-/cli-service-5.0.8.tgz",
"integrity": "sha512-nV7tYQLe7YsTtzFrfOMIHc5N2hp5lHG2rpYr0aNja9rNljdgcPZLyQRb2YRivTHqTv7lI962UXFURcpStHgyFw==",
"dev": true,
"peer": true,
"requires": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -14287,7 +14340,8 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true
"dev": true,
"peer": true
},
"acorn-import-assertions": {
"version": "1.8.0",
@@ -14345,6 +14399,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -14686,6 +14741,7 @@
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
"integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
"dev": true,
"peer": true,
"requires": {
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
@@ -15225,6 +15281,7 @@
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz",
"integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==",
"dev": true,
"peer": true,
"requires": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.7",
@@ -15266,6 +15323,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -15621,6 +15679,14 @@
"domelementtype": "^2.2.0"
}
},
"dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"requires": {
"@types/trusted-types": "^2.0.7"
}
},
"domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -15768,6 +15834,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
"peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -16007,6 +16074,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -17470,6 +17538,11 @@
"semver": "^6.0.0"
}
},
"marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -17575,6 +17648,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -18185,6 +18259,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"peer": true,
"requires": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -19717,6 +19792,7 @@
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"peer": true,
"requires": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",
@@ -19730,7 +19806,7 @@
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"requires": {}
},
},
"vue-eslint-parser": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
@@ -19926,6 +20002,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz",
"integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==",
"dev": true,
"peer": true,
"requires": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
@@ -20074,6 +20151,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -20152,6 +20230,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",

View File

@@ -15,7 +15,9 @@
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",

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>