Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/cases/server/overlay-type-errors/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test('should display type errors on overlay correctly', async ({ page }) => {
// The first span is "<span style="color:#888">TS2322: </span>"
const firstSpan = errorOverlay.locator('span').first();
expect(await firstSpan.textContent()).toEqual('TS2322: ');
expect(await firstSpan.getAttribute('style')).toEqual('color:#888');
expect(await firstSpan.getAttribute('style')).toEqual('color:#888;');

// The first link is "<a class="file-link">/src/index.ts:3:1</a>"
const firstLink = errorOverlay.locator('.file-link').first();
Expand Down
125 changes: 29 additions & 96 deletions packages/core/src/server/ansiHTML.ts
Original file line number Diff line number Diff line change
@@ -1,128 +1,61 @@
/**
* This module is modified based on `ansi-html-community`
* https://github.com/mahdyar/ansi-html-community
*
* Licensed under the Apache License, Version 2.0 (the "License");
* https://github.com/mahdyar/ansi-html-community/blob/master/LICENSE
*/

// https://github.com/chalk/ansi-regex
function ansiRegex() {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)';
const pattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
].join('|');

return new RegExp(pattern, 'g');
}

const colors: Record<string, string> = {
black: '#000',
// hsl(0deg 95% 70%)
red: '#fb6a6a',
// hsl(135deg 90% 70%)
green: '#6ef790',
// hsl(65deg 90% 75%)
yellow: '#eff986',
// hsl(185deg 90% 70%)
cyan: '#6eecf7',
// hsl(210deg 90% 70%)
blue: '#6eb2f7',
// hsl(325deg 90% 70%)
magenta: '#f76ebe',
lightgrey: '#f0f0f0',
darkgrey: '#888',
};

const styles: Record<string, string> = {
30: 'black',
31: 'red',
32: 'green',
33: 'yellow',
34: 'blue',
35: 'magenta',
36: 'cyan',
37: 'lightgrey',
};

