Skip to content

Commit c900cd4

Browse files
committed
feat: the joy of creation
1 parent d279352 commit c900cd4

11 files changed

Lines changed: 785 additions & 43 deletions

File tree

README.md

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,130 @@
1-
# package-name
1+
# clickable-path
22

33
[![npm version][npm-version-src]][npm-version-href]
44
[![npm downloads][npm-downloads-src]][npm-downloads-href]
55
[![Github Actions][github-actions-src]][github-actions-href]
66
[![Codecov][codecov-src]][codecov-href]
77

8-
> Package description
8+
> Make the file paths your CLI prints ctrl/cmd-clickable
99
10-
## Usage
10+
CLIs print paths, but making them open can be tricky.
11+
12+
Thankfully, [OSC 8 hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) exist. They point at an invisible absolute `file://` URL.
1113

12-
Install package:
14+
`clickable-path` is a zero-dependency package to print paths as OSC 8 hyperlinks if your terminal supports them, falling back to a plain label if not.
15+
16+
## Usage
1317

1418
```sh
15-
# npm
16-
npm install package-name
19+
npm install clickable-path
20+
```
1721

18-
# pnpm
19-
pnpm install package-name
22+
> [!NOTE]
23+
> Requires Node 22.1 or newer, which is where `pathToFileURL` gained the `windows` option used to convert Windows-shaped paths on any platform.
24+
25+
```js
26+
import { link } from 'clickable-path'
27+
28+
console.log(`Building ${link('/home/me/project/nuxt.config.ts')}`)
29+
// -> label `nuxt.config.ts`, linking file:///home/me/project/nuxt.config.ts
30+
31+
console.log(link('src/index.ts', { line: 12 }))
32+
// -> label `src/index.ts:12`, linking file:///home/me/project/src/index.ts#L12
2033
```
2134

35+
### API
36+
37+
#### `link(path, options?)`
38+
39+
Wraps `path` in an OSC 8 hyperlink pointing at it on disk, labelled with the `cwd`-relative form (plus `:<line>:<column>` if given) or with whatever `options.formatter` returns. Relative input is resolved against `options.cwd`.
40+
41+
#### `createLinker(defaults?)`
42+
43+
Returns a `{ link }` with `defaults` pre-applied, so you can configure a `formatter` and cwd once rather than at every call site:
44+
2245
```js
23-
import {} from 'package-name'
46+
const { link } = createLinker({
47+
formatter: absolute => absolute.replace(rootDir, '~'),
48+
})
49+
50+
console.log(link('/project/app/tailwind.config.ts'))
51+
// label `~/tailwind.config.ts`, linking file:///project/app/tailwind.config.ts
2452
```
2553

