1const ESC = "\u001b";
2
3const ANSI_COLORS = [
4 "var(--clo-term-ansi-black, #000000)",
5 "var(--clo-term-ansi-red, #cd3131)",
6 "var(--clo-term-ansi-green, #0dbc79)",
7 "var(--clo-term-ansi-yellow, #e5e510)",
8 "var(--clo-term-ansi-blue, #2472c8)",
9 "var(--clo-term-ansi-magenta, #bc3fbc)",
10 "var(--clo-term-ansi-cyan, #11a8cd)",
11 "var(--clo-term-ansi-white, #e5e5e5)",
12 "var(--clo-term-ansi-bright-black, #666666)",
13 "var(--clo-term-ansi-bright-red, #f14c4c)",
14 "var(--clo-term-ansi-bright-green, #23d18b)",
15 "var(--clo-term-ansi-bright-yellow, #f5f543)",
16 "var(--clo-term-ansi-bright-blue, #3b8eea)",
17 "var(--clo-term-ansi-bright-magenta, #d670d6)",
18 "var(--clo-term-ansi-bright-cyan, #29b8db)",
19 "var(--clo-term-ansi-bright-white, #ffffff)",
20] as const;
21
22const UNDERLINES = ["", "solid", "double", "wavy", "dotted", "dashed"] as const;
23
24interface AnsiState {
25 bold: boolean;
26 faint: boolean;
27 italic: boolean;
28 underline: "" | "solid" | "double" | "wavy" | "dotted" | "dashed";
29 inverse: boolean;
30 invisible: boolean;
31 strikethrough: boolean;
32 overline: boolean;
33 foreground: string;
34 background: string;
35}
36
37function initialState(): AnsiState {
38 return {
39 bold: false,
40 faint: false,
41 italic: false,
42 underline: "",
43 inverse: false,
44 invisible: false,
45 strikethrough: false,
46 overline: false,
47 foreground: "",
48 background: "",
49 };
50}
51
52function byte(value: number): number {
53 return Math.max(0, Math.min(255, Number.isFinite(value) ? value : 0));
54}
55
56function colorByte(value: string | undefined): number | null {
57 if (value === undefined || !/^\d+$/.test(value)) return null;
58 const result = Number(value);
59 return result <= 255 ? result : null;
60}
61
62function paletteColor(index: number): string {
63 index = Math.trunc(byte(index));
64 const color = ANSI_COLORS[index];
65 if (color !== undefined) return color;
66 if (index >= 232) {
67 const value = byte(8 + (Math.min(index, 255) - 232) * 10);
68 return `rgb(${value} ${value} ${value})`;
69 }
70 const value = Math.max(0, Math.min(215, index - 16));
71 const levels = [0, 95, 135, 175, 215, 255] as const;
72 return `rgb(${levels[Math.floor(value / 36)]} ${levels[Math.floor((value % 36) / 6)]} ${levels[value % 6]})`;
73}
74
75function setColor(state: AnsiState, background: boolean, color: string): void {
76 if (background) state.background = color;
77 else state.foreground = color;
78}
79
80function applyCode(state: AnsiState, code: number): AnsiState {
81 if (code === 0) return initialState();
82 if (code === 1) state.bold = true;
83 else if (code === 2) state.faint = true;
84 else if (code === 3) state.italic = true;
85 else if (code === 4) state.underline = "solid";
86 else if (code === 7) state.inverse = true;
87 else if (code === 8) state.invisible = true;
88 else if (code === 9) state.strikethrough = true;
89 else if (code === 21) state.underline = "double";
90 else if (code === 22) {
91 state.bold = false;
92 state.faint = false;
93 } else if (code === 23) state.italic = false;
94 else if (code === 24) state.underline = "";
95 else if (code === 27) state.inverse = false;
96 else if (code === 28) state.invisible = false;
97 else if (code === 29) state.strikethrough = false;
98 else if (code === 39) state.foreground = "";
99 else if (code === 49) state.background = "";
100 else if (code === 53) state.overline = true;
101 else if (code === 55) state.overline = false;
102 else if (code >= 30 && code <= 37) state.foreground = paletteColor(code - 30);
103 else if (code >= 40 && code <= 47) state.background = paletteColor(code - 40);
104 else if (code >= 90 && code <= 97) state.foreground = paletteColor(code - 82);
105 else if (code >= 100 && code <= 107) state.background = paletteColor(code - 92);
106 return state;
107}
108
109function applySgr(state: AnsiState, parameters: string): AnsiState {
110 const groups = parameters === "" ? ["0"] : parameters.split(";");
111 for (let index = 0; index < groups.length; index++) {
112 const parts = groups[index]!.split(":");
113 const code = Number(parts[0] || 0);
114 if ((code === 38 || code === 48) && parts.length > 1) {
115 const mode = Number(parts[1]);
116 if (mode === 5 && parts.length === 3) {
117 const color = colorByte(parts[2]);
118 if (color !== null) setColor(state, code === 48, paletteColor(color));
119 } else if (
120 mode === 2 &&
121 (parts.length === 5 || (parts.length === 6 && (parts[2] === "" || /^\d+$/.test(parts[2]!))))
122 ) {
123 const rgb = parts.slice(-3).map(colorByte);
124 if (rgb.every((value) => value !== null))
125 setColor(state, code === 48, `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`);
126 }
127 continue;
128 }
129 if (code === 4 && parts.length > 1) {
130 state.underline = UNDERLINES[Number(parts[1])] ?? "";
131 continue;
132 }
133 if ((code === 38 || code === 48) && groups[index + 1] === "5") {
134 const color = colorByte(groups[index + 2]);
135 if (color !== null) setColor(state, code === 48, paletteColor(color));
136 index += 2;
137 continue;
138 }
139 if ((code === 38 || code === 48) && groups[index + 1] === "2") {
140 const rgb = groups.slice(index + 2, index + 5).map(colorByte);
141 if (rgb.length === 3 && rgb.every((value) => value !== null))
142 setColor(state, code === 48, `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`);
143 index += 4;
144 continue;
145 }
146 state = applyCode(state, code);
147 }
148 return state;
149}
150
151function styles(state: AnsiState): string {
152 const rules: string[] = [];
153 if (state.bold) rules.push("font-weight:700");
154 if (state.faint) rules.push("opacity:0.68");
155 if (state.italic) rules.push("font-style:italic");
156 const decorations = [
157 state.underline ? "underline" : "",
158 state.strikethrough ? "line-through" : "",
159 state.overline ? "overline" : "",
160 ].filter(Boolean);
161 if (decorations.length > 0) rules.push(`text-decoration-line:${decorations.join(" ")}`);
162 if (state.underline && state.underline !== "solid")
163 rules.push(`text-decoration-style:${state.underline}`);
164
165 let foreground = state.foreground;
166 let background = state.background;
167 if (state.inverse) {
168 [foreground, background] = [
169 background || "var(--clo-term-bg, #1e1e2e)",
170 foreground || "var(--clo-term-fg, #cdd6f4)",
171 ];
172 }
173 if (state.invisible) foreground = "transparent";
174 if (foreground) rules.push(`color:${foreground}`);
175 if (background) rules.push(`background-color:${background}`);
176 return rules.join(";");
177}
178
179function escapeHtml(text: string): string {
180 return text.replace(/[&<>"'\r]/g, (character) => {
181 if (character === "&") return "&amp;";
182 if (character === "<") return "&lt;";
183 if (character === ">") return "&gt;";
184 if (character === '"') return "&quot;";
185 if (character === "'") return "&#39;";
186 return "&#13;";
187 });
188}
189
190function stringEnd(text: string, start: number, bell: boolean): number {
191 for (let index = start; index < text.length; index++) {
192 const code = text.charCodeAt(index);
193 if (code === 0x18 || code === 0x1a || code === 0x9c || (bell && code === 0x07))
194 return index + 1;
195 if (text[index] === ESC && text[index + 1] === "\\") return index + 2;
196 if (text[index] === ESC) return index;
197 }
198 return text.length;
199}
200
201/**
202 * Convert styled ANSI text to escaped inline HTML. Terminal cursor, input,
203 * viewport, and scrolling behavior are intentionally absent; non-SGR control
204 * sequences are removed. Whitespace layout remains the caller's choice.
205 */
206export function ansiToHtml(text: string): string {
207 let state = initialState();
208 let html = "";
209 let plainStart = 0;
210 let index = 0;
211
212 const append = (value: string) => {
213 if (value === "") return;
214 const escaped = escapeHtml(value);
215 const style = styles(state);
216 html += style === "" ? escaped : `<span style="${style}">${escaped}</span>`;
217 };
218 const emit = (end: number) => append(text.slice(plainStart, end));
219
220 while (index < text.length) {
221 const code = text.charCodeAt(index);
222 const preservedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d;
223 if (
224 code !== 0x1b &&
225 code !== 0x7f &&
226 !(code < 0x20 && !preservedWhitespace) &&
227 !(code >= 0x80 && code <= 0x9f)
228 ) {
229 index++;
230 continue;
231 }
232
233 emit(index);
234 const next = text[index + 1];
235 if ((code === 0x1b && next === "[") || code === 0x9b) {
236 let parameters = "";
237 let end = index + (code === 0x9b ? 1 : 2);
238 while (end < text.length) {
239 const final = text.charCodeAt(end);
240 if (final === 0x18 || final === 0x1a) {
241 end++;
242 break;
243 }
244 if (final === 0x1b || (final >= 0x80 && final <= 0x9f)) break;
245 if (final < 0x20 || final === 0x7f) {
246 if (final === 0x09 || final === 0x0a || final === 0x0d) append(text[end]!);
247 end++;
248 continue;
249 }
250 if (final >= 0x40 && final <= 0x7e) {
251 if (final === 0x6d && /^[0-9:;]*$/.test(parameters)) state = applySgr(state, parameters);
252 end++;
253 break;
254 }
255 parameters += text[end];
256 end++;
257 }
258 index = end;
259 } else if ((code === 0x1b && next === "]") || code === 0x9d) {
260 index = stringEnd(text, index + (code === 0x9d ? 1 : 2), true);
261 } else if (
262 (code === 0x1b && (next === "P" || next === "X" || next === "^" || next === "_")) ||
263 code === 0x90 ||
264 code === 0x98 ||
265 code === 0x9e ||
266 code === 0x9f
267 ) {
268 index = stringEnd(text, index + (code === 0x1b ? 2 : 1), false);
269 } else if (code === 0x1b) {
270 index++;
271 while (index < text.length) {
272 const intermediate = text.charCodeAt(index);
273 if (intermediate < 0x20 || intermediate > 0x2f) break;
274 index++;
275 }
276 const final = text.charCodeAt(index);
277 if (final >= 0x30 && final <= 0x7e) index++;
278 } else index++;
279 plainStart = index;
280 }
281 emit(text.length);
282 return html;
283}