Skip to content

Commit

Permalink
[cli] Add Output#link() function to format terminal hyperlinks (#8370)
Browse files Browse the repository at this point in the history
This adds a `link()` helper function to the `Output` class that is inspired by the `terminal-link` npm package.

The main difference with this version is that it's more tightly integrated with the `Output` class for the purposes of being able to toggle hyperlinks support on/off for [unit tests](https://github.com/vercel/vercel/blob/4a54b19f46953af2a808bad3e461798e5a40e350/packages/cli/test/unit/util/output/create-output.test.ts) by setting the `output.supportsHyperlink` boolean.

> **Note:** Since hyperlinks are still a relatively new feature, and users might not yet understand how to interact with them, we should only use this function for progressive enhancement scenarios at this time, and _not_ as part of a critical UX.
  • Loading branch information
TooTallNate committed Dec 20, 2022
1 parent 2a492fa commit 21f25f5
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 44 deletions.
9 changes: 4 additions & 5 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@
"@sindresorhus/slugify": "0.11.0",
"@swc/core": "1.2.218",
"@tootallnate/once": "1.1.2",
"@types/ansi-escapes": "3.0.0",
"@types/ansi-regex": "4.0.0",
"@types/async-retry": "1.2.1",
"@types/bytes": "3.0.0",
"@types/chance": "1.1.3",
Expand Down Expand Up @@ -104,8 +102,8 @@
"@zeit/source-map-support": "0.6.2",
"ajv": "6.12.2",
"alpha-sort": "2.0.1",
"ansi-escapes": "3.0.0",
"ansi-regex": "3.0.0",
"ansi-escapes": "4.3.2",
"ansi-regex": "5.0.1",
"arg": "5.0.0",
"async-listen": "1.2.0",
"async-retry": "1.1.3",
Expand Down Expand Up @@ -161,8 +159,9 @@
"rimraf": "3.0.2",
"semver": "5.5.0",
"serve-handler": "6.1.1",
"strip-ansi": "5.2.0",
"strip-ansi": "6.0.1",
"stripe": "5.1.0",
"supports-hyperlinks": "2.2.0",
"tar-fs": "1.16.3",
"test-listen": "1.1.0",
"text-table": "0.2.0",
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/util/output/create-output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import chalk from 'chalk';
import * as ansiEscapes from 'ansi-escapes';
import { supportsHyperlink as detectSupportsHyperlink } from 'supports-hyperlinks';
import renderLink from './link';
import wait, { StopSpinner } from './wait';
import type { WritableTTY } from '../../types';
Expand All @@ -8,24 +10,34 @@ const IS_TEST = process.env.NODE_ENV === 'test';

export interface OutputOptions {
debug?: boolean;
supportsHyperlink?: boolean;
}

export interface LogOptions {
color?: typeof chalk;
}

interface LinkOptions {
fallback?: false | (() => string);
}

export class Output {
stream: WritableTTY;
debugEnabled: boolean;
supportsHyperlink: boolean;
private spinnerMessage: string;
private _spinner: StopSpinner | null;

constructor(
stream: WritableTTY,
{ debug: debugEnabled = false }: OutputOptions = {}
{
debug: debugEnabled = false,
supportsHyperlink = detectSupportsHyperlink(stream),
}: OutputOptions = {}
) {
this.stream = stream;
this.debugEnabled = debugEnabled;
this.supportsHyperlink = supportsHyperlink;
this.spinnerMessage = '';
this._spinner = null;
}
Expand Down Expand Up @@ -167,4 +179,27 @@ export class Output {

return promise;
};

/**
* Returns an ANSI formatted hyperlink when support has been enabled.
*/
link = (
text: string,
url: string,
{ fallback }: LinkOptions = {}
): string => {
// Based on https://github.com/sindresorhus/terminal-link (MIT license)
if (!this.supportsHyperlink) {
// If the fallback has been explicitly disabled, don't modify the text itself
if (fallback === false) {
return renderLink(text);
}

return typeof fallback === 'function'
? fallback()
: `${text} (${renderLink(url)})`;
}

return ansiEscapes.link(chalk.cyan(text), url);
};
}
37 changes: 37 additions & 0 deletions packages/cli/test/unit/util/output/create-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import stripAnsi from 'strip-ansi';
import { client } from '../../../mocks/client';

