linegauge 0.0.1 → 0.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.
- package/LICENSE +21 -0
- package/README.md +95 -12
- package/dist/index.d.ts +31 -5
- package/dist/index.js +31 -5
- package/dist/slice.d.ts +10 -0
- package/dist/slice.js +100 -0
- package/dist/strip.d.ts +26 -0
- package/dist/strip.js +62 -0
- package/dist/style.d.ts +71 -0
- package/dist/style.js +276 -0
- package/dist/truncate.d.ts +12 -0
- package/dist/truncate.js +42 -0
- package/dist/widest.d.ts +6 -0
- package/dist/widest.js +25 -0
- package/dist/width.d.ts +45 -0
- package/dist/width.js +226 -0
- package/dist/wrap.d.ts +12 -0
- package/dist/wrap.js +271 -0
- package/package.json +29 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ofri Peretz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,21 +1,104 @@
|
|
|
1
1
|
# linegauge
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
[`.sdlc/research/candidate-layers.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/candidate-layers.md).
|
|
6
|
-
An intent and a working release follow if and when the roadmap takes it up.
|
|
3
|
+
**Measuring, wrapping, truncating and slicing styled terminal text — without the edge
|
|
4
|
+
fraying.**
|
|
7
5
|
|
|
8
|
-
A
|
|
6
|
+
A printer's line gauge is the steel rule marked in picas and points: a compositor holds it
|
|
7
|
+
against a line of type and checks it fits the measure it was set to.
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
Zero dependencies. Grapheme-correct over the platform's own `Intl.Segmenter`.
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
```bash
|
|
12
|
+
npm i linegauge
|
|
13
|
+
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
## One problem wearing five names
|
|
16
|
+
|
|
17
|
+
`width` · `wrap` · `truncate` · `slice` · `widest`
|
|
18
|
+
|
|
19
|
+
They look like five utilities. They are one: **cutting styled text without letting the edge
|
|
20
|
+
come apart** — no dangling escape sequence, no half a grapheme, no severed emoji cluster.
|
|
21
|
+
Each has to know where the ANSI is and where the cluster boundaries are, and once you know
|
|
22
|
+
that, you may as well answer all five.
|
|
23
|
+
|
|
24
|
+
The ecosystem splits it across twelve packages — `strip-ansi`, `string-width`,
|
|
25
|
+
`ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`,
|
|
26
|
+
`eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — which
|
|
27
|
+
between them sit under most of the terminal ecosystem.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { width, wrap, truncate, slice, widest } from 'linegauge';
|
|
33
|
+
|
|
34
|
+
width('古代'); // 4 — East Asian wide, two columns each
|
|
35
|
+
width('👨👩👧👦'); // 2 — one cluster, not four people
|
|
36
|
+
|
|
37
|
+
wrap('a long sentence that needs folding', 12);
|
|
38
|
+
truncate('the quick brown fox', 10); // 'the quick…'
|
|
39
|
+
slice(styled, 2, 4); // columns 2 and 3, styles intact
|
|
40
|
+
widest(['a', 'bbb', 'cc']); // 3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### The default export is `string-width`
|
|
44
|
+
|
|
45
|
+
Byte-for-byte call-compatible, so this resolves without a code change:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "overrides": { "string-width": "npm:linegauge@^0.2" } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What "without the edge fraying" means
|
|
52
|
+
|
|
53
|
+
**A cluster is atomic.** A cut that would land inside a grapheme drops the whole cluster
|
|
54
|
+
rather than half of it. Half an emoji is not a narrower emoji, it is mojibake, and a flag
|
|
55
|
+
cut down the middle is two unrelated regional-indicator letters.
|
|
56
|
+
|
|
57
|
+
**A style that was open stays open — and gets closed.** A cut re-emits the styles active at
|
|
58
|
+
its start and closes them at its end, so the result is self-contained: paste it anywhere and
|
|
59
|
+
it neither loses its colour nor leaks it into what follows.
|
|
60
|
+
|
|
61
|
+
**The ellipsis is inside the budget, not on top of it.** `truncate(text, 10)` occupies ten
|
|
62
|
+
columns or fewer, never eleven. That is the property a table column depends on, and getting
|
|
63
|
+
it wrong is how a layout gains a phantom column under one input.
|
|
64
|
+
|
|
65
|
+
**`widest` takes lines, not a blob.** It accepts any iterable of strings, so the caller says
|
|
66
|
+
where the boundaries are rather than having a newline convention assumed for them.
|
|
67
|
+
|
|
68
|
+
## Graded by the packages it replaces
|
|
69
|
+
|
|
70
|
+
The incumbent is the specification. `width` runs against `string-width`, `wrap` against
|
|
71
|
+
`wrap-ansi`, `slice` against `slice-ansi` and `truncate` against `cli-truncate`.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
| | |
|
|
76
|
+
| :-- | :-- |
|
|
77
|
+
| `width(text, { countAnsiEscapeCodes })` | terminal columns the text occupies |
|
|
78
|
+
| `wrap(text, columns, options)` | fold to a width, styles preserved across rows |
|
|
79
|
+
| `truncate(text, columns, { position, ellipsis })` | cut to a budget, ellipsis counted inside it |
|
|
80
|
+
| `slice(text, start, end)` | the columns `[start, end)`, self-contained |
|
|
81
|
+
| `widest(lines)` | the width of the widest line of any iterable |
|
|
82
|
+
| `lineCount(text, columns)` | rows the text occupies at that width |
|
|
83
|
+
| `measure(text)` | columns of plain text, no escape scan |
|
|
84
|
+
|
|
85
|
+
Non-strings answer `0` rather than throwing, because a width function is usually reached
|
|
86
|
+
with whatever a template produced.
|
|
87
|
+
|
|
88
|
+
## Design notes
|
|
89
|
+
|
|
90
|
+
**Ambiguous-width characters count narrow**, which is what a terminal does unless told it is
|
|
91
|
+
rendering an East Asian locale. `string-width` makes that an option; nothing above this has
|
|
92
|
+
ever needed the other answer, so it is not one here.
|
|
93
|
+
|
|
94
|
+
**Not a terminal emulator.** Semicolon-delimited SGR, colon-delimited extended colour and
|
|
95
|
+
OSC 8 hyperlinks are understood. Every other complete CSI or OSC command is carried through
|
|
96
|
+
as an opaque zero-width unit, and anything that only looks like an introducer stays plain
|
|
97
|
+
text.
|
|
98
|
+
|
|
99
|
+
**Still at the Design→Build gate:** an exported `strip`, and the ASCII fast path — a byte
|
|
100
|
+
scan when the string has no non-ASCII code unit, so the segmenter is reached only when it
|
|
101
|
+
earns its cost.
|
|
19
102
|
|
|
20
103
|
## Licence
|
|
21
104
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* that importing it costs nothing and promises nothing.
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
5
|
*/
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* linegauge — how much of a terminal line a string occupies, and how to fold it.
|
|
8
|
+
*
|
|
9
|
+
* F1 of the foundation tier, built by moving rather than by writing: `width` and `wrap`
|
|
10
|
+
* were already in `flagstaff`, already ported, already graded differentially against
|
|
11
|
+
* `string-width` and `wrap-ansi`. This package is where they belong, because measuring a
|
|
12
|
+
* line is not drawing one — a spinner, a box, a table and a status line all need the
|
|
13
|
+
* measurement, and nothing about the measurement needs any of them.
|
|
14
|
+
*
|
|
15
|
+
* The default export is `width`, byte-for-byte call-compatible with `string-width`'s
|
|
16
|
+
* default (R8), so `overrides: { "string-width": "npm:linegauge@^1" }` resolves.
|
|
17
|
+
*
|
|
18
|
+
* `slice`, `truncate` and `widest` came next, built on the style stack `wrap` already
|
|
19
|
+
* carried — which is the consolidation the design is named for: `slice-ansi`, `wrap-ansi`
|
|
20
|
+
* and `cli-truncate` each keep their own copy of it, and they disagree at the edges.
|
|
21
|
+
*
|
|
22
|
+
* `strip` (R3) followed, and it is where the measured divergence from Node's own
|
|
23
|
+
* `stripVTControlCharacters` is recorded — one shape in sixteen, and it was a live bug in
|
|
24
|
+
* `width()`.
|
|
25
|
+
*
|
|
26
|
+
* Still at the Design→Build gate: the R2 ASCII fast path.
|
|
27
|
+
*/
|
|
28
|
+
export { lineCount, measure, width, width as default, type WidthOptions } from './width.js';
|
|
29
|
+
export { slice } from './slice.js';
|
|
30
|
+
export { strip } from './strip.js';
|
|
31
|
+
export { truncate, type TruncateOptions } from './truncate.js';
|
|
32
|
+
export { widest } from './widest.js';
|
|
33
|
+
export { wrap, type WrapOptions } from './wrap.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* that importing it costs nothing and promises nothing.
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
5
|
*/
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* linegauge — how much of a terminal line a string occupies, and how to fold it.
|
|
8
|
+
*
|
|
9
|
+
* F1 of the foundation tier, built by moving rather than by writing: `width` and `wrap`
|
|
10
|
+
* were already in `flagstaff`, already ported, already graded differentially against
|
|
11
|
+
* `string-width` and `wrap-ansi`. This package is where they belong, because measuring a
|
|
12
|
+
* line is not drawing one — a spinner, a box, a table and a status line all need the
|
|
13
|
+
* measurement, and nothing about the measurement needs any of them.
|
|
14
|
+
*
|
|
15
|
+
* The default export is `width`, byte-for-byte call-compatible with `string-width`'s
|
|
16
|
+
* default (R8), so `overrides: { "string-width": "npm:linegauge@^1" }` resolves.
|
|
17
|
+
*
|
|
18
|
+
* `slice`, `truncate` and `widest` came next, built on the style stack `wrap` already
|
|
19
|
+
* carried — which is the consolidation the design is named for: `slice-ansi`, `wrap-ansi`
|
|
20
|
+
* and `cli-truncate` each keep their own copy of it, and they disagree at the edges.
|
|
21
|
+
*
|
|
22
|
+
* `strip` (R3) followed, and it is where the measured divergence from Node's own
|
|
23
|
+
* `stripVTControlCharacters` is recorded — one shape in sixteen, and it was a live bug in
|
|
24
|
+
* `width()`.
|
|
25
|
+
*
|
|
26
|
+
* Still at the Design→Build gate: the R2 ASCII fast path.
|
|
27
|
+
*/
|
|
28
|
+
export { lineCount, measure, width, width as default } from './width.js';
|
|
29
|
+
export { slice } from './slice.js';
|
|
30
|
+
export { strip } from './strip.js';
|
|
31
|
+
export { truncate } from './truncate.js';
|
|
32
|
+
export { widest } from './widest.js';
|
|
33
|
+
export { wrap } from './wrap.js';
|
|
8
34
|
//# sourceMappingURL=index.js.map
|
package/dist/slice.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* `[start, end)` in display columns. A negative or reversed range is empty rather than an
|
|
8
|
+
* error, matching `String.prototype.slice`'s temperament if not its units.
|
|
9
|
+
*/
|
|
10
|
+
export declare function slice(string: string, start?: number, end?: number): string;
|
package/dist/slice.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* R4 — cut a styled string in **display columns**, never in code units.
|
|
8
|
+
*
|
|
9
|
+
* `"\u001B[31mred\u001B[39m".slice(0, 3)` returns three characters of an escape sequence
|
|
10
|
+
* and no red at all; that is the bug this exists to remove, and it is the same bug in every
|
|
11
|
+
* hand-rolled column-cutter. Three rules, none of which a code-unit slice can honour:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Never split a grapheme cluster.** A cluster that straddles a boundary is included
|
|
14
|
+
* whole — the cut rounds *outward*, never inward, so a slice can be one column wider
|
|
15
|
+
* than asked but never returns half a family emoji.
|
|
16
|
+
* 2. **Close what is open at the cut, and reopen it at the start.** A style opened before
|
|
17
|
+
* `start` is re-emitted at the front of the result, because the caller is going to
|
|
18
|
+
* print this fragment somewhere the opener never reached.
|
|
19
|
+
* 3. **A hyperlink is a style too** (`OSC 8`), closed and reopened the same way.
|
|
20
|
+
*
|
|
21
|
+
* Built on the same stack `wrap` uses rather than a second copy of it, which is the whole
|
|
22
|
+
* consolidation: `slice-ansi` and `wrap-ansi` each carry their own, and they disagree.
|
|
23
|
+
*/
|
|
24
|
+
import { applyParameters, closingSequence, hyperlink, matchEscape, openingSequence, segmenter } from './style.js';
|
|
25
|
+
import { measure } from './width.js';
|
|
26
|
+
/**
|
|
27
|
+
* Rule 2. Deliberately without `wrap`'s `applyLeadingResets`: that optimisation drops a
|
|
28
|
+
* style whose reset immediately follows the cut, which is right for a row boundary and
|
|
29
|
+
* wrong here — by the time the first kept cluster is known the walk has already run past
|
|
30
|
+
* the whole plain-text segment, so the "next" sequence it would inspect belongs to text
|
|
31
|
+
* this slice still contains. Reopening a style that is about to be reset costs bytes;
|
|
32
|
+
* dropping one that is not costs the colour.
|
|
33
|
+
*/
|
|
34
|
+
function open(cut) {
|
|
35
|
+
if (cut.started)
|
|
36
|
+
return;
|
|
37
|
+
cut.started = true;
|
|
38
|
+
cut.body += openingSequence(cut.active);
|
|
39
|
+
if (cut.link !== undefined)
|
|
40
|
+
cut.body += hyperlink(cut.link.uri, cut.link.parameters);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* An escape sequence: it always moves the stack, and is copied through only when the cut
|
|
44
|
+
* has started and has not finished. The stack is what gets re-emitted at `start`, which is
|
|
45
|
+
* why a sequence outside the range is still read.
|
|
46
|
+
*/
|
|
47
|
+
function takeEscape(cut, escape, end) {
|
|
48
|
+
const groups = escape.groups ?? {};
|
|
49
|
+
if (groups['sgr'] !== undefined)
|
|
50
|
+
applyParameters(groups['sgr'], cut.active);
|
|
51
|
+
else if (groups['uri'] !== undefined)
|
|
52
|
+
cut.link = groups['uri'].length === 0 ? undefined : { parameters: groups['parameters'] ?? '', uri: groups['uri'] };
|
|
53
|
+
if (cut.started && cut.column < end)
|
|
54
|
+
cut.body += escape[0];
|
|
55
|
+
}
|
|
56
|
+
/** One run of plain text, cluster by cluster. Returns true when the range has been filled. */
|
|
57
|
+
function takeText(cut, run, start, end) {
|
|
58
|
+
for (const { segment } of segmenter.segment(run)) {
|
|
59
|
+
const columns = measure(segment);
|
|
60
|
+
// Rule 1: a cluster is in when any column it occupies is in, so a zero-width mark rides
|
|
61
|
+
// with the cluster it follows rather than falling off the front of a slice.
|
|
62
|
+
if (cut.column + Math.max(columns, 1) > start && cut.column < end) {
|
|
63
|
+
open(cut);
|
|
64
|
+
cut.body += segment;
|
|
65
|
+
}
|
|
66
|
+
cut.column += columns;
|
|
67
|
+
if (cut.column >= end && columns > 0)
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return cut.column >= end;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `[start, end)` in display columns. A negative or reversed range is empty rather than an
|
|
74
|
+
* error, matching `String.prototype.slice`'s temperament if not its units.
|
|
75
|
+
*/
|
|
76
|
+
export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
|
|
77
|
+
if (end <= start || string.length === 0)
|
|
78
|
+
return '';
|
|
79
|
+
const cut = { active: [], link: undefined, column: 0, body: '', started: false };
|
|
80
|
+
let index = 0;
|
|
81
|
+
while (index < string.length) {
|
|
82
|
+
const escape = matchEscape(string, index);
|
|
83
|
+
if (escape !== undefined) {
|
|
84
|
+
takeEscape(cut, escape, end);
|
|
85
|
+
index += escape[0].length;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let run = '';
|
|
89
|
+
while (index < string.length && matchEscape(string, index) === undefined) {
|
|
90
|
+
run += string[index];
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
if (takeText(cut, run, start, end))
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (!cut.started)
|
|
97
|
+
return '';
|
|
98
|
+
return cut.body + (cut.link === undefined ? '' : hyperlink('')) + closingSequence(cut.active);
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=slice.js.map
|
package/dist/strip.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Everything a terminal would print, with the escape sequences removed: CSI (SGR and the
|
|
8
|
+
* cursor and erase forms), OSC including `OSC 8` hyperlinks under both terminators, DCS, the
|
|
9
|
+
* charset selections, and the single-character escapes. A lone `ESC` with nothing that parses
|
|
10
|
+
* after it is text and is kept — the same answer `strip-ansi` gives.
|
|
11
|
+
*
|
|
12
|
+
* Two passes, which is the design's prescription taken literally: *"using
|
|
13
|
+
* `util.stripVTControlCharacters` where it is exact and a local scan where it is not."*
|
|
14
|
+
*
|
|
15
|
+
* 1. The local scan, over `style.ts`'s `ANSI_ESCAPE`. It removes CSI and OSC **including
|
|
16
|
+
* the colon form** of an extended colour, which is the one shape Node gets wrong.
|
|
17
|
+
* 2. Node's stripper on what is left — the single-character escapes (`ESC c`), the charset
|
|
18
|
+
* selections (`ESC ( B`) and a truncated sequence at the end of a string. `ANSI_ESCAPE`
|
|
19
|
+
* matches none of those, because `wrap` never needed them: it was built to find the
|
|
20
|
+
* sequences it has to *reopen*, and a charset selection is not one.
|
|
21
|
+
*
|
|
22
|
+
* Order is load-bearing. Ours runs first so the colon form is already gone by the time Node
|
|
23
|
+
* sees the string; reversed, pass 2 would leave `:2::255:0:0m` behind as text and pass 1
|
|
24
|
+
* would have nothing left to match.
|
|
25
|
+
*/
|
|
26
|
+
export declare function strip(string: string): string;
|
package/dist/strip.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* R3 — remove the sequences a terminal consumes, and leave the text it prints.
|
|
8
|
+
*
|
|
9
|
+
* The design says to use `util.stripVTControlCharacters` "where it is exact and a local scan
|
|
10
|
+
* where it is not — measured, and the divergence recorded rather than assumed". Measured, on
|
|
11
|
+
* Node 24 over sixteen sequence shapes: **fifteen agree with `strip-ansi` and one does not.**
|
|
12
|
+
*
|
|
13
|
+
* ESC[38:2::255:0:0m node -> ":2::255:0:0m" strip-ansi -> ""
|
|
14
|
+
*
|
|
15
|
+
* That is the colon form of an extended colour (ITU T.416, which `chalk`, `wrap-ansi` and
|
|
16
|
+
* every 24-bit-colour library emit): Node's scanner stops at the first `:` and leaves the
|
|
17
|
+
* rest of the sequence in the output as text. One shape, and the one that matters most,
|
|
18
|
+
* because it is **not** a rare dialect — it is how a truecolor SGR is written when the
|
|
19
|
+
* sub-parameter form is used.
|
|
20
|
+
*
|
|
21
|
+
* It was a live bug here, not a theoretical one. `width.ts` called
|
|
22
|
+
* `stripVTControlCharacters`, so `width("ESC[38:2::255:0:0mred ESC[39m")` answered **15**
|
|
23
|
+
* where `string-width` answers **3** — and every caller that measures went with it: `wrap`,
|
|
24
|
+
* `slice`, `truncate`, `widest`, and in `flagstaff` the box, the table and the spinner.
|
|
25
|
+
*
|
|
26
|
+
* So the local scan is the whole implementation, over `style.ts`'s `ANSI_ESCAPE` — which has
|
|
27
|
+
* always handled the colon form, because `wrap` needs to reopen those colours across a row.
|
|
28
|
+
* The package understood the syntax in one module and mis-stripped it in another, which is
|
|
29
|
+
* precisely the duplication the consolidation exists to remove.
|
|
30
|
+
*/
|
|
31
|
+
import { stripVTControlCharacters as nodeStrip } from 'node:util';
|
|
32
|
+
import { forEachSegment } from './style.js';
|
|
33
|
+
/**
|
|
34
|
+
* Everything a terminal would print, with the escape sequences removed: CSI (SGR and the
|
|
35
|
+
* cursor and erase forms), OSC including `OSC 8` hyperlinks under both terminators, DCS, the
|
|
36
|
+
* charset selections, and the single-character escapes. A lone `ESC` with nothing that parses
|
|
37
|
+
* after it is text and is kept — the same answer `strip-ansi` gives.
|
|
38
|
+
*
|
|
39
|
+
* Two passes, which is the design's prescription taken literally: *"using
|
|
40
|
+
* `util.stripVTControlCharacters` where it is exact and a local scan where it is not."*
|
|
41
|
+
*
|
|
42
|
+
* 1. The local scan, over `style.ts`'s `ANSI_ESCAPE`. It removes CSI and OSC **including
|
|
43
|
+
* the colon form** of an extended colour, which is the one shape Node gets wrong.
|
|
44
|
+
* 2. Node's stripper on what is left — the single-character escapes (`ESC c`), the charset
|
|
45
|
+
* selections (`ESC ( B`) and a truncated sequence at the end of a string. `ANSI_ESCAPE`
|
|
46
|
+
* matches none of those, because `wrap` never needed them: it was built to find the
|
|
47
|
+
* sequences it has to *reopen*, and a charset selection is not one.
|
|
48
|
+
*
|
|
49
|
+
* Order is load-bearing. Ours runs first so the colon form is already gone by the time Node
|
|
50
|
+
* sees the string; reversed, pass 2 would leave `:2::255:0:0m` behind as text and pass 1
|
|
51
|
+
* would have nothing left to match.
|
|
52
|
+
*/
|
|
53
|
+
export function strip(string) {
|
|
54
|
+
if (string === '')
|
|
55
|
+
return '';
|
|
56
|
+
let out = '';
|
|
57
|
+
forEachSegment(string, (text) => {
|
|
58
|
+
out += text;
|
|
59
|
+
});
|
|
60
|
+
return nodeStrip(out);
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=strip.js.map
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The style stack — the part of this package that every cutting operation shares.
|
|
8
|
+
*
|
|
9
|
+
* `wrap`, `slice` and `truncate` are one algorithm wearing three names: walk the string,
|
|
10
|
+
* keep a stack of the SGR parameters a terminal currently has open, emit graphemes while
|
|
11
|
+
* inside the range you were asked for, and at every cut emit the closer for whatever is
|
|
12
|
+
* open and re-emit the openers on resume. The twelve incumbents write that three times
|
|
13
|
+
* between them — `wrap-ansi`, `slice-ansi` and `cli-truncate` each carry their own copy —
|
|
14
|
+
* and disagree at the edges, which is most of the reason this package exists.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `wrap.ts` when `slice` arrived, with wrap-ansi's own suite as the check:
|
|
17
|
+
* this module is a move, and `wrap.test.ts` grades every case against the real `wrap-ansi`,
|
|
18
|
+
* so a mistake in the extraction is a red suite rather than a subtle divergence.
|
|
19
|
+
*
|
|
20
|
+
* It is not a published subpath. `linegauge/wrap` and `linegauge/slice` each reach it, and
|
|
21
|
+
* R8 keeps them from reaching each other.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ESC = "\u001B";
|
|
24
|
+
export declare const BELL = "\u0007";
|
|
25
|
+
export declare const CSI = "[";
|
|
26
|
+
export declare const OSC = "]";
|
|
27
|
+
export declare const SGR_TERMINATOR = "m";
|
|
28
|
+
/** How many columns a tab advances to the next stop. */
|
|
29
|
+
export declare const TAB_SIZE = 8;
|
|
30
|
+
export declare const ESCAPES: Set<string>;
|
|
31
|
+
export declare const ANSI_ESCAPE: RegExp;
|
|
32
|
+
export declare const ROW_BOUNDARY: RegExp;
|
|
33
|
+
/** Every printable ASCII character is its own cluster of width one — skip the segmenter. */
|
|
34
|
+
export declare const ASCII_PRINTABLE: RegExp;
|
|
35
|
+
/**
|
|
36
|
+
* Which SGR code closes which modifier. This is ECMA-48, not any library's table — bold
|
|
37
|
+
* opens with 1 and closes with 22 wherever you read it — so it lives here rather than
|
|
38
|
+
* being imported, which keeps `wrap()` free of `roundel/chalk` and takes 18 KB off every
|
|
39
|
+
* subpath that wraps. The colour families close with 39, 49 and 59 and are handled by name
|
|
40
|
+
* above, before this map is consulted.
|
|
41
|
+
*/
|
|
42
|
+
export declare const MODIFIER_CLOSE: Map<number, number>;
|
|
43
|
+
export declare const MODIFIER_CLOSE_CODES: Set<number>;
|
|
44
|
+
export declare const segmenter: Intl.Segmenter;
|
|
45
|
+
export declare const sgr: (code: number | string) => string;
|
|
46
|
+
export declare const hyperlink: (url: string, parameters?: string) => string;
|
|
47
|
+
/** The complete escape sequence starting at `index`, or nothing when none starts there. */
|
|
48
|
+
export declare function matchEscape(string: string, index: number): RegExpExecArray | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Walk a string as alternating runs of plain text and complete escape sequences. A
|
|
51
|
+
* character that looks like an introducer but starts no valid sequence stays plain text.
|
|
52
|
+
*/
|
|
53
|
+
export declare function forEachSegment(string: string, onPlainText: (text: string) => void, onEscape?: (escape: string) => void): void;
|
|
54
|
+
export interface SgrToken {
|
|
55
|
+
code: number;
|
|
56
|
+
open: string;
|
|
57
|
+
hasArguments: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface ActiveStyle {
|
|
60
|
+
/** One slot per thing a terminal tracks separately, so a second red replaces the first. */
|
|
61
|
+
family: string;
|
|
62
|
+
open: string;
|
|
63
|
+
close: number;
|
|
64
|
+
}
|
|
65
|
+
export declare function sgrTokens(parameters: string): SgrToken[];
|
|
66
|
+
export declare function applyToken(token: SgrToken, active: ActiveStyle[]): void;
|
|
67
|
+
export declare const applyParameters: (parameters: string, active: ActiveStyle[]) => void;
|
|
68
|
+
/** A row that opens with its own resets should not have them undone by the reopening. */
|
|
69
|
+
export declare function applyLeadingResets(string: string, startIndex: number, active: ActiveStyle[]): void;
|
|
70
|
+
export declare const closingSequence: (active: ActiveStyle[]) => string;
|
|
71
|
+
export declare const openingSequence: (active: ActiveStyle[]) => string;
|