/** * marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。 * * 为什么后处理而非 marked 自定义 renderer: * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 * - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象), * 版本无关,build 不依赖 renderer TS 签名。 * * mp-weixin rich-text 限制(已核官方文档,非臆测): * - 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true); * 跳转需第三方 mp-html(本次不做,记后续)。 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 * - 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示 * (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。 * - 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。 */ // 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。 // 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。 import hljs from 'highlight.js/lib/core' import javascript from 'highlight.js/lib/languages/javascript' import typescript from 'highlight.js/lib/languages/typescript' import json from 'highlight.js/lib/languages/json' import xml from 'highlight.js/lib/languages/xml' import css from 'highlight.js/lib/languages/css' import markdown from 'highlight.js/lib/languages/markdown' import rust from 'highlight.js/lib/languages/rust' import go from 'highlight.js/lib/languages/go' import python from 'highlight.js/lib/languages/python' import java from 'highlight.js/lib/languages/java' import bash from 'highlight.js/lib/languages/bash' import sql from 'highlight.js/lib/languages/sql' import yaml from 'highlight.js/lib/languages/yaml' import diff from 'highlight.js/lib/languages/diff' hljs.registerLanguage('javascript', javascript) hljs.registerLanguage('typescript', typescript) hljs.registerLanguage('json', json) hljs.registerLanguage('xml', xml) hljs.registerLanguage('css', css) hljs.registerLanguage('markdown', markdown) hljs.registerLanguage('rust', rust) hljs.registerLanguage('go', go) hljs.registerLanguage('python', python) hljs.registerLanguage('java', java) hljs.registerLanguage('bash', bash) hljs.registerLanguage('sql', sql) hljs.registerLanguage('yaml', yaml) hljs.registerLanguage('diff', diff) // 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用) const LANG_ALIASES: Record = { js: 'javascript', ts: 'typescript', py: 'python', sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown', html: 'xml', vue: 'xml', svg: 'xml', } /** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */ const TOKEN_STYLE: Record = { 'hljs-keyword': 'color:#ff7b72', 'hljs-doctag': 'color:#ff7b72', 'hljs-meta': 'color:#ff7b72', 'hljs-type': 'color:#ff7b72', 'hljs-template-tag': 'color:#ff7b72', 'hljs-template-variable': 'color:#ff7b72', 'hljs-title': 'color:#d2a8ff', 'hljs-attr': 'color:#79c0ff', 'hljs-attribute': 'color:#79c0ff', 'hljs-literal': 'color:#79c0ff', 'hljs-number': 'color:#79c0ff', 'hljs-operator': 'color:#79c0ff', 'hljs-variable': 'color:#79c0ff', 'hljs-selector-attr': 'color:#79c0ff', 'hljs-selector-class': 'color:#79c0ff', 'hljs-selector-id': 'color:#79c0ff', 'hljs-regexp': 'color:#a5d6ff', 'hljs-string': 'color:#a5d6ff', 'hljs-built_in': 'color:#ffa657', 'hljs-symbol': 'color:#ffa657', 'hljs-comment': 'color:#8b949e', 'hljs-code': 'color:#8b949e', 'hljs-formula': 'color:#8b949e', 'hljs-name': 'color:#7ee787', 'hljs-quote': 'color:#7ee787', 'hljs-selector-tag': 'color:#7ee787', 'hljs-selector-pseudo': 'color:#7ee787', 'hljs-section': 'color:#1f6feb;font-weight:bold', 'hljs-bullet': 'color:#f2cc60', 'hljs-addition': 'color:#aff5b4;background-color:#033a16', 'hljs-deletion': 'color:#ffdcd7;background-color:#67060c', 'hljs-emphasis': 'font-style:italic', 'hljs-strong': 'font-weight:bold', } /** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */ function styleTag(html: string, tag: string, style: string): string { const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g') return html.replace(re, (m, attrs: string) => { if (attrs && /\sstyle\s*=/.test(attrs)) { // 已有 style:追加(在现有 style 值末尾加分号 + 新 style) return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => { const merged = prev.endsWith(';') ? prev : `${prev};` return `style="${merged}${style}"` }) } return `<${tag}${attrs || ''} style="${style}">` }) } /** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */ function unescapeCode(s: string): string { return s .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") } /** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */ function inlineHljs(html: string): string { return html.replace(//g, (_m, cls: string) => { const style = cls .split(/\s+/) .map((c) => TOKEN_STYLE[c]) .filter(Boolean) .join(';') return style ? `` : '' }) } /** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */ function codeLangBadge(lang: string): string { // 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 前占据顶部一行 return `
${lang}
` } /** * 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。 * marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。 * 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。 */ function highlightCodeBlocks(html: string): string { return html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
    const langMatch = /class="language-([^"]+)"/i.exec(attrs)
    const langRaw = langMatch ? langMatch[1] : ''
    const lang = LANG_ALIASES[langRaw] ?? langRaw
    const badge = langRaw ? codeLangBadge(langRaw) : ''
    if (!lang || !hljs.getLanguage(lang)) {
      // 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
      return badge ? `
${badge}${inner}
` : m } let value: string try { value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value } catch { return badge ? `
${badge}${inner}
` : m } return `
${badge}${inlineHljs(value)}
` }) } /** * 对 marked.parse 产出的 HTML 注入 inline style。 * * 设计取舍:pre(块代码)给深色背景; 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code * 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读); * 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。 * * 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text` * (模板的 `.msg text{color:#e0e0e0}` 仅命中 `` 子元素)。marked 产出的 p/h1-6/li/td/th * 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。 * strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。 */ export function styleMarkdown(html: string): string { let out = html // 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选) out = out.replace(/]*type="checkbox"[^>]*>/gi, (m) => { const checked = /checked/i.test(m) const color = checked ? '#6bd99a' : '#666666' return `${checked ? '☑' : '☐'}` }) // 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式) out = highlightCodeBlocks(out) // 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本) // word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的 // word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。 const BODY_STYLE = 'color:#e0e0e0;word-break:break-all' for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) { out = styleTag(out, tag, BODY_STYLE) } // 块代码
:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
  out = styleTag(
    out,
    'pre',
    'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
  )
  // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
  out = styleTag(
    out,
    'code',
    'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
  )
  // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
  out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
  // 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
  // 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
  out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
  // 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
  // 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
  // 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
  // border 提升可读。
  out = styleTag(
    out,
    'table',
    'display:table;border-collapse:collapse;width:100%;max-width:100%;font-size:12px;word-break:break-all',
  )
  // th/td:边框 + 内边距保留,字号与 table 对齐(font-size:12px)
  out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;font-size:12px;background-color:#1a1a1a')
  out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px;font-size:12px')
  return out
}