54+
The formatter receives the resolved absolute path, plus `line` and `column` if they were passed, and its return value is used without modification. (The default formatter is `path.relative` with a `:<line>:<column>` suffix.) Showing the position is up to the formatter; a label that omits it still links to it, so the file opens at the right line either way. Every `LinkOptions` key can be defaulted this way and overridden per call.
55+
56+
> [!TIP]
57+
> Pad and align inside the formatter, not on the result. The returned string contains invisible escapes, so `link(path).padEnd(20)` pads to the wrong width, while `formatter: absolute => basename(absolute).padEnd(20)` lines up as expected.
58+
59+
#### `supportsHyperlinks(stream?)`
60+
61+
Whether `stream` (default `process.stdout`) will render hyperlinks.
62+
63+
The environment is read on every call rather than snapshotted at module load, so a `.env` file or a `--no-color` flag applied after import is still respected. It costs around a microsecond; if you are linking in a hot loop, call this once and pass the result as `enabled`.
64+
65+
#### `LinkOptions`
66+
67+
| Option | Default | |
68+
| --- | --- | --- |
69+
| `formatter` | `cwd`-relative path plus `:<line>:<column>` | builds the label, given the absolute path, `line` and `column` |
70+
| `cwd` | `process.cwd()`, read at call time | base for relative paths |
71+
| `stream` | `process.stdout` | stream whose TTY state gates output |
72+
| `line` / `column` | | shown as `:<line>:<column>` and linked as `#L<line>,<column>` |
73+
| `id` | | OSC 8 `id` param, so a label wrapped across lines hovers as one link |
74+
| `enabled` | detection result | force on/off |
75+
76+
Paths are converted with `pathToFileURL`, so spaces, `#`, `?` and non-ASCII characters are percent-encoded. Windows-shaped inputs (`C:\...`, `\\server\share\...`) are converted on any platform.
77+
78+
### Non-TTY and CI
79+
80+
> [!NOTE]
81+
> No escapes are emitted when the target stream isn't a TTY, or when `CI` is set, as they would otherwise end up in log files. Netlify is the exception, since it renders build logs as HTML and never allocates a TTY.
82+
83+
Overrides, in order of precedence: `FORCE_HYPERLINK` (set to `0` to disable), `--no-hyperlink` / `--hyperlink` flags, then `NO_COLOR` / `NO_HYPERLINK` / `NO_HYPERLINKS`.
84+
85+
> [!WARNING]
86+
> `FORCE_HYPERLINK=1` bypasses every check, including the CI and non-TTY ones. Escape sequences will end up in whatever you are redirecting to.
87+
88+
`NO_COLOR` disables hyperlinks here. Colour support and hyperlink support are not the same capability, so there is no general colour-support check, but someone who has asked for no escape sequences at all should get none.
89+
90+
### Terminal support
91+
92+
| Terminal | Detection |
93+
| --- | --- |
94+
| Windows Terminal >= 1.4 | `WT_SESSION` (any other terminal on win32 is treated as unsupported) |
95+
| VS Code >= 1.72 | `TERM_PROGRAM=vscode` + version |
96+
| Cursor | `TERM_PROGRAM=vscode` + `CURSOR_TRACE_ID` (own 0.x version line) |
97+
| iTerm2 >= 3.1 | `TERM_PROGRAM=iTerm.app` + version |
98+
| WezTerm >= 20200620 | `TERM_PROGRAM=WezTerm`, including Nix's `0-unstable-YYYY-MM-DD` scheme |
99+
| ghostty | `TERM_PROGRAM=ghostty` or `TERM=xterm-ghostty` |
100+
| kitty | `TERM=xterm-kitty` |
101+
| Alacritty >= 0.11 | `TERM=alacritty` |
102+
| zed, rio, Tabby, Warp, Orca | `TERM_PROGRAM` |
103+
| GNOME Terminal / VTE >= 0.50.1 | `VTE_VERSION` (0.50.0 is excluded: it segfaults on hyperlinks) |
104+
| tmux >= 3.4 | `TERM_PROGRAM=tmux` + version |
105+
106+
Terminal.app is explicitly unsupported: it ignores OSC 8, though it degrades gracefully to the plain label. TeamCity is excluded. Anything unrecognised gets no escapes, since a wrong positive prints visible junk.
107+
108+
Passing `line` (and optionally `column`) appends `:12:3` to the default label and `#L12,3` to the URL. Most terminals ignore the fragment and simply open the file, so treat jump-to-line as a hint. You can decide whether to show a position in a custom `formatter` if you want.
109+
110+
> [!IMPORTANT]
111+
> The comma in `#L12,3` is deliberate. VS Code parses the fragment with `/^L?(\d+)(?:,(\d+))?/`, so `#L12:3` matches the line and silently drops the column.
112+
113+
### Differences from `terminal-link` and `supports-hyperlinks`
114+
115+
- **When hyperlinks are unsupported, only the label is printed.** `terminal-link` falls back to appending the raw URL (`nuxt.config.ts file:///home/me/nuxt.config.ts`), which is noise in a log file and breaks any width maths. Here the output is exactly what you would have printed anyway.
116+
- **tmux is detected.** tmux overwrites `TERM_PROGRAM` with `tmux`, hiding the outer terminal, so `supports-hyperlinks` reports no support inside every tmux session. tmux itself has handled OSC 8 since 3.4, so that version and up are supported directly.
117+
- **Detection reads the environment per call.** `supports-hyperlinks` snapshots `supportsHyperlinks.stdout` at module load, so anything that mutates the environment after import is missed, such as loading a `.env` file, or normalising `--no-color` into `NO_COLOR` while parsing argv.
118+
- **No colour-support coupling.** `supports-hyperlinks` returns false whenever `supports-color` does, but this isn't necessarily correct. (We still honour `NO_COLOR` if set.)
119+
- **The OSC 8 `id` param is exposed**, which `terminal-link` does not surface.
120+
- **Labels are derived, not passed in.** `terminal-link(text, url)` makes every call site build both halves; here the path is the argument and the label comes from a formatter you configure once.
121+
- **Zero dependencies**, versus `supports-color` + `has-flag` + `ansi-escapes`.
122+
123+
## Credits
124+
125+
- [`supports-hyperlinks`](https://github.com/chalk/supports-hyperlinks) and [`terminal-link`](https://github.com/sindresorhus/terminal-link) by [Sindre Sorhus](https://github.com/sindresorhus) and [James Talmage](https://github.com/jamestalmage) are excellent. Detection coverage in this package is informed by `supports-hyperlinks`, reimplemented so there are no dependencies and no module-load-time caching. If you want hyperlinks in general rather than paths specifically, `terminal-link` would be a good choice.
126+
- [Egmont Koblinger](https://github.com/egmontkob)'s [hyperlinks in terminal emulators](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) is the de facto OSC 8 spec.
127+
26128
## 💻 Development
27129

28130
- Clone this repository
@@ -38,11 +140,11 @@ Published under [MIT License](./LICENCE).
38140

39141
<!-- Badges -->
40142

41-
[npm-version-src]: https://npmx.dev/api/registry/badge/version/package-name
42-
[npm-version-href]: https://npmx.dev/package/package-name
43-
[npm-downloads-src]: https://npmx.dev/api/registry/badge/downloads/package-name
44-
[npm-downloads-href]: https://npm.chart.dev/package-name
45-
[github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/package-name/ci.yml?branch=main&style=flat-square
46-
[github-actions-href]: https://github.com/danielroe/package-name/actions?query=workflow%3Aci
47-
[codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/package-name/main?style=flat-square
48-
[codecov-href]: https://codecov.io/gh/danielroe/package-name
143+
[npm-version-src]: https://npmx.dev/api/registry/badge/version/clickable-path
144+
[npm-version-href]: https://npmx.dev/package/clickable-path
145+
[npm-downloads-src]: https://npmx.dev/api/registry/badge/downloads/clickable-path
146+
[npm-downloads-href]: https://npm.chart.dev/clickable-path
147+
[github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/clickable-path/ci.yml?branch=main&style=flat-square
148+
[github-actions-href]: https://github.com/danielroe/clickable-path/actions?query=workflow%3Aci
149+
[codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/clickable-path/main?style=flat-square
150+
[codecov-href]: https://codecov.io/gh/danielroe/clickable-path

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
2-
"name": "package-name",
2+
"name": "clickable-path",
33
"type": "module",
44
"version": "0.0.0",
55
"packageManager": "pnpm@11.15.1",
6-
"description": "",
6+
"description": "Make file paths printed by CLIs ctrl/cmd-clickable, using OSC 8 hyperlinks",
77
"license": "MIT",
8-
"repository": "danielroe/package-name",
8+
"repository": "danielroe/clickable-path",
99
"sideEffects": false,
1010
"exports": {
1111
".": "./src/index.ts",
@@ -28,6 +28,9 @@
2828
"files": [
2929
"dist"
3030
],
31+
"engines": {
32+
"node": ">=22.1.0"
33+
},
3134
"scripts": {
3235
"build": "tsdown",
3336
"dev": "vitest dev",
@@ -42,6 +45,7 @@
4245
},
4346
"devDependencies": {
4447
"@antfu/eslint-config": "9.1.0",
48+
"@types/node": "^26.1.1",
4549
"@vitest/coverage-v8": "4.1.10",
4650
"eslint": "10.7.0",
4751
"installed-check": "10.0.1",

playground/index.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import assert from 'node:assert'
2-
import * as pkg from 'package-name'
2+
import { createLinker, link, supportsHyperlinks } from 'clickable-path'
33

4+
const { link: aliased } = createLinker({
5+
formatter: absolute => absolute.replace(import.meta.dirname, '~'),
6+
})
7+
8+
// eslint-disable-next-line no-console
9+
console.log('supported:', supportsHyperlinks())
10+
// eslint-disable-next-line no-console
11+
console.log('relative:', link('./package.json'))
12+
// eslint-disable-next-line no-console
13+
console.log('aliased:', aliased('./package.json'))
414
// eslint-disable-next-line no-console
5-
console.log(pkg.welcome())
15+
console.log('with line:', link('./index.js', { line: 3, column: 1 }))
616

7-
assert.strictEqual(pkg.welcome(), 'hello world')
17+
assert.strictEqual(link('./package.json', { enabled: false }), 'package.json')

playground/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
"dev": "node index.js"
66
},
77
"dependencies": {
8-
"package-name": "latest"
8+
"clickable-path": "latest"
99
}
1010
}

pnpm-lock.yaml

Lines changed: 28 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
shellEmulator: true
2+
3+
trustPolicy: no-downgrade
4+
15
packages:
26
- playground
37

@@ -6,4 +10,4 @@ allowBuilds:
610
simple-git-hooks: true
711

812
overrides:
9-
package-name: "link:."
13+
clickable-path: 'link:.'

0 commit comments

Comments
 (0)