diff --git a/README.md b/README.md index 770b64f..b09b3c5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,21 @@ console.log(link('src/index.ts', { line: 12 })) Wraps `path` in an OSC 8 hyperlink pointing at it on disk, labelled with the `cwd`-relative form (plus `::` if given) or with whatever `options.formatter` returns. Relative input is resolved against `options.cwd`. +> [!NOTE] +> `util.stripVTControlCharacters` corrupts these sequences on current Node releases, choking on the comma in `#L12,3` ([fixed](https://github.com/nodejs/node/pull/64319) but unreleased). Measure visible width with your own OSC 8-aware regex. + +#### `hyperlink(label, url, options?)` + +Wraps `label` in an OSC 8 hyperlink pointing at `url`, which is used as the target verbatim: no path resolution and no `file://` conversion. Use this for links that are already URLs (a docs page, an issue, a `vscode://` deep link); use `link` for file paths. + +```js +import { hyperlink } from 'clickable-path' + +console.log(hyperlink('E1: missing config', 'https://nuxt.com/docs/errors/e1')) +``` + +`label` is emitted byte-for-byte, so a label that already contains colour escapes is passed through untouched. Accepts the `enabled`, `stream` and `id` options (`HyperlinkOptions`), and returns `label` alone when hyperlinks are unsupported. + #### `createLinker(defaults?)` Returns a `{ link }` with `defaults` pre-applied, so you can configure a `formatter` and cwd once rather than at every call site: diff --git a/src/index.ts b/src/index.ts index 0b0c159..5096579 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,20 @@ export type { StreamLike } from './supports.ts' const OSC = '\u001B]' const BEL = '\u0007' -export interface LinkOptions { +export interface HyperlinkOptions { + /** Stream whose TTY state determines whether escapes are emitted. Defaults to `process.stdout`. */ + stream?: StreamLike + /** Force hyperlinks on or off, bypassing terminal detection. */ + enabled?: boolean + /** + * OSC 8 `id` param, letting a terminal treat separate runs of cells as one + * hyperlink, so a label wrapped across lines highlights as a whole on hover. + * Must not contain `;`, `:` or `=`. + */ + id?: string +} + +export interface LinkOptions extends HyperlinkOptions { /** * Builds the visible label from the absolute target and any `line`/`column`, * e.g. as a CLI's own alias: `absolute => absolute.replace(rootDir, '~')`. @@ -22,20 +35,10 @@ export interface LinkOptions { formatter?: (absolute: string, line?: number, column?: number) => string /** Directory used to resolve relative paths. Defaults to `process.cwd()`, read at call time. */ cwd?: string - /** Stream whose TTY state determines whether escapes are emitted. Defaults to `process.stdout`. */ - stream?: StreamLike /** 1-based line number, shown as `:` in the default label and linked as `#L`. */ line?: number /** 1-based column number, shown and linked after the line as `:`. */ column?: number - /** Force hyperlinks on or off, bypassing terminal detection. */ - enabled?: boolean - /** - * OSC 8 `id` param, letting a terminal treat separate runs of cells as one - * hyperlink, so a label wrapped across lines highlights as a whole on hover. - * Must not contain `;`, `:` or `=`. - */ - id?: string } export interface Linker { @@ -94,13 +97,25 @@ export function link(target: string, options: LinkOptions = {}): string { const [suffix, fragment] = position(options) const label = options.formatter?.(absolute, options.line, options.column) ?? relative + suffix + return hyperlink(label, url + fragment, options) +} + +/** + * Wrap `label` in an OSC 8 hyperlink pointing at `url`, which is used as the + * link target verbatim: no path resolution and no `file://` conversion. + * + * The label is emitted byte-for-byte, so a label that already contains colour + * escapes is passed through untouched. When the terminal does not support + * hyperlinks the label is returned on its own. + */ +export function hyperlink(label: string, url: string, options: HyperlinkOptions = {}): string { if (!(options.enabled ?? supportsHyperlinks(options.stream))) { return label } const params = options.id === undefined ? '' : `id=${options.id}` - return `${OSC}8;${params};${url}${fragment}${BEL}${label}${OSC}8;;${BEL}` + return `${OSC}8;${params};${url}${BEL}${label}${OSC}8;;${BEL}` } /** diff --git a/test/index.test.ts b/test/index.test.ts index b1d9fb9..4669e59 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -5,7 +5,7 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createLinker, link, supportsHyperlinks } from '../src' +import { createLinker, hyperlink, link, supportsHyperlinks } from '../src' const tty = { isTTY: true } const notTty = { isTTY: false } @@ -195,6 +195,36 @@ describe('createLinker', () => { }) }) +describe('hyperlink', () => { + it('should use the url as the target verbatim', () => { + const output = hyperlink('e1', 'https://nuxt.com/docs/errors/e1', { enabled: true }) + expect(JSON.stringify(output)).toMatchInlineSnapshot(`""\\u001b]8;;https://nuxt.com/docs/errors/e1\\u0007e1\\u001b]8;;\\u0007""`) + expect(parse(output)).toEqual({ label: 'e1', url: 'https://nuxt.com/docs/errors/e1' }) + }) + + it('should not resolve a url-like label against the cwd', () => { + expect(parse(hyperlink('open', 'vscode://file/a/b.ts:1', { enabled: true })).url).toBe('vscode://file/a/b.ts:1') + }) + + it('should return the bare label when disabled', () => { + expect(hyperlink('e1', 'https://nuxt.com', { enabled: false })).toBe('e1') + }) + + it('should return the bare label for a non-TTY stream', () => { + expect(hyperlink('e1', 'https://nuxt.com', { stream: notTty })).toBe('e1') + }) + + it('should emit an id param when given one', () => { + expect(JSON.stringify(hyperlink('e1', 'https://nuxt.com', { enabled: true, id: 'docs' }))).toMatchInlineSnapshot(`""\\u001b]8;id=docs;https://nuxt.com\\u0007e1\\u001b]8;;\\u0007""`) + }) + + it('should pass a pre-styled label through untouched', () => { + const label = `\u001B[4m${'nuxt.com'}\u001B[24m` + const output = hyperlink(label, 'https://nuxt.com', { enabled: true }) + expect(parse(output).label).toBe(label) + }) +}) + describe('supportsHyperlinks', () => { it('should be false for a non-TTY stream', () => { vi.stubEnv('TERM_PROGRAM', 'iTerm.app')