|
| 1 | +// ANSI SGR + bracket markup tokenizer. |
| 2 | +// Produces a flat array of styled segments: { text, classes }. |
| 3 | +// |
| 4 | +// Supports: |
| 5 | +// - ANSI CSI SGR sequences: \x1b[<params>m (0, 1, 3, 4, 22, 23, 24, 30-37, 39, 90-97, 38;5;N, 38;2;R;G;B) |
| 6 | +// - Bracket markup: [b]..[/b], [i]..[/i], [u]..[/u], [dim]..[/dim], [muted]..[/muted], [link]..[/link], |
| 7 | +// [red] [green] [yellow] [blue] [magenta] [cyan] [white] [black] |
| 8 | +// [brred] [brgreen] [bryellow] [brblue] [brmagenta] [brcyan] [brwhite] [brblack] |
| 9 | +// - Plain text passthrough |
| 10 | +// |
| 11 | +// Bracket tags can nest. ANSI state machine handles standard SGR codes only; |
| 12 | +// other CSI/OSC sequences are dropped silently. |
| 13 | + |
| 14 | +const ANSI_FG = { |
| 15 | + 30: "black", 31: "red", 32: "green", 33: "yellow", |
| 16 | + 34: "blue", 35: "magenta", 36: "cyan", 37: "white", |
| 17 | + 90: "br-black", 91: "br-red", 92: "br-green", 93: "br-yellow", |
| 18 | + 94: "br-blue", 95: "br-magenta", 96: "br-cyan", 97: "br-white", |
| 19 | +}; |
| 20 | + |
| 21 | +const COLOR_NAMES = new Set([ |
| 22 | + "red", "green", "yellow", "blue", "magenta", "cyan", "white", "black", |
| 23 | + "brred", "brgreen", "bryellow", "brblue", "brmagenta", "brcyan", "brwhite", "brblack", |
| 24 | +]); |
| 25 | +const TAG_TO_FG = { |
| 26 | + red: "red", green: "green", yellow: "yellow", blue: "blue", |
| 27 | + magenta: "magenta", cyan: "cyan", white: "white", black: "black", |
| 28 | + brred: "br-red", brgreen: "br-green", bryellow: "br-yellow", brblue: "br-blue", |
| 29 | + brmagenta: "br-magenta", brcyan: "br-cyan", brwhite: "br-white", brblack: "br-black", |
| 30 | +}; |
| 31 | + |
| 32 | +function classesFromState(state) { |
| 33 | + const cls = []; |
| 34 | + if (state.fg) cls.push(`fg-${state.fg}`); |
| 35 | + if (state.bold) cls.push("bold"); |
| 36 | + if (state.italic) cls.push("italic"); |
| 37 | + if (state.underline) cls.push("underline"); |
| 38 | + if (state.dim) cls.push("dim"); |
| 39 | + return cls; |
| 40 | +} |
| 41 | + |
| 42 | +function emit(out, text, state) { |
| 43 | + if (!text) return; |
| 44 | + out.push({ text, classes: classesFromState(state) }); |
| 45 | +} |
| 46 | + |
| 47 | +// Step 1: parse ANSI escape codes into a flat segment list, ignoring brackets. |
| 48 | +function parseAnsi(input) { |
| 49 | + const segments = []; |
| 50 | + const state = { fg: null, bold: false, italic: false, underline: false, dim: false }; |
| 51 | + let buf = ""; |
| 52 | + let i = 0; |
| 53 | + while (i < input.length) { |
| 54 | + const ch = input.charCodeAt(i); |
| 55 | + if (ch === 0x1b && input[i + 1] === "[") { |
| 56 | + if (buf) { emit(segments, buf, state); buf = ""; } |
| 57 | + // Find terminator |
| 58 | + let j = i + 2; |
| 59 | + while (j < input.length) { |
| 60 | + const c = input.charCodeAt(j); |
| 61 | + // CSI parameter bytes: 0x30-0x3f; intermediates: 0x20-0x2f; final: 0x40-0x7e |
| 62 | + if (c >= 0x40 && c <= 0x7e) break; |
| 63 | + j++; |
| 64 | + } |
| 65 | + const final = input[j]; |
| 66 | + const params = input.slice(i + 2, j); |
| 67 | + if (final === "m") applySgr(state, params); |
| 68 | + i = j + 1; |
| 69 | + continue; |
| 70 | + } |
| 71 | + buf += input[i]; |
| 72 | + i++; |
| 73 | + } |
| 74 | + if (buf) emit(segments, buf, state); |
| 75 | + return segments; |
| 76 | +} |
| 77 | + |
| 78 | +function applySgr(state, paramsStr) { |
| 79 | + const tokens = paramsStr.split(";").map((t) => (t === "" ? 0 : Number(t))); |
| 80 | + let i = 0; |
| 81 | + while (i < tokens.length) { |
| 82 | + const t = tokens[i]; |
| 83 | + if (t === 0) { |
| 84 | + state.fg = null; state.bold = false; state.italic = false; |
| 85 | + state.underline = false; state.dim = false; |
| 86 | + } else if (t === 1) state.bold = true; |
| 87 | + else if (t === 2) state.dim = true; |
| 88 | + else if (t === 3) state.italic = true; |
| 89 | + else if (t === 4) state.underline = true; |
| 90 | + else if (t === 22) { state.bold = false; state.dim = false; } |
| 91 | + else if (t === 23) state.italic = false; |
| 92 | + else if (t === 24) state.underline = false; |
| 93 | + else if (t === 39) state.fg = null; |
| 94 | + else if (ANSI_FG[t]) state.fg = ANSI_FG[t]; |
| 95 | + else if (t === 38) { |
| 96 | + const mode = tokens[i + 1]; |
| 97 | + if (mode === 5) { |
| 98 | + state.fg = map256(tokens[i + 2]); |
| 99 | + i += 2; |
| 100 | + } else if (mode === 2) { |
| 101 | + // Truecolor not mapped to a named slot; skip params and leave fg unchanged. |
| 102 | + i += 4; |
| 103 | + } |
| 104 | + } |
| 105 | + // ignore 40-49, 48 etc (we don't render backgrounds for now) |
| 106 | + i++; |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +// Map 256-color cube to nearest named slot. Coarse but adequate. |
| 111 | +function map256(n) { |
| 112 | + if (n == null) return null; |
| 113 | + if (n < 8) return ANSI_FG[30 + n] || null; |
| 114 | + if (n < 16) return ANSI_FG[90 + (n - 8)] || null; |
| 115 | + // Grayscale ramp (232 = near-black, 255 = near-white). The middle range |
| 116 | + // is the "muted" gray that gh uses for footer URLs, bullet separators, etc. |
| 117 | + if (n >= 232 && n <= 243) return "muted"; |
| 118 | + if (n >= 244 && n <= 250) return "br-black"; // softer gray |
| 119 | + // Color cube fallback: no good mapping, let the default fg apply. |
| 120 | + return null; |
| 121 | +} |
| 122 | +// Step 2: walk segments and split on bracket markup, updating per-segment classes. |
| 123 | +function parseBrackets(segments) { |
| 124 | + const out = []; |
| 125 | + const stack = []; // each entry: array of class strings added by this tag |
| 126 | + const tagRe = /\[(\/?)([a-zA-Z]+)\]/g; |
| 127 | + for (const seg of segments) { |
| 128 | + const text = seg.text; |
| 129 | + let last = 0; |
| 130 | + tagRe.lastIndex = 0; |
| 131 | + let m; |
| 132 | + const baseClasses = seg.classes.slice(); |
| 133 | + while ((m = tagRe.exec(text)) !== null) { |
| 134 | + const before = text.slice(last, m.index); |
| 135 | + if (before) out.push({ text: before, classes: mergeClasses(baseClasses, stack) }); |
| 136 | + const closing = m[1] === "/"; |
| 137 | + const tag = m[2].toLowerCase(); |
| 138 | + const added = tagToClasses(tag); |
| 139 | + if (added.length === 0) { |
| 140 | + // Not a recognized tag; treat as literal text. |
| 141 | + out.push({ text: m[0], classes: mergeClasses(baseClasses, stack) }); |
| 142 | + } else if (closing) { |
| 143 | + // Pop most recent matching frame. |
| 144 | + for (let i = stack.length - 1; i >= 0; i--) { |
| 145 | + if (stack[i].tag === tag) { stack.splice(i, 1); break; } |
| 146 | + } |
| 147 | + } else { |
| 148 | + stack.push({ tag, classes: added }); |
| 149 | + } |
| 150 | + last = m.index + m[0].length; |
| 151 | + } |
| 152 | + const tail = text.slice(last); |
| 153 | + if (tail) out.push({ text: tail, classes: mergeClasses(baseClasses, stack) }); |
| 154 | + } |
| 155 | + return out; |
| 156 | +} |
| 157 | + |
| 158 | +function tagToClasses(tag) { |
| 159 | + if (tag === "b" || tag === "bold") return ["bold"]; |
| 160 | + if (tag === "i" || tag === "italic") return ["italic"]; |
| 161 | + if (tag === "u" || tag === "underline") return ["underline"]; |
| 162 | + if (tag === "dim") return ["dim"]; |
| 163 | + if (tag === "muted") return ["fg-muted"]; |
| 164 | + if (tag === "link") return ["fg-br-blue", "underline"]; |
| 165 | + if (COLOR_NAMES.has(tag)) return [`fg-${TAG_TO_FG[tag]}`]; |
| 166 | + return []; |
| 167 | +} |
| 168 | + |
| 169 | +function mergeClasses(base, stack) { |
| 170 | + const set = new Set(base); |
| 171 | + for (const frame of stack) { |
| 172 | + for (const c of frame.classes) set.add(c); |
| 173 | + } |
| 174 | + return Array.from(set); |
| 175 | +} |
| 176 | + |
| 177 | +// Step 3: optional auto-styling for plain-looking segments. |
| 178 | +// Operates only on segments that have no styling yet, to avoid clobbering |
| 179 | +// user-specified colors. Splits on detected patterns and inserts styled spans. |
| 180 | +function autoStyle(segments) { |
| 181 | + const out = []; |
| 182 | + for (const seg of segments) { |
| 183 | + if (seg.classes.length > 0) { |
| 184 | + out.push(seg); |
| 185 | + continue; |
| 186 | + } |
| 187 | + autoStyleSegment(seg.text, out); |
| 188 | + } |
| 189 | + return out; |
| 190 | +} |
| 191 | + |
| 192 | +function autoStyleSegment(text, out) { |
| 193 | + // Process line by line so we can detect $ prompts. |
| 194 | + const lines = text.split(/(\n)/); |
| 195 | + for (const line of lines) { |
| 196 | + if (line === "\n") { |
| 197 | + out.push({ text: "\n", classes: [] }); |
| 198 | + continue; |
| 199 | + } |
| 200 | + if (line === "") continue; |
| 201 | + // Prompt line: leading `$ ` |
| 202 | + const promptMatch = line.match(/^(\s*)(\$)( )(.*)$/); |
| 203 | + if (promptMatch) { |
| 204 | + const [, leading, dollar, space, rest] = promptMatch; |
| 205 | + if (leading) out.push({ text: leading, classes: [] }); |
| 206 | + out.push({ text: dollar, classes: ["fg-muted"] }); |
| 207 | + out.push({ text: space, classes: [] }); |
| 208 | + // Apply inline auto-stylers to the rest of the prompt line |
| 209 | + autoStyleInline(rest, out); |
| 210 | + continue; |
| 211 | + } |
| 212 | + autoStyleInline(line, out); |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +function autoStyleInline(text, out) { |
| 217 | + // Detect URLs and color/dim them; detect standalone +N/-N tokens for diff stats; detect #NNN refs. |
| 218 | + // Single regex with alternation; iterate over matches. |
| 219 | + const re = /(https?:\/\/[^\s)>\]]+)|(?<![\w/-])([+-]\d+)(?![\w-])|(?<![\w/-])(#\d+)(?![\w-])/g; |
| 220 | + let last = 0; |
| 221 | + let m; |
| 222 | + while ((m = re.exec(text)) !== null) { |
| 223 | + if (m.index > last) out.push({ text: text.slice(last, m.index), classes: [] }); |
| 224 | + if (m[1]) { |
| 225 | + out.push({ text: m[1], classes: ["fg-muted"] }); |
| 226 | + } else if (m[2]) { |
| 227 | + const cls = m[2].startsWith("+") ? "fg-br-green" : "fg-br-red"; |
| 228 | + out.push({ text: m[2], classes: [cls] }); |
| 229 | + } else if (m[3]) { |
| 230 | + out.push({ text: m[3], classes: ["fg-br-blue"] }); |
| 231 | + } |
| 232 | + last = m.index + m[0].length; |
| 233 | + } |
| 234 | + if (last < text.length) out.push({ text: text.slice(last), classes: [] }); |
| 235 | +} |
| 236 | + |
| 237 | +export function parse(input, { autoStyle: enableAuto = true } = {}) { |
| 238 | + const ansiSegments = parseAnsi(input ?? ""); |
| 239 | + const bracketSegments = parseBrackets(ansiSegments); |
| 240 | + return enableAuto ? autoStyle(bracketSegments) : bracketSegments; |
| 241 | +} |
| 242 | + |
| 243 | +export function renderToDom(target, input, opts) { |
| 244 | + const segments = parse(input, opts); |
| 245 | + target.replaceChildren(); |
| 246 | + const frag = document.createDocumentFragment(); |
| 247 | + for (const seg of segments) { |
| 248 | + if (seg.classes.length === 0) { |
| 249 | + frag.appendChild(document.createTextNode(seg.text)); |
| 250 | + } else { |
| 251 | + const span = document.createElement("span"); |
| 252 | + span.className = seg.classes.join(" "); |
| 253 | + span.textContent = seg.text; |
| 254 | + frag.appendChild(span); |
| 255 | + } |
| 256 | + } |
| 257 | + target.appendChild(frag); |
| 258 | +} |
0 commit comments