Files
OpenIsle/frontend_nuxt/utils/markdown.js
2025-10-02 21:42:52 +08:00

268 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
if (theme === 'dark') {
import('highlight.js/styles/atom-one-dark.css')
} else {
import('highlight.js/styles/atom-one-light.css')
}
}
/** @section 自定义插件:@mention */
function mentionPlugin(md) {
const mentionReg = /^@\[([^\]]+)\]/
function mention(state, silent) {
const pos = state.pos
if (state.src.charCodeAt(pos) !== 0x40) return false
const match = mentionReg.exec(state.src.slice(pos))
if (!match) return false
if (!silent) {
const tokenOpen = state.push('link_open', 'a', 1)
tokenOpen.attrs = [
['href', `/users/${match[1]}`],
['target', '_blank'],
['class', 'mention-link'],
['rel', 'noopener noreferrer'],
]
const text = state.push('text', '', 0)
text.content = `@${match[1]}`
state.push('link_close', 'a', -1)
}
state.pos += match[0].length
return true
}
md.inline.ruler.before('emphasis', 'mention', mention)
}
/** @section 自定义插件:贴吧表情 :tieba123: */
function tiebaEmojiPlugin(md) {
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
const name = tokens[idx].content
const file = tiebaEmoji[name]
return `<img class="emoji" src="${file}" alt="${name}">`
}
md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => {
const pos = state.pos
if (state.src.charCodeAt(pos) !== 0x3a) return false
const match = state.src.slice(pos).match(/^:tieba(\d+):/)
if (!match) return false
const key = `tieba${match[1]}`
if (!tiebaEmoji[key]) return false
if (!silent) {
const token = state.push('tieba-emoji', '', 0)
token.content = key
}
state.pos += match[0].length
return true
})
}
/** @section 链接外开 */
function linkPlugin(md) {
const defaultRender =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const hrefIndex = token.attrIndex('href')
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1]
if (href.startsWith('http://') || href.startsWith('https://')) {
token.attrPush(['target', '_blank'])
token.attrPush(['rel', 'noopener noreferrer'])
}
}
return defaultRender(tokens, idx, options, env, self)
}
}
/** @section MarkdownIt 实例:开启 HTML但配合强净化 */
const md = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
highlight: (str, lang) => {
if (lang === 'mermaid') {
return `<pre class="mermaid">${str}</pre>`
}
let code = ''
if (lang && hljs.getLanguage(lang)) {
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
} else {
code = hljs.highlightAuto(str).value
}
const lineNumbers = code
.trim()
.split('\n')
.map(() => `<div class="line-number"></div>`)
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>`
},
})
const md2TextRender = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
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',
'iframe',
],
// 允许的属性
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'],
iframe: [
'src',
'title',
'width',
'height',
'allow',
'allowfullscreen',
'frameborder',
'referrerpolicy',
],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {
a: ['mention-link'],
img: ['emoji'],
pre: ['code-block', 'mermaid'],
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) {
const raw = md.render(text || '')
const html = sanitizeHtml(raw, SANITIZE_CFG)
if (typeof window !== 'undefined') {
setTimeout(async () => {
const mermaid = await import('mermaid')
mermaid.default.initialize({ startOnLoad: false })
mermaid.default.run({ nodes: document.querySelectorAll('.mermaid') })
})
}
return html
}
export function handleMarkdownClick(e) {
if (e.target.classList.contains('copy-code-btn')) {
const pre = e.target.closest('pre')
const codeEl = pre && pre.querySelector('code')
if (codeEl) {
navigator.clipboard.writeText(codeEl.innerText).then(() => {
toast.success('已复制')
})
}
}
}
/** @section 纯文本提取(保持你原有“统一正则法”) */
export function stripMarkdown(text) {
const html = md2TextRender.render(text || '')
let plainText = html.replace(/<[^>]+>/g, '')
plainText = plainText
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return plainText
}
export function stripMarkdownLength(text, length) {
const plain = stripMarkdown(text)
if (!length || plain.length <= length) {
return plain
}
return plain.slice(0, length) + '...'
}