/** * 调用栈的结构化高亮。 * * 与 `convertAnsiToHtml` 的分工:那个按 ANSI 转义上色,只适用于 `util.inspect` * 的彩色转储。amagi 的错误现在只印 `error.stack`(纯文本、没有 ANSI),交给它 * 整段就是一个颜色 —— 所以这里按**结构**上色:错误名、`[kind/CODE]` 标签、 * 函数名、目录、文件名、行列号各一色,外部帧(依赖库与 node 内部)整行压暗, * 一眼能挑出项目里的帧。归属由调用方的 {@link HighlightStackOptions.isOwnFrame} 判定 —— * 不能只看路径里有没有 `node_modules`:发布后的插件自己就装在 `node_modules` 里。 * * 单独成文件(不带 JSX / React import)是为了能直接单测 —— 颜色是否真的分层, * 只有断言才说得清。 */ /** * 调用栈高亮用的一组颜色。 * * 整个错误页是暖红底,所以这里只用邻近色:玫瑰 → 红 → 橙 → 琥珀,靠色相小步移动 * 加明度差把各段分开,不再借蓝紫这类对比色 —— 蓝紫压在红底上会跳出来抢戏。 */ export const stackPalette = (dark: boolean) => ({ base: dark ? 'rgba(255,255,255,0.82)' : 'rgba(127,29,29,0.88)', dim: dark ? 'rgba(255,255,255,0.32)' : 'rgba(127,29,29,0.38)', errName: dark ? '#f87171' : '#dc2626', tag: dark ? '#fb923c' : '#9a3412', fn: dark ? '#fda4af' : '#be123c', file: dark ? '#fcd34d' : '#a16207', num: dark ? '#fdba74' : '#c2410c' }) const escapeHtmlText = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') /** 转义 + 给 CJK 段落套字体,与 convertAnsiToHtml 的 formatLogContent 同款 */ const withCjkFont = (str: string) => escapeHtmlText(str).replace(/([㐀-鿿豈-﫿 -〿＀-￯]+)/g, '$1') /** * 兜底的外部帧判定:只看文本特征,认不出路径。 * * 它区分不了「第三方依赖」与「装在 node_modules 里跑的插件自己」—— 发布后的 kkk 正是 * 后者,调用帧形如 * `file:///…/node_modules/.pnpm/koishi-plugin-kkk@…/node_modules/koishi-plugin-kkk/lib/core_chunk/main.js:84860:65`, * `node_modules` 出现两次,于是每一帧都被压暗、整块图失去分层。所以它只作兜底(单测等 * 无调用方上下文的场景),真实渲染一律由调用方给出 {@link HighlightStackOptions.isOwnFrame}。 * @param location - 帧里的位置原文 * @returns 是否属于本插件自己的代码 */ const defaultIsOwnFrame = (location: string): boolean => !/node_modules|^node:|\(node:/.test(location) /** {@link highlightStack} 的选项。 */ export interface HighlightStackOptions { /** * 判断一个调用帧的位置是否属于本插件自己的代码;返回 false 的帧整行压暗。 * @param location - 帧里的位置原文,形如 `路径:行:列` 或 `node:internal/…` */ isOwnFrame?: (location: string) => boolean } /** * 按结构高亮 JS 调用栈。 * @param text - `error.stack` 原文(纯文本) * @param dark - 是否深色模式 * @param options - 可选覆盖项;不给 `isOwnFrame` 时退回文本特征判定 * @returns 可交给 dangerouslySetInnerHTML 的 HTML */ export const highlightStack = (text: string, dark: boolean, options: HighlightStackOptions = {}): string => { const isOwnFrame = options.isOwnFrame ?? defaultIsOwnFrame const c = stackPalette(dark) const paint = (color: string, str: string, bold = false) => `${withCjkFont(str)}` /** * `路径:行:列` —— 目录压暗、文件名高亮、行列号另一色。 * @param loc - 位置原文 * @param foreign - 外部帧,整行压暗 * @param ownFrame - 有路径可判归属的帧;裸文件名无从判断,按外部保守处理 */ const paintLocation = (loc: string, foreign: boolean, ownFrame: boolean): string => { const dimAll = foreign || !ownFrame const pos = /^(.*?):(\d+):(\d+)$/.exec(loc) const body = pos ? pos[1] : loc const tail = pos ? paint(dimAll ? c.dim : c.num, `:${pos[2]}:${pos[3]}`) : '' const cut = Math.max(body.lastIndexOf('/'), body.lastIndexOf('\\')) if (cut < 0) return paint(dimAll ? c.dim : c.file, body) + tail return paint(c.dim, body.slice(0, cut + 1)) + paint(dimAll ? c.dim : c.file, body.slice(cut + 1)) + tail } return text .split('\n') .map((line) => { // 调用帧:` at [async |new ]名字 (位置)` 或 ` at 位置` const frame = /^(\s*)at\s+(.*)$/.exec(line) if (frame) { const rest = frame[2] const prefix = paint(c.dim, `${frame[1]}at `) const named = /^(?:(async|new)\s+)?(.+?)\s+\((.+)\)$/.exec(rest) if (named) { const foreign = !isOwnFrame(named[3]) const modifier = named[1] ? paint(c.dim, `${named[1]} `) : '' return ( prefix + modifier + paint(foreign ? c.dim : c.fn, named[2]) + paint(c.dim, ' (') + paintLocation(named[3], foreign, true) + paint(c.dim, ')') ) } // 匿名帧:整段就是位置;没有行列号的多半是裸文件名,无从判归属,按外部处理 const hasPosition = /^(.*?):(\d+):(\d+)$/.test(rest) return prefix + paintLocation(rest, !isOwnFrame(rest), hasPosition) } // 首行:`AmagiError: [risk/ANTIBOT_PAGE] 平台返回了反爬页面 (...)` const head = /^([A-Za-z_$][\w$]*):\s([\s\S]*)$/.exec(line) if (head) { const tagMatch = /^(\[[^\]]+\])\s?([\s\S]*)$/.exec(head[2]) const body = tagMatch ? paint(c.tag, tagMatch[1], true) + paint(c.base, ` ${tagMatch[2]}`) : paint(c.base, head[2]) return paint(c.errName, head[1], true) + paint(c.dim, ': ') + body } return paint(c.base, line) }) .join('\n') }