Files
cma-management/frontend/src/utils/knowledgeRender.ts
T
fullstack-bot 23f12fc866
ci/woodpecker/push/main Pipeline failed
fix(knowledge): render DB dynamic articles via markdown safe renderer
A: dynamic knowledge-articles content goes through marked instead of raw v-html (escape-first, no new deps).

B: plain-text fallback normalize (bracket headings, circled numbers to ordered list, blank lines, key-number bold, idempotent).

C: detail-content styles for h2/h3/pre/blockquote/paragraph spacing.

D: dialog width 700 to 860, content padding, in-dialog scroll.

Task: project-20260924-knowledge-dialog-render-fullstack
2026-09-24 23:32:50 +08:00

155 lines
6.5 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.
/**
* 知识库文章渲染(2026-09-24 knowledge-dialog-render)
*
* 口径:
* - md 单一来源(后端 /api/cma/docs 运行期读 md 目录)沿用页面既有渲染器:
* 正文可能含内联 HTML(如 <span style="color:…">),**不得转义**,否则颜色会丢。
* - DB 动态知识库文章(/knowledge-articles):先做纯文本规范化,再交给「原始 HTML 一律降级为纯文本」
* 的 marked 实例渲染 ⇒ 任何 <script>/<img onerror> 只会以字面文本出现,不会被 v-html 当 HTML 执行。
* - 全链路零新依赖(marked 已在项目中)。
*/
import { marked, Marked } from "marked"
/** HTML 实体转义(& 必须最先,避免二次转义) */
export function escapeHtmlEntities(s: string): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
/** 属性值转义(在实体转义基础上补引号,防止从 href/title 属性里逃逸) */
function escapeAttr(s: string): string {
return escapeHtmlEntities(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}
/** 只放行常规协议/站内锚点,挡住 javascript: / vbscript: / data: 等可执行协议 */
const SAFE_URL_RE = /^(?:https?:|mailto:|tel:|\/|#|\.{1,2}\/)/i
/**
* 安全渲染实例:覆盖 marked 的三处「原始输入直出」出口
* - html :块级/行内原始 HTML 一律降级为纯文本
* - link :非法协议(javascript: 等)不给 <a>,只留文本
* - image :非法协议不给 <img>
* ⇒ 不引入 DOMPurify 也能杜绝 v-html 执行注入(marked 其余节点自带实体转义)。
*/
const safeMarked = new Marked({
renderer: {
html: (token: any) => escapeHtmlEntities(token?.text ?? token?.raw ?? ""),
link(this: any, token: any) {
const text = token?.tokens ? this.parser.parseInline(token.tokens) : escapeHtmlEntities(token?.text ?? "")
const href = String(token?.href ?? "")
if (!SAFE_URL_RE.test(href.trim())) return text
const title = token?.title ? ` title="${escapeAttr(String(token.title))}"` : ""
return `<a href="${escapeAttr(href)}"${title}>${text}</a>`
},
image(this: any, token: any) {
const alt = escapeAttr(String(token?.text ?? ""))
const href = String(token?.href ?? "").trim()
if (!SAFE_URL_RE.test(href)) return alt
const title = token?.title ? ` title="${escapeAttr(String(token.title))}"` : ""
return `<img src="${escapeAttr(href)}" alt="${alt}"${title}>`
},
},
})
/** 段首 ①…⑩ → markdown 有序列表标记 */
const CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
const KEY_NUM_RE = /(?<![\w.])-?\d+(?:,\d{3})*(?:\.\d+)?(?:%|亿|万|倍|元|tokens)/g
/** 紧随其后的字符会破坏 CommonMark 强调闭合(闭合符后必须空白/标点)——汉字/字母/数字 */
const CLOSE_BLOCKER_RE = /[0-9A-Za-z\u4e00-\u9fff]/
/** 零宽空格:仅在「加粗数字紧跟汉字/字母」这一形态里补在闭合 `**` 之前,视觉上不可见 */
const ZWSP = "\u200B"
function convertLeadingCircled(line: string): string {
const idx = CIRCLED.indexOf(line.charAt(0))
if (idx < 0) return line
return `${idx + 1}. ${line.slice(1).replace(/^[\s::、]+/, "")}`
}
/** 关键数字/百分比加粗;跳过既有 **强调** 与 `行内代码`,避免嵌套 */
function boldKeyNumbers(line: string): string {
return line
.split(/(\*\*[^*]+\*\*|`[^`]*`)/g)
.map((seg, i) => {
if (i % 2 === 1) return seg
let out = ""
let last = 0
for (const m of seg.matchAll(KEY_NUM_RE)) {
const start = m.index ?? 0
const end = start + m[0].length
// 闭合 `**` 后紧跟汉字/字母/数字时 CommonMark 不认闭合(会原样显示 **)⇒ 闭合前补一个零宽空格
const guard = CLOSE_BLOCKER_RE.test(seg.charAt(end)) ? ZWSP : ""
out += seg.slice(last, start) + `**${m[0]}${guard}**`
last = end
}
return out + seg.slice(last)
})
.join("")
}
function isStructuralLine(line: string): boolean {
return /^(?:[-*+]|\d+[.)])\s/.test(line) || /^\s*\|/.test(line)
}
/**
* 纯文本兜底规范化:仅当内容**不含** markdown 标题时启用(幂等——已含 `#` 标题的原样返回,
* 不会套双层标题/双重加粗)。转换项:
* 1) 行首 `【xxx】` → `#### xxx`;同一行剩余文字另起一段
* 2) 段首 `①…⑩` → `1.` `2.` …(保留行内层级,只动段首)
* 3) 段与段之间补空行(marked 才会真正分段);块内若全是列表/表格行则保持紧凑,避免把列表打散
* 4) 关键数字/百分比(-62%、127.6%、101亿、470,301元…)加粗
*/
export function normalizePlainText(src: string): string {
const text = String(src ?? "").replace(/\r\n?/g, "\n")
if (/^[ \t]{0,3}#{1,6}[ \t]/m.test(text)) return text
const out: string[] = []
for (const block of text.split(/\n[ \t]*\n+/)) {
const raw = block
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== "")
if (raw.length === 0) continue
const conv: string[] = []
let inFence = false
for (const line of raw) {
if (/^\s*```/.test(line)) inFence = !inFence
if (inFence) {
conv.push(line)
continue
}
const h = /^[ \t]*【([^】]{1,30})】[ \t]*(.*)$/.exec(line)
if (h) {
conv.push(`#### ${h[1].trim()}`)
if (h[2].trim()) conv.push(boldKeyNumbers(h[2].trim()))
} else {
conv.push(boldKeyNumbers(convertLeadingCircled(line.trim())))
}
}
const body = conv.filter((l) => !/^####\s/.test(l) && !l.startsWith("```"))
const compact = body.length > 0 && body.every(isStructuralLine)
for (const l of conv) {
out.push(l)
// 紧凑块:只保证标题后有空行;非紧凑块:每行都成为独立段落
if (!compact || /^####\s/.test(l)) out.push("")
}
// 块尾必须留空行:否则下一块的标题/表格会被 list 的 lazy continuation 吞进上一条列表项
out.push("")
}
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim()
}
/**
* 统一入口:把知识库条目渲染成可直接 v-html 的 HTML 字符串。
* @param item `kind === "md"` = 运行期 md 单一来源;其余 = DB 动态知识库文章
*/
export function renderKnowledgeContent(item: { content?: string; kind?: string } | null | undefined): string {
const raw = String(item?.content ?? "")
if (!raw) return ""
if (item?.kind === "md") return marked.parse(raw) as string
return safeMarked.parse(normalizePlainText(raw)) as string
}