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
155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
/**
|
||
* 知识库文章渲染(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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
}
|
||
|
||
/** 属性值转义(在实体转义基础上补引号,防止从 href/title 属性里逃逸) */
|
||
function escapeAttr(s: string): string {
|
||
return escapeHtmlEntities(s).replace(/"/g, """).replace(/'/g, "'")
|
||
}
|
||
|
||
/** 只放行常规协议/站内锚点,挡住 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
|
||
}
|