linegauge 0.3.1 → 0.3.3
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/dist/index.js +0 -28
- package/dist/slice.js +1 -53
- package/dist/strip.js +0 -69
- package/dist/style.d.ts +1 -1
- package/dist/style.js +2 -59
- package/dist/truncate.js +0 -22
- package/dist/widest.js +0 -15
- package/dist/width.js +22 -160
- package/dist/wrap.js +1 -59
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,34 +1,6 @@
|
|
|
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
|
-
* 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
|
-
* R2's fast path: locked by `differential.test.ts`.
|
|
27
|
-
*/
|
|
28
1
|
export { lineCount, measure, width, width as default } from './width.js';
|
|
29
2
|
export { slice } from './slice.js';
|
|
30
3
|
export { strip } from './strip.js';
|
|
31
4
|
export { truncate } from './truncate.js';
|
|
32
5
|
export { widest } from './widest.js';
|
|
33
6
|
export { wrap } from './wrap.js';
|
|
34
|
-
//# sourceMappingURL=index.js.map
|
package/dist/slice.js
CHANGED
|
@@ -1,36 +1,5 @@
|
|
|
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
1
|
import { applyParameters, closingSequence, hyperlink, matchEscape, openingSequence, segmenter } from './style.js';
|
|
25
2
|
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
3
|
function open(cut) {
|
|
35
4
|
if (cut.started)
|
|
36
5
|
return;
|
|
@@ -39,11 +8,6 @@ function open(cut) {
|
|
|
39
8
|
if (cut.link !== undefined)
|
|
40
9
|
cut.body += hyperlink(cut.link.uri, cut.link.parameters);
|
|
41
10
|
}
|
|
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
11
|
function takeEscape(cut, escape, end) {
|
|
48
12
|
const groups = escape.groups ?? {};
|
|
49
13
|
if (groups['sgr'] !== undefined)
|
|
@@ -53,12 +17,9 @@ function takeEscape(cut, escape, end) {
|
|
|
53
17
|
if (cut.started && cut.column < end)
|
|
54
18
|
cut.body += escape[0];
|
|
55
19
|
}
|
|
56
|
-
/** One run of plain text, cluster by cluster. Returns true when the range has been filled. */
|
|
57
20
|
function takeText(cut, run, start, end) {
|
|
58
|
-
for (const { segment } of segmenter.segment(run)) {
|
|
21
|
+
for (const { segment } of segmenter().segment(run)) {
|
|
59
22
|
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
23
|
if (cut.column + Math.max(columns, 1) > start && cut.column < end) {
|
|
63
24
|
open(cut);
|
|
64
25
|
cut.body += segment;
|
|
@@ -69,10 +30,6 @@ function takeText(cut, run, start, end) {
|
|
|
69
30
|
}
|
|
70
31
|
return cut.column >= end;
|
|
71
32
|
}
|
|
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
33
|
export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
|
|
77
34
|
if (end <= start || string.length === 0)
|
|
78
35
|
return '';
|
|
@@ -97,13 +54,4 @@ export function slice(string, start = 0, end = Number.POSITIVE_INFINITY) {
|
|
|
97
54
|
return '';
|
|
98
55
|
return cut.body + (cut.link === undefined ? '' : hyperlink('')) + closingSequence(cut.active);
|
|
99
56
|
}
|
|
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
57
|
export { slice as default };
|
|
109
|
-
//# sourceMappingURL=slice.js.map
|
package/dist/strip.js
CHANGED
|
@@ -1,55 +1,5 @@
|
|
|
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
1
|
import { stripVTControlCharacters as nodeStrip } from 'node:util';
|
|
32
2
|
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
3
|
export function strip(string) {
|
|
54
4
|
if (string === '')
|
|
55
5
|
return '';
|
|
@@ -59,23 +9,4 @@ export function strip(string) {
|
|
|
59
9
|
});
|
|
60
10
|
return nodeStrip(out);
|
|
61
11
|
}
|
|
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
12
|
export { strip as default };
|
|
81
|
-
//# sourceMappingURL=strip.js.map
|
package/dist/style.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export declare const ASCII_PRINTABLE: RegExp;
|
|
|
41
41
|
*/
|
|
42
42
|
export declare const MODIFIER_CLOSE: Map<number, number>;
|
|
43
43
|
export declare const MODIFIER_CLOSE_CODES: Set<number>;
|
|
44
|
-
export declare const segmenter: Intl.Segmenter;
|
|
44
|
+
export declare const segmenter: () => Intl.Segmenter;
|
|
45
45
|
export declare const sgr: (code: number | string) => string;
|
|
46
46
|
export declare const hyperlink: (url: string, parameters?: string) => string;
|
|
47
47
|
/** The complete escape sequence starting at `index`, or nothing when none starts there. */
|
package/dist/style.js
CHANGED
|
@@ -1,28 +1,5 @@
|
|
|
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
1
|
export const ESC = '\u001B';
|
|
24
2
|
export const BELL = '\u0007';
|
|
25
|
-
/** The single-byte C1 form of `ESC [`, which a terminal accepts and a suite will send. */
|
|
26
3
|
const C1_CSI = '\u009B';
|
|
27
4
|
export const CSI = '[';
|
|
28
5
|
export const OSC = ']';
|
|
@@ -44,13 +21,9 @@ const BACKGROUND_FIRST = 40;
|
|
|
44
21
|
const BACKGROUND_LAST = 47;
|
|
45
22
|
const BACKGROUND_BRIGHT_FIRST = 100;
|
|
46
23
|
const BACKGROUND_BRIGHT_LAST = 107;
|
|
47
|
-
/** How many columns a tab advances to the next stop. */
|
|
48
24
|
export const TAB_SIZE = 8;
|
|
49
|
-
/** `38;5;n` — the code, the mode, and one index. */
|
|
50
25
|
const COLOR_256_PARTS = 3;
|
|
51
|
-
/** `38;2;r;g;b` — the code, the mode, and three components. */
|
|
52
26
|
const COLOR_RGB_PARTS = 3;
|
|
53
|
-
/** `38:2::r:g:b` carries a colour space between the mode and the components. */
|
|
54
27
|
const COLON_RGB_WITH_SPACE = 6;
|
|
55
28
|
export const ESCAPES = new Set([ESC, C1_CSI]);
|
|
56
29
|
const ESCAPE_CHARACTERS = [...ESCAPES].join('');
|
|
@@ -59,24 +32,11 @@ const CSI_PARAMETERS = '[0-?]*[ -/]*[@-~]';
|
|
|
59
32
|
const SGR_PARAMETERS = `(?<sgr>[0-9;:]*)${SGR_TERMINATOR}`;
|
|
60
33
|
const OSC_TERMINATOR = `(?:${BELL}|${ESC}\\\\)`;
|
|
61
34
|
const OSC_PAYLOAD = String.raw `[^\u0000-\u001F\u007F-\u009F]*`;
|
|
62
|
-
/** `OSC 8 ; params ; URI ST` — a hyperlink, whose URI is tracked so a row can reopen it. */
|
|
63
35
|
const LINK_PARAMETERS = String.raw `8;(?<parameters>[^;\u0000-\u001F\u007F-\u009F]*);(?<uri>${OSC_PAYLOAD})${OSC_TERMINATOR}`;
|
|
64
|
-
// Deliberately not a terminal emulator: semicolon-delimited SGR, colon-delimited extended
|
|
65
|
-
// colour and OSC 8 links are understood; every other complete CSI or OSC command is carried
|
|
66
|
-
// through as an opaque zero-width unit, and anything that only looks like an introducer
|
|
67
|
-
// stays plain text. `y` (sticky), so a match is anchored where the scan asked.
|
|
68
36
|
export const ANSI_ESCAPE = new RegExp(`${CSI_INTRODUCER}(?:${SGR_PARAMETERS}|${CSI_PARAMETERS})|${ESC}\\${OSC}(?:${LINK_PARAMETERS}|${OSC_PAYLOAD}${OSC_TERMINATOR})`, 'y');
|
|
69
37
|
const ESCAPE_INTRODUCER = new RegExp(`[${ESCAPE_CHARACTERS}]`, 'g');
|
|
70
38
|
export const ROW_BOUNDARY = new RegExp(`[\\n${ESCAPE_CHARACTERS}]`, 'g');
|
|
71
|
-
/** Every printable ASCII character is its own cluster of width one — skip the segmenter. */
|
|
72
39
|
export const ASCII_PRINTABLE = /^[ -~]*$/;
|
|
73
|
-
/**
|
|
74
|
-
* Which SGR code closes which modifier. This is ECMA-48, not any library's table — bold
|
|
75
|
-
* opens with 1 and closes with 22 wherever you read it — so it lives here rather than
|
|
76
|
-
* being imported, which keeps `wrap()` free of `roundel/chalk` and takes 18 KB off every
|
|
77
|
-
* subpath that wraps. The colour families close with 39, 49 and 59 and are handled by name
|
|
78
|
-
* above, before this map is consulted.
|
|
79
|
-
*/
|
|
80
40
|
export const MODIFIER_CLOSE = new Map([
|
|
81
41
|
[1, 22],
|
|
82
42
|
[2, 22],
|
|
@@ -88,20 +48,16 @@ export const MODIFIER_CLOSE = new Map([
|
|
|
88
48
|
[53, 55],
|
|
89
49
|
]);
|
|
90
50
|
export const MODIFIER_CLOSE_CODES = new Set(MODIFIER_CLOSE.values());
|
|
91
|
-
|
|
51
|
+
let cached;
|
|
52
|
+
export const segmenter = () => (cached ??= new Intl.Segmenter());
|
|
92
53
|
export const sgr = (code) => `${ESC}${CSI}${code}${SGR_TERMINATOR}`;
|
|
93
54
|
export const hyperlink = (url, parameters = '') => `${ESC}${OSC}8;${parameters};${url}${BELL}`;
|
|
94
|
-
/** The complete escape sequence starting at `index`, or nothing when none starts there. */
|
|
95
55
|
export function matchEscape(string, index) {
|
|
96
56
|
if (!ESCAPES.has(string[index] ?? ''))
|
|
97
57
|
return undefined;
|
|
98
58
|
ANSI_ESCAPE.lastIndex = index;
|
|
99
59
|
return ANSI_ESCAPE.exec(string) ?? undefined;
|
|
100
60
|
}
|
|
101
|
-
/**
|
|
102
|
-
* Walk a string as alternating runs of plain text and complete escape sequences. A
|
|
103
|
-
* character that looks like an introducer but starts no valid sequence stays plain text.
|
|
104
|
-
*/
|
|
105
61
|
export function forEachSegment(string, onPlainText, onEscape = () => undefined) {
|
|
106
62
|
let plainStart = 0;
|
|
107
63
|
let index = 0;
|
|
@@ -125,7 +81,6 @@ export function forEachSegment(string, onPlainText, onEscape = () => undefined)
|
|
|
125
81
|
onPlainText(string.slice(plainStart));
|
|
126
82
|
}
|
|
127
83
|
const isDigits = (value) => /^\d+$/.test(value);
|
|
128
|
-
/** `38:5:9` and `38:2::r:g:b` — the colon form, which carries its arguments in one parameter. */
|
|
129
84
|
function colonColorToken(parameter) {
|
|
130
85
|
const parts = parameter.split(':');
|
|
131
86
|
const code = Number.parseInt(parts[0] ?? '', 10);
|
|
@@ -145,7 +100,6 @@ function colonColorToken(parameter) {
|
|
|
145
100
|
}
|
|
146
101
|
return undefined;
|
|
147
102
|
}
|
|
148
|
-
/** One extended-colour parameter run, `38;5;n` or `38;2;r;g;b`, or nothing if malformed. */
|
|
149
103
|
function extendedColorToken(code, parameters, index) {
|
|
150
104
|
const mode = Number.parseInt(parameters[index + 1] ?? '', 10);
|
|
151
105
|
const first = Number.parseInt(parameters[index + 2] ?? '', 10);
|
|
@@ -206,7 +160,6 @@ function colorStyle(token) {
|
|
|
206
160
|
}
|
|
207
161
|
return undefined;
|
|
208
162
|
}
|
|
209
|
-
/** True when the code closed something rather than opening it. */
|
|
210
163
|
function applyResetCode(code, active) {
|
|
211
164
|
if (code === SGR_RESET) {
|
|
212
165
|
active.length = 0;
|
|
@@ -225,7 +178,6 @@ function applyResetCode(code, active) {
|
|
|
225
178
|
return true;
|
|
226
179
|
}
|
|
227
180
|
if (MODIFIER_CLOSE_CODES.has(code)) {
|
|
228
|
-
// One close code can end several modifiers — `22` ends both bold and dim.
|
|
229
181
|
for (let index = active.length - 1; index >= 0; index -= 1) {
|
|
230
182
|
const style = active[index];
|
|
231
183
|
if (style !== undefined && style.family.startsWith('modifier-') && style.close === code)
|
|
@@ -251,13 +203,6 @@ export function applyToken(token, active) {
|
|
|
251
203
|
active.push({ family, open: token.open, close });
|
|
252
204
|
return;
|
|
253
205
|
}
|
|
254
|
-
// An SGR parameter this file has no close code for — `ESC[20m`, `ESC[1001m`. It used to
|
|
255
|
-
// be dropped here, which is the one failure on the `slice-ansi` row that was ours
|
|
256
|
-
// (`spec.md` § R10 category E): the text survived a cut and its style did not, silently.
|
|
257
|
-
// The sequence is the caller's, not this library's to vet, so it is carried through and
|
|
258
|
-
// reopened like any other style. `SGR_RESET` is its closer because it is the only one
|
|
259
|
-
// that is correct for a parameter whose meaning is unknown — there is nothing to derive a
|
|
260
|
-
// narrower close from — and it is what `slice-ansi` emits for the same input.
|
|
261
206
|
const family = `unknown-${token.open}`;
|
|
262
207
|
removeFamily(active, family);
|
|
263
208
|
active.push({ family, open: token.open, close: SGR_RESET });
|
|
@@ -270,7 +215,6 @@ const applyResets = (parameters, active) => {
|
|
|
270
215
|
for (const { code } of sgrTokens(parameters))
|
|
271
216
|
applyResetCode(code, active);
|
|
272
217
|
};
|
|
273
|
-
/** A row that opens with its own resets should not have them undone by the reopening. */
|
|
274
218
|
export function applyLeadingResets(string, startIndex, active) {
|
|
275
219
|
let index = startIndex;
|
|
276
220
|
while (index < string.length) {
|
|
@@ -284,4 +228,3 @@ export function applyLeadingResets(string, startIndex, active) {
|
|
|
284
228
|
}
|
|
285
229
|
export const closingSequence = (active) => [...active].reverse().map((style) => sgr(style.close)).join('');
|
|
286
230
|
export const openingSequence = (active) => active.map((style) => sgr(style.open)).join('');
|
|
287
|
-
//# sourceMappingURL=style.js.map
|
package/dist/truncate.js
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
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
|
-
* R6 — shorten a styled string to `columns` display columns, with the ellipsis **inside**
|
|
8
|
-
* the budget.
|
|
9
|
-
*
|
|
10
|
-
* That last clause is the whole value and the off-by-one every hand-rolled truncator gets
|
|
11
|
-
* wrong: `truncate(s, 10)` must return something ten columns wide, not ten columns plus an
|
|
12
|
-
* ellipsis. `cli-truncate` exists as a package (36 M/wk) essentially to get this right, and
|
|
13
|
-
* pays `slice-ansi` and `string-width` to do it.
|
|
14
|
-
*
|
|
15
|
-
* The ellipsis is measured with `width`, not assumed to be one column: a caller who passes
|
|
16
|
-
* `"..."` has spent three, and a caller who passes an emoji has spent two.
|
|
17
|
-
*/
|
|
18
1
|
import { slice } from './slice.js';
|
|
19
2
|
import { width } from './width.js';
|
|
20
3
|
export function truncate(string, columns, options = {}) {
|
|
@@ -25,8 +8,6 @@ export function truncate(string, columns, options = {}) {
|
|
|
25
8
|
if (total <= columns)
|
|
26
9
|
return string;
|
|
27
10
|
const mark = width(ellipsis);
|
|
28
|
-
// No room for both. The ellipsis alone is the most informative thing that fits, and when
|
|
29
|
-
// even that does not fit the honest answer is nothing rather than a cut-up ellipsis.
|
|
30
11
|
if (mark >= columns)
|
|
31
12
|
return mark === columns ? ellipsis : '';
|
|
32
13
|
const keep = columns - mark;
|
|
@@ -34,9 +15,6 @@ export function truncate(string, columns, options = {}) {
|
|
|
34
15
|
return ellipsis + slice(string, total - keep);
|
|
35
16
|
if (position === 'end')
|
|
36
17
|
return slice(string, 0, keep) + ellipsis;
|
|
37
|
-
// Middle: the left half rounds up, so an odd budget spends its extra column on the text
|
|
38
|
-
// the reader meets first.
|
|
39
18
|
const left = Math.ceil(keep / 2);
|
|
40
19
|
return slice(string, 0, left) + ellipsis + slice(string, total - (keep - left));
|
|
41
20
|
}
|
|
42
|
-
//# sourceMappingURL=truncate.js.map
|
package/dist/widest.js
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
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
|
-
* R7 — the widest of many lines, in one pass, allocating nothing per line.
|
|
8
|
-
*
|
|
9
|
-
* Every caller that draws a box or a table needs this, and every one of them writes
|
|
10
|
-
* `Math.max(...lines.map(width))` — which builds an array it throws away and blows the call
|
|
11
|
-
* stack on a large enough table, because a spread is an argument list and V8 stops somewhere
|
|
12
|
-
* around 125,000. Taking an `Iterable` also means a generator works, so a caller measuring a
|
|
13
|
-
* file does not have to hold it.
|
|
14
|
-
*/
|
|
15
1
|
import { width } from './width.js';
|
|
16
2
|
export function widest(lines) {
|
|
17
3
|
let max = 0;
|
|
@@ -22,4 +8,3 @@ export function widest(lines) {
|
|
|
22
8
|
}
|
|
23
9
|
return max;
|
|
24
10
|
}
|
|
25
|
-
//# sourceMappingURL=widest.js.map
|
package/dist/width.js
CHANGED
|
@@ -1,34 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Display width of a string in terminal columns (R7).
|
|
3
|
-
*
|
|
4
|
-
* A column count is the one measurement the output stack cannot avoid: a spinner has to
|
|
5
|
-
* know how many lines its frame occupied before it can erase them, and a box has to know
|
|
6
|
-
* where its right edge falls. `string-width` does this in four packages; here it is one
|
|
7
|
-
* function over `Intl.Segmenter` and a range table, because a package this size is not
|
|
8
|
-
* worth a dependency tree (U5).
|
|
9
|
-
*
|
|
10
|
-
* The rules, in the order a cluster meets them:
|
|
11
|
-
* 1. ANSI and other control sequences are not printed — `strip()`, which is a local scan
|
|
12
|
-
* rather than `util.stripVTControlCharacters`: that one leaves the colon form of an
|
|
13
|
-
* extended colour behind, and this function answered 15 for a three-column string
|
|
14
|
-
* because of it. See `strip.ts` for the measurement.
|
|
15
|
-
* 2. A grapheme cluster made only of ignorable, control, mark or surrogate code points
|
|
16
|
-
* occupies no column.
|
|
17
|
-
* 3. An RGI emoji sequence is two columns, however many code points it is made of.
|
|
18
|
-
* 4. Otherwise the East Asian Width of the cluster's first visible code point, plus the
|
|
19
|
-
* halfwidth-and-fullwidth forms trailing it in the same cluster (a dakuten).
|
|
20
|
-
*
|
|
21
|
-
* Ambiguous-width characters are counted narrow, which is what a terminal does unless it
|
|
22
|
-
* has been told it is rendering an East Asian locale. `string-width` makes that an option;
|
|
23
|
-
* nothing above this function has ever needed the other answer, so it is not one here.
|
|
24
|
-
*/
|
|
25
1
|
import { strip } from './strip.js';
|
|
26
|
-
/**
|
|
27
|
-
* East Asian Wide and Fullwidth, as sorted `[low, high]` pairs flattened into one array —
|
|
28
|
-
* Unicode 17's W and F categories, merged where they touch. Generated from the same
|
|
29
|
-
* `EastAsianWidth.txt` derivation everyone uses; a binary search over 122 ranges is the
|
|
30
|
-
* whole lookup.
|
|
31
|
-
*/
|
|
32
2
|
const WIDE = [
|
|
33
3
|
0x1100, 0x115F, 0x231A, 0x231B, 0x2329, 0x232A, 0x23E9, 0x23EC, 0x23F0, 0x23F0,
|
|
34
4
|
0x23F3, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615, 0x2630, 0x2637, 0x2648, 0x2653,
|
|
@@ -58,18 +28,7 @@ const WIDE = [
|
|
|
58
28
|
];
|
|
59
29
|
const NARROW = 1;
|
|
60
30
|
const WIDE_COLUMNS = 2;
|
|
61
|
-
/** Half the flat array is lows, so a step over pairs. */
|
|
62
31
|
const PAIR = 2;
|
|
63
|
-
/**
|
|
64
|
-
* East Asian **Ambiguous** — the characters a terminal renders one column wide in a Latin
|
|
65
|
-
* context and two in a CJK one. `±`, `×`, `÷`, the box-drawing set, Greek and Cyrillic.
|
|
66
|
-
*
|
|
67
|
-
* Unlike WIDE above, this table is **generated**, by `scripts/generate-ambiguous.mjs`: 179
|
|
68
|
-
* ranges is past the size where transcribing a text file by hand is honest work. The sweep
|
|
69
|
-
* reads `get-east-asian-width`, already a devDependency because the suite grades against it,
|
|
70
|
-
* and the result is committed — no dependency at run time, and a Unicode update produces a
|
|
71
|
-
* reviewable diff rather than a silent drift. `--check` fails when the two disagree.
|
|
72
|
-
*/
|
|
73
32
|
const AMBIGUOUS = [
|
|
74
33
|
0x00A1, 0x00A1, 0x00A4, 0x00A4, 0x00A7, 0x00A8, 0x00AA, 0x00AA, 0x00AD, 0x00AE,
|
|
75
34
|
0x00B0, 0x00B4, 0x00B6, 0x00BA, 0x00BC, 0x00BF, 0x00C6, 0x00C6, 0x00D0, 0x00D0,
|
|
@@ -108,7 +67,6 @@ const AMBIGUOUS = [
|
|
|
108
67
|
0x1F100, 0x1F10A, 0x1F110, 0x1F12D, 0x1F130, 0x1F169, 0x1F170, 0x1F18D, 0x1F18F, 0x1F190,
|
|
109
68
|
0x1F19B, 0x1F1AC, 0xE0100, 0xE01EF, 0xF0000, 0xFFFFD, 0x100000, 0x10FFFD,
|
|
110
69
|
];
|
|
111
|
-
/** Binary search over a flat `[low, high]` table. Both tables are laid out for this. */
|
|
112
70
|
function inTable(table, codePoint) {
|
|
113
71
|
let low = 0;
|
|
114
72
|
let high = table.length / PAIR - 1;
|
|
@@ -131,86 +89,43 @@ function isWide(codePoint) {
|
|
|
131
89
|
function isAmbiguous(codePoint) {
|
|
132
90
|
return inTable(AMBIGUOUS, codePoint);
|
|
133
91
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
* bug that stays invisible until a box comes out a column short.
|
|
148
|
-
*/
|
|
149
|
-
const ZERO_WIDTH_CLUSTER = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/v;
|
|
150
|
-
const LEADING_NON_PRINTING = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/v;
|
|
151
|
-
const RGI_EMOJI = /^\p{RGI_Emoji}$/v;
|
|
152
|
-
const SPACING_MARK = /^\p{Spacing_Mark}$/v;
|
|
153
|
-
const EXTENDED_PICTOGRAPHIC = /^\p{Extended_Pictographic}$/u;
|
|
154
|
-
/**
|
|
155
|
-
* An **unqualified keycap**: the base, then `U+20E3`, with the `U+FE0F` that would have made
|
|
156
|
-
* it fully qualified missing. The base class is explicit — a digit, `#` or `*` — because
|
|
157
|
-
* `U+260E U+FE0F U+20E3` is *not* a keycap, and `string-width`'s suite grades that too.
|
|
158
|
-
*/
|
|
92
|
+
let zeroWidthClass;
|
|
93
|
+
let leadingClass;
|
|
94
|
+
let rgiClass;
|
|
95
|
+
let spacingClass;
|
|
96
|
+
let pictographicClass;
|
|
97
|
+
const INVISIBLE_CLASSES = ['\\p{Default_Ignorable_Code_Point}', '\\p{Control}', '\\p{Format}', '\\p{Nonspacing_Mark}', '\\p{Enclosing_Mark}', '\\p{Surrogate}'];
|
|
98
|
+
const INVISIBLE_ALTERNATION = INVISIBLE_CLASSES.join('|');
|
|
99
|
+
const INVISIBLE_SET = INVISIBLE_CLASSES.join('');
|
|
100
|
+
const ZERO_WIDTH_CLUSTER = () => (zeroWidthClass ??= new RegExp(`^(?:${INVISIBLE_ALTERNATION})+$`, 'v'));
|
|
101
|
+
const LEADING_NON_PRINTING = () => (leadingClass ??= new RegExp(`^[${INVISIBLE_SET}]+`, 'v'));
|
|
102
|
+
const RGI_EMOJI = () => (rgiClass ??= new RegExp('^\\p{RGI_Emoji}$', 'v'));
|
|
103
|
+
const SPACING_MARK = () => (spacingClass ??= new RegExp('^\\p{Spacing_Mark}$', 'v'));
|
|
104
|
+
const EXTENDED_PICTOGRAPHIC = () => (pictographicClass ??= new RegExp('^\\p{Extended_Pictographic}$', 'u'));
|
|
159
105
|
const UNQUALIFIED_KEYCAP = /^[\d#*]\u20E3$/u;
|
|
160
106
|
const ZWJ = '\u200D';
|
|
161
|
-
/** Two pictographs is what separates an emoji ZWJ sequence from an Indic conjunct. */
|
|
162
107
|
const EMOJI_ZWJ_PICTOGRAPHS = 2;
|
|
163
|
-
/**
|
|
164
|
-
* Longest cluster worth testing for an emoji sequence. Real ones run well under thirty code
|
|
165
|
-
* units; the cap is here so a pathological cluster cannot turn a width call into a scan.
|
|
166
|
-
*/
|
|
167
108
|
const EMOJI_SCAN_LIMIT = 50;
|
|
168
|
-
/** The Halfwidth and Fullwidth Forms block, which a cluster can carry after its base. */
|
|
169
109
|
const FORMS_FIRST = 0xff00;
|
|
170
110
|
const FORMS_LAST = 0xffef;
|
|
171
|
-
/**
|
|
172
|
-
* Conjoining jamo, in the three classes modern Hangul composes from: leading (L), vowel (V)
|
|
173
|
-
* and trailing (T). Each has an archaic extension block beside the main one.
|
|
174
|
-
*/
|
|
175
111
|
const JAMO_LEADING = [0x1100, 0x115f, 0xa960, 0xa97c];
|
|
176
112
|
const JAMO_VOWEL = [0x1160, 0x11a7, 0xd7b0, 0xd7c6];
|
|
177
113
|
const JAMO_TRAILING = [0x11a8, 0x11ff, 0xd7cb, 0xd7fb];
|
|
178
|
-
|
|
179
|
-
|
|
114
|
+
let cached;
|
|
115
|
+
const segmenter = () => (cached ??= new Intl.Segmenter());
|
|
180
116
|
function columnsOf(codePoint, ambiguousIsWide) {
|
|
181
117
|
return isWide(codePoint) || (ambiguousIsWide && isAmbiguous(codePoint)) ? WIDE_COLUMNS : NARROW;
|
|
182
118
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Columns a cluster's trailing **spacing marks and fullwidth forms** add. `ガ` is a base
|
|
185
|
-
* plus a wide mark; `क` + `ा` is a base plus a spacing mark. Both advance the cursor past
|
|
186
|
-
* the base, and neither is a separate cluster, so neither can be counted anywhere else.
|
|
187
|
-
*
|
|
188
|
-
* The walk is over the *visible* cluster — what is left after the leading non-printing run
|
|
189
|
-
* has been removed — so that the character being skipped as "the base" is the base, not a
|
|
190
|
-
* format character standing in front of it.
|
|
191
|
-
*/
|
|
192
119
|
function trailingColumns(visible, ambiguousIsWide) {
|
|
193
120
|
let extra = 0;
|
|
194
121
|
for (const character of [...visible].slice(1)) {
|
|
195
122
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
196
123
|
const isForm = codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST;
|
|
197
|
-
if (isForm || SPACING_MARK.test(character))
|
|
124
|
+
if (isForm || SPACING_MARK().test(character))
|
|
198
125
|
extra += columnsOf(codePoint, ambiguousIsWide);
|
|
199
126
|
}
|
|
200
127
|
return extra;
|
|
201
128
|
}
|
|
202
|
-
/**
|
|
203
|
-
* Whether a cluster is an emoji sequence that `\p{RGI_Emoji}` refuses because it is
|
|
204
|
-
* **minimally qualified or unqualified** — the same sequence with its `U+FE0F` left off.
|
|
205
|
-
* A terminal renders `U+2764 ZWJ U+1F525` as one two-column emoji whether or not the
|
|
206
|
-
* variation selector is there; the regex only matches the fully-qualified spelling.
|
|
207
|
-
*
|
|
208
|
-
* Two shapes, and both need their guard. A ZWJ sequence counts only when **two or more**
|
|
209
|
-
* pictographs are joined, which is what keeps `क् ZWJ ष` — an Indic conjunct using the same
|
|
210
|
-
* joiner — one column. A keycap counts only over a digit, `#` or `*`, which is what keeps
|
|
211
|
-
* the invalid `U+260E U+FE0F U+20E3` one column. Both of those are cases in the suite that
|
|
212
|
-
* grades this file, and both passed before this function existed.
|
|
213
|
-
*/
|
|
214
129
|
function isUnqualifiedEmojiSequence(cluster) {
|
|
215
130
|
if (cluster.length > EMOJI_SCAN_LIMIT)
|
|
216
131
|
return false;
|
|
@@ -220,14 +135,13 @@ function isUnqualifiedEmojiSequence(cluster) {
|
|
|
220
135
|
return false;
|
|
221
136
|
let pictographs = 0;
|
|
222
137
|
for (const character of cluster) {
|
|
223
|
-
if (EXTENDED_PICTOGRAPHIC.test(character))
|
|
138
|
+
if (EXTENDED_PICTOGRAPHIC().test(character))
|
|
224
139
|
pictographs += 1;
|
|
225
140
|
if (pictographs >= EMOJI_ZWJ_PICTOGRAPHS)
|
|
226
141
|
return true;
|
|
227
142
|
}
|
|
228
143
|
return false;
|
|
229
144
|
}
|
|
230
|
-
/** Whether `codePoint` falls in one of a flat `[low, high]` pair list. */
|
|
231
145
|
function inPairs(pairs, codePoint) {
|
|
232
146
|
for (let i = 0; i < pairs.length; i += PAIR) {
|
|
233
147
|
if (codePoint >= (pairs[i] ?? 0) && codePoint <= (pairs[i + 1] ?? 0))
|
|
@@ -238,25 +152,10 @@ function inPairs(pairs, codePoint) {
|
|
|
238
152
|
function isJamo(codePoint) {
|
|
239
153
|
return inPairs(JAMO_LEADING, codePoint) || inPairs(JAMO_VOWEL, codePoint) || inPairs(JAMO_TRAILING, codePoint);
|
|
240
154
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Columns a cluster of **conjoining Hangul jamo** occupies, or `undefined` when the cluster
|
|
243
|
-
* does not start with one — in which case the caller's ordinary base-scalar path is right.
|
|
244
|
-
*
|
|
245
|
-
* This is the one category that needs a walk rather than a table lookup. `Intl.Segmenter`
|
|
246
|
-
* joins a whole run of jamo into a single grapheme cluster (GB6, GB7, GB8), so measuring the
|
|
247
|
-
* cluster by its first code point answered **2** for a six-jamo run a terminal draws **12**
|
|
248
|
-
* columns wide. Modern Hangul composes L + V, or L + V + T, into one syllable block two
|
|
249
|
-
* columns wide; jamo that find no partner stay additive at their own East Asian Width, which
|
|
250
|
-
* makes a leading jamo 2 and a vowel or trailing jamo 1.
|
|
251
|
-
*
|
|
252
|
-
* A cluster that begins with jamo and then turns into something else — a leading jamo
|
|
253
|
-
* followed by a precomposed syllable — measures the jamo by this rule and the remainder by
|
|
254
|
-
* East Asian Width, which is how `U+1100 U+AC00` comes to 4 rather than 2.
|
|
255
|
-
*/
|
|
256
155
|
function hangulColumns(visible, ambiguousIsWide) {
|
|
257
156
|
const codePoints = [];
|
|
258
157
|
for (const character of visible) {
|
|
259
|
-
if (ZERO_WIDTH_CLUSTER.test(character))
|
|
158
|
+
if (ZERO_WIDTH_CLUSTER().test(character))
|
|
260
159
|
continue;
|
|
261
160
|
codePoints.push(character.codePointAt(0) ?? 0);
|
|
262
161
|
}
|
|
@@ -279,21 +178,16 @@ function hangulColumns(visible, ambiguousIsWide) {
|
|
|
279
178
|
}
|
|
280
179
|
return columns;
|
|
281
180
|
}
|
|
282
|
-
/**
|
|
283
|
-
* Columns a string of *plain* text occupies — no escape scan. The wrapper below has
|
|
284
|
-
* already split its input into text runs and complete sequences, so rescanning would only
|
|
285
|
-
* give a malformed sequence a second chance to be mistaken for one.
|
|
286
|
-
*/
|
|
287
181
|
export function measure(text, ambiguousIsWide = false) {
|
|
288
182
|
let columns = 0;
|
|
289
|
-
for (const { segment } of segmenter.segment(text)) {
|
|
290
|
-
if (ZERO_WIDTH_CLUSTER.test(segment))
|
|
183
|
+
for (const { segment } of segmenter().segment(text)) {
|
|
184
|
+
if (ZERO_WIDTH_CLUSTER().test(segment))
|
|
291
185
|
continue;
|
|
292
|
-
if (RGI_EMOJI.test(segment) || isUnqualifiedEmojiSequence(segment)) {
|
|
186
|
+
if (RGI_EMOJI().test(segment) || isUnqualifiedEmojiSequence(segment)) {
|
|
293
187
|
columns += WIDE_COLUMNS;
|
|
294
188
|
continue;
|
|
295
189
|
}
|
|
296
|
-
const visible = segment.replace(LEADING_NON_PRINTING, '');
|
|
190
|
+
const visible = segment.replace(LEADING_NON_PRINTING(), '');
|
|
297
191
|
const hangul = hangulColumns(visible, ambiguousIsWide);
|
|
298
192
|
if (hangul !== undefined) {
|
|
299
193
|
columns += hangul;
|
|
@@ -304,57 +198,25 @@ export function measure(text, ambiguousIsWide = false) {
|
|
|
304
198
|
}
|
|
305
199
|
return columns;
|
|
306
200
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Printable ASCII is one column per code unit, and nothing that makes `measure` correct can
|
|
309
|
-
* change that answer: there are no escape sequences, no combining marks and no emoji between
|
|
310
|
-
* 0x20 and 0x7E. `countAnsiEscapeCodes` cannot change it either — `ESC` is 0x1B, below the
|
|
311
|
-
* range, so a string this accepts has no escapes to count.
|
|
312
|
-
*
|
|
313
|
-
* It is not a micro-optimisation. `widest` over many lines is one `Intl.Segmenter` walk per
|
|
314
|
-
* line, and `truncate.test.ts`'s 200,000-line case — the one proving `widest` survives where
|
|
315
|
-
* `Math.max(...)` throws — timed out at five seconds without this. ASCII is the common line.
|
|
316
|
-
*/
|
|
317
201
|
function asciiColumns(text) {
|
|
318
202
|
for (let i = 0; i < text.length; i += 1) {
|
|
319
|
-
// `codePointAt` over `charCodeAt` (Interlace unicode-safety rule, and it is the right
|
|
320
|
-
// call): on a surrogate pair this returns the whole code point, which is above 0x7E and
|
|
321
|
-
// bails to the full path. `charCodeAt` would have seen a lone high surrogate instead.
|
|
322
|
-
// `?? 0` cannot mislead — 0 is below 0x20, so an out-of-range index also bails.
|
|
323
203
|
const code = text.codePointAt(i) ?? 0;
|
|
324
204
|
if (code < 0x20 || code > 0x7e)
|
|
325
205
|
return undefined;
|
|
326
206
|
}
|
|
327
207
|
return text.length;
|
|
328
208
|
}
|
|
329
|
-
/**
|
|
330
|
-
* How many terminal columns `input` occupies once its escape sequences are removed.
|
|
331
|
-
*
|
|
332
|
-
* **Non-strings measure 0.** `string-width` has always answered `0` for a number, `null` or
|
|
333
|
-
* `undefined` rather than throwing, and callers rely on it — a width function is usually
|
|
334
|
-
* reached with whatever a template produced. Three cases of its suite grade exactly this, and
|
|
335
|
-
* the check is `typeof` rather than a truthiness test so that `0` and `false` are not quietly
|
|
336
|
-
* treated as strings that happen to be empty.
|
|
337
|
-
*/
|
|
338
209
|
export function width(input, options = {}) {
|
|
339
210
|
if (typeof input !== 'string' || input === '')
|
|
340
211
|
return 0;
|
|
341
212
|
const ascii = asciiColumns(input);
|
|
342
213
|
if (ascii !== undefined)
|
|
343
214
|
return ascii;
|
|
344
|
-
// `strip`, not `node:util`'s: Node's scanner stops at the first colon in the ITU T.416
|
|
345
|
-
// sub-parameter form (`ESC[38:2::255:0:0m`), which chalk and wrap-ansi both emit — it
|
|
346
|
-
// measured 15 where string-width says 3. The fast path above never reaches here with an
|
|
347
|
-
// escape in it, so the two fixes are disjoint: 0x1B is below its 0x20 floor.
|
|
348
215
|
return measure(options.countAnsiEscapeCodes === true ? input : strip(input), options.ambiguousIsNarrow === false);
|
|
349
216
|
}
|
|
350
|
-
/**
|
|
351
|
-
* Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
|
|
352
|
-
* actually asks for. An empty line still occupies one.
|
|
353
|
-
*/
|
|
354
217
|
export function lineCount(text, columns) {
|
|
355
218
|
let count = 0;
|
|
356
219
|
for (const line of strip(text).split('\n'))
|
|
357
220
|
count += Math.max(1, Math.ceil(width(line) / columns));
|
|
358
221
|
return count;
|
|
359
222
|
}
|
|
360
|
-
//# sourceMappingURL=width.js.map
|
package/dist/wrap.js
CHANGED
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
import { ASCII_PRINTABLE, ROW_BOUNDARY, TAB_SIZE, applyLeadingResets, applyParameters, closingSequence, forEachSegment, hyperlink, matchEscape, openingSequence, segmenter, sgr } from './style.js';
|
|
2
|
-
/**
|
|
3
|
-
* Wrapping text that carries ANSI, ported from wrap-ansi 10 — the third dependency the
|
|
4
|
-
* render façades share, after the spinner corpus and the width function (R7, R10).
|
|
5
|
-
*
|
|
6
|
-
* The hard part is not the arithmetic, it is that a style opened on one row must not leak
|
|
7
|
-
* into the next: a terminal that reflows, a pager, or an agent reading one line at a time
|
|
8
|
-
* all see rows independently. So every row closes the styles it inherited and the next row
|
|
9
|
-
* reopens them, which has a second use here — after wrapping, **each row is self-contained**,
|
|
10
|
-
* and dropping leading rows needs no ANSI state tracking at all. `flagstaff/log-update`
|
|
11
|
-
* relies on exactly that, which is why it carries no port of `slice-ansi`.
|
|
12
|
-
*
|
|
13
|
-
* Graded differentially against the real `wrap-ansi` in `wrap.test.ts`, the way `width.ts`
|
|
14
|
-
* is graded against `string-width`: the incumbent is the specification.
|
|
15
|
-
*/
|
|
16
2
|
import { measure } from './width.js';
|
|
17
|
-
/** The visible width of a string, escape sequences ignored. */
|
|
18
3
|
export function visibleWidth(string) {
|
|
19
4
|
let plainText = '';
|
|
20
5
|
forEachSegment(string, (part) => {
|
|
@@ -22,11 +7,6 @@ export function visibleWidth(string) {
|
|
|
22
7
|
});
|
|
23
8
|
return measure(plainText);
|
|
24
9
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Escape sequences, which are zero width and must never be split, and grapheme clusters.
|
|
27
|
-
* A sequence written *inside* a cluster splits it — the supported boundary is between
|
|
28
|
-
* clusters and sequences, not within one.
|
|
29
|
-
*/
|
|
30
10
|
function tokenize(string) {
|
|
31
11
|
const tokens = [];
|
|
32
12
|
forEachSegment(string, (plainText) => {
|
|
@@ -35,12 +15,11 @@ function tokenize(string) {
|
|
|
35
15
|
tokens.push({ value: character, width: 1 });
|
|
36
16
|
return;
|
|
37
17
|
}
|
|
38
|
-
for (const { segment } of segmenter.segment(plainText))
|
|
18
|
+
for (const { segment } of segmenter().segment(plainText))
|
|
39
19
|
tokens.push({ value: segment, width: measure(segment) });
|
|
40
20
|
}, (escape) => tokens.push({ value: escape, width: 0 }));
|
|
41
21
|
return tokens;
|
|
42
22
|
}
|
|
43
|
-
/** Split on spaces, ignoring spaces that appear inside a recognised sequence. */
|
|
44
23
|
function splitWords(string) {
|
|
45
24
|
let current = { value: '', plainText: '', width: 0 };
|
|
46
25
|
const words = [current];
|
|
@@ -56,20 +35,14 @@ function splitWords(string) {
|
|
|
56
35
|
}, (escape) => {
|
|
57
36
|
current.value += escape;
|
|
58
37
|
});
|
|
59
|
-
// Measured once per word rather than per run, so a cluster an escape splits counts once.
|
|
60
38
|
for (const word of words)
|
|
61
39
|
word.width = measure(word.plainText);
|
|
62
40
|
return words;
|
|
63
41
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Break one long word across rows. Takes the visible width of the row it starts on and
|
|
66
|
-
* returns the width of the row it ends on, so the caller never measures a row itself.
|
|
67
|
-
*/
|
|
68
42
|
function wrapWord(rows, word, columns, rowWidth) {
|
|
69
43
|
const tokens = tokenize(word);
|
|
70
44
|
let visible = rowWidth;
|
|
71
45
|
for (const [index, token] of tokens.entries()) {
|
|
72
|
-
// Sequences and combining marks are zero width, so they stay on the current row.
|
|
73
46
|
if (token.width > 0 && visible > 0 && visible + token.width > columns) {
|
|
74
47
|
rows.push('');
|
|
75
48
|
visible = 0;
|
|
@@ -81,15 +54,11 @@ function wrapWord(rows, word, columns, rowWidth) {
|
|
|
81
54
|
visible = 0;
|
|
82
55
|
}
|
|
83
56
|
}
|
|
84
|
-
// The last row copied over can be nothing but escape characters.
|
|
85
57
|
const last = rows.at(-1) ?? '';
|
|
86
58
|
if (!visible && last.length > 0 && rows.length > 1)
|
|
87
59
|
rows[rows.length - 2] += rows.pop() ?? '';
|
|
88
|
-
// Tokens are measured one at a time, so a cluster an escape splits counts once per part.
|
|
89
|
-
// Only the finished row gives the true width, and it is at most one row to measure.
|
|
90
60
|
return visibleWidth(rows.at(-1) ?? '');
|
|
91
61
|
}
|
|
92
|
-
/** Drop the spaces trailing the last visible character, keeping the sequences among them. */
|
|
93
62
|
function trimVisibleEnd(string) {
|
|
94
63
|
if (!string.includes(' '))
|
|
95
64
|
return string;
|
|
@@ -99,7 +68,6 @@ function trimVisibleEnd(string) {
|
|
|
99
68
|
const segment = segments[index];
|
|
100
69
|
if (segment === undefined || segment.isEscape)
|
|
101
70
|
continue;
|
|
102
|
-
// Scanned rather than matched: a trailing-space pattern backtracks quadratically.
|
|
103
71
|
let end = segment.value.length;
|
|
104
72
|
while (end > 0 && segment.value[end - 1] === ' ')
|
|
105
73
|
end -= 1;
|
|
@@ -133,11 +101,6 @@ function expandTabs(line) {
|
|
|
133
101
|
});
|
|
134
102
|
return expanded;
|
|
135
103
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Close the active styles and hyperlink before every row break and reopen them after, so
|
|
138
|
-
* each row stands on its own. Only sequences and newlines matter, so the string is scanned
|
|
139
|
-
* directly rather than segmented.
|
|
140
|
-
*/
|
|
141
104
|
function restoreStylesAcrossRows(preString) {
|
|
142
105
|
let out = '';
|
|
143
106
|
let activeHyperlink;
|
|
@@ -164,9 +127,7 @@ function restoreStylesAcrossRows(preString) {
|
|
|
164
127
|
index += escape[0].length;
|
|
165
128
|
continue;
|
|
166
129
|
}
|
|
167
|
-
// Everything up to the row break is copied verbatim, sequences included.
|
|
168
130
|
out += preString.slice(copied, index);
|
|
169
|
-
// An empty row never reopened anything, so there is nothing to close.
|
|
170
131
|
if (index > copied) {
|
|
171
132
|
if (activeHyperlink !== undefined)
|
|
172
133
|
out += hyperlink('');
|
|
@@ -175,8 +136,6 @@ function restoreStylesAcrossRows(preString) {
|
|
|
175
136
|
out += '\n';
|
|
176
137
|
index += 1;
|
|
177
138
|
copied = index;
|
|
178
|
-
// An empty row has nothing to style, so the styles stay closed until the next row with
|
|
179
|
-
// content; a trailing row break leaves no row at all.
|
|
180
139
|
if (index < preString.length && preString[index] !== '\n') {
|
|
181
140
|
const opening = [...active];
|
|
182
141
|
applyLeadingResets(preString, index, opening);
|
|
@@ -187,23 +146,19 @@ function restoreStylesAcrossRows(preString) {
|
|
|
187
146
|
}
|
|
188
147
|
return out + preString.slice(copied);
|
|
189
148
|
}
|
|
190
|
-
/** Whether a word longer than the row should start on the next row instead of this one. */
|
|
191
149
|
function shouldStartLongWordOnNextRow(wordWidth, columns, rowLength) {
|
|
192
150
|
const remaining = columns - rowLength;
|
|
193
151
|
const breaksStartingThisRow = 1 + Math.floor((wordWidth - remaining - 1) / columns);
|
|
194
152
|
const breaksStartingNextRow = Math.floor((wordWidth - 1) / columns);
|
|
195
153
|
return breaksStartingNextRow < breaksStartingThisRow;
|
|
196
154
|
}
|
|
197
|
-
/** One line of input — the caller has already split on newlines. */
|
|
198
155
|
function wrapLine(string, columns, options) {
|
|
199
156
|
const trim = options.trim !== false;
|
|
200
157
|
if (trim && string.trim() === '')
|
|
201
158
|
return '';
|
|
202
159
|
const words = splitWords(string);
|
|
203
160
|
let rows = [''];
|
|
204
|
-
// Tracked as rows are built: remeasuring per word makes wrapping quadratic in the line.
|
|
205
161
|
let rowLength = 0;
|
|
206
|
-
// A row that already starts with content can never become trimmable again.
|
|
207
162
|
let trimmedRowIndex = -1;
|
|
208
163
|
let isFirstWord = true;
|
|
209
164
|
for (const word of words) {
|
|
@@ -231,7 +186,6 @@ function wrapLine(string, columns, options) {
|
|
|
231
186
|
rowLength += 1;
|
|
232
187
|
}
|
|
233
188
|
}
|
|
234
|
-
// 'hard': a row is never allowed to extend past `columns`.
|
|
235
189
|
if (options.hard === true && options.wordWrap !== false && word.width > columns) {
|
|
236
190
|
if (shouldStartLongWordOnNextRow(word.width, columns, rowLength)) {
|
|
237
191
|
rows.push('');
|
|
@@ -259,7 +213,6 @@ function wrapLine(string, columns, options) {
|
|
|
259
213
|
rows = rows.map((row) => trimVisibleEnd(row));
|
|
260
214
|
return restoreStylesAcrossRows(rows.join('\n'));
|
|
261
215
|
}
|
|
262
|
-
/** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
|
|
263
216
|
export function wrap(string, columns, options = {}) {
|
|
264
217
|
return String(string)
|
|
265
218
|
.normalize()
|
|
@@ -268,15 +221,4 @@ export function wrap(string, columns, options = {}) {
|
|
|
268
221
|
.map((line) => wrapLine(expandTabs(line), columns, options))
|
|
269
222
|
.join('\n');
|
|
270
223
|
}
|
|
271
|
-
/**
|
|
272
|
-
* The default export, for the reason `strip.ts` gives at length: `wrap-ansi` 10's suite
|
|
273
|
-
* imports its entry point's **default**, and that suite now grades this file.
|
|
274
|
-
*
|
|
275
|
-
* Written without quoting the specifier, deliberately. `subpath-isolation.test.ts` scans the
|
|
276
|
-
* *emitted text* of `dist/wrap.js` for relative imports, comments included, so a doc comment
|
|
277
|
-
* that spells one out makes the entry look as though it reaches a sibling. Caught by that
|
|
278
|
-
* lock on 2026-09-14, which is the lock doing exactly its job on the wrong input.
|
|
279
|
-
*/
|
|
280
|
-
// 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.
|
|
281
224
|
export { wrap as default };
|
|
282
|
-
//# sourceMappingURL=wrap.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linegauge",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A printer's line gauge \u2014 the steel rule marked in picas and points. Measuring, wrapping, truncating and slicing styled terminal text without the edge fraying \u2014 grapheme-correct over Intl.Segmenter. Drop-in paths for string-width, wrap-ansi, strip-ansi and slice-ansi. Zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"!dist/**/*.test.*"
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
|
-
"build": "tsc -p tsconfig.build.json",
|
|
48
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/strip-comments.mjs dist",
|
|
49
49
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
50
|
"test": "vitest run --passWithNoTests",
|
|
51
51
|
"coverage": "vitest run --coverage.enabled",
|
|
@@ -75,6 +75,6 @@
|
|
|
75
75
|
"unicode"
|
|
76
76
|
],
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"vitest": "^5.0.
|
|
78
|
+
"vitest": "^5.0.1"
|
|
79
79
|
}
|
|
80
80
|
}
|