@ix-xs/node-comfort 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +366 -56
  12. package/src/Http.js +263 -72
  13. package/src/Id.js +53 -15
  14. package/src/Logger.js +100 -22
  15. package/src/Num.js +2 -1
  16. package/src/Obj.js +225 -36
  17. package/src/SQLite.js +154 -97
  18. package/src/Schema.js +281 -40
  19. package/src/Str.js +77 -29
  20. package/src/Sys.js +123 -22
  21. package/src/Time.js +96 -22
  22. package/src/Utils.js +18 -3
  23. package/src/errors.js +75 -4
  24. package/src/internal/shared.js +68 -14
  25. package/src/internal/terminal.js +49 -0
  26. package/types/index.d.ts +28 -5
  27. package/types/src/Arr.d.ts +4 -2
  28. package/types/src/Arr.d.ts.map +1 -1
  29. package/types/src/Checker.d.ts +29 -3
  30. package/types/src/Checker.d.ts.map +1 -1
  31. package/types/src/Cli.d.ts +6 -4
  32. package/types/src/Cli.d.ts.map +1 -1
  33. package/types/src/Color.d.ts +2 -2
  34. package/types/src/Color.d.ts.map +1 -1
  35. package/types/src/Crypto.d.ts +126 -28
  36. package/types/src/Crypto.d.ts.map +1 -1
  37. package/types/src/Emitter.d.ts +6 -6
  38. package/types/src/Emitter.d.ts.map +1 -1
  39. package/types/src/Env.d.ts +19 -1
  40. package/types/src/Env.d.ts.map +1 -1
  41. package/types/src/FS.d.ts +27 -20
  42. package/types/src/FS.d.ts.map +1 -1
  43. package/types/src/Http.d.ts +42 -5
  44. package/types/src/Http.d.ts.map +1 -1
  45. package/types/src/Id.d.ts.map +1 -1
  46. package/types/src/Logger.d.ts +11 -3
  47. package/types/src/Logger.d.ts.map +1 -1
  48. package/types/src/Num.d.ts.map +1 -1
  49. package/types/src/Obj.d.ts +15 -2
  50. package/types/src/Obj.d.ts.map +1 -1
  51. package/types/src/SQLite.d.ts +104 -12
  52. package/types/src/SQLite.d.ts.map +1 -1
  53. package/types/src/Schema.d.ts +61 -5
  54. package/types/src/Schema.d.ts.map +1 -1
  55. package/types/src/Str.d.ts +32 -7
  56. package/types/src/Str.d.ts.map +1 -1
  57. package/types/src/Sys.d.ts +42 -9
  58. package/types/src/Sys.d.ts.map +1 -1
  59. package/types/src/Time.d.ts +10 -5
  60. package/types/src/Time.d.ts.map +1 -1
  61. package/types/src/Utils.d.ts +16 -6
  62. package/types/src/Utils.d.ts.map +1 -1
  63. package/types/src/errors.d.ts +6 -2
  64. package/types/src/errors.d.ts.map +1 -1
  65. package/types/src/internal/shared.d.ts +7 -0
  66. package/types/src/internal/shared.d.ts.map +1 -1
  67. package/types/src/internal/terminal.d.ts +13 -0
  68. package/types/src/internal/terminal.d.ts.map +1 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,141 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.2.0] - 2026-11-03
