Skip to content

Commit 0e49047

Browse files
Support hyperlinks in supported terminals (#37)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent a28eb7d commit 0e49047

File tree

2 files changed

+55
-12
lines changed

2 files changed

+55
-12
lines changed

index.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ const ESCAPES = new Set([
1010

1111
const END_CODE = 39;
1212

13-
const wrapAnsi = code => `${ESCAPES.values().next().value}[${code}m`;
13+
const ANSI_ESCAPE_BELL = '\u0007';
14+
const ANSI_CSI = '[';
15+
const ANSI_OSC = ']';
16+
const ANSI_SGR_TERMINATOR = 'm';
17+
const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`;
18+
19+
const wrapAnsi = code => `${ESCAPES.values().next().value}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`;
20+
const wrapAnsiHyperlink = uri => `${ESCAPES.values().next().value}${ANSI_ESCAPE_LINK}${uri}${ANSI_ESCAPE_BELL}`;
1421

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

2431
let isInsideEscape = false;
32+
let isInsideLinkEscape = false;
2533
let visible = stringWidth(stripAnsi(rows[rows.length - 1]));
2634

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

3745
if (ESCAPES.has(character)) {
3846
isInsideEscape = true;
39-
} else if (isInsideEscape && character === 'm') {
40-
isInsideEscape = false;
41-
continue;
47+
isInsideLinkEscape = characters.slice(index + 1).join('').startsWith(ANSI_ESCAPE_LINK);
4248
}
4349

4450
if (isInsideEscape) {
51+
if (isInsideLinkEscape) {
52+
if (character === ANSI_ESCAPE_BELL) {
53+
isInsideEscape = false;
54+
isInsideLinkEscape = false;
55+
}
56+
} else if (character === ANSI_SGR_TERMINATOR) {
57+
isInsideEscape = false;
58+
}
59+
4560
continue;
4661
}
4762

@@ -90,9 +105,9 @@ const exec = (string, columns, options = {}) => {
90105
return '';
91106
}
92107

93-
let pre = '';
94108
let ret = '';
95109
let escapeCode;
110+
let escapeUri;
96111

97112
const lengths = wordLengths(string);
98113
let rows = [''];
@@ -151,24 +166,39 @@ const exec = (string, columns, options = {}) => {
151166
rows = rows.map(stringVisibleTrimSpacesRight);
152167
}
153168

154-
pre = rows.join('\n');
169+
const pre = [...rows.join('\n')];
155170

156-
for (const [index, character] of [...pre].entries()) {
171+
for (const [index, character] of pre.entries()) {
157172
ret += character;
158173

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

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

166-
if (escapeCode && code) {
167-
if (pre[index + 1] === '\n') {
186+
if (pre[index + 1] === '\n') {
187+
if (escapeUri) {
188+
ret += wrapAnsiHyperlink('');
189+
}
190+
191+
if (escapeCode && code) {
168192
ret += wrapAnsi(code);
169-
} else if (character === '\n') {
193+
}
194+
} else if (character === '\n') {
195+
if (escapeCode && code) {
170196
ret += wrapAnsi(escapeCode);
171197
}
198+
199+
if (escapeUri) {
200+
ret += wrapAnsiHyperlink(escapeUri);
201+
}
172202
}
173203
}
174204

test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,19 @@ test('#27, does not remove spaces in line with ansi escapes when no trimming', t
149149
t.is(wrapAnsi(chalk.bgGreen(' hello '), 10, {hard: true, trim: false}), chalk.bgGreen(' hello '));
150150
});
151151

152+
test('#35, wraps hyperlinks, preserving clickability in supporting terminals', t => {
153+
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});
154+
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.');
155+
156+
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});
157+
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.');
158+
});
159+
160+
test('covers non-SGR/non-hyperlink ansi escapes', t => {
161+
t.is(wrapAnsi('Hello, \u001B[1D World!', 8), 'Hello,\u001B[1D\nWorld!');
162+
t.is(wrapAnsi('Hello, \u001B[1D World!', 8, {trim: false}), 'Hello, \u001B[1D \nWorld!');
163+
});
164+
152165
test('#39, normalizes newlines', t => {
153166
t.is(wrapAnsi('foobar\r\nfoobar\r\nfoobar\nfoobar', 3, {hard: true}), 'foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar');
154167
t.is(wrapAnsi('foo bar\r\nfoo bar\r\nfoo bar\nfoo bar', 3), 'foo\nbar\nfoo\nbar\nfoo\nbar\nfoo\nbar');

0 commit comments

Comments
 (0)