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 CHANGED
@@ -1,29 +1,165 @@
1
1
  # linegauge
2
2
 
3
- **Not yet released.** This version reserves the name. The layer is planned as wave **F1** of the
4
- foundation tier: its intent and design are at
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
- * Not here yet, and each still at the Design→Build gate: `slice`, `truncate`, `widest`,
19
- * the R2 ASCII fast path, and an exported `strip`. This release is the move, so that a
20
- * green `flagstaff` across the deletion proves the consolidation is real before anything
21
- * new is written on top of it.
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
- * Not here yet, and each still at the Design→Build gate: `slice`, `truncate`, `widest`,
19
- * the R2 ASCII fast path, and an exported `strip`. This release is the move, so that a
20
- * green `flagstaff` across the deletion proves the consolidation is real before anything
21
- * new is written on top of it.
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
@@ -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
@@ -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
@@ -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;