linegauge 0.1.0 → 0.3.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/README.md +159 -23
- package/dist/index.d.ts +14 -5
- package/dist/index.js +13 -4
- package/dist/slice.d.ts +18 -0
- package/dist/slice.js +109 -0
- package/dist/strip.d.ts +44 -0
- package/dist/strip.js +81 -0
- package/dist/style.d.ts +71 -0
- package/dist/style.js +287 -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 +35 -3
- package/dist/width.js +255 -23
- package/dist/wrap.d.ts +12 -0
- package/dist/wrap.js +13 -254
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,29 +1,165 @@
|
|
|
1
1
|
# linegauge
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
[`.sdlc/intents/linegauge/`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/linegauge/),
|
|
6
|
-
under the umbrella
|
|
7
|
-
[`cli-foundation-stack`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/cli-foundation-stack/),
|
|
8
|
-
and the measurements behind it are in
|
|
9
|
-
[`candidate-layers.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/candidate-layers.md)
|
|
10
|
-
and
|
|
11
|
-
[`replacement-map.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/replacement-map.md).
|
|
12
|
-
Both artifacts are `draft`; the human gate has not run, and no working release ships before
|
|
13
|
-
burgee's compatibility scoreboard is public.
|
|
14
|
-
|
|
15
|
-
A **line gauge** is the printer's ruler — the steel rule marked in picas and points that a compositor uses to measure a line of type and check it fits the measure it was set to.
|
|
16
|
-
|
|
17
|
-
That is this package's whole job. Width, wrap, truncate and slice are one problem wearing four names: **cutting styled text without letting the edge come apart** — no dangling escape sequence, no half a grapheme, no severed emoji cluster.
|
|
18
|
-
|
|
19
|
-
## What it will be
|
|
20
|
-
|
|
21
|
-
- **One package, not twelve.** `width` · `wrap` · `truncate` · `slice` · `strip` · `widest`. The incumbents split this across `strip-ansi`, `string-width`, `ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`, `eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — **2.16 B weekly downloads** between them.
|
|
22
|
-
- **Grapheme-correct by construction.** ZWJ families, regional-indicator flags, skin-tone modifiers, keycaps, combining marks and East Asian wide characters, over the platform's own `Intl.Segmenter`.
|
|
23
|
-
- **Fast path for ASCII.** A byte scan when the string has no non-ASCII code unit; the segmenter only when it earns its cost.
|
|
24
|
-
- **Drop-in paths** for `string-width`, `wrap-ansi`, `strip-ansi` and `slice-ansi`, graded by their own suites.
|
|
25
|
-
- **Zero external dependencies.**
|
|
3
|
+
**Measuring, wrapping, truncating and slicing styled terminal text — without the edge
|
|
4
|
+
fraying.**
|
|
26
5
|
|
|
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.
|
|
8
|
+
|
|
9
|
+
Zero dependencies. Grapheme-correct over the platform's own `Intl.Segmenter`.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i linegauge
|
|
13
|
+
```
|
|
14
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
## Plugins
|
|
104
|
+
|
|
105
|
+
**linegauge hosts no plugin key, and that is a decision rather than an omission.** Every
|
|
106
|
+
other package in the family hosts one — `tokens` in roundel, `spinners` and `borders` and
|
|
107
|
+
`glyphs` and `components` in flagstaff, `capabilities` in paratext, `sources` in seniority,
|
|
108
|
+
`handlers` in closeout, `resolvers` in bellpull, `widgets` in caique. Each of those keys sits
|
|
109
|
+
over a question with more than one right answer: which colour, which glyph, which terminal,
|
|
110
|
+
where configuration lives, how an executable is found. A plugin settles it for one program
|
|
111
|
+
without making anybody else wrong.
|
|
112
|
+
|
|
113
|
+
These six functions are not that kind of question. `width('古代')` is 4 because Unicode
|
|
114
|
+
classes those code points East Asian Wide and a terminal gives each of them two columns;
|
|
115
|
+
`slice` returns the columns it was asked for or it returns the wrong string. A plugin key
|
|
116
|
+
here would not extend what linegauge does — it would let a caller redefine what the terminal
|
|
117
|
+
does, silently, for everything above it. The failure would not even surface as an error: a
|
|
118
|
+
box comes out a column short, a table gains a phantom column, and nothing throws.
|
|
119
|
+
|
|
120
|
+
There is a second reason, and it is the one that decides it. This package's correctness is
|
|
121
|
+
differential — `width` is graded against `string-width`, `wrap` against `wrap-ansi`, `slice`
|
|
122
|
+
against `slice-ansi`, `truncate` against `cli-truncate`. A registered contribution would put
|
|
123
|
+
answers under the published pass rate that no grader ever saw, so the number would stop
|
|
124
|
+
meaning what it says.
|
|
125
|
+
|
|
126
|
+
The two things that genuinely vary are already handled without a registry:
|
|
127
|
+
|
|
128
|
+
- **The Unicode data.** The Wide and Fullwidth table is Unicode's, and cluster boundaries
|
|
129
|
+
come from the platform's `Intl.Segmenter`. When Unicode ships a version the table changes —
|
|
130
|
+
that is a release of this package, re-graded, not a registration a caller can make.
|
|
131
|
+
- **The environment.** How wide the terminal is, and whether there is one, are the caller's
|
|
132
|
+
to pass; nothing here reads `process`. That is a parameter, not a plugin.
|
|
133
|
+
|
|
134
|
+
The family's plugin contract records this refusal next to the other layers' keys (R5a), so
|
|
135
|
+
"no key" is one of the contract's answers rather than a hole in it. If a real second answer
|
|
136
|
+
ever arrives — an ambiguous-width policy some terminal actually needs — it lands as an option
|
|
137
|
+
with a differential test behind it, because the graders have to see it.
|
|
138
|
+
|
|
139
|
+
## Benchmarks
|
|
140
|
+
|
|
141
|
+
Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
|
|
142
|
+
|
|
143
|
+
Graded by the incumbent's own test suite:
|
|
144
|
+
|
|
145
|
+
| suite | passing |
|
|
146
|
+
| :-- | --: |
|
|
147
|
+
| `slice-ansi` | 15 / 15 ¹ |
|
|
148
|
+
| `string-width` | 229 / 229 |
|
|
149
|
+
| `strip-ansi` | 8 / 8 |
|
|
150
|
+
| `wrap-ansi` | 80 / 80 |
|
|
151
|
+
|
|
152
|
+
¹ A case the incumbent marks `test.failing()` — it cannot do the thing and says so in
|
|
153
|
+
its own suite — which this package passes. The runner reports that as a failure, because
|
|
154
|
+
to the incumbent an unexpected pass means a stale annotation; it is counted here as the
|
|
155
|
+
pass it is, and marked rather than left to look like the ones beside it.
|
|
156
|
+
|
|
157
|
+
Weight, installed and tree-inclusive: **83,538 bytes** against **170,342** for the incumbents it replaces — a ratio of **0.4904**.
|
|
158
|
+
## Where it sits
|
|
159
|
+
|
|
160
|
+
It hosts no plugin key of its own.
|
|
161
|
+
|
|
162
|
+
`burgee` and `flagstaff` build on it, and it builds on nothing in this family.
|
|
27
163
|
## Licence
|
|
28
164
|
|
|
29
165
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -15,10 +15,19 @@
|
|
|
15
15
|
* The default export is `width`, byte-for-byte call-compatible with `string-width`'s
|
|
16
16
|
* default (R8), so `overrides: { "string-width": "npm:linegauge@^1" }` resolves.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
* R2's fast path: locked by `differential.test.ts`.
|
|
22
27
|
*/
|
|
23
|
-
export { lineCount, measure, width, width as default } from './width.js';
|
|
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';
|
|
24
33
|
export { wrap, type WrapOptions } from './wrap.js';
|
package/dist/index.js
CHANGED
|
@@ -15,11 +15,20 @@
|
|
|
15
15
|
* The default export is `width`, byte-for-byte call-compatible with `string-width`'s
|
|
16
16
|
* default (R8), so `overrides: { "string-width": "npm:linegauge@^1" }` resolves.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
* R2's fast path: locked by `differential.test.ts`.
|
|
22
27
|
*/
|
|
23
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';
|
|
24
33
|
export { wrap } from './wrap.js';
|
|
25
34
|
//# sourceMappingURL=index.js.map
|
package/dist/slice.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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;
|
|
11
|
+
/**
|
|
12
|
+
* The default export, for the reason `strip.ts` gives at length: `slice-ansi`'s suite
|
|
13
|
+
* imports its entry point's **default**, and that suite now grades this file.
|
|
14
|
+
*
|
|
15
|
+
* The specifier is described rather than quoted on purpose — see the note on `wrap`'s
|
|
16
|
+
* default: `subpath-isolation.test.ts` reads the emitted text, comments and all.
|
|
17
|
+
*/
|
|
18
|
+
export { slice as default };
|
package/dist/slice.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
/**
|
|
101
|
+
* The default export, for the reason `strip.ts` gives at length: `slice-ansi`'s suite
|
|
102
|
+
* imports its entry point's **default**, and that suite now grades this file.
|
|
103
|
+
*
|
|
104
|
+
* The specifier is described rather than quoted on purpose — see the note on `wrap`'s
|
|
105
|
+
* default: `subpath-isolation.test.ts` reads the emitted text, comments and all.
|
|
106
|
+
*/
|
|
107
|
+
// eslint-disable-next-line import-next/no-default-export -- the incumbent's own suite imports a default; see above. This is the drop-in surface, not a style choice.
|
|
108
|
+
export { slice as default };
|
|
109
|
+
//# sourceMappingURL=slice.js.map
|
package/dist/strip.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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;
|
|
27
|
+
/**
|
|
28
|
+
* The same function again, as the default export, because `strip-ansi`'s own suite imports
|
|
29
|
+
* a default — and that suite is now this module's grader
|
|
30
|
+
* (`compat-oracle/vendor/strip-ansi`, `baseline/strip-ansi.json`).
|
|
31
|
+
*
|
|
32
|
+
* It is a *subpath* default rather than the package's, and it has to be: R8 spends the root
|
|
33
|
+
* default on `width`, so `overrides: { "string-width": "npm:linegauge@^1" }` resolves. A
|
|
34
|
+
* `strip-ansi` façade can therefore only ever be `linegauge/strip`, and this is the line
|
|
35
|
+
* that makes `import stripAnsi from 'linegauge/strip'` read exactly like the import it
|
|
36
|
+
* replaces. `truncate` and `widest` deliberately do not have one yet: an export is a
|
|
37
|
+
* contract forever, and neither has a vendored suite holding it to the incumbent's shape.
|
|
38
|
+
*
|
|
39
|
+
* Spelled `strip as default` rather than `export default strip` to match how `index.ts`
|
|
40
|
+
* publishes `width as default`: the alias is a live binding to the same declaration, so
|
|
41
|
+
* there is exactly one `strip` in the module however it is imported — which is the property
|
|
42
|
+
* `facade-defaults.test.ts` asserts with `toBe`, not `toEqual`.
|
|
43
|
+
*/
|
|
44
|
+
export { strip as default };
|
package/dist/strip.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
/**
|
|
63
|
+
* The same function again, as the default export, because `strip-ansi`'s own suite imports
|
|
64
|
+
* a default — and that suite is now this module's grader
|
|
65
|
+
* (`compat-oracle/vendor/strip-ansi`, `baseline/strip-ansi.json`).
|
|
66
|
+
*
|
|
67
|
+
* It is a *subpath* default rather than the package's, and it has to be: R8 spends the root
|
|
68
|
+
* default on `width`, so `overrides: { "string-width": "npm:linegauge@^1" }` resolves. A
|
|
69
|
+
* `strip-ansi` façade can therefore only ever be `linegauge/strip`, and this is the line
|
|
70
|
+
* that makes `import stripAnsi from 'linegauge/strip'` read exactly like the import it
|
|
71
|
+
* replaces. `truncate` and `widest` deliberately do not have one yet: an export is a
|
|
72
|
+
* contract forever, and neither has a vendored suite holding it to the incumbent's shape.
|
|
73
|
+
*
|
|
74
|
+
* Spelled `strip as default` rather than `export default strip` to match how `index.ts`
|
|
75
|
+
* publishes `width as default`: the alias is a live binding to the same declaration, so
|
|
76
|
+
* there is exactly one `strip` in the module however it is imported — which is the property
|
|
77
|
+
* `facade-defaults.test.ts` asserts with `toBe`, not `toEqual`.
|
|
78
|
+
*/
|
|
79
|
+
// eslint-disable-next-line import-next/no-default-export -- the incumbent's own suite imports a default; see above. This is the drop-in surface, not a style choice.
|
|
80
|
+
export { strip as default };
|
|
81
|
+
//# 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;
|