Skip to content

Commit

Permalink
Support hyperlinks in supported terminals (#37)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
nicholaschiasson and sindresorhus committed Apr 22, 2020
1 parent a28eb7d commit 0e49047
Show file tree
Hide file tree
Showing 2 changed files with 55 additions and 12 deletions.
54 changes: 42 additions & 12 deletions index.js
Expand Up @@ -10,7 +10,14 @@ const ESCAPES = new Set([

const END_CODE = 39;

const wrapAnsi = code => `${ESCAPES.values().next().value}[${code}m`;
const ANSI_ESCAPE_BELL = '\u0007';
const ANSI_CSI = '[';
const ANSI_OSC = ']';
const ANSI_SGR_TERMINATOR = 'm';
const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`;

const wrapAnsi = code => `${ESCAPES.values().next().value}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`;
const wrapAnsiHyperlink = uri => `${ESCAPES.values().next().value}${ANSI_ESCAPE_LINK}${uri}${ANSI_ESCAPE_BELL}`;

// Calculate the length of words split on ' ', ignoring
// the extra characters added by ansi escape codes
Expand All @@ -22,6 +29,7 @@ const wrapWord = (rows, word, columns) => {
const characters = [...word];

let isInsideEscape = false;
let isInsideLinkEscape = false;
let visible = stringWidth(stripAnsi(rows[rows.length - 1]));

for (const [index, character] of characters.entries()) {
Expand All @@ -36,12 +44,19 @@ const wrapWord = (rows, word, columns) => {

if (ESCAPES.has(character)) {
isInsideEscape = true;
} else if (isInsideEscape && character === 'm') {
isInsideEscape = false;
continue;
isInsideLinkEscape = characters.slice(index + 1).join('').startsWith(ANSI_ESCAPE_LINK);
}

if (isInsideEscape) {
if (isInsideLinkEscape) {
if (character === ANSI_ESCAPE_BELL) {
isInsideEscape = false;
isInsideLinkEscape = false;
}
} else if (character === ANSI_SGR_TERMINATOR) {
isInsideEscape = false;
}

continue;
}

Expand Down Expand Up @@ -90,9 +105,9 @@ const exec = (string, columns, options = {}) => {
return '';
}

let pre = '';
let ret = '';
let escapeCode;
let escapeUri;

const lengths = wordLengths(string);
let rows = [''];
Expand Down Expand Up @@ -151,24 +166,39 @@ const exec = (string, columns, options = {}) => {
rows = rows.map(stringVisibleTrimSpacesRight);
}

pre = rows.join('\n');
const pre = [...rows.join('\n')];

for (const [index, character] of [...pre].entries()) {
for (const [index, character] of pre.entries()) {
ret += character;

if (ESCAPES.has(character)) {
const code = parseFloat(/\d[^m]*/.exec(pre.slice(index, index + 4)));
escapeCode = code === END_CODE ? null : code;
const {groups} = new RegExp(`(?:\\${ANSI_CSI}(?<code>\\d+)m|\\${ANSI_ESCAPE_LINK}(?<uri>.*)${ANSI_ESCAPE_BELL})`).exec(pre.slice(index).join('')) || {groups: {}};
if (groups.code !== undefined) {
const code = parseFloat(groups.code);
escapeCode = code === END_CODE ? null : code;
} else if (groups.uri !== undefined) {
escapeUri = groups.uri.length === 0 ? null : groups.uri;
}
}

const code = ansiStyles.codes.get(Number(escapeCode));

if (escapeCode && code) {
if (pre[index + 1] === '\n') {
if (pre[index + 1] === '\n') {
if (escapeUri) {
ret += wrapAnsiHyperlink('');
}

if (escapeCode && code) {
ret += wrapAnsi(code);
} else if (character === '\n') {
}
} else if (character === '\n') {
if (escapeCode && code) {
ret += wrapAnsi(escapeCode);
}

if (escapeUri) {
ret += wrapAnsiHyperlink(escapeUri);
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions test.js
Expand Up @@ -149,6 +149,19 @@ test('#27, does not remove spaces in line with ansi escapes when no trimming', t
t.is(wrapAnsi(chalk.bgGreen(' hello '), 10, {hard: true, trim: false}), chalk.bgGreen(' hello '));
});

test('#35, wraps hyperlinks, preserving clickability in supporting terminals', t => {
const result1 = wrapAnsi('Check out \u001B]8;;https://www.example.com\u0007my website\u001B]8;;\u0007, it is \u001B]8;;https://www.example.com\u0007supercalifragilisticexpialidocious\u001B]8;;\u0007.', 16, {hard: true});
t.is(result1, 'Check out \u001B]8;;https://www.example.com\u0007my\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007website\u001B]8;;\u0007, it is\n\u001B]8;;https://www.example.com\u0007supercalifragili\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007sticexpialidocio\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007us\u001B]8;;\u0007.');

const result2 = wrapAnsi(`Check out \u001B]8;;https://www.example.com\u0007my \uD83C\uDE00 ${chalk.bgGreen('website')}\u001B]8;;\u0007, it ${chalk.bgRed('is \u001B]8;;https://www.example.com\u0007super\uD83C\uDE00califragilisticexpialidocious\u001B]8;;\u0007')}.`, 16, {hard: true});
t.is(result2, 'Check out \u001B]8;;https://www.example.com\u0007my 🈀\u001B]8;;\u0007\n\u001B]8;;https://www.example.com\u0007\u001B[42mwebsite\u001B[49m\u001B]8;;\u0007, it \u001B[41mis\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007super🈀califragi\u001B]8;;\u0007\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007listicexpialidoc\u001B]8;;\u0007\u001B[49m\n\u001B[41m\u001B]8;;https://www.example.com\u0007ious\u001B]8;;\u0007\u001B[49m.');
});

test('covers non-SGR/non-hyperlink ansi escapes', t => {
t.is(wrapAnsi('Hello, \u001B[1D World!', 8), 'Hello,\u001B[1D\nWorld!');
t.is(wrapAnsi('Hello, \u001B[1D World!', 8, {trim: false}), 'Hello, \u001B[1D \nWorld!');
});

test('#39, normalizes newlines', t => {
t.is(wrapAnsi('foobar\r\nfoobar\r\nfoobar\nfoobar', 3, {hard: true}), 'foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar');
t.is(wrapAnsi('foo bar\r\nfoo bar\r\nfoo bar\nfoo bar', 3), 'foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar');
Expand Down

0 comments on commit 0e49047

Please sign in to comment.