290 lines
5.8 KiB
TypeScript
290 lines
5.8 KiB
TypeScript
import type { CSSProperties } from "react"
|
|
import wrapAnsi from "wrap-ansi"
|
|
|
|
export type AnsiSegment = {
|
|
style: CSSProperties
|
|
text: string
|
|
}
|
|
|
|
type AnsiState = {
|
|
background?: string
|
|
bold?: boolean
|
|
dim?: boolean
|
|
foreground?: string
|
|
italic?: boolean
|
|
strikethrough?: boolean
|
|
underline?: boolean
|
|
underlineColor?: string
|
|
}
|
|
|
|
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[([0-9;]*)m`, "g")
|
|
|
|
const ANSI_COLORS = [
|
|
"#4b5563",
|
|
"#f87171",
|
|
"#4ade80",
|
|
"#facc15",
|
|
"#60a5fa",
|
|
"#c084fc",
|
|
"#22d3ee",
|
|
"#f3f4f6",
|
|
]
|
|
|
|
const ANSI_BRIGHT_COLORS = [
|
|
"#6b7280",
|
|
"#fb7185",
|
|
"#86efac",
|
|
"#fde047",
|
|
"#93c5fd",
|
|
"#e879f9",
|
|
"#67e8f9",
|
|
"#ffffff",
|
|
]
|
|
|
|
function cloneAnsiState(state: AnsiState): AnsiState {
|
|
return { ...state }
|
|
}
|
|
|
|
function ansi256ToHex(code: number): string {
|
|
if (code < 0 || code > 255) {
|
|
return "inherit"
|
|
}
|
|
|
|
if (code < 8) {
|
|
return ANSI_COLORS[code]
|
|
}
|
|
|
|
if (code < 16) {
|
|
return ANSI_BRIGHT_COLORS[code - 8]
|
|
}
|
|
|
|
if (code < 232) {
|
|
const index = code - 16
|
|
const red = Math.floor(index / 36)
|
|
const green = Math.floor((index % 36) / 6)
|
|
const blue = index % 6
|
|
const scale = [0, 95, 135, 175, 215, 255]
|
|
return `rgb(${scale[red]}, ${scale[green]}, ${scale[blue]})`
|
|
}
|
|
|
|
const gray = 8 + (code - 232) * 10
|
|
return `rgb(${gray}, ${gray}, ${gray})`
|
|
}
|
|
|
|
function codeToColor(code: number): string | undefined {
|
|
if (code >= 30 && code <= 37) {
|
|
return ANSI_COLORS[code - 30]
|
|
}
|
|
|
|
if (code >= 40 && code <= 47) {
|
|
return ANSI_COLORS[code - 40]
|
|
}
|
|
|
|
if (code >= 90 && code <= 97) {
|
|
return ANSI_BRIGHT_COLORS[code - 90]
|
|
}
|
|
|
|
if (code >= 100 && code <= 107) {
|
|
return ANSI_BRIGHT_COLORS[code - 100]
|
|
}
|
|
|
|
if (code === 39 || code === 49) {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function applyExtendedColor(
|
|
state: AnsiState,
|
|
codes: number[],
|
|
index: number,
|
|
target: "foreground" | "background" | "underlineColor",
|
|
): number {
|
|
const mode = codes[index + 1]
|
|
|
|
if (mode === 5) {
|
|
const colorCode = codes[index + 2]
|
|
if (colorCode !== undefined) {
|
|
state[target] = ansi256ToHex(colorCode)
|
|
return index + 2
|
|
}
|
|
}
|
|
|
|
if (mode === 2) {
|
|
const red = codes[index + 2]
|
|
const green = codes[index + 3]
|
|
const blue = codes[index + 4]
|
|
if (red !== undefined && green !== undefined && blue !== undefined) {
|
|
state[target] = `rgb(${red}, ${green}, ${blue})`
|
|
return index + 4
|
|
}
|
|
}
|
|
|
|
return index
|
|
}
|
|
|
|
function styleToCss(style: AnsiState): CSSProperties {
|
|
return {
|
|
backgroundColor: style.background,
|
|
color: style.foreground,
|
|
fontStyle: style.italic ? "italic" : undefined,
|
|
fontWeight: style.bold ? 700 : undefined,
|
|
opacity: style.dim ? 0.7 : undefined,
|
|
textDecorationColor: style.underlineColor,
|
|
textDecorationLine:
|
|
[
|
|
style.underline ? "underline" : "",
|
|
style.strikethrough ? "line-through" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ") || undefined,
|
|
}
|
|
}
|
|
|
|
export function parseAnsiSegments(input: string): AnsiSegment[] {
|
|
const segments: AnsiSegment[] = []
|
|
const state: AnsiState = {}
|
|
let lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
|
|
const pushText = (text: string) => {
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
segments.push({
|
|
style: styleToCss(cloneAnsiState(state)),
|
|
text,
|
|
})
|
|
}
|
|
|
|
ANSI_PATTERN.lastIndex = 0
|
|
|
|
while ((match = ANSI_PATTERN.exec(input)) !== null) {
|
|
pushText(input.slice(lastIndex, match.index))
|
|
|
|
const codes = (match[1] || "0")
|
|
.split(";")
|
|
.map((value) => (value === "" ? 0 : Number.parseInt(value, 10)))
|
|
.filter((value) => Number.isFinite(value))
|
|
|
|
for (let index = 0; index < codes.length; index += 1) {
|
|
const code = codes[index]
|
|
|
|
if (code === 0) {
|
|
Object.keys(state).forEach((key) => {
|
|
delete state[key as keyof AnsiState]
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (code === 1) {
|
|
state.bold = true
|
|
continue
|
|
}
|
|
|
|
if (code === 2) {
|
|
state.dim = true
|
|
continue
|
|
}
|
|
|
|
if (code === 3) {
|
|
state.italic = true
|
|
continue
|
|
}
|
|
|
|
if (code === 4) {
|
|
state.underline = true
|
|
continue
|
|
}
|
|
|
|
if (code === 9) {
|
|
state.strikethrough = true
|
|
continue
|
|
}
|
|
|
|
if (code === 21 || code === 22) {
|
|
delete state.bold
|
|
delete state.dim
|
|
continue
|
|
}
|
|
|
|
if (code === 23) {
|
|
delete state.italic
|
|
continue
|
|
}
|
|
|
|
if (code === 24) {
|
|
delete state.underline
|
|
continue
|
|
}
|
|
|
|
if (code === 29) {
|
|
delete state.strikethrough
|
|
continue
|
|
}
|
|
|
|
if (code === 39) {
|
|
delete state.foreground
|
|
continue
|
|
}
|
|
|
|
if (code === 49) {
|
|
delete state.background
|
|
continue
|
|
}
|
|
|
|
if (code === 59) {
|
|
delete state.underlineColor
|
|
continue
|
|
}
|
|
|
|
if (code === 38) {
|
|
index = applyExtendedColor(state, codes, index, "foreground")
|
|
continue
|
|
}
|
|
|
|
if (code === 48) {
|
|
index = applyExtendedColor(state, codes, index, "background")
|
|
continue
|
|
}
|
|
|
|
if (code === 58) {
|
|
index = applyExtendedColor(state, codes, index, "underlineColor")
|
|
continue
|
|
}
|
|
|
|
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
state.foreground = codeToColor(code)
|
|
continue
|
|
}
|
|
|
|
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
|
state.background = codeToColor(code)
|
|
}
|
|
}
|
|
|
|
lastIndex = ANSI_PATTERN.lastIndex
|
|
}
|
|
|
|
pushText(input.slice(lastIndex))
|
|
|
|
if (segments.length === 0) {
|
|
return [{ style: {}, text: input }]
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
export function wrapLogLine(line: string, columns: number): string {
|
|
const normalized = line.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
|
|
|
if (columns < 20) {
|
|
return normalized
|
|
}
|
|
|
|
return wrapAnsi(normalized, columns, {
|
|
hard: true,
|
|
trim: false,
|
|
wordWrap: false,
|
|
})
|
|
}
|