上一版我自己写了 textarea + 正则工具栏,看着能用,一到粘贴图片、
中文输入法、撤销栈就露馅——这几样正是成熟组件积累的部分。按要求改成品。
- 编辑器开关 def: RichTextEditor = Vditor 4.0.1(MIT,ir 模式)
* 运行时资源自托管:web/scripts/vendor-vditor.mjs 把 lute/i18n/icons
同步到 public/vditor,predev/prebuild 自动执行,不走 unpkg CDN
(内网会一直卡在加载)
- 只读渲染换 markdown-it + dompurify,删掉自写的正则渲染器
- 附件直链的令牌只在内存里存在:落库前 stripAttachmentTokens,
渲染/装载前补回(<img> 带不了 Authorization 头,raw?token= 是必要折中)
- Vditor 不会替我们插入 upload.handler 的返回值(只当提示语 tip.show),
所以上传完自己 insertMD
验证:pytest 70 passed;e2e richtext 13 项、smoke 7、tickets 8、
knowledge 7 全绿;tsc 0 错;主包 299 kB/gzip 75 kB,Vditor 独立
chunk 385 kB 按需加载。
This commit is contained in:
+58
-161
@@ -1,179 +1,76 @@
|
||||
/**
|
||||
* 极简 Markdown 渲染器。
|
||||
* Markdown 渲染:markdown-it + DOMPurify。
|
||||
*
|
||||
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
|
||||
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
|
||||
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
|
||||
* 这两块不自己写。上一次我用正则拼标签,能显示粗体和列表,但表格、嵌套列表、
|
||||
* 代码块语言标注、链接里的括号都会出错——那是通用解析器的活,不是产品特有的逻辑。
|
||||
*
|
||||
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
|
||||
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
|
||||
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
|
||||
* 安全策略:markdown-it 关掉原始 HTML(`html: false`),DOMPurify 再过一遍。
|
||||
* 两道防线看着重复,但它们的失败模式不一样:前者防"作者写进 HTML",
|
||||
* 后者防"链接/图片协议"这类由解析器生成的属性被滥用。
|
||||
*/
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
import DOMPurify from "dompurify";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
import { tokenizeAttachmentHtml } from "./attachmentUrls";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false, // 不解析原始 HTML
|
||||
linkify: true, // 裸 URL 自动成链接
|
||||
breaks: true, // 换行即换行(中文写作里这是预期行为)
|
||||
typographer: false,
|
||||
});
|
||||
|
||||
/** 站内相对地址、http(s)、mailto 之外的协议一律拦掉。 */
|
||||
const SAFE_PROTOCOL = /^(?:https?:|mailto:|\/|#)/i;
|
||||
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ??
|
||||
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
|
||||
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const href = String(tokens[idx].attrGet("href") ?? "");
|
||||
if (!SAFE_PROTOCOL.test(href)) {
|
||||
tokens[idx].attrSet("href", "#");
|
||||
}
|
||||
// 外链统一新窗口打开;站内链接保持原行为
|
||||
if (/^https?:/i.test(href)) {
|
||||
tokens[idx].attrSet("target", "_blank");
|
||||
tokens[idx].attrSet("rel", "noreferrer noopener");
|
||||
}
|
||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
|
||||
}
|
||||
const defaultImage =
|
||||
md.renderer.rules.image ??
|
||||
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
|
||||
|
||||
/** 只放行 http/https/mailto 与站内相对地址,挡掉 javascript: 这类伪协议。 */
|
||||
function safeUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^(https?:\/\/|mailto:|\/)/i.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
|
||||
function inline(raw: string): string {
|
||||
let text = escapeHtml(raw);
|
||||
|
||||
// 行内代码先摘出来,避免里面的 * 和 _ 被当成强调
|
||||
const codes: string[] = [];
|
||||
text = text.replace(/`([^`]+)`/g, (_m, code: string) => {
|
||||
codes.push(code);
|
||||
return `\u0000CODE${codes.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
text = text
|
||||
//  与 [text](url)
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
return `<img src="${href}" alt="${alt}" loading="lazy" />`;
|
||||
})
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const rel = external ? ' target="_blank" rel="noreferrer noopener"' : "";
|
||||
return `<a href="${href}"${rel}>${label}</a>`;
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(?:^|\s)__([^_]+)__(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/(?:^|\s)_([^_]+)_(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
||||
|
||||
// 还原行内代码(内容已在上面转义过,这里只负责套标签)
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, idx: string) => `<code>${codes[Number(idx)]}</code>`);
|
||||
}
|
||||
|
||||
/** 渲染成可直接塞进 dangerouslySetInnerHTML 的字符串。 */
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// 代码块:内容原样保留,只做转义,不做任何行内解析
|
||||
const fence = line.match(/^\s*```(\w*)\s*$/);
|
||||
if (fence) {
|
||||
const lang = fence[1];
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1; // 跳过结束围栏(文件末尾没闭合也能渲染)
|
||||
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
out.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*$/.test(line)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
|
||||
out.push("<hr />");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 引用:连续以 > 开头的行合并成一个 blockquote
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
body.push(lines[i].replace(/^\s*>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<blockquote>${inline(body.join(" "))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 列表:同类型的连续行归一组,嵌套只支持一层(够用,且不会渲染错)
|
||||
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
||||
const ordered = line.match(/^(\s*)\d+[.)]\s+(.*)$/);
|
||||
if (bullet || ordered) {
|
||||
const isOrdered = Boolean(ordered);
|
||||
const items: string[] = [];
|
||||
const pattern = isOrdered ? /^(\s*)\d+[.)]\s+(.*)$/ : /^(\s*)[-*+]\s+(.*)$/;
|
||||
while (i < lines.length) {
|
||||
const m = lines[i].match(pattern);
|
||||
if (!m) break;
|
||||
const [, padding, content] = m;
|
||||
// 缩进两格以上视为子项,用一段加粗缩进表示(不生成嵌套 ol/ul,避免打错标签)
|
||||
items.push(
|
||||
padding.length >= 2
|
||||
? `<li class="ml-4">${inline(content)}</li>`
|
||||
: `<li>${inline(content)}</li>`,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
const tag = isOrdered ? "ol" : "ul";
|
||||
out.push(`<${tag}>${items.join("")}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落:吃到下一个空行或下一个块级语法为止
|
||||
const paragraph: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i];
|
||||
if (
|
||||
/^\s*$/.test(current) ||
|
||||
/^\s*```/.test(current) ||
|
||||
/^(#{1,4})\s+/.test(current) ||
|
||||
/^\s*>\s?/.test(current) ||
|
||||
/^\s*[-*+]\s+/.test(current) ||
|
||||
/^\s*\d+[.)]\s+/.test(current)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraph.push(current);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<p>${inline(paragraph.join("\n")).replace(/\n/g, "<br />")}</p>`);
|
||||
md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
||||
const src = String(tokens[idx].attrGet("src") ?? "");
|
||||
if (!SAFE_PROTOCOL.test(src)) {
|
||||
tokens[idx].attrSet("src", "");
|
||||
}
|
||||
tokens[idx].attrSet("loading", "lazy");
|
||||
return defaultImage(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
return out.join("\n");
|
||||
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
|
||||
export function renderMarkdown(source: string): string {
|
||||
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
|
||||
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
|
||||
});
|
||||
// 库里存的是不带令牌的直链,渲染这一层再补上(见 lib/attachmentUrls.ts)
|
||||
return tokenizeAttachmentHtml(safe);
|
||||
}
|
||||
|
||||
/** 纯文本摘要,用于列表页显示(去掉语法符号)。 */
|
||||
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
|
||||
export function plainSummary(source: string, limit = 90): string {
|
||||
const text = source
|
||||
const text = (source ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#>*_~`-]/g, " ")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#>*_~`|-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
|
||||
Reference in New Issue
Block a user