Files
timi-web/src/utils/Markdown.ts
2025-07-08 16:33:11 +08:00

210 lines
5.4 KiB
TypeScript
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.

import { marked } from "marked";
import { gfmHeadingId } from "marked-gfm-heading-id";
import { markedHighlight } from "marked-highlight";
import { mangle } from "marked-mangle";
import Prism from "prismjs";
import "prismjs/themes/prism.css";
import { Toolkit } from "~/index";
export default class Markdown {
private static instance: Markdown;
renderer = new marked.Renderer();
private constructor() {
marked.use(mangle());
marked.use(gfmHeadingId());
marked.use(markedHighlight({
highlight(code: string, lang: string) {
if (Prism.languages[lang]) {
return Prism.highlight(code, Prism.languages[lang], lang);
} else {
return code;
}
}
}));
// Markdown 解析器配置
marked.setOptions({
renderer: this.renderer,
pedantic: false,
gfm: true,
breaks: true
});
Prism.hooks.add("complete", (env: any) => {
if (!env.code) return;
// 行号渲染调整
const el = env.element;
const lineNumber = el.querySelector(".line-numbers-rows") as HTMLDivElement;
if (lineNumber) {
const clone = lineNumber.cloneNode(true);
el.removeChild(lineNumber);
// 加容器做滚动
el.innerHTML = `<span class="codes">${el.innerHTML}</span>`;
el.insertBefore(clone, el.firstChild);
if (el.parentNode) {
const markdownRoot = el.parentNode.parentNode;
if (markdownRoot) {
const maxHeight = markdownRoot.dataset.maxHeight;
if (maxHeight === "auto") {
return;
}
// 注册双击事件
const lines = lineNumber.children.length;
if (lines < 18) {
return;
}
const parent = el.parentNode;
parent.addEventListener("dblclick", (): void => {
const isExpand = parent.classList.contains("expand");
if (isExpand) {
parent.style.maxHeight = maxHeight;
parent.classList.remove("expand");
} else {
parent.style.maxHeight = lines * 22 + "px";
parent.classList.add("expand");
}
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
});
}
}
}
});
/**
* ### 超链渲染方式
*
* 1. 链接前加 ~ 符号会被渲染为新标签打开。例:`[文本](~链接)`
* 3. 没有标题的链接将使用文本作为标题
* 4. 没有链接的会被渲染为 span 标签
*/
this.renderer.link = function ({ href, title, text }) {
title = title ?? text;
if (!href) {
return `<span>${text}</span>`;
}
// 新标签打开
let target = "_self";
if (href.startsWith("~")) {
target = "_blank";
href = href.substring(1);
}
// 内部资源链接
if (href.indexOf("@") !== -1) {
href = Toolkit.toResURL(href);
}
{
// 处理嵌套 markdown这可能不是最优解
const tokens = (marked.lexer(text, { inline: true } as any) as any)[0].tokens;
text = this.parser.parseInline(tokens);
}
return `<a href="${href}" target="${target}" title="${title}">${text}</a>`;
};
/**
* ### 重点内容扩展
*
* ```md
* 默认 `文本` 表现为红色
* 使用 `[red bold]文本` 可以自定义类
* ```
*/
this.renderer.codespan = ({ text }) => {
const clazz = text.match(/\[(.+?)]/);
if (clazz) {
return `<span class="${clazz[1]}">${text.substring(text.indexOf("]") + 1)}</span>`;
} else {
return `<span class="red">${text}</span>`;
}
};
/**
* ### 组件渲染方式(原为图像渲染方式)
*
* ```md
* [] 内文本以 # 开始时,该组件带边框
* ```
*
* 1. 渲染为网页:`![]($/html/index.html)`
* 2. 渲染为视频:`![](#/media/video.mp4)`
* 3. 渲染为音频:`![](~/media/music.mp3)`
* 4. 渲染为图片:`![](/image/photo.png)`
* 6. 带边框图片:`![#图片Alt](/image/photo.png)`
*/
this.renderer.image = ({ href, title, text }) => {
const clazz = ["media"];
const hasBorder = text[0] === "#";
if (hasBorder) {
clazz.push("border");
}
title = title ?? text;
const extendTags = ["~", "#", "$"];
let extendTag;
if (extendTags.indexOf(href.charAt(0)) !== -1) {
extendTag = href.charAt(0);
}
// 内部资源链接
if (href.indexOf("@") !== -1) {
if (extendTag) {
href = href.substring(1);
}
href = Toolkit.toResURL(href);
}
const clazzStr = clazz.join(" ");
let elStr;
switch (extendTag) {
case "~":
elStr = `<audio class="${clazzStr}" controls><source type="audio/mp3" src="${href}" title="${title}" /></audio>`;
break;
case "#":
elStr = `<video class="${clazzStr}" controls><source type="video/mp4" src="${href}" title="${title}" /></video>`;
break;
case "$":
elStr = `<iframe class="${clazzStr}" src="${href}" allowfullscreen title="${title}"></iframe>`;
break;
default:
elStr = `<img class="${clazzStr}" src="${href}" alt="${title}" />`;
break;
}
return elStr + `<span class="media-tips">${title}</span>`;
};
}
/**
* ### 解析 Markdown 文本为 HTML 节点
*
* ```js
* const html = toHTML('# Markdown Content');
* ```
*
* @param mkData Markdown 文本
* @returns HTML 节点
*/
public toHTML(mkData: string | undefined): string | Promise<string> {
if (mkData) {
return marked(mkData);
} else {
return "";
}
}
public static getInstance(): Markdown {
if (!Markdown.instance) {
Markdown.instance = new Markdown();
}
return Markdown.instance;
}
}