fix: markdown 支持 video

This commit is contained in:
tim
2025-08-26 13:39:33 +08:00
parent 689b719e18
commit 62c1983fd5
4 changed files with 177 additions and 22 deletions

View File

@@ -139,6 +139,9 @@ body {
margin-bottom: 0.8em;
}
.info-content-text video {
max-width: 100%;
}
.info-content-text {
word-break: break-word;
max-width: 100%;

View File

@@ -15,6 +15,7 @@
"markdown-it": "^14.1.0",
"nprogress": "^0.2.0",
"nuxt": "latest",
"sanitize-html": "^2.17.0",
"sockjs-client": "^1.6.1",
"vditor": "^3.11.1",
"vue-easy-lightbox": "^1.19.0",
@@ -6923,6 +6924,25 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -7260,6 +7280,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -8874,6 +8903,12 @@
"protocols": "^2.0.0"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse-url": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz",
@@ -10018,6 +10053,32 @@
"node": ">=10"
}
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",

View File

@@ -9,20 +9,21 @@
"generate": "nuxt generate"
},
"dependencies": {
"@stomp/stompjs": "^7.0.0",
"cropperjs": "^1.6.2",
"echarts": "^5.6.0",
"flatpickr": "^4.6.13",
"highlight.js": "^11.11.1",
"ldrs": "^1.0.0",
"markdown-it": "^14.1.0",
"nuxt": "latest",
"nprogress": "^0.2.0",
"nuxt": "latest",
"sanitize-html": "^2.17.0",
"sockjs-client": "^1.6.1",
"vditor": "^3.11.1",
"vue-easy-lightbox": "^1.19.0",
"vue-echarts": "^7.0.3",
"vue-toastification": "^2.0.0-rc.5",
"flatpickr": "^4.6.13",
"vue-flatpickr-component": "^12.0.0",
"@stomp/stompjs": "^7.0.0",
"sockjs-client": "^1.6.1"
"vue-toastification": "^2.0.0-rc.5"
}
}

View File

@@ -1,5 +1,11 @@
// markdown.js
import hljs from 'highlight.js/lib/common'
import MarkdownIt from 'markdown-it'
import sanitizeHtml from 'sanitize-html'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
// 动态切换 hljs 主题(保持你原有逻辑)
if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||
@@ -10,10 +16,8 @@ if (typeof window !== 'undefined') {
import('highlight.js/styles/atom-one-light.css')
}
}
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
/** @section 自定义插件:@mention */
function mentionPlugin(md) {
const mentionReg = /^@\[([^\]]+)\]/
function mention(state, silent) {
@@ -27,6 +31,7 @@ function mentionPlugin(md) {
['href', `/users/${match[1]}`],
['target', '_blank'],
['class', 'mention-link'],
['rel', 'noopener noreferrer'],
]
const text = state.push('text', '', 0)
text.content = `@${match[1]}`
@@ -38,6 +43,7 @@ function mentionPlugin(md) {
md.inline.ruler.before('emphasis', 'mention', mention)
}
/** @section 自定义插件:贴吧表情 :tieba123: */
function tiebaEmojiPlugin(md) {
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
const name = tokens[idx].content
@@ -60,7 +66,7 @@ function tiebaEmojiPlugin(md) {
})
}
// 链接在新窗口打开
/** @section 链接外开 */
function linkPlugin(md) {
const defaultRender =
md.renderer.rules.link_open ||
@@ -74,7 +80,6 @@ function linkPlugin(md) {
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
@@ -85,8 +90,9 @@ function linkPlugin(md) {
}
}
/** @section MarkdownIt 实例:开启 HTML但配合强净化 */
const md = new MarkdownIt({
html: false,
html: true, // ⭐ 允许行内 HTML为 <video> 服务)
linkify: true,
breaks: true,
highlight: (str, lang) => {
@@ -100,6 +106,7 @@ const md = new MarkdownIt({
.trim()
.split('\n')
.map(() => `<div class="line-number"></div>`)
// 保留你原有的 CodeBlock + 复制按钮 + 行号结构
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
},
})
@@ -108,8 +115,96 @@ md.use(mentionPlugin)
md.use(tiebaEmojiPlugin)
md.use(linkPlugin)
/** @section sanitize-html 配置:只白名单需要的标签/属性/类名 */
const SANITIZE_CFG = {
// 允许的标签(包含你代码块里用到的 button/div
allowedTags: [
'a',
'p',
'div',
'span',
'pre',
'code',
'button',
'img',
'br',
'hr',
'blockquote',
'strong',
'em',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'video',
'source',
],
// 允许的属性
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'class'],
img: ['src', 'alt', 'title', 'width', 'height', 'class'],
div: ['class'],
span: ['class'],
pre: ['class'],
code: ['class'],
button: ['class'],
video: [
'controls',
'autoplay',
'muted',
'loop',
'playsinline',
'poster',
'preload',
'width',
'height',
'crossorigin',
],
source: ['src', 'type'],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {
a: ['mention-link'],
img: ['emoji'],
pre: ['code-block'],
div: ['line-numbers', 'line-number'],
code: ['hljs', /^language-/],
button: ['copy-code-btn'],
},
// 允许的协议(视频可能是 blob: / data:
allowedSchemes: ['http', 'https', 'data', 'blob'],
allowProtocolRelative: false,
// 统一移除所有 on* 事件、style 等(默认就会清理)
transformTags: {
// 没写 controls 的 video强制加上避免静默自动播放
video: (tagName, attribs) => {
const attrs = { ...attribs }
if (!('controls' in attrs)) attrs.controls = 'controls'
// 安全建议:若允许 autoplay默认要求 muted
if ('autoplay' in attrs && !('muted' in attrs)) {
attrs.muted = 'muted'
}
return { tagName, attribs: attrs }
},
},
}
/** @section 渲染 & 事件 */
export function renderMarkdown(text) {
return md.render(text || '')
const raw = md.render(text || '')
// ⭐ 核心:对最终 HTML 进行一次净化
return sanitizeHtml(raw, SANITIZE_CFG)
}
export function handleMarkdownClick(e) {
@@ -124,20 +219,16 @@ export function handleMarkdownClick(e) {
}
}
/** @section 纯文本提取(保持你原有“统一正则法”) */
export function stripMarkdown(text) {
const html = md.render(text || '')
// 统一使用正则表达式方法,确保服务端和客户端行为一致
let plainText = html.replace(/<[^>]+>/g, '')
// 标准化空白字符处理
plainText = plainText
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return plainText
}
@@ -146,6 +237,5 @@ export function stripMarkdownLength(text, length) {
if (!length || plain.length <= length) {
return plain
}
// 截断并加省略号
return plain.slice(0, length) + '...'
}