8
+
9
+ A security release. Three audits went through every module looking for what an attacker could do with the
10
+ values a program receives: a request body, a header, a file name, a `.env` in a cloned repository, a log
11
+ line. More than fifty problems came out of them, a few serious ones. They are all fixed, each with a test
12
+ that failed before. Most fixes change nothing for code that works today, and the ones you could notice are
13
+ listed under "Changed". The package also makes use of what Node.js 26 brings.
14
+
15
+ ### Security
16
+
17
+ - **JWT**: a public key could be used as an HMAC secret when it was read from a file with a BOM, had text
18
+ before its PEM header, or was in DER form, so anyone could sign `HS256` tokens with it. Anything that looks
19
+ like an asymmetric key is now refused as a secret, and `HS*` secrets need at least 32 bytes (RFC 7518).
20
+ An invalid `clockTolerance` or `now` no longer turns the expiry check off, signatures must be written in
21
+ canonical Base64URL, and a `crit` header is refused unless you list it in `options.crit`.
22
+ - **sys**: on Windows, an argument passed to an npm command (`npm.cmd`, global shims) could run another
23
+ command (the "BatBadBut" problem). Arguments are now escaped twice for every `.cmd` or `.bat` that passes
24
+ them on, and a line break, which can't be escaped, is refused.
25
+ - **SQLite**: `update()`, `delete()` and `increment()` with a filter whose values were all `undefined`
26
+ (`{ id: undefined }`, `{ $or: [{}] }`, `{ id: { notIn: [] } }`) changed or deleted the whole table. They
27
+ now throw. A filter value taken from a JSON body could also become an operator (`{ "ne": "x" }` matched
28
+ every row): the new `operators: "tagged"` option and `SQLite.op` close that door, see "Added".
29
+ - **http**: with a `baseURL`, a path like `"https://evil.example"`, `"\\evil.example"` or `"../../admin"`
30
+ left the base and took the client's `auth` header with it. Paths now stay under the base. Responses are
31
+ limited to 100 MB once decompressed (a 500 KB gzip bomb used to fill 512 MB), redirects to another origin
32
+ drop `x-api-key` and the other secret headers, the temporary file of `download()` has an unguessable name,
33
+ and an `HttpError` no longer shows query-string secrets or cookies in its message and `toJSON()`.
34
+ - **env**: the `.env` loaded automatically at startup set `NODE_OPTIONS` (code run in every child process),
35
+ `NODE_TLS_REJECT_UNAUTHORIZED` and other dangerous variables. They are now left out, with one warning on
36
+ stderr. `env.parse()` no longer reads `process.env` for `${VAR}`, and a value that expands to more than
37
+ 64 KB is refused.
38
+ - **Denial of service**: a few hundred bytes could block the process for seconds or minutes in
39
+ `isMimeType()`, the duration parser (`"5m"`, behind every `timeout`, `ttl` and `expiresIn`), `time.add()`,
40
+ the case converters of `str`, the paths of `obj`, a recursive `schema.union()`, `.env` comments and
41
+ expansion, `time.parse()`, `obj.set()` with a key like `"a.4000000000"`, `arr.unzip()`, `num.parseBytes()`
42
+ and `fs.sanitizeFilename()`. All of them are now linear.
43
+ - **logger and cli**: a logged value could send escape sequences to the terminal (write to the clipboard,
44
+ change the title, clear the screen), forge a log line with `\r` or `\n`, or be read as markup. They are now
45
+ shown as text, and the colors and links you add with `nc.color` still work. `redact` also covers `Map`,
46
+ `Set` and the causes of errors, and log files are created readable by their owner only.
47
+ - **id**: a program started from a startup snapshot (`node --build-snapshot`, single executable
48
+ applications) printed the same ids at each start. They are now drawn again in each process.
49
+ - **memoize**: `func.memoize()`, `func.dedupe()` and `cache.wrap()` could give one call the cached result of
50
+ another, for example `{ role: "admin" }` and `{ role: "guest" }` held in a `Set`.
51
+ - **fs**: copying or moving a file onto itself, or a folder into itself, deleted it. `copy()` followed
52
+ junctions out of the folder on Windows, `emptyFolder()` emptied the folder a junction pointed to, and
53
+ `sanitizeFilename()` let `COM¹`, `CONIN$` and right-to-left characters through.
54
+ - **Smaller fixes**: TOTP secrets need 10 bytes (16 for new ones from `totpSecret()`) and the window is
55
+ capped at 10, `verifyPassword()` refuses hashes that would take minutes, `safeEqual("", "")` is `false`, `str.mask()` no longer shows a short
56
+ secret in full, `isURL()` and `schema.string().url()` refuse text that only parses once cleaned
57
+ (`https://a.com/"><script>`), `sys.open()` only opens web and mail links, and `obj.set()` converts each
58
+ key once, so a key object can't pass the `__proto__` check and then change.
59
+
60
+ ### Added
61
+
62
+ - **crypto**: Argon2id passwords, with `hashPassword(password, { algorithm: "argon2id" })` (Node.js 24.7 or
63
+ later). The hashes use the standard PHC format, so other libraries can read them. `verifyPassword()` reads
64
+ both kinds, and `needsRehash()` says when a hash should move to the algorithm you ask for.
65
+ - **crypto**: `totpStep()` gives the time step of a valid code, and `verifyTOTP({ after })` refuses a code
66
+ that was already used. `verifyJWT()` takes `typ` and `crit`, and `clockTolerance` takes text like `"30s"`.
67
+ - **SQLite**: `SQLite.op` builds operators (`SQLite.op.gte(18)`, `SQLite.op.in([1, 2])`) that a value from
68
+ JSON can't imitate, and `new SQLite(path, { operators: "tagged" })` only accepts those. In that mode, a read
69
+ throws on an `undefined` filter value too, so a missing field can't drop its condition.
70
+ - **http**: `maxResponseSize`, `allowAbsoluteUrls` and `sensitiveHeaders` options, and the client follows
71
+ redirects itself (`redirect: "follow" | "manual" | "error"`).
72
+ - **sys**: `open(target, { protocols, allowUNC })` and `which(command, { cwd })`.
73
+ - **schema**: `parse()`, `safeParse()` and `is()` take `{ maxDepth }` (1000 by default), and a value nested
74
+ deeper gets a `"too_deep"` issue instead of a stack overflow.
75
+ - **str**: `levenshtein(a, b, { maxDistance })` and `closest(..., { maxDistance })` stop counting early.
76
+ - **checker**: `isDataURL(value, { types })` limits the accepted media types.
77
+ - **logger**: the `mode` of log files (`0o600` by default).
78
+ - **`using`**: watchers, schedules (`every()`, `cron()`), spinners, progress bars, `when()` tasks,
79
+ `dontCrash()`, and the functions returned by `emitter.on()`, `onAny()` and `sys.onShutdown()` work with
80
+ `using` (Node.js 24 and later): they stop at the end of the block.
81
+ - **time**: dates can be `Temporal` objects (`Temporal.Instant`, `Temporal.ZonedDateTime`).
82
+
83
+ ### Changed
84
+
85
+ - **Node.js 22.23.4, 24.22.1, 26.12.1 or a later version of these lines is required** (`engines`). Older
86
+ versions can be crashed by a single malicious HTTP response, which no package can prevent (2.1.1 only
87
+ warned about it). Node.js 25 is not supported.
88
+ - **Faster on Node.js 26**: time zones go through `Temporal`, and `time.add()` or `time.startOf()` with a
89
+ zone are more than twice as fast.
90
+ - JWT `HS*` secrets shorter than 32 bytes throw a `TypeError`, when signing and when verifying.
91
+ - TOTP secrets shorter than 10 bytes throw, `totpSecret()` needs at least 16 bytes, and `window` must be an
92
+ integer from 0 to 10.
93
+ - `update()`, `delete()`, `increment()` and `set()` of SQLite throw on an `undefined` filter value, an empty
94
+ `$and` or `$or`, or a filter that matches every row (use `clearTable()` to empty a table).
95
+ - `http`: with a `baseURL`, a URL outside it throws a `TypeError` unless you pass `allowAbsoluteUrls: true`,
96
+ and then the client's `auth`, `headers` and `query` stay home. A `params` value of `.` or `..` throws.
97
+ Bodies over 100 MB throw `ERR_RESPONSE_TOO_LARGE`, and `redirect: "error"` throws `ERR_REDIRECT`, which
98
+ is not retried.
99
+ - `sys.run()` on Windows rejects a line break in an argument to a `.cmd` or `.bat`. A relative command is
100
+ looked up from `options.cwd`, and `extendEnv: false` without `env` gives an empty environment.
101
+ - `sys.open()` throws for a scheme other than `http:`, `https:` and `mailto:`, for a path that doesn't
102
+ exist, for a control character and, on Windows, for a network path or a double quote.
103
+ - `env.parse()` only expands `${VAR}` from the text itself and the `env` option: pass `{ env: process.env }`
104
+ for the old behaviour. `env.load()` is unchanged.
105
+ - The logger shows control characters as text, indents the lines of a message after the first one even
106
+ without a timestamp (but not for `log()` with no scope and no timestamp, which prints like `console.log`),
107
+ and reads markup in the first argument only. Without colors (and in files), colors
108
+ are removed from logged strings. In JSON, a logged object can't replace `time`, `level`, `scope` or `msg`.
109
+ - `color.strip()` also removes `\r`, `\b` and every other control character but tabs and new lines.
110
+ - `str.mask()` hides everything when `start` and `end` would show the whole text, `arr.pluck()` and the
111
+ other string iteratees ignore what an item inherits from `Object.prototype`, and `obj.get({}, "toString")`
112
+ is `undefined`.
113
+ - `obj.set()` and `obj.unflatten()` make an object, not an array, for a number key above 10000.
114
+ - `isURL()` and `schema.string().url()` refuse spaces, double quotes, `<`, `>`, backticks and backslashes, and
115
+ `isEmail()`, `isHostname()`, `isMimeType()` and `isCron()` are stricter on look-alike characters, label
116
+ lengths, line breaks and trailing garbage. `time.cron()` refuses `"*/5/x"` too.
117
+ - `memoize`, `dedupe` and `cache.wrap()` compare `Set`, `Map` and class instances given as arguments by
118
+ identity.
119
+ - `fs.copyFile()`, `moveFile()`, `copy()`, `move()` and the folder helpers return `false` (or `0`) instead
120
+ of losing data when the destination is the source or inside it.
121
+ - `schema.union()` stops checking an option at its first issue, and `is()` stops at the first issue: the
122
+ refinements after it no longer run.
123
+ - `crypto.verifyPassword()` throws a `RangeError` for a hash that asks for more work than allowed (scrypt
124
+ with `p` over 16, Argon2 with more than 1 GiB of memory), without doing that work: the password may be
125
+ right, the hash has to be replaced.
126
+
127
+ ### Fixed
128
+
129
+ - `fs.move()` keeps the destination when the move fails, `findUp({ stopAt })` honors a path with a trailing
130
+ separator or another letter case, and `color.link()` can't be cut short by its own text.
131
+ - `fs.moveFilesIn()` deleted every selected file, even one whose copy failed. Now a file that can't be
132
+ copied stays where it is, and `moveFile()` can change only the letter case of a name on Windows and macOS.
133
+ - A response body is cancelled when `onDownloadProgress` throws, so the connection doesn't stay open.
134
+ - `s.literal(5n)` and enums of bigints report a failure instead of throwing.
135
+
136
+ ### Repository
137
+
138
+ - Every GitHub Action is pinned to a commit, the jobs keep no git credentials, and CodeQL scans the code and
139
+ the workflows on each pull request. Dependabot keeps the tools up to date.
140
+ - `scripts/bench.js` measures 63 hot paths and compares two runs.
141
+
7
142
  ## [2.1.1] - 2026-10-30