const openTags: Record<string, string> = {
'1': 'font-weight:bold', // bold
'2': 'opacity:0.5', // dim
'3': '<i>', // italic
'4': '<u>', // underscore
'8': 'display:none', // hidden
'9': '<del>', // delete
const openCodes: Record<string, string> = {
1: 'font-weight:bold', // bold
2: 'opacity:0.5', // dim
3: 'font-style:italic', // italic
4: 'text-decoration:underline', // underscore
8: 'display:none', // hidden
9: 'text-decoration:line-through', // delete
30: 'color:#000', // darkgrey
31: 'color:#fb6a6a', // red, hsl(0deg 95% 70%)
32: 'color:#6ef790', // green, hsl(65deg 90% 75%)
33: 'color:#eff986', // yellow, hsl(185deg 90% 70%)
34: 'color:#6eb2f7', // blue, hsl(325deg 90% 70%)
35: 'color:#f76ebe', // magenta, hsl(300deg 90% 70%)
36: 'color:#6eecf7', // cyan, hsl(210deg 90% 70%)
37: 'color:#f0f0f0', // lightgrey, hsl(0deg 0% 94%)
90: 'color:#888', // darkgrey
};

const closeTags: Record<string, string> = {
'23': '</i>', // reset italic
'24': '</u>', // reset underscore
'29': '</del>', // reset delete
};

for (const n of [0, 21, 22, 27, 28, 39, 49]) {
closeTags[n.toString()] = '</span>';
}
const closeCode = [0, 21, 22, 23, 24, 27, 28, 29, 39, 49];

/**
* Converts text with ANSI color codes to HTML markup.
* Converts text with ANSI color codes to HTML markup
*/
export function ansiHTML(text: string): string {
// Returns the text if the string has no ANSI escape code.
if (!ansiRegex().test(text)) {
return text;
}

// Cache opened sequence.
// Cache opened sequence
const ansiCodes: string[] = [];
// Replace with markup.
// Replace with markup
let ret = text.replace(
// biome-ignore lint/suspicious/noControlCharactersInRegex: allowed
/\x1B\[(\d+)m/g,
/\x1B\[([0-9;]+)m/g,
(_match: string, seq: string): string => {
const ot = openTags[seq];
if (ot) {
const openStyle = openCodes[seq];
if (openStyle) {
// If current sequence has been opened, close it.
if (ansiCodes.indexOf(seq) !== -1) {
ansiCodes.pop();
return '</span>';
}
// Open tag.
ansiCodes.push(seq);
return ot[0] === '<' ? ot : `<span style="${ot}">`;
return `<span style="${openStyle};">`;
}

const ct = closeTags[seq];
if (ct) {
if (closeCode.includes(Number(seq))) {
// Pop sequence
ansiCodes.pop();
return ct;
return '</span>';
}
return '';
},
);

// Make sure tags are closed.
const l = ansiCodes.length;
if (l > 0) {
ret += Array(l + 1).join('</span>');
// Make sure tags are closed
if (ansiCodes.length > 0) {
ret += Array(ansiCodes.length + 1).join('</span>');
}

return ret;
}

function setTags(): void {
openTags['90'] = `color:${colors.darkgrey}`;

for (const code in styles) {
const color = styles[code];
const oriColor = colors[color] || colors.black;
openTags[code] = `color:${oriColor}`;
}
}

setTags();

export default ansiHTML;
70 changes: 70 additions & 0 deletions packages/core/tests/ansi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ansiHTML } from '../src/server/ansiHTML';

describe('ansiHTML', () => {
it('should convert ANSI color codes to HTML', () => {
const redInput = '\x1B[31mHello, World!\x1B[0m';
const redExpected = '<span style="color:#fb6a6a;">Hello, World!</span>';
expect(ansiHTML(redInput)).toEqual(redExpected);

const blueInput = '\x1B[34mHello, World!\x1B[0m';
const blueExpected = '<span style="color:#6eb2f7;">Hello, World!</span>';
expect(ansiHTML(blueInput)).toEqual(blueExpected);

const greenInput = '\x1B[32mHello, World!\x1B[0m';
const greenExpected = '<span style="color:#6ef790;">Hello, World!</span>';
expect(ansiHTML(greenInput)).toEqual(greenExpected);

const yellowInput = '\x1B[33mHello, World!\x1B[0m';
const yellowExpected = '<span style="color:#eff986;">Hello, World!</span>';
expect(ansiHTML(yellowInput)).toEqual(yellowExpected);

const cyanInput = '\x1B[36mHello, World!\x1B[0m';
const cyanExpected = '<span style="color:#6eecf7;">Hello, World!</span>';
expect(ansiHTML(cyanInput)).toEqual(cyanExpected);

const magentaInput = '\x1B[35mHello, World!\x1B[0m';
const magentaExpected = '<span style="color:#f76ebe;">Hello, World!</span>';
expect(ansiHTML(magentaInput)).toEqual(magentaExpected);

const lightgreyInput = '\x1B[37mHello, World!\x1B[0m';
const lightgreyExpected =
'<span style="color:#f0f0f0;">Hello, World!</span>';
expect(ansiHTML(lightgreyInput)).toEqual(lightgreyExpected);

const darkgreyInput = '\x1B[90mHello, World!\x1B[0m';
const darkgreyExpected = '<span style="color:#888;">Hello, World!</span>';
expect(ansiHTML(darkgreyInput)).toEqual(darkgreyExpected);
});

it('should convert ANSI bold codes to HTML', () => {
const input = '\x1B[1mHello, World!\x1B[0m';
const expected = '<span style="font-weight:bold;">Hello, World!</span>';
expect(ansiHTML(input)).toEqual(expected);
});

it('should convert ANSI dim codes to HTML', () => {
const input = '\x1B[2mHello, World!\x1B[0m';
const expected = '<span style="opacity:0.5;">Hello, World!</span>';
expect(ansiHTML(input)).toEqual(expected);
});

it('should convert ANSI italic codes to HTML', () => {
const input = '\x1B[3mHello, World!\x1B[0m';
const expected = '<span style="font-style:italic;">Hello, World!</span>';
expect(ansiHTML(input)).toEqual(expected);
});

it('should convert ANSI underline codes to HTML', () => {
const input = '\x1B[4mHello, World!\x1B[0m';
const expected =
'<span style="text-decoration:underline;">Hello, World!</span>';
expect(ansiHTML(input)).toEqual(expected);
});

it('should convert ANSI delete codes to HTML', () => {
const input = '\x1B[9mHello, World!\x1B[0m';
const expected =
'<span style="text-decoration:line-through;">Hello, World!</span>';
expect(ansiHTML(input)).toEqual(expected);
});
});
Loading