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 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
- export const segmenter = new Intl.Segmenter();
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
- * `v`-mode properties: the whole point of using them is that Node ships the tables.
136
- *
137
- * The mark classes are spelled out as `Nonspacing_Mark` and `Enclosing_Mark` rather than
138
- * the `\p{Mark}` that stood here, because `\p{Mark}` is those two **and** `Spacing_Mark` —
139
- * and a spacing mark is exactly the kind that does occupy a column. `ा`, Devanagari
140
- * vowel sign AA, answered 0 under the wider class and a terminal draws it one column wide.
141
- *
142
- * `\p{Format}` joins the zero-width class for the mirror-image reason. A prepended
143
- * concatenation mark — `U+0600`, `U+06DD`, `U+070F` — is `Format` but *not*
144
- * `Default_Ignorable`, so it fell through to the base-scalar path below, which stripped it
145
- * as leading non-printing, found an empty remainder, read code point 0 and charged a column
146
- * for it. Charging a column for a character the cursor never advances past is the shape of
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
- const segmenter = new Intl.Segmenter();
179
- /** East Asian Width of one code point, in columns, under the caller's ambiguous policy. */
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.1",
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.0"
78
+ "vitest": "^5.0.1"
79
79
  }
80
80
  }