UNPKG

@steve02081504/virtual-console

Version:

A virtual console for capturing and manipulating terminal output.

213 lines (196 loc) 8.06 kB
import { pathToFileURL } from '../core/stack.mjs' import { escapeHtml, stripTerminalDecorations, terminalChunkToHtml, traceStackFrameAnsi, } from './ansi.mjs' import { cssStyleStringToAnsiPrefix } from './css-to-ansi.mjs' import { formatSnapshotAnsi, formatSnapshotPlain, resolveValueRenderOptions } from './snapshot-display.mjs' /** * @typedef {object} RenderHtmlOptions * @property {string} [traceStackWrapperStyle] - 包裹 trace 栈块的 `style` 属性值。 * @property {string} [traceStackLinkStyle] - trace 栈内链接的 `style`。 * @property {boolean} [omitPrintfCss=false] - 为 `true` 时忽略 `css` 片段的 `span` 包裹。 * @property {boolean} [supportsAnsi=true] - 是否按条目能力为 `value` 生成着色 ANSI 再转 HTML。 * @property {string} [indent='\t'] - 多行结构缩进单元。 * @property {number} [maxDepth=Infinity] - 值快照最大展开深度(与 `dirOptions.depth` 取较小值)。 * @property {(frame: import('../shared.d.mts').StackFrame) => string | undefined} [resolveTraceFrameHref] - * 若返回非空字符串则用作该帧 `<a href>`;未提供或返回假值时回退到内置 `file:` URL 逻辑。 */ /** * @typedef {object} RenderPlainOptions * @property {string} [indent='\t'] - 多行结构缩进单元。 * @property {number} [maxDepth=Infinity] - 值快照最大展开深度(与 `dirOptions.depth` 取较小值)。 */ /** * @typedef {object} RenderAnsiOptions * @property {boolean} [colorize=true] - 为 `false` 时用 {@link stripTerminalDecorations} 剥着色与 OSC8。 * @property {boolean} [omitPrintfCss=false] - 为 `true` 时不把 `%c` 样式映射为 ANSI 真彩色。 * @property {string} [indent='\t'] - 多行结构缩进单元。 * @property {number} [maxDepth=Infinity] - 值快照最大展开深度(与 `dirOptions.depth` 取较小值)。 */ /** * @param {import('../shared.d.mts').LogSegment} segment - `kind: 'trace'`。 * @param {RenderHtmlOptions} renderContext - HTML 选项。 * @returns {string} 栈块 HTML。 */ function traceStackHtml(segment, renderContext) { const traceStackWrapperStyle = renderContext.traceStackWrapperStyle ?? 'color:gray;font-size:0.9em' const traceStackLinkStyle = renderContext.traceStackLinkStyle ?? 'color:inherit;text-decoration:none' const stackHtml = segment.stack.map(frame => { const raw = escapeHtml(frame.raw) const resolvedHref = renderContext.resolveTraceFrameHref?.(frame) if (resolvedHref) return `<a href="${escapeHtml(resolvedHref)}" style="${escapeHtml(traceStackLinkStyle)}">${raw}</a>` if (frame.filePath && frame.line > 0) { const url = escapeHtml(`${pathToFileURL(frame.filePath)}:${frame.line}:${frame.column}`) return `<a href="${url}" style="${escapeHtml(traceStackLinkStyle)}">${raw}</a>` } return raw }).join('<br/>\n') return `<span style="${escapeHtml(traceStackWrapperStyle)}">${stackHtml}</span>` } /** * 统一计算 `value` 片段的 plain 或 ANSI 文本。 * @param {import('../shared.d.mts').LogSegment} segment - `kind: 'value'` 片段。 * @param {{ indent: string, maxDepth: number, defaultColorize?: boolean }} options - 统一渲染参数。 * @param {'ansi' | 'plain'} mode - 目标格式。 * @returns {string} 格式化结果。 */ function renderValueSegment(segment, options, mode) { const resolveColorize = mode === 'ansi' ? options.defaultColorize : true const opts = resolveValueRenderOptions(segment, resolveColorize) const depth = Math.min(opts.depth, options.maxDepth) const base = { depth, indent: options.indent } if (mode === 'ansi') return formatSnapshotAnsi(segment.snapshot, { ...base, colorize: opts.colorize }) return formatSnapshotPlain(segment.snapshot, base) } /** * 将 trace 快照转为逐帧原始文本。 * @param {import('../shared.d.mts').LogSegment} segment - `kind: 'trace'` 片段。 * @returns {string} `\n` 拼接的帧文本。 */ function renderTraceRaw(segment) { return segment.stack.map(frame => frame.raw).join('\n') } /** * @param {import('../shared.d.mts').LogSegment[]} segments - `LogEntry#toSegments()` 产物。 * @param {RenderHtmlOptions} [htmlOptions] - trace 栈与链接样式、`css` 开关。 * @returns {string} 拼接后的 HTML。 */ export function renderHtml(segments, htmlOptions = {}) { if (!segments?.length) return '' const omitPrintfCss = htmlOptions.omitPrintfCss === true const supportsAnsi = htmlOptions.supportsAnsi !== false const indent = htmlOptions.indent ?? '\t' const maxDepth = htmlOptions.maxDepth ?? Infinity let spanOpen = false const parts = [] /** * 若存在由 `css` 片段打开且尚未闭合的 `span`,则追加 `</span>` 并清除打开状态。 * @returns {void} */ const closeSpan = () => { if (spanOpen) { parts.push('</span>') spanOpen = false } } for (const segment of segments) { if (segment.kind === 'css') { closeSpan() if (!omitPrintfCss && segment.css) { parts.push(`<span style="${escapeHtml(segment.css)}">`) spanOpen = true } continue } if (segment.kind === 'trace' && parts.length) parts.push('<br/>\n') if (segment.kind === 'text') parts.push(terminalChunkToHtml(segment.text)) else if (segment.kind === 'value') { const ansiInner = renderValueSegment(segment, { indent, maxDepth, defaultColorize: supportsAnsi }, 'ansi') parts.push(terminalChunkToHtml(ansiInner)) } else if (segment.kind === 'trace') parts.push(traceStackHtml(segment, htmlOptions)) } closeSpan() return parts.join('') } /** * @param {import('../shared.d.mts').LogSegment[]} segments - 片段数组。 * @param {RenderPlainOptions} [plainOptions] - plain 渲染选项。 * @returns {string} 去装饰后的纯文本,`trim` 后返回。 */ export function renderPlain(segments, plainOptions = {}) { if (!segments?.length) return '' const indent = plainOptions.indent ?? '\t' const maxDepth = plainOptions.maxDepth ?? Infinity const parts = [] for (const segment of segments) { if (segment.kind === 'css') continue if (segment.kind === 'text') parts.push(stripTerminalDecorations(segment.text)) else if (segment.kind === 'value') parts.push(renderValueSegment(segment, { indent, maxDepth }, 'plain')) else if (segment.kind === 'trace') parts.push(renderTraceRaw(segment)) } return parts.join('') } /** * @param {import('../shared.d.mts').LogSegment[]} segments - 片段数组。 * @param {RenderAnsiOptions} [ansiOptions] - 终端 ANSI 选项。 * @returns {string} 终端 ANSI 拼接串。 */ export function renderAnsi(segments, ansiOptions = {}) { if (!segments?.length) return '' const baseColorize = ansiOptions.colorize !== false const omitPrintfCss = ansiOptions.omitPrintfCss === true const indent = ansiOptions.indent ?? '\t' const maxDepth = ansiOptions.maxDepth ?? Infinity /** @type {string} 当前 `%c` 映射得到的 ANSI 前缀(无重置后缀) */ let printfStylePrefix = '' /** * 为正文包裹当前 printf 样式前缀并在末尾复位 SGR。 * @param {string} inner - 片段内文本或格式化后的值。 * @returns {string} 带样式的 ANSI 片段。 */ const wrapPrintfStyle = inner => { if (!baseColorize) return stripTerminalDecorations(inner) if (!printfStylePrefix) return inner return `${printfStylePrefix}${inner}\x1b[0m` } const parts = [] for (const segment of segments) { if (segment.kind === 'css') { printfStylePrefix = baseColorize && !omitPrintfCss ? cssStyleStringToAnsiPrefix(segment.css) : '' continue } if (segment.kind === 'text') { const t = String(segment.text ?? '') parts.push(wrapPrintfStyle(t)) } else if (segment.kind === 'value') { const inner = renderValueSegment(segment, { indent, maxDepth, defaultColorize: baseColorize }, 'ansi') parts.push(wrapPrintfStyle(inner)) } else if (segment.kind === 'trace') { const inner = baseColorize ? segment.stack.map(traceStackFrameAnsi).join('\n') : renderTraceRaw(segment) parts.push(wrapPrintfStyle(inner)) } } return parts.join('') }