Skip to content

Commit

Permalink
refactor: rewrite ansiHTML and add test cases (#4277)
Browse files Browse the repository at this point in the history
  • Loading branch information
chenjiahan authored Dec 26, 2024
1 parent 2e5750b commit 638c519
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 97 deletions.
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);
});
});

0 comments on commit 638c519

Please sign in to comment.