describe('Output', () => {
describe('link()', () => {
it('should return hyperlink ANSI codes when `supportsHyperlink=true`', () => {
client.output.supportsHyperlink = true;
const val = client.output.link('Click Here', 'https://example.com');
expect(val).toEqual(
'\x1B]8;;https://example.com\x07Click Here\x1B]8;;\x07'
);
expect(stripAnsi(val)).toEqual('Click Here');
});

it('should return default fallback when `supportsHyperlink=false`', () => {
client.output.supportsHyperlink = false;
const val = client.output.link('Click Here', 'https://example.com');
expect(val).toEqual('Click Here (https://example.com)');
});

it('should return text fallback when `supportsHyperlink=false` with `fallback: false`', () => {
client.output.supportsHyperlink = false;
const val = client.output.link('Click Here', 'https://example.com', {
fallback: false,
});
expect(val).toEqual('Click Here');
});

it('should return fallback when `supportsHyperlink=false` with `fallback` function', () => {
client.output.supportsHyperlink = false;
const val = client.output.link('Click Here', 'https://example.com', {
fallback: () => 'other',
});
expect(val).toEqual('other');
});
});
});
4 changes: 4 additions & 0 deletions packages/cli/types/supports-hyperlinks/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module 'supports-hyperlinks' {
import { Writable } from 'stream';
export function supportsHyperlink(stream: Writable): boolean;
}
71 changes: 33 additions & 38 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2532,18 +2532,6 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==

"@types/ansi-escapes@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/ansi-escapes/-/ansi-escapes-3.0.0.tgz#619bbc6d46fc75da6d784e53b5a25d2efff07108"
integrity sha512-aamJrX6PdmIO8E9qhZaYmXiMGXwnkF2lcga/VbqLf8g90aaKGZ4cSFP5AabqxAbmp0h69C9yE3a4fUBtVpqtmg==
dependencies:
"@types/node" "*"

"@types/ansi-regex@4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/ansi-regex/-/ansi-regex-4.0.0.tgz#cb20bb66da7700ea9b26f16971f03f0e092eddad"
integrity sha512-r1W316vjsZXn1/csLC4HcCJs6jIHIzksHJd7xx+Dl+PAb0S2Dh9cR8ZsIMEfGmbBtP7JNWlf2KKahSkDP6rg3g==

"@types/async-retry@1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@types/async-retry/-/async-retry-1.2.1.tgz#fa9ac165907a8ee78f4924f4e393b656c65b5bb4"
Expand Down Expand Up @@ -3616,10 +3604,12 @@ ansi-align@^3.0.0:
dependencies:
string-width "^3.0.0"

ansi-escapes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
integrity sha512-O/klc27mWNUigtv0F8NJWbLF00OcegQalkqKURWdosW08YZKi4m6CnSUSvIZG1otNJbTWhN01Hhz389DW7mvDQ==
ansi-escapes@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
dependencies:
type-fest "^0.21.3"

ansi-escapes@^3.0.0, ansi-escapes@^3.2.0:
version "3.2.0"
Expand All @@ -3633,16 +3623,21 @@ ansi-escapes@^4.2.0, ansi-escapes@^4.2.1:
dependencies:
type-fest "^0.11.0"

ansi-regex@3.0.0, ansi-regex@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
ansi-regex@5.0.1, ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==

ansi-regex@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=

ansi-regex@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=

ansi-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
Expand All @@ -3653,11 +3648,6 @@ ansi-regex@^5.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==

ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==

ansi-styles@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
Expand Down Expand Up @@ -12444,12 +12434,12 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"

strip-ansi@5.2.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
strip-ansi@6.0.1, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^4.1.0"
ansi-regex "^5.0.1"

strip-ansi@^3.0.0, strip-ansi@^3.0.1:
version "3.0.1"
Expand All @@ -12465,20 +12455,20 @@ strip-ansi@^4.0.0:
dependencies:
ansi-regex "^3.0.0"

strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
dependencies:
ansi-regex "^4.1.0"

strip-ansi@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
dependencies:
ansi-regex "^5.0.0"

strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-bom-buf@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-2.0.0.tgz#ff9c223937f8e7154b77e9de9bde094186885c15"
Expand Down Expand Up @@ -12622,7 +12612,7 @@ supports-color@^8.0.0:
dependencies:
has-flag "^4.0.0"

supports-hyperlinks@^2.0.0:
supports-hyperlinks@2.2.0, supports-hyperlinks@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb"
integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==
Expand Down Expand Up @@ -13238,6 +13228,11 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==

type-fest@^0.21.3:
version "0.21.3"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==

type-fest@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1"
Expand Down

0 comments on commit 21f25f5

Please sign in to comment.