8
143
 
9
144
  ### Security
package/README.md CHANGED
@@ -51,7 +51,7 @@ nc.sys.onShutdown(() => db.close());
51
51
  npm install @ix-xs/node-comfort
52
52
  ```
53
53
 
54
- Node.js 22.13 or newer. That's the first version where `node:sqlite` works without a flag. If your program calls servers you don't control, use at least 22.23.4, 24.22.1 or 26.12.1: [older versions can be crashed by a malicious response](https://ix-xs.github.io/node-comfort/system/http/#servers-you-dont-control).
54
+ Node.js 22.23.4, 24.22.1, 26.12.1 or a later version of these lines. Older versions [can be crashed by a malicious HTTP response](https://ix-xs.github.io/node-comfort/system/http/#servers-you-dont-control), and no package can prevent it. On Node.js 26, time zones go through `Temporal` and are more than twice as fast.
55
55
 
56
56
  ## Why node-comfort
57
57
 
@@ -60,7 +60,7 @@ Node.js 22.13 or newer. That's the first version where `node:sqlite` works witho
60
60
  - **Fast to load.** Namespaces load the first time you use them. Requiring the package takes about 4 ms, and a script that only logs never loads the database or the HTTP client.
61
61
  - **Safe by default.** Prototype-pollution-proof objects, scrypt passwords, AES-256-GCM, constant-time comparisons, parameterized SQL, atomic file writes.
62
62
  - **One way of doing things.** Durations are always `"5m"` or milliseconds, options always come last, and every error has a stable `code`.
63
- - **Tested everywhere.** Linux, macOS and Windows, Node.js 22 and 24, TypeScript 5.9, 6 and 7.
63
+ - **Tested everywhere.** Linux, macOS and Windows, Node.js 22, 24 and 26, TypeScript 5.9, 6 and 7.
64
64
 
65
65
  ## What's inside
66
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ix-xs/node-comfort",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "The zero-dependency standard library for Node.js: logger, filesystem, SQLite, schema validation, HTTP client, CLI toolkit, cache, env, async pools, colors, events, i18n dates, ids, crypto and hundreds of fully typed helpers.",
5
5
  "keywords": [
6
6
  "utils",
@@ -178,7 +178,7 @@
178
178
  "LICENSE"
179
179
  ],
180
180
  "engines": {
181
- "node": ">=22.13.0"
181
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
182
182
  },
183
183
  "publishConfig": {
184
184
  "access": "public"
package/src/Arr.js CHANGED
@@ -65,6 +65,9 @@ const { intl, preciseSum } = require("./internal/shared.js");
65
65
  const _fn = (iteratee) => {
66
66
  if (typeof iteratee === "function") return /** @type {(item: any, index: number) => any} */ (iteratee);
67
67
  if (typeof iteratee === "string" || typeof iteratee === "number" || typeof iteratee === "symbol") {
68
+ // Members of Object.prototype (`__proto__`, `constructor`...) are not data, unless the item has its own.
69
+ // Getters of a class still work.
70
+ if (iteratee in Object.prototype) return (item) => (item == null || !Object.hasOwn(item, iteratee) ? undefined : item[iteratee]);
68
71
  return (item) => (item == null ? undefined : item[iteratee]);
69
72
  }
70
73
  return (item) => item;
@@ -384,6 +387,7 @@ function partition(array, predicate) {
384
387
  * @returns {Array<T[K]>}
385
388
  */
386
389
  function pluck(array, key) {
390
+ if (key in Object.prototype) return array.map(_fn(key));
387
391
  return array.map((item) => (item == null ? /** @type {T[K]} */ (/** @type {unknown} */ (undefined)) : item[key]));
388
392
  }
389
393
 
@@ -729,7 +733,8 @@ function flatten(array, depth) {
729
733
  }
730
734
 
731
735
  /**
732
- * Pairs items by position. Shorter arrays leave `undefined` holes.
736
+ * Pairs items by position. Shorter arrays leave `undefined` holes, and
737
+ * values that are not arrays count as empty ones.
733
738
  *
734
739
  * @example
735
740
  * arr.zip(["a", "b"], [1, 2]); // [["a", 1], ["b", 2]]
@@ -743,7 +748,8 @@ function zip(...arrays) {
743
748
  }
744
749
 
745
750
  /**
746
- * The opposite of `zip()`: rows become columns.
751
+ * The opposite of `zip()`: rows become columns. Rows that are not arrays
752
+ * count as empty ones.
747
753
  *
748
754
  * @example
749
755
  * arr.unzip([["a", 1], ["b", 2]]); // [["a", "b"], [1, 2]]
@@ -753,15 +759,17 @@ function zip(...arrays) {
753
759
  * @returns {Array<Array<T | undefined>>}
754
760
  */
755
761
  function unzip(tuples) {
756
- return _zip(tuples);
762
+ return Array.isArray(tuples) ? _zip(tuples) : [];
757
763
  }
758
764
 
759
765
  /**
760
766
  * @template T
761
- * @param {ReadonlyArray<ReadonlyArray<T>>} arrays
767
+ * @param {ReadonlyArray<ReadonlyArray<T>>} input
762
768
  * @returns {Array<Array<T | undefined>>}
763
769
  */
764
- const _zip = (arrays) => {
770
+ const _zip = (input) => {
771
+ // Only real arrays: a parsed `{ "length": 1e9 }` would allocate a billion rows.
772
+ const arrays = input.map((array) => (Array.isArray(array) ? array : []));
765
773
  let size = 0;
766
774
  for (const array of arrays) if (array.length > size) size = array.length;
767
775
  /** @type {Array<Array<T | undefined>>} */
package/src/Checker.js CHANGED
@@ -15,13 +15,24 @@ const shared = require("./internal/shared.js");
15
15
 
16
16
  /** @type {typeof import("node:net") | undefined} */
17
17
  let _net;
18
+ /** @type {typeof import("node:url") | undefined} */
19
+ let _url;
18
20
 
19
- const EMAIL = /^(?!\.)(?!.*\.\.)[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]{1,64}(?<!\.)@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+(?:\p{L}{2,}|xn--[a-z0-9-]{2,})$/iu;
21
+ // No "i" flag: with "u", it would let the Kelvin sign match "k" and the long s match "s".
22
+ const EMAIL = /^(?!\.)(?!.*\.\.)[\p{L}\p{N}.!#$%&'*+/=?^_`{|}~-]{1,64}(?<!\.)@(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+(?:\p{L}{2,}|[xX][nN]--[A-Za-z0-9-]{2,})$/u;
23
+ // The same rules for ASCII addresses, the common case, which need no further check.
24
+ const EMAIL_ASCII = /^(?!\.)(?!.*\.\.)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}(?<!\.)@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,}|[xX][nN]--[A-Za-z0-9-]{2,})$/;
20
25
  const TLD = /\.(?:[a-z]{2,}|xn--[a-z0-9-]{2,})\.?$/i;
21
26
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
27
  const UUID_NIL_OR_MAX = /^(?:0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12})$/i;
23
28
  const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|([+-])(\d{2}):?(\d{2}))?)?$/;
24
29
  const HOST_LABEL = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?$/u;
30
+ // Characters a URL parser silently drops, encodes or rewrites.
31
+ // The apostrophe is legal, as in /wiki/Ender's_Game.
32
+ const URL_UNSAFE = /[\x00-\x20\x7f<>"`\\]/;
33
+ // Quoted and bare parameter values can't overlap, so there is nothing to backtrack into.
34
+ const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;\s*[a-z0-9-]+=(?:"[^"\r\n]*"|[^;\s"]+))*$/i;
35
+ const DATA_URL = /^data:(?:[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+)?(?:;[a-z0-9-]+=[^;,]*)*(?:;base64)?,/i;
25
36
  const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
26
37
 
27
38
  /**
@@ -59,6 +70,12 @@ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
59
70
  * @property {boolean} [urlSafe] Expect the URL-safe alphabet (`-` and `_`), padding optional.
60
71
  */
61
72
 
73
+ /**
74
+ * Options for `isDataURL()`.
75
+ * @typedef {object} IsDataURLOptions
76
+ * @property {string[]} [types] Accepted media types, like `["image/png", "image/jpeg"]`, case ignored. A URL without a type, like `data:,Hello`, counts as `text/plain`. Without this option, any type passes.
77
+ */
78
+
62
79
  /**
63
80
  * Is it an array?
64
81
  *
@@ -580,7 +597,10 @@ function isOneOf(value, allowed) {
580
597
  /**
581
598
  * Does it look like an email address? The rules are practical rather than
582
599
  * the full RFC: one `@`, no stray dots, a real top-level domain.
583
- * International addresses are accepted.
600
+ * International addresses are accepted, but not look-alike characters such
601
+ * as the Kelvin sign in the domain. A valid address can still contain
602
+ * quotes, `&`, `|` or backticks: it is not safe to put unescaped into HTML
603
+ * or a shell command.
584
604
  *
585
605
  * @example
586
606
  * nc.isEmail("jose@exemple.fr"); // true
@@ -591,11 +611,19 @@ function isOneOf(value, allowed) {
591
611
  * @returns {value is string}
592
612
  */
593
613
  function isEmail(value) {
594
- return typeof value === "string" && value.length <= 254 && EMAIL.test(value);
614
+ if (typeof value !== "string" || value.length > 254) return false;
615
+ if (EMAIL_ASCII.test(value)) return true;
616
+ if (shared.ASCII.test(value) || !EMAIL.test(value)) return false;
617
+ // Compatibility characters (Kelvin sign, fullwidth letters...) become other letters in DNS.
618
+ const domain = value.slice(value.lastIndexOf("@") + 1);
619
+ return domain === domain.normalize("NFKC");
595
620
  }
596
621
 
597
622
  /**
598
623
  * Is it an absolute URL with an allowed protocol (http and https by default)?
624
+ * The text must already be clean: spaces, control characters, double
625
+ * quotes, backticks, `<`, `>` and backslashes are refused, even where the URL
626
+ * parser would quietly fix them. An apostrophe is fine.
599
627
  *
600
628
  * @example
601
629
  * nc.isURL("https://example.com/a?b=1"); // true
@@ -607,7 +635,7 @@ function isEmail(value) {
607
635
  * @returns {value is string}
608
636
  */
609
637
  function isURL(value, options = {}) {
610
- if (typeof value !== "string") return false;
638
+ if (typeof value !== "string" || URL_UNSAFE.test(value)) return false;
611
639
  const url = URL.parse(value);
612
640
  if (!url) return false;
613
641
  const protocols = options.protocols;
@@ -891,6 +919,8 @@ function isJWT(value) {
891
919
  /**
892
920
  * Is it a host name, like `example.com`, `api.internal` or `localhost`?
893
921
  * International names are accepted. IP addresses aren't: see `isIP`.
922
+ * Lengths are counted the way DNS sees them, once converted to ASCII
923
+ * (`xn--`): 63 bytes per label, 253 in all.
894
924
  *
895
925
  * @example
896
926
  * nc.isHostname("api.example.com"); // true
@@ -907,6 +937,11 @@ function isHostname(value, options = {}) {
907
937
  const labels = value.split(".");
908
938
  if (!labels.every((label) => HOST_LABEL.test(label))) return false;
909
939
  if (/^\d+$/.test(labels[labels.length - 1])) return false;
940
+ if (!shared.ASCII.test(value)) {
941
+ // An international label takes more room once encoded: "é" x 63 is 67 bytes long.
942
+ const ascii = (_url ??= require("node:url")).domainToASCII(value);
943
+ if (ascii === "" || ascii.length > 253 || ascii.split(".").some((label) => label.length > 63)) return false;
944
+ }
910
945
  return !options.requireTld || (labels.length > 1 && TLD.test(value));
911
946
  }
912
947
 
@@ -991,17 +1026,25 @@ function isULID(value) {
991
1026
  }
992
1027
 
993
1028
  /**
994
- * Is it a `data:` URL, like an inline image?
1029
+ * Is it a `data:` URL, like an inline image? Only the header is checked,
1030
+ * not the data. Without `types`, any media type passes, `text/html` and
1031
+ * `image/svg+xml` included, and both can run scripts.
995
1032
  *
996
1033
  * @example
997
1034
  * nc.isDataURL("data:image/png;base64,iVBORw0KGgo="); // true
998
1035
  * nc.isDataURL("data:,Hello"); // true
1036
+ * nc.isDataURL("data:text/html,<p>Hi</p>", { types: ["image/png"] }); // false
999
1037
  *
1000
1038
  * @param {unknown} value
1039
+ * @param {IsDataURLOptions} [options]
1001
1040
  * @returns {value is string}
1002
1041
  */
1003
- function isDataURL(value) {
1004
- return typeof value === "string" && /^data:(?:[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+)?(?:;[a-z0-9-]+=[^;,]*)*(?:;base64)?,/i.test(value);
1042
+ function isDataURL(value, options = {}) {
1043
+ if (typeof value !== "string" || !DATA_URL.test(value)) return false;
1044
+ const types = options.types;
1045
+ if (!types) return true;
1046
+ const type = value.slice(5, value.search(/[;,]/)).toLowerCase() || "text/plain";
1047
+ return types.some((allowed) => allowed.toLowerCase() === type);
1005
1048
  }
1006
1049
 
1007
1050
  /**
@@ -1016,7 +1059,7 @@ function isDataURL(value) {
1016
1059
  * @returns {value is string}
1017
1060
  */
1018
1061
  function isMimeType(value) {
1019
- return typeof value === "string" && /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;\s*[a-z0-9-]+=(?:"[^"]*"|[^;\s]+))*$/i.test(value);
1062
+ return typeof value === "string" && MIME_TYPE.test(value);
1020
1063
  }
1021
1064
 
1022
1065
  /**
@@ -1071,6 +1114,11 @@ function isLocale(value) {
1071
1114
  */
1072
1115
  function isCron(value) {
1073
1116
  if (typeof value !== "string") return false;
1117
+ // "*/5/x" is not a step and "1-5-x" is not a range, even though the parser stops reading early.
1118
+ for (const part of value.trim().split(/[\s,]+/)) {
1119
+ const step = part.split("/");
1120
+ if (step.length > 2 || step[0].split("-").length > 2) return false;
1121
+ }
1074
1122
  try {
1075
1123
  shared.parseCron(value);
1076
1124
  return true;
package/src/Cli.js CHANGED
@@ -6,6 +6,10 @@
6
6
  * boxes. When nobody's at the keyboard (CI, pipes), prompts read plain lines
7
7
  * and animations print only their final state.
8
8
  *
9
+ * Tables, boxes, trees and menus show what a terminal could act on as text
10
+ * (`\x1b`, `\r`), so a value can't move the cursor or break the layout.
11
+ * Colors and links in them still work.
12
+ *
9
13
  * @example
10
14
  * const { flags } = cli.args({
11
15
  * port: { type: "number", short: "p", default: 3000, description: "Port to listen on" },
@@ -66,8 +70,12 @@ const { AbortError } = require("./errors.js");
66
70
  */
67
71
 
68
72
  /**
69
- * A spinner. Every method returns it.
70
- * @typedef {object} Spinner
73
+ * A spinner. Every method returns it. With `using`, it stops at the end of the block.
74
+ * @typedef {_SpinnerMethods & Disposable} Spinner
75
+ */
76
+
77
+ /**
78
+ * @typedef {object} _SpinnerMethods
71
79
  * @property {(text?: string) => Spinner} start Starts spinning, optionally with new text.
72
80
  * @property {() => Spinner} stop Stops and clears the line.
73
81
  * @property {(text?: string) => Spinner} succeed Stops with a green `✔`.
@@ -91,8 +99,12 @@ const { AbortError } = require("./errors.js");
91
99
  */
92
100
 
93
101
  /**
94
- * A progress bar.
95
- * @typedef {object} ProgressBar
102
+ * A progress bar. With `using`, it stops at the end of the block.
103
+ * @typedef {_ProgressBarMethods & Disposable} ProgressBar
104
+ */
105
+
106
+ /**
107
+ * @typedef {object} _ProgressBarMethods
96
108
  * @property {(amount?: number, label?: string) => ProgressBar} tick Adds to the value, 1 by default.
97
109
  * @property {(value: number, label?: string) => ProgressBar} update Sets the value.
98
110
  * @property {() => ProgressBar} stop Draws the final state and frees the line.
@@ -257,18 +269,28 @@ const _align = (text, width, align) => {
257
269
  return text + " ".repeat(gap);
258
270
  };
259
271
 
272
+ const { safe: _safe } = require("./internal/terminal.js");
273
+
274
+ /**
275
+ * Text a terminal can't act on: control characters and escape sequences are
276
+ * shown as text (`\x1b`, `\r`). Colors and links stay, a cell may be colored
277
+ * on purpose.
278
+ * @param {string} text
279
+ */
280
+ const _text = (text) => _safe(text, true);
281
+
260
282
  /** @param {unknown} value */
261
283
  const _cell = (value) => {
262
284
  if (value === null || value === undefined) return "";
263
285
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
264
286
  if (typeof value === "object") {
265
287
  try {
266
- return JSON.stringify(value);
288
+ return _text(JSON.stringify(value) ?? String(value));
267
289
  } catch {
268
- return String(value);
290
+ return _text(String(value));
269
291
  }
270
292
  }
271
- return String(value);
293
+ return _text(String(value));
272
294
  };
273
295
 
274
296
  /** @param {NodeJS.WriteStream | undefined} stream */
@@ -328,7 +350,7 @@ function table(rows, options = {}) {
328
350
  const columns = keys.map((column) => (typeof column === "object" ? column : { key: column }));
329
351
  if (!columns.length) return "";
330
352
  const showHeader = options.header ?? !isArrayRows;
331
- const headers = columns.map((column) => column.header ?? String(column.key));
353
+ const headers = columns.map((column) => _text(column.header ?? String(column.key)));
332
354
  const widths = headers.map((header) => Math.max(markdown ? 3 : 1, showHeader ? color.width(header) : 0));
333
355
  const numeric = columns.map(() => rows.length > 0);
334
356
 
@@ -409,8 +431,8 @@ function box(text, options = {}) {
409
431
  const palette = _palette(options.colors);
410
432
  const b = BORDERS[options.border ?? "rounded"];
411
433
  const pad = typeof options.padding === "number" ? { x: options.padding, y: Math.floor(options.padding / 2) } : { x: 2, y: 0, ...options.padding };
412
- const lines = String(text).split(_LINE_BREAK);
413
- const title = options.title ? ` ${options.title} ` : "";
434
+ const lines = _text(String(text)).split(_LINE_BREAK);
435
+ const title = options.title ? ` ${_text(options.title)} ` : "";
414
436
  let longest = Math.max(options.width ?? 0, color.width(title) + 2 - pad.x * 2);
415
437
  for (const line of lines) longest = Math.max(longest, color.width(line));
416
438
  const inner = longest + pad.x * 2;
@@ -487,7 +509,8 @@ function tree(data, options = {}) {
487
509
  entries.forEach(([label, value], i) => {
488
510
  const isLast = i === entries.length - 1;
489
511
  const nested = isBranch(value);
490
- const text = nested || value === null || value === undefined || value === true ? label : `${label}: ${_cell(value)}`;
512
+ const name = _text(label);
513
+ const text = nested || value === null || value === undefined || value === true ? name : `${name}: ${_cell(value)}`;
491
514
  const [first, ...rest] = text.split(_LINE_BREAK);
492
515
  out.push(indent + (isLast ? last : branch) + first);
493
516
  for (const line of rest) out.push(indent + (isLast ? blank : pipe) + line);
@@ -562,6 +585,10 @@ function spinner(text = "", options = {}) {
562
585
  return self;
563
586
  },
564
587
  stop: () => finish("", undefined),
588
+ [Symbol.dispose]: () => {
589
+ // Leaving the block early, by an error or a return, stops the spinner: one that already finished stays as it is.
590
+ if (spinning) finish("", undefined);
591
+ },
565
592
  succeed: (finalText) => finish(_paint(palette, "green", "✔"), finalText),
566
593
  fail: (finalText) => finish(_paint(palette, "red", "✖"), finalText),
567
594
  warn: (finalText) => finish(_paint(palette, "yellow", "⚠"), finalText),
@@ -698,6 +725,9 @@ function progress(options) {
698
725
  stream.write(interactive ? `\n${SHOW_CURSOR}` : `${line()}\n`);
699
726
  return self;
700
727
  },
728
+ [Symbol.dispose]() {
729
+ self.stop();
730
+ },
701
731
  get value() {
702
732
  return value;
703
733
  },
@@ -908,17 +938,29 @@ async function confirm(question, options = {}) {
908
938
  const _normalizeChoices = (choices) =>
909
939
  choices.map((c) => (typeof c === "string" ? { label: c, value: /** @type {V} */ (/** @type {unknown} */ (c)) } : c));
910
940
 
941
+ /**
942
+ * The question and its numbered choices, for when nobody can use the arrow keys.
943
+ * @param {string} question
944
+ * @param {Array<{ label: string, disabled?: boolean }>} items
945
+ */
946
+ const _numbered = (question, items) =>
947
+ `? ${_text(String(question))}\n${items.map((it, i) => ` ${i + 1}. ${_text(String(it.label))}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`;
948
+
911
949
  /**
912
950
  * The arrow-key list behind `select()` and `multiselect()`.
913
951
  * @template V
914
- * @param {string} question
952
+ * @param {string} rawQuestion
915
953
  * @param {Array<{ label: string, value: V, hint?: string, disabled?: boolean }>} items
916
954
  * @param {SelectOptions} options
917
955
  * @param {boolean} multiple
918
956
  * @returns {Promise<number[]>}
919
957
  */
920
- const _interactiveList = (question, items, options, multiple) =>
958
+ const _interactiveList = (rawQuestion, items, options, multiple) =>
921
959
  new Promise((resolve, reject) => {
960
+ // Choices often come from data: shown as text, they can't drive the terminal.
961
+ const question = _text(String(rawQuestion));
962
+ const labels = items.map((item) => _text(String(item.label)));
963
+ const hints = items.map((item) => (item.hint ? _text(String(item.hint)) : ""));
922
964
  const input = options.input ?? process.stdin;
923
965
  const output = options.output ?? process.stdout;
924
966
  const palette = color.create(color.detect(output));
@@ -942,8 +984,8 @@ const _interactiveList = (question, items, options, multiple) =>
942
984
  const item = items[i];
943
985
  const active = i === cursor;
944
986
  const mark = multiple ? (selected.has(i) ? palette.green("◉ ") : "◯ ") : "";
945
- const text = item.disabled ? palette.gray(`${item.label} (disabled)`) : active ? palette.cyan(item.label) : item.label;
946
- lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${item.hint ? ` ${palette.gray(item.hint)}` : ""}`);
987
+ const text = item.disabled ? palette.gray(`${labels[i]} (disabled)`) : active ? palette.cyan(labels[i]) : labels[i];
988
+ lines.push(`${active ? palette.cyan("❯") : " "} ${mark}${text}${hints[i] ? ` ${palette.gray(hints[i])}` : ""}`);
947
989
  }
948
990
  if (error) lines.push(palette.red(`✖ ${error}`));
949
991
  // Going back up assumes one row per line, so nothing may wrap.
@@ -994,7 +1036,7 @@ const _interactiveList = (question, items, options, multiple) =>
994
1036
  } else {
995
1037
  const picked = multiple ? [...selected].sort((a, b) => a - b) : [cursor];
996
1038
  // Fold the list into one line that says what was chosen.
997
- output.write(`${_up(drawn)}${CLEAR_BELOW}${_oneRow(`${palette.green("✔")} ${question} ${palette.cyan(picked.map((i) => items[i].label).join(", "))}`, output)}\n`);
1039
+ output.write(`${_up(drawn)}${CLEAR_BELOW}${_oneRow(`${palette.green("✔")} ${question} ${palette.cyan(picked.map((i) => labels[i]).join(", "))}`, output)}\n`);
998
1040
  cleanup();
999
1041
  resolve(picked);
1000
1042
  return;
@@ -1038,7 +1080,7 @@ async function select(question, choices, options = {}) {
1038
1080
  return items[index].value;
1039
1081
  }
1040
1082
  const output = options.output ?? process.stdout;
1041
- output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
1083
+ output.write(_numbered(question, items));
1042
1084
  for (;;) {
1043
1085
  const answer = (await _ask(`Choose 1-${items.length}${options.default !== undefined ? ` (${options.default + 1})` : ""}: `, input, output)).trim();
1044
1086
  const index = answer === "" && options.default !== undefined ? options.default : Number(answer) - 1;
@@ -1068,7 +1110,7 @@ async function multiselect(question, choices, options = {}) {
1068
1110
  return indexes.map((i) => items[i].value);
1069
1111
  }
1070
1112
  const output = options.output ?? process.stdout;
1071
- output.write(`? ${question}\n${items.map((it, i) => ` ${i + 1}. ${it.label}${it.disabled ? " (disabled)" : ""}`).join("\n")}\n`);
1113
+ output.write(_numbered(question, items));
1072
1114
  for (;;) {
1073
1115
  const answer = (await _ask("Choose (comma-separated numbers): ", input, output)).trim();
1074
1116
  const indexes = answer === "" ? [] : answer.split(/[\s,]+/).filter(Boolean).map((n) => Number(n) - 1);
package/src/Color.js CHANGED
@@ -114,10 +114,10 @@ const { segmenter, graphemes } = require("./internal/shared.js");
114
114
  * @property {boolean} enabled Whether colors are being output.
115
115
  * @property {(level?: ColorLevel) => ColorModule} create A separate instance with its own level, for another stream or for tests.
116
116
  * @property {(stream?: NodeJS.WriteStream | { isTTY?: boolean }) => ColorLevel} detect The color level a stream supports, taking `NO_COLOR` and `FORCE_COLOR` into account.
117
- * @property {(text: string) => string} strip Removes colors and other terminal escape codes.
117
+ * @property {(text: string) => string} strip Removes colors, every other terminal escape code and every control character but tabs and new lines (so `\r` and `\b` go too).
118
118
  * @property {(text: string) => number} width How many columns the text takes, ignoring colors and counting emoji and CJK as 2.
119
119
  * @property {(text: string, columns: number, options?: { ellipsis?: string }) => string} truncate Cuts text to a number of columns and adds `…`. Colors are kept and still closed properly.
120
- * @property {(text: string, url: string) => string} link A clickable link in terminals that support it, `"text (url)"` elsewhere.
120
+ * @property {(text: string, url: string) => string} link A clickable link in terminals that support it, `"text (url)"` elsewhere. Control characters are removed from both, so they can't end the link early; the text keeps its colors.
121
121
  * @property {(text: string, colors: string[]) => string} gradient Colors the text along a gradient of hex colors.
122
122
  */
123
123
 
@@ -142,7 +142,16 @@ for (const [name, [open, close]] of Object.entries(_CODES)) _STYLES[name] = { op
142
142
  * @typedef {{ open: string, close: string } | { rgb: [number, number, number], bg: boolean } | { ansi256: number, bg: boolean }} StyleEntry
143
143
  */
144
144
 
145
- const _ANSI_REGEX = /[\u001b\u009b](?:\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g;
145
+ // Terminal sequences, by kind. The string ones (OSC, DCS, APC, PM, SOS) end
146
+ // at their terminator, or at the next ESC, as a terminal reads them. Their
147
+ // body also stops at a string introducer, so the scan stays linear.
148
+ const _STRING_SEQUENCES = /(?:\x1b[\]P^_X]|[\x90\x98\x9d\x9e\x9f])[^\x07\x1b\x90\x98\x9c-\x9f]*(?:\x07|\x1b\\|\x9c)/g;
149
+ const _CSI_SEQUENCES = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g;
150
+ const _SHORT_SEQUENCES = /\x1b[ -/]*[0-~]/g;
151
+ const _ANSI_REGEX = /(?:\x1b[\]P^_X]|[\x90\x98\x9d\x9e\x9f])[^\x07\x1b\x90\x98\x9c-\x9f]*(?:\x07|\x1b\\|\x9c)|(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]/g;
152
+ // Every control character but tab and new line: C0, DEL and C1.
153
+ const _CONTROLS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
154
+ const _HAS_CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/;
146
155
 
147
156
  /**
148
157
  * @param {string} hex
@@ -302,7 +311,10 @@ const _charWidth = (grapheme) => {
302
311
  */
303
312
  const strip = (text) => {
304
313
  const value = typeof text === "string" ? text : String(text);
305
- return value.indexOf("\u001b") === -1 && value.indexOf("\u009b") === -1 ? value : value.replace(_ANSI_REGEX, "");
314
+ if (!_HAS_CONTROL.test(value)) return value;
315
+ // One kind at a time, then every control character left: no ESC remains to
316
+ // rebuild a sequence from the pieces around it.
317
+ return value.replace(_STRING_SEQUENCES, "").replace(_CSI_SEQUENCES, "").replace(_SHORT_SEQUENCES, "").replace(_CONTROLS, "");
306
318
  };
307
319
 
308
320
  /**
@@ -384,6 +396,16 @@ const _supportsHyperlinks = () => {
384
396
  return false;
385
397
  };
386
398
 
399
+ const _LINK_CONTROLS = /[\x00-\x1f\x7f-\x9f]/g;
400
+ const _HAS_LINK_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
401
+ const _LINK_TEXT = /(\x1b\[[\d;:]*m)|(?:\x1b[\]P^_X]|[\x90\x98\x9d\x9e\x9f])[^\x07\x1b\x90\x98\x9c-\x9f]*(?:\x07|\x1b\\|\x9c)|(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]|[\x00-\x1f\x7f-\x9f]/g;
402
+
403
+ /**
404
+ * The text of a link: colors stay, any other sequence or control character goes.
405
+ * @param {string} text
406
+ */
407
+ const _linkText = (text) => (_HAS_LINK_CONTROL.test(text) ? text.replace(_LINK_TEXT, (match, style) => style ?? "") : text);
408
+
387
409
  /** @param {number} value */
388
410
  const _byte = (value) => (value > 0 ? (value < 255 ? Math.round(value) : 255) : 0);
389
411
 
@@ -542,8 +564,12 @@ const _createModule = (forcedLevel) => {
542
564
  truncate: { enumerable: true, value: truncate },
543
565
  link: {
544
566
  enumerable: true,
545
- value: (/** @type {string} */ text, /** @type {string} */ url) =>
546
- currentLevel() > 0 && _supportsHyperlinks() ? `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007` : `${text} (${url})`,
567
+ value: (/** @type {string} */ text, /** @type {string} */ url) => {
568
+ // A control character would end the link early and start something else.
569
+ const label = _linkText(String(text));
570
+ const target = String(url).replace(_LINK_CONTROLS, "");
571
+ return currentLevel() > 0 && _supportsHyperlinks() ? `\u001b]8;;${target}\u0007${label}\u001b]8;;\u0007` : `${label} (${target})`;
572
+ },
547
573
  },
548
574
  gradient: { enumerable: true, value: gradient },
549
575
  });