@depup/wrap-ansi 10.0.0-depup.0 → 10.0.2-depup.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +5 -4
  2. package/changes.json +9 -5
  3. package/index.js +391 -197
  4. package/package.json +15 -14
package/README.md CHANGED
@@ -13,16 +13,17 @@ npm install @depup/wrap-ansi
13
13
 
14
14
  | Field | Value |
15
15
  |-------|-------|
16
- | Original | [wrap-ansi](https://www.npmjs.com/package/wrap-ansi) @ 10.0.0 |
17
- | Processed | 2026-03-19 |
16
+ | Original | [wrap-ansi](https://www.npmjs.com/package/wrap-ansi) @ 10.0.2 |
17
+ | Processed | 2026-09-27 |
18
18
  | Smoke test | passed |
19
- | Deps updated | 1 |
19
+ | Deps updated | 2 |
20
20
 
21
21
  ## Dependency Changes
22
22
 
23
23
  | Dependency | From | To |
24
24
  |------------|------|-----|
25
- | strip-ansi | ^7.1.2 | ^7.2.0 |
25
+ | ansi-styles | ^6.2.3 | ^7.0.0 |
26
+ | string-width | ^8.2.0 | ^8.3.0 |
26
27
 
27
28
  ---
28
29
 
package/changes.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "bumped": {
3
- "strip-ansi": {
4
- "from": "^7.1.2",
5
- "to": "^7.2.0"
3
+ "ansi-styles": {
4
+ "from": "^6.2.3",
5
+ "to": "^7.0.0"
6
+ },
7
+ "string-width": {
8
+ "from": "^8.2.0",
9
+ "to": "^8.3.0"
6
10
  }
7
11
  },
8
- "timestamp": "2026-03-19T03:36:32.743Z",
9
- "totalUpdated": 1
12
+ "timestamp": "2026-09-27T01:05:09.603Z",
13
+ "totalUpdated": 2
10
14
  }
package/index.js CHANGED
@@ -1,15 +1,9 @@
1
1
  import stringWidth from 'string-width';
2
- import stripAnsi from 'strip-ansi';
3
2
  import ansiStyles from 'ansi-styles';
4
3
 
5
4
  const ANSI_ESCAPE = '\u001B';
6
- const ANSI_ESCAPE_CSI = '\u009B';
7
- const ESCAPES = new Set([
8
- ANSI_ESCAPE,
9
- ANSI_ESCAPE_CSI,
10
- ]);
11
-
12
5
  const ANSI_ESCAPE_BELL = '\u0007';
6
+ const C1_CSI = '\u009B';
13
7
  const ANSI_CSI = '[';
14
8
  const ANSI_OSC = ']';
15
9
  const ANSI_SGR_TERMINATOR = 'm';
@@ -20,61 +14,219 @@ const ANSI_SGR_RESET_UNDERLINE_COLOR = 59;
20
14
  const ANSI_SGR_FOREGROUND_EXTENDED = 38;
21
15
  const ANSI_SGR_BACKGROUND_EXTENDED = 48;
22
16
  const ANSI_SGR_UNDERLINE_COLOR_EXTENDED = 58;
23
- const ANSI_SGR_COLOR_MODE_256 = 5;
24
17
  const ANSI_SGR_COLOR_MODE_RGB = 2;
25
- const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`;
26
- const ANSI_ESCAPE_REGEX = new RegExp(`^\\u001B(?:\\${ANSI_CSI}(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}|${ANSI_ESCAPE_LINK}(?<uri>[^\\u0007\\u001B]*)(?:\\u0007|\\u001B\\\\))`);
27
- const ANSI_ESCAPE_CSI_REGEX = new RegExp(`^\\u009B(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}`);
18
+ const ANSI_SGR_COLOR_MODE_256 = 5;
19
+ const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;`;
20
+ // The first character of every sequence we recognize.
21
+ const ESCAPES = new Set([
22
+ ANSI_ESCAPE,
23
+ C1_CSI,
24
+ ]);
25
+ const ESCAPE_CHARACTERS = [...ESCAPES].join('');
26
+
27
+ const CSI_INTRODUCER = `(?:${ANSI_ESCAPE}\\${ANSI_CSI}|${C1_CSI})`;
28
+ const CSI_PARAMETERS = '[0-?]*[ -/]*[@-~]';
29
+ const SGR_PARAMETERS = `(?<sgr>[0-9;:]*)${ANSI_SGR_TERMINATOR}`;
30
+ const OSC_STRING_TERMINATOR = `(?:${ANSI_ESCAPE_BELL}|${ANSI_ESCAPE}\\\\)`;
31
+ const OSC_STRING_PAYLOAD = String.raw`[^\u0000-\u001F\u007F-\u009F]*`;
32
+ // A hyperlink is `OSC 8 ; parameters ; URI ST`, where `parameters` is a possibly empty list of `key=value` pairs joined by `:`.
33
+ const LINK_PARAMETERS = String.raw`8;(?<parameters>[^;\u0000-\u001F\u007F-\u009F]*);(?<uri>${OSC_STRING_PAYLOAD})${OSC_STRING_TERMINATOR}`;
34
+ const OSC_STRING = `${OSC_STRING_PAYLOAD}${OSC_STRING_TERMINATOR}`;
35
+
36
+ // Supported boundary: semicolon-delimited SGR styling, colon-delimited RGB/indexed colors, and OSC 8 hyperlinks are tracked, while ordinary CSI sequences and other complete 7-bit OSC commands are preserved as opaque zero-width units. This is intentionally not a terminal emulator, so other colon-delimited SGR semantics, C0 bytes inside sequences, DCS/SOS/PM/APC control strings, cancellations, generic ESC sequences, and 8-bit control-string forms are unsupported. Newlines always delimit input lines before ANSI parsing.
37
+ const ANSI_ESCAPE_REGEX = new RegExp(
38
+ `${CSI_INTRODUCER}(?:${SGR_PARAMETERS}|${CSI_PARAMETERS})`
39
+ + `|${ANSI_ESCAPE}\\${ANSI_OSC}(?:${LINK_PARAMETERS}|${OSC_STRING})`,
40
+ 'y',
41
+ );
42
+
28
43
  const ANSI_SGR_MODIFIER_CLOSE_CODES = new Set(ansiStyles.codes.values());
29
44
  ANSI_SGR_MODIFIER_CLOSE_CODES.delete(ANSI_SGR_RESET);
30
45
 
31
46
  const segmenter = new Intl.Segmenter();
32
- const getGraphemes = string => Array.from(segmenter.segment(string), ({segment}) => segment);
47
+ // Complete ANSI sequences have already been removed before measuring these strings. Avoid string-width's ANSI scan so malformed sequences are not rescanned.
48
+ const getStringWidth = string => stringWidth(string, {countAnsiEscapeCodes: true});
33
49
  const TAB_SIZE = 8;
34
50
 
51
+ // Finds the next character that could introduce a sequence, so plain text is skipped in one native step.
52
+ const ESCAPE_INTRODUCER_REGEX = new RegExp(`[${ESCAPE_CHARACTERS}]`, 'g');
53
+ // The final pass only cares about sequences and row boundaries, so it skips everything else in one native step.
54
+ const ROW_BOUNDARY_REGEX = new RegExp(`[\\n${ESCAPE_CHARACTERS}]`, 'g');
55
+ // Every printable ASCII character is its own grapheme cluster of width one, which lets the segmenter be skipped.
56
+ const ASCII_PRINTABLE_REGEX = /^[ -~]*$/;
57
+
35
58
  const wrapAnsiCode = code => `${ANSI_ESCAPE}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`;
36
- const wrapAnsiHyperlink = url => `${ANSI_ESCAPE}${ANSI_ESCAPE_LINK}${url}${ANSI_ESCAPE_BELL}`;
59
+ const wrapAnsiHyperlink = (url, parameters = '') => `${ANSI_ESCAPE}${ANSI_ESCAPE_LINK}${parameters};${url}${ANSI_ESCAPE_BELL}`;
60
+
61
+ // Match a complete escape sequence starting at `index`, or return `undefined` when none starts there.
62
+ const matchAnsiEscape = (string, index) => {
63
+ if (!ESCAPES.has(string[index])) {
64
+ return;
65
+ }
66
+
67
+ ANSI_ESCAPE_REGEX.lastIndex = index;
68
+ return ANSI_ESCAPE_REGEX.exec(string) ?? undefined;
69
+ };
70
+
71
+ // Walk a string as alternating plain text runs and complete escape sequences.
72
+ // A character that looks like an introducer but does not start a valid sequence stays plain text.
73
+ const forEachSegment = (string, onPlainText, onEscape = () => {}) => {
74
+ let plainStart = 0;
75
+ let index = 0;
76
+
77
+ while (index < string.length) {
78
+ ESCAPE_INTRODUCER_REGEX.lastIndex = index;
79
+ const introducer = ESCAPE_INTRODUCER_REGEX.exec(string);
80
+
81
+ if (!introducer) {
82
+ break;
83
+ }
84
+
85
+ const escape = matchAnsiEscape(string, introducer.index);
86
+
87
+ if (!escape) {
88
+ index = introducer.index + 1;
89
+ continue;
90
+ }
91
+
92
+ if (introducer.index > plainStart) {
93
+ onPlainText(string.slice(plainStart, introducer.index));
94
+ }
95
+
96
+ onEscape(escape[0]);
97
+ index = introducer.index + escape[0].length;
98
+ plainStart = index;
99
+ }
100
+
101
+ if (plainStart < string.length) {
102
+ onPlainText(string.slice(plainStart));
103
+ }
104
+ };
105
+
106
+ // The visible width of a string, ignoring escape sequences.
107
+ const getWidth = string => {
108
+ let plainText = '';
109
+
110
+ forEachSegment(string, part => {
111
+ plainText += part;
112
+ });
113
+
114
+ return getStringWidth(plainText);
115
+ };
116
+
117
+ // Split a string into escape sequences, which are zero width and must never be split, and grapheme clusters.
118
+ // The supported boundary is between grapheme clusters and ANSI sequences; ANSI inserted inside a cluster is treated as separate segments.
119
+ const getTokens = string => {
120
+ const tokens = [];
121
+
122
+ forEachSegment(string, plainText => {
123
+ if (ASCII_PRINTABLE_REGEX.test(plainText)) {
124
+ for (const character of plainText) {
125
+ tokens.push({value: character, width: 1});
126
+ }
127
+
128
+ return;
129
+ }
130
+
131
+ for (const {segment} of segmenter.segment(plainText)) {
132
+ tokens.push({value: segment, width: getStringWidth(segment)});
133
+ }
134
+ }, escape => {
135
+ tokens.push({value: escape, width: 0});
136
+ });
137
+
138
+ return tokens;
139
+ };
140
+
141
+ // Split on spaces, ignoring spaces that appear inside a recognized sequence.
142
+ const splitWords = string => {
143
+ let currentWord = {value: '', plainText: ''};
144
+ const words = [currentWord];
145
+
146
+ forEachSegment(string, plainText => {
147
+ const parts = plainText.split(' ');
148
+ currentWord.value += parts[0];
149
+ currentWord.plainText += parts[0];
150
+
151
+ for (let index = 1; index < parts.length; index++) {
152
+ currentWord = {value: parts[index], plainText: parts[index]};
153
+ words.push(currentWord);
154
+ }
155
+ }, escape => {
156
+ currentWord.value += escape;
157
+ });
158
+
159
+ // Measured once per word rather than per run, so a grapheme cluster split by an escape still counts once.
160
+ for (const word of words) {
161
+ word.width = getStringWidth(word.plainText);
162
+ }
163
+
164
+ return words;
165
+ };
166
+
167
+ const getColonColorToken = parameter => {
168
+ const parts = parameter.split(':');
169
+ const code = Number.parseInt(parts[0], 10);
170
+ const mode = Number.parseInt(parts[1], 10);
171
+
172
+ if (![ANSI_SGR_FOREGROUND_EXTENDED, ANSI_SGR_BACKGROUND_EXTENDED, ANSI_SGR_UNDERLINE_COLOR_EXTENDED].includes(code)) {
173
+ return;
174
+ }
175
+
176
+ if (mode === ANSI_SGR_COLOR_MODE_256 && parts.length === 3 && /^\d+$/.test(parts[2])) {
177
+ return {code, open: parameter, hasArguments: true};
178
+ }
179
+
180
+ if (mode !== ANSI_SGR_COLOR_MODE_RGB) {
181
+ return;
182
+ }
183
+
184
+ const components = parts.length === 6 ? parts.slice(3) : parts.slice(2);
185
+ const colorSpace = parts.length === 6 ? parts[2] : undefined;
186
+ if (components.length === 3 && components.every(component => /^\d+$/.test(component)) && (colorSpace === undefined || /^\d*$/.test(colorSpace))) {
187
+ return {code, open: parameter, hasArguments: true};
188
+ }
189
+ };
37
190
 
38
191
  const getSgrTokens = sgrParameters => {
39
- const codes = sgrParameters.split(';').map(sgrParameter => sgrParameter === '' ? ANSI_SGR_RESET : Number.parseInt(sgrParameter, 10));
192
+ const parameters = sgrParameters.split(';');
40
193
  const sgrTokens = [];
41
194
 
42
- for (let index = 0; index < codes.length; index++) {
43
- const code = codes[index];
195
+ for (let index = 0; index < parameters.length; index++) {
196
+ const parameter = parameters[index];
197
+ if (parameter.includes(':')) {
198
+ const colonColorToken = getColonColorToken(parameter);
199
+ if (colonColorToken) {
200
+ sgrTokens.push(colonColorToken);
201
+ }
202
+
203
+ continue;
204
+ }
205
+
206
+ const code = parameter === '' ? ANSI_SGR_RESET : Number.parseInt(parameter, 10);
44
207
 
45
208
  if (!Number.isFinite(code)) {
46
209
  continue;
47
210
  }
48
211
 
49
- if (
50
- (
51
- code === ANSI_SGR_FOREGROUND_EXTENDED
52
- || code === ANSI_SGR_BACKGROUND_EXTENDED
53
- || code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED
54
- )
55
- ) {
56
- if (index + 1 >= codes.length) {
212
+ if (code === ANSI_SGR_FOREGROUND_EXTENDED || code === ANSI_SGR_BACKGROUND_EXTENDED || code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED) {
213
+ if (index + 1 >= parameters.length) {
57
214
  break;
58
215
  }
59
216
 
60
- const mode = codes[index + 1];
61
-
62
- if (mode === ANSI_SGR_COLOR_MODE_256 && Number.isFinite(codes[index + 2])) {
63
- sgrTokens.push([code, mode, codes[index + 2]]);
217
+ const mode = Number.parseInt(parameters[index + 1], 10);
218
+ const colorIndex = Number.parseInt(parameters[index + 2], 10);
219
+ if (mode === ANSI_SGR_COLOR_MODE_256 && Number.isFinite(colorIndex)) {
220
+ sgrTokens.push({code, open: [code, mode, colorIndex].join(';'), hasArguments: true});
64
221
  index += 2;
65
222
  continue;
66
223
  }
67
224
 
68
- const red = codes[index + 2];
69
- const green = codes[index + 3];
70
- const blue = codes[index + 4];
71
- if (
72
- mode === ANSI_SGR_COLOR_MODE_RGB
73
- && Number.isFinite(red)
74
- && Number.isFinite(green)
75
- && Number.isFinite(blue)
76
- ) {
77
- sgrTokens.push([code, mode, red, green, blue]);
225
+ const red = Number.parseInt(parameters[index + 2], 10);
226
+ const green = Number.parseInt(parameters[index + 3], 10);
227
+ const blue = Number.parseInt(parameters[index + 4], 10);
228
+ if (mode === ANSI_SGR_COLOR_MODE_RGB && Number.isFinite(red) && Number.isFinite(green) && Number.isFinite(blue)) {
229
+ sgrTokens.push({code, open: [code, mode, red, green, blue].join(';'), hasArguments: true});
78
230
  index += 4;
79
231
  continue;
80
232
  }
@@ -82,7 +234,7 @@ const getSgrTokens = sgrParameters => {
82
234
  break;
83
235
  }
84
236
 
85
- sgrTokens.push([code]);
237
+ sgrTokens.push({code, open: String(code), hasArguments: false});
86
238
  }
87
239
 
88
240
  return sgrTokens;
@@ -110,27 +262,28 @@ const removeModifierStylesByClose = (activeStyles, closeCode) => {
110
262
  }
111
263
  };
112
264
 
113
- const getColorStyle = (code, sgrToken) => {
114
- if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97) || (code === ANSI_SGR_FOREGROUND_EXTENDED && sgrToken.length > 1)) {
265
+ const getColorStyle = sgrToken => {
266
+ const {code, open, hasArguments} = sgrToken;
267
+ if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97) || (code === ANSI_SGR_FOREGROUND_EXTENDED && hasArguments)) {
115
268
  return {
116
269
  family: 'foreground',
117
- open: sgrToken.join(';'),
270
+ open,
118
271
  close: ANSI_SGR_RESET_FOREGROUND,
119
272
  };
120
273
  }
121
274
 
122
- if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107) || (code === ANSI_SGR_BACKGROUND_EXTENDED && sgrToken.length > 1)) {
275
+ if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107) || (code === ANSI_SGR_BACKGROUND_EXTENDED && hasArguments)) {
123
276
  return {
124
277
  family: 'background',
125
- open: sgrToken.join(';'),
278
+ open,
126
279
  close: ANSI_SGR_RESET_BACKGROUND,
127
280
  };
128
281
  }
129
282
 
130
- if (code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED && sgrToken.length > 1) {
283
+ if (code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED && hasArguments) {
131
284
  return {
132
285
  family: 'underlineColor',
133
- open: sgrToken.join(';'),
286
+ open,
134
287
  close: ANSI_SGR_RESET_UNDERLINE_COLOR,
135
288
  };
136
289
  }
@@ -166,13 +319,13 @@ const applySgrResetCode = (code, activeStyles) => {
166
319
  };
167
320
 
168
321
  const applySgrToken = (sgrToken, activeStyles) => {
169
- const [code] = sgrToken;
322
+ const {code} = sgrToken;
170
323
 
171
324
  if (applySgrResetCode(code, activeStyles)) {
172
325
  return;
173
326
  }
174
327
 
175
- const colorStyle = getColorStyle(code, sgrToken);
328
+ const colorStyle = getColorStyle(sgrToken);
176
329
  if (colorStyle) {
177
330
  upsertActiveStyle(activeStyles, colorStyle);
178
331
  return;
@@ -182,7 +335,7 @@ const applySgrToken = (sgrToken, activeStyles) => {
182
335
  if (close !== undefined && close !== ANSI_SGR_RESET) {
183
336
  upsertActiveStyle(activeStyles, {
184
337
  family: `modifier-${code}`,
185
- open: sgrToken.join(';'),
338
+ open: sgrToken.open,
186
339
  close,
187
340
  });
188
341
  }
@@ -195,97 +348,52 @@ const applySgrParameters = (sgrParameters, activeStyles) => {
195
348
  };
196
349
 
197
350
  const applySgrResets = (sgrParameters, activeStyles) => {
198
- for (const sgrToken of getSgrTokens(sgrParameters)) {
199
- const [code] = sgrToken;
351
+ for (const {code} of getSgrTokens(sgrParameters)) {
200
352
  applySgrResetCode(code, activeStyles);
201
353
  }
202
354
  };
203
355
 
204
- const applyLeadingSgrResets = (string, activeStyles) => {
205
- let remainder = string;
206
-
207
- while (remainder.length > 0) {
208
- if (remainder.startsWith(ANSI_ESCAPE) && remainder[1] !== '\\') {
209
- const match = ANSI_ESCAPE_REGEX.exec(remainder);
210
- if (!match) {
211
- break;
212
- }
356
+ const applyLeadingSgrResets = (string, startIndex, activeStyles) => {
357
+ let index = startIndex;
213
358
 
214
- if (match.groups.sgr !== undefined) {
215
- applySgrResets(match.groups.sgr, activeStyles);
216
- }
217
-
218
- remainder = remainder.slice(match[0].length);
219
- continue;
359
+ while (index < string.length) {
360
+ const match = matchAnsiEscape(string, index);
361
+ if (!match) {
362
+ break;
220
363
  }
221
364
 
222
- if (remainder.startsWith(ANSI_ESCAPE_CSI)) {
223
- const match = ANSI_ESCAPE_CSI_REGEX.exec(remainder);
224
- if (!match || match.groups.sgr === undefined) {
225
- break;
226
- }
227
-
365
+ if (match.groups.sgr !== undefined) {
228
366
  applySgrResets(match.groups.sgr, activeStyles);
229
- remainder = remainder.slice(match[0].length);
230
- continue;
231
367
  }
232
368
 
233
- break;
369
+ index += match[0].length;
234
370
  }
235
371
  };
236
372
 
237
373
  const getClosingSgrSequence = activeStyles => [...activeStyles].reverse().map(activeStyle => wrapAnsiCode(activeStyle.close)).join('');
238
374
  const getOpeningSgrSequence = activeStyles => activeStyles.map(activeStyle => wrapAnsiCode(activeStyle.open)).join('');
239
375
 
240
- // Calculate the length of words split on ' ', ignoring
241
- // the extra characters added by ANSI escape codes
242
- const wordLengths = string => string.split(' ').map(word => stringWidth(word));
243
-
244
376
  // Wrap a long word across multiple rows
245
377
  // ANSI escape codes do not count towards length
246
- const wrapWord = (rows, word, columns) => {
247
- const characters = getGraphemes(word);
378
+ // Takes the visible width of the last row and returns the width of the row the word ends on, so callers never have to measure the rows themselves.
379
+ const wrapWord = (rows, word, columns, rowWidth) => {
380
+ const tokens = getTokens(word);
248
381
 
249
- let isInsideEscape = false;
250
- let isInsideLinkEscape = false;
251
- let visible = stringWidth(stripAnsi(rows.at(-1)));
382
+ let visible = rowWidth;
252
383
 
253
- for (const [index, character] of characters.entries()) {
254
- const characterLength = stringWidth(character);
384
+ for (let index = 0; index < tokens.length; index++) {
385
+ const token = tokens[index];
255
386
 
256
- if (visible + characterLength <= columns) {
257
- rows[rows.length - 1] += character;
258
- } else {
259
- rows.push(character);
387
+ // Escape sequences and combining marks are zero width, so they always stay on the current row.
388
+ if (token.width > 0 && visible > 0 && visible + token.width > columns) {
389
+ rows.push('');
260
390
  visible = 0;
261
391
  }
262
392
 
263
- if (ESCAPES.has(character) && !(isInsideLinkEscape && character === ANSI_ESCAPE && characters[index + 1] === '\\')) {
264
- isInsideEscape = true;
265
-
266
- const ansiEscapeLinkCandidate = characters.slice(index + 1, index + 1 + ANSI_ESCAPE_LINK.length).join('');
267
- isInsideLinkEscape = ansiEscapeLinkCandidate === ANSI_ESCAPE_LINK;
268
- }
269
-
270
- if (isInsideEscape) {
271
- if (isInsideLinkEscape) {
272
- if (
273
- character === ANSI_ESCAPE_BELL
274
- || (character === '\\' && index > 0 && characters[index - 1] === ANSI_ESCAPE) // ST terminator (ESC \)
275
- ) {
276
- isInsideEscape = false;
277
- isInsideLinkEscape = false;
278
- }
279
- } else if (character === ANSI_SGR_TERMINATOR) {
280
- isInsideEscape = false;
281
- }
282
-
283
- continue;
284
- }
285
-
286
- visible += characterLength;
393
+ rows[rows.length - 1] += token.value;
394
+ visible += token.width;
287
395
 
288
- if (visible === columns && index < characters.length - 1) {
396
+ if (visible === columns && index < tokens.length - 1) {
289
397
  rows.push('');
290
398
  visible = 0;
291
399
  }
@@ -296,26 +404,46 @@ const wrapWord = (rows, word, columns) => {
296
404
  if (!visible && rows.at(-1).length > 0 && rows.length > 1) {
297
405
  rows[rows.length - 2] += rows.pop();
298
406
  }
407
+
408
+ // The tokens are measured one by one, so a grapheme cluster that an escape sequence splits is counted once per part rather than once as a whole. Only the finished row tells the true width, and it is at most one row long to measure.
409
+ return getWidth(rows.at(-1));
299
410
  };
300
411
 
301
412
  // Trims spaces from a string ignoring invisible sequences
302
413
  const stringVisibleTrimSpacesRight = string => {
303
- const words = string.split(' ');
304
- let last = words.length;
414
+ if (!string.includes(' ')) {
415
+ return string;
416
+ }
305
417
 
306
- while (last > 0) {
307
- if (stringWidth(words[last - 1]) > 0) {
308
- break;
418
+ const segments = [];
419
+ forEachSegment(string, plainText => {
420
+ segments.push({value: plainText, isEscape: false});
421
+ }, escape => {
422
+ segments.push({value: escape, isEscape: true});
423
+ });
424
+
425
+ // Drop the spaces that trail the last visible character, but keep the invisible sequences among them.
426
+ for (let index = segments.length - 1; index >= 0; index--) {
427
+ const segment = segments[index];
428
+
429
+ if (segment.isEscape) {
430
+ continue;
309
431
  }
310
432
 
311
- last--;
312
- }
433
+ // Scanned rather than matched with a regex, as a trailing-space pattern backtracks quadratically.
434
+ let end = segment.value.length;
435
+ while (end > 0 && segment.value[end - 1] === ' ') {
436
+ end--;
437
+ }
313
438
 
314
- if (last === words.length) {
315
- return string;
439
+ segment.value = segment.value.slice(0, end);
440
+
441
+ if (getStringWidth(segment.value) > 0) {
442
+ break;
443
+ }
316
444
  }
317
445
 
318
- return words.slice(0, last).join(' ') + words.slice(last).join('');
446
+ return segments.map(segment => segment.value).join('');
319
447
  };
320
448
 
321
449
  const expandTabs = line => {
@@ -323,24 +451,103 @@ const expandTabs = line => {
323
451
  return line;
324
452
  }
325
453
 
326
- const segments = line.split('\t');
327
454
  let visible = 0;
328
455
  let expandedLine = '';
456
+ let plainTextSinceTab = '';
457
+
458
+ const expandPlainText = plainText => {
459
+ const segments = plainText.split('\t');
329
460
 
330
- for (const [index, segment] of segments.entries()) {
331
- expandedLine += segment;
332
- visible += stringWidth(segment);
461
+ for (const [index, segment] of segments.entries()) {
462
+ expandedLine += segment;
463
+ plainTextSinceTab += segment;
333
464
 
334
- if (index < segments.length - 1) {
335
- const spaces = TAB_SIZE - (visible % TAB_SIZE);
336
- expandedLine += ' '.repeat(spaces);
337
- visible += spaces;
465
+ if (index < segments.length - 1) {
466
+ visible += getStringWidth(plainTextSinceTab);
467
+ plainTextSinceTab = '';
468
+ const spaces = TAB_SIZE - (visible % TAB_SIZE);
469
+ expandedLine += ' '.repeat(spaces);
470
+ visible += spaces;
471
+ }
338
472
  }
339
- }
473
+ };
474
+
475
+ forEachSegment(line, expandPlainText, escape => {
476
+ expandedLine += escape;
477
+ });
340
478
 
341
479
  return expandedLine;
342
480
  };
343
481
 
482
+ // Close the active styles and hyperlink before every row break and reopen them after, so each row stands on its own.
483
+ // Only sequences and newlines matter here, so the string is scanned directly rather than split into grapheme clusters.
484
+ const restoreStylesAcrossRows = preString => {
485
+ let returnValue = '';
486
+ let activeHyperlink;
487
+ const activeStyles = [];
488
+ let index = 0;
489
+ let copiedIndex = 0;
490
+
491
+ while (index < preString.length) {
492
+ ROW_BOUNDARY_REGEX.lastIndex = index;
493
+ const boundary = ROW_BOUNDARY_REGEX.exec(preString);
494
+
495
+ if (!boundary) {
496
+ break;
497
+ }
498
+
499
+ index = boundary.index;
500
+
501
+ if (boundary[0] !== '\n') {
502
+ const escape = matchAnsiEscape(preString, index);
503
+
504
+ if (!escape) {
505
+ index++;
506
+ continue;
507
+ }
508
+
509
+ const {groups} = escape;
510
+ if (groups.sgr !== undefined) {
511
+ applySgrParameters(groups.sgr, activeStyles);
512
+ } else if (groups.uri !== undefined) {
513
+ activeHyperlink = groups.uri.length === 0 ? undefined : {parameters: groups.parameters, uri: groups.uri};
514
+ }
515
+
516
+ index += escape[0].length;
517
+ continue;
518
+ }
519
+
520
+ // Everything up to the row break is copied verbatim, sequences included.
521
+ returnValue += preString.slice(copiedIndex, index);
522
+
523
+ // An empty row never reopened anything, so there is nothing to close.
524
+ if (index > copiedIndex) {
525
+ if (activeHyperlink) {
526
+ returnValue += wrapAnsiHyperlink('');
527
+ }
528
+
529
+ returnValue += getClosingSgrSequence(activeStyles);
530
+ }
531
+
532
+ returnValue += '\n';
533
+ index++;
534
+ copiedIndex = index;
535
+
536
+ // An empty row has nothing to style, so the styles stay closed until the next row with content. A trailing row break leaves no row at all.
537
+ if (index < preString.length && preString[index] !== '\n') {
538
+ const openingStyles = [...activeStyles];
539
+ applyLeadingSgrResets(preString, index, openingStyles);
540
+ returnValue += getOpeningSgrSequence(openingStyles);
541
+
542
+ if (activeHyperlink) {
543
+ returnValue += wrapAnsiHyperlink(activeHyperlink.uri, activeHyperlink.parameters);
544
+ }
545
+ }
546
+ }
547
+
548
+ return returnValue + preString.slice(copiedIndex);
549
+ };
550
+
344
551
  // The wrap-ansi module can be invoked in either 'hard' or 'soft' wrap mode.
345
552
  //
346
553
  // 'hard' will never allow a string to take up more than columns characters.
@@ -351,21 +558,35 @@ const exec = (string, columns, options = {}) => {
351
558
  return '';
352
559
  }
353
560
 
354
- let returnValue = '';
355
- let escapeUrl;
356
- const activeStyles = [];
357
-
358
- const lengths = wordLengths(string);
561
+ const words = splitWords(string);
359
562
  let rows = [''];
563
+ // Tracked as rows are built. Remeasuring the row for every word makes wrapping quadratic in the line length.
564
+ let rowLength = 0;
565
+ // Words are only ever appended, so a row that already starts with content can never become trimmable again. Retrimming it for every word makes wrapping quadratic in the line length.
566
+ let trimmedRowIndex = -1;
360
567
 
361
- for (const [index, word] of string.split(' ').entries()) {
362
- if (options.trim !== false) {
363
- rows[rows.length - 1] = rows.at(-1).trimStart();
364
- }
568
+ let isFirstWord = true;
365
569
 
366
- let rowLength = stringWidth(rows.at(-1));
570
+ for (const word of words) {
571
+ const rowIndex = rows.length - 1;
572
+
573
+ if (options.trim !== false && trimmedRowIndex !== rowIndex) {
574
+ const row = rows[rowIndex];
575
+ const trimmedRow = row.trimStart();
576
+
577
+ if (trimmedRow.length !== row.length) {
578
+ rows[rowIndex] = trimmedRow;
579
+ rowLength = getWidth(trimmedRow);
580
+ }
367
581
 
368
- if (index !== 0) {
582
+ if (trimmedRow.length > 0) {
583
+ trimmedRowIndex = rowIndex;
584
+ }
585
+ }
586
+
587
+ if (isFirstWord) {
588
+ isFirstWord = false;
589
+ } else {
369
590
  if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) {
370
591
  // If we start with a new word but the current row length equals the length of the columns, add a new row
371
592
  rows.push('');
@@ -379,88 +600,61 @@ const exec = (string, columns, options = {}) => {
379
600
  }
380
601
 
381
602
  // In 'hard' wrap mode, the length of a line is never allowed to extend past 'columns'
382
- if (options.hard && options.wordWrap !== false && lengths[index] > columns) {
603
+ if (options.hard && options.wordWrap !== false && word.width > columns) {
383
604
  const remainingColumns = columns - rowLength;
384
- const breaksStartingThisLine = 1 + Math.floor((lengths[index] - remainingColumns - 1) / columns);
385
- const breaksStartingNextLine = Math.floor((lengths[index] - 1) / columns);
605
+ const breaksStartingThisLine = 1 + Math.floor((word.width - remainingColumns - 1) / columns);
606
+ const breaksStartingNextLine = Math.floor((word.width - 1) / columns);
386
607
  if (breaksStartingNextLine < breaksStartingThisLine) {
387
608
  rows.push('');
609
+ rowLength = 0;
388
610
  }
389
611
 
390
- wrapWord(rows, word, columns);
612
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
391
613
  continue;
392
614
  }
393
615
 
394
- if (rowLength + lengths[index] > columns && rowLength > 0 && lengths[index] > 0) {
616
+ if (rowLength + word.width > columns && rowLength > 0 && word.width > 0) {
395
617
  if (options.wordWrap === false && rowLength < columns) {
396
- wrapWord(rows, word, columns);
618
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
397
619
  continue;
398
620
  }
399
621
 
400
622
  rows.push('');
623
+ rowLength = 0;
401
624
  }
402
625
 
403
- if (rowLength + lengths[index] > columns && options.wordWrap === false) {
404
- wrapWord(rows, word, columns);
626
+ if (rowLength + word.width > columns && options.wordWrap === false) {
627
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
405
628
  continue;
406
629
  }
407
630
 
408
- rows[rows.length - 1] += word;
631
+ rows[rows.length - 1] += word.value;
632
+ rowLength += word.width;
409
633
  }
410
634
 
411
635
  if (options.trim !== false) {
412
636
  rows = rows.map(row => stringVisibleTrimSpacesRight(row));
413
637
  }
414
638
 
415
- const preString = rows.join('\n');
416
- const pre = getGraphemes(preString);
417
-
418
- // We need to keep a separate index as `String#slice()` works on Unicode code units, while `pre` is an array of grapheme clusters.
419
- let preStringIndex = 0;
420
-
421
- for (const [index, character] of pre.entries()) {
422
- returnValue += character;
423
-
424
- if (character === ANSI_ESCAPE && pre[index + 1] !== '\\') {
425
- const {groups} = ANSI_ESCAPE_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
426
- if (groups.sgr !== undefined) {
427
- applySgrParameters(groups.sgr, activeStyles);
428
- } else if (groups.uri !== undefined) {
429
- escapeUrl = groups.uri.length === 0 ? undefined : groups.uri;
430
- }
431
- } else if (character === ANSI_ESCAPE_CSI) {
432
- const {groups} = ANSI_ESCAPE_CSI_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
433
- if (groups.sgr !== undefined) {
434
- applySgrParameters(groups.sgr, activeStyles);
435
- }
436
- }
437
-
438
- if (pre[index + 1] === '\n') {
439
- if (escapeUrl) {
440
- returnValue += wrapAnsiHyperlink('');
441
- }
442
-
443
- returnValue += getClosingSgrSequence(activeStyles);
444
- } else if (character === '\n') {
445
- const openingStyles = [...activeStyles];
446
- applyLeadingSgrResets(preString.slice(preStringIndex + 1), openingStyles);
447
- returnValue += getOpeningSgrSequence(openingStyles);
639
+ return restoreStylesAcrossRows(rows.join('\n'));
640
+ };
448
641
 
449
- if (escapeUrl) {
450
- returnValue += wrapAnsiHyperlink(escapeUrl);
451
- }
452
- }
642
+ // Normalize the text, but not the sequences: a combining mark that follows a sequence composes with its last character and destroys it.
643
+ const normalizeText = string => {
644
+ let normalizedString = '';
453
645
 
454
- preStringIndex += character.length;
455
- }
646
+ forEachSegment(string, plainText => {
647
+ normalizedString += plainText.normalize();
648
+ }, escape => {
649
+ normalizedString += escape;
650
+ });
456
651
 
457
- return returnValue;
652
+ return normalizedString;
458
653
  };
459
654
 
460
- // For each newline, invoke the method separately
655
+ // For each newline, invoke the method separately.
461
656
  export default function wrapAnsi(string, columns, options) {
462
- return String(string)
463
- .normalize()
657
+ return normalizeText(String(string))
464
658
  .replaceAll('\r\n', '\n')
465
659
  .split('\n')
466
660
  .map(line => exec(expandTabs(line), columns, options))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@depup/wrap-ansi",
3
- "version": "10.0.0-depup.0",
3
+ "version": "10.0.2-depup.0",
4
4
  "description": "Wordwrap a string with ANSI escape codes (with updated dependencies)",
5
5
  "license": "MIT",
6
6
  "repository": "chalk/wrap-ansi",
@@ -19,7 +19,7 @@
19
19
  "node": ">=20"
20
20
  },
21
21
  "scripts": {
22
- "test": "xo && nyc ava && tsd"
22
+ "test": "xo && node --test && tsd"
23
23
  },
24
24
  "files": [
25
25
  "index.js",
@@ -61,30 +61,31 @@
61
61
  "text"
62
62
  ],
63
63
  "dependencies": {
64
- "ansi-styles": "^6.2.3",
65
- "string-width": "^8.2.0",
66
- "strip-ansi": "^7.2.0"
64
+ "ansi-styles": "^7.0.0",
65
+ "string-width": "^8.3.0"
67
66
  },
68
67
  "devDependencies": {
69
- "ava": "^6.4.1",
70
68
  "chalk": "^5.6.2",
71
- "coveralls": "^3.1.1",
72
69
  "has-ansi": "^6.0.2",
73
- "nyc": "^17.1.0",
70
+ "strip-ansi": "^7.1.2",
74
71
  "tsd": "^0.33.0",
75
72
  "xo": "^1.2.3"
76
73
  },
77
74
  "depup": {
78
75
  "changes": {
79
- "strip-ansi": {
80
- "from": "^7.1.2",
81
- "to": "^7.2.0"
76
+ "ansi-styles": {
77
+ "from": "^6.2.3",
78
+ "to": "^7.0.0"
79
+ },
80
+ "string-width": {
81
+ "from": "^8.2.0",
82
+ "to": "^8.3.0"
82
83
  }
83
84
  },
84
- "depsUpdated": 1,
85
+ "depsUpdated": 2,
85
86
  "originalPackage": "wrap-ansi",
86
- "originalVersion": "10.0.0",
87
- "processedAt": "2026-03-19T03:36:52.255Z",
87
+ "originalVersion": "10.0.2",
88
+ "processedAt": "2026-09-27T01:05:14.220Z",
88
89
  "smokeTest": "passed"
89
90
  }
90
91
  }