diff --git a/index.js b/index.js index a6e5443..049c5a4 100755 --- a/index.js +++ b/index.js @@ -9,8 +9,15 @@ const ESCAPES = new Set([ ]); const END_CODE = 39; +const ESCAPE_TERMINATOR = 'm'; -const wrapAnsi = code => `${ESCAPES.values().next().value}[${code}m`; +const ANSI_ESCAPE_BELL = '\u0007'; +const ANSI_CSI = '['; +const ANSI_OSC = ']'; +const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`; + +const wrapAnsi = code => `${ESCAPES.values().next().value}${ANSI_CSI}${code}m`; +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 @@ -20,12 +27,15 @@ const wordLengths = string => string.split(' ').map(character => stringWidth(cha // Ansi escape codes do not count towards length const wrapWord = (rows, word, columns) => { const characters = [...word]; + const cursor = [...characters]; let isInsideEscape = false; + let isInsideLinkEscape = false; let visible = stringWidth(stripAnsi(rows[rows.length - 1])); for (const [index, character] of characters.entries()) { const characterLength = stringWidth(character); + cursor.shift(); if (visible + characterLength <= columns) { rows[rows.length - 1] += character; @@ -36,12 +46,19 @@ const wrapWord = (rows, word, columns) => { if (ESCAPES.has(character)) { isInsideEscape = true; - } else if (isInsideEscape && character === 'm') { - isInsideEscape = false; - continue; + isInsideLinkEscape = cursor.join('').startsWith(ANSI_ESCAPE_LINK); } if (isInsideEscape) { + if (isInsideLinkEscape) { + if (character === ANSI_ESCAPE_BELL) { + isInsideEscape = false; + isInsideLinkEscape = false; + } + } else if (character === ESCAPE_TERMINATOR) { + isInsideEscape = false; + } + continue; } @@ -90,9 +107,9 @@ const exec = (string, columns, options = {}) => { return ''; } - let pre = ''; let ret = ''; let escapeCode; + let escapeUri; const lengths = wordLengths(string); let rows = ['']; @@ -151,24 +168,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}(?\\d+)m|\\${ANSI_ESCAPE_LINK}(?.*)${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); + } } } diff --git a/test.js b/test.js index 3536c71..7924735 100755 --- a/test.js +++ b/test.js @@ -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');