fix(knowledge): render DB dynamic articles via markdown safe renderer
ci/woodpecker/push/main Pipeline failed

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
This commit is contained in:
fullstack-bot
2026-09-24 23:32:50 +08:00
parent 07dc10d557
commit 23f12fc866
2 changed files with 179 additions and 14 deletions
+154
View File
@@ -0,0 +1,154 @@
/**
* 知识库文章渲染(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
}