linegauge 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/width.js CHANGED
@@ -8,7 +8,10 @@
8
8
  * worth a dependency tree (U5).
9
9
  *
10
10
  * The rules, in the order a cluster meets them:
11
- * 1. ANSI and other control sequences are not printed — `stripVTControlCharacters`.
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.
12
15
  * 2. A grapheme cluster made only of ignorable, control, mark or surrogate code points
13
16
  * occupies no column.
14
17
  * 3. An RGI emoji sequence is two columns, however many code points it is made of.
@@ -19,7 +22,7 @@
19
22
  * has been told it is rendering an East Asian locale. `string-width` makes that an option;
20
23
  * nothing above this function has ever needed the other answer, so it is not one here.
21
24
  */
22
- import { stripVTControlCharacters } from 'node:util';
25
+ import { strip } from './strip.js';
23
26
  /**
24
27
  * East Asian Wide and Fullwidth, as sorted `[low, high]` pairs flattened into one array —
25
28
  * Unicode 17's W and F categories, merged where they touch. Generated from the same
@@ -57,13 +60,62 @@ const NARROW = 1;
57
60
  const WIDE_COLUMNS = 2;
58
61
  /** Half the flat array is lows, so a step over pairs. */
59
62
  const PAIR = 2;
60
- function isWide(codePoint) {
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
+ const AMBIGUOUS = [
74
+ 0x00A1, 0x00A1, 0x00A4, 0x00A4, 0x00A7, 0x00A8, 0x00AA, 0x00AA, 0x00AD, 0x00AE,
75
+ 0x00B0, 0x00B4, 0x00B6, 0x00BA, 0x00BC, 0x00BF, 0x00C6, 0x00C6, 0x00D0, 0x00D0,
76
+ 0x00D7, 0x00D8, 0x00DE, 0x00E1, 0x00E6, 0x00E6, 0x00E8, 0x00EA, 0x00EC, 0x00ED,
77
+ 0x00F0, 0x00F0, 0x00F2, 0x00F3, 0x00F7, 0x00FA, 0x00FC, 0x00FC, 0x00FE, 0x00FE,
78
+ 0x0101, 0x0101, 0x0111, 0x0111, 0x0113, 0x0113, 0x011B, 0x011B, 0x0126, 0x0127,
79
+ 0x012B, 0x012B, 0x0131, 0x0133, 0x0138, 0x0138, 0x013F, 0x0142, 0x0144, 0x0144,
80
+ 0x0148, 0x014B, 0x014D, 0x014D, 0x0152, 0x0153, 0x0166, 0x0167, 0x016B, 0x016B,
81
+ 0x01CE, 0x01CE, 0x01D0, 0x01D0, 0x01D2, 0x01D2, 0x01D4, 0x01D4, 0x01D6, 0x01D6,
82
+ 0x01D8, 0x01D8, 0x01DA, 0x01DA, 0x01DC, 0x01DC, 0x0251, 0x0251, 0x0261, 0x0261,
83
+ 0x02C4, 0x02C4, 0x02C7, 0x02C7, 0x02C9, 0x02CB, 0x02CD, 0x02CD, 0x02D0, 0x02D0,
84
+ 0x02D8, 0x02DB, 0x02DD, 0x02DD, 0x02DF, 0x02DF, 0x0300, 0x036F, 0x0391, 0x03A1,
85
+ 0x03A3, 0x03A9, 0x03B1, 0x03C1, 0x03C3, 0x03C9, 0x0401, 0x0401, 0x0410, 0x044F,
86
+ 0x0451, 0x0451, 0x2010, 0x2010, 0x2013, 0x2016, 0x2018, 0x2019, 0x201C, 0x201D,
87
+ 0x2020, 0x2022, 0x2024, 0x2027, 0x2030, 0x2030, 0x2032, 0x2033, 0x2035, 0x2035,
88
+ 0x203B, 0x203B, 0x203E, 0x203E, 0x2074, 0x2074, 0x207F, 0x207F, 0x2081, 0x2084,
89
+ 0x20AC, 0x20AC, 0x2103, 0x2103, 0x2105, 0x2105, 0x2109, 0x2109, 0x2113, 0x2113,
90
+ 0x2116, 0x2116, 0x2121, 0x2122, 0x2126, 0x2126, 0x212B, 0x212B, 0x2153, 0x2154,
91
+ 0x215B, 0x215E, 0x2160, 0x216B, 0x2170, 0x2179, 0x2189, 0x2189, 0x2190, 0x2199,
92
+ 0x21B8, 0x21B9, 0x21D2, 0x21D2, 0x21D4, 0x21D4, 0x21E7, 0x21E7, 0x2200, 0x2200,
93
+ 0x2202, 0x2203, 0x2207, 0x2208, 0x220B, 0x220B, 0x220F, 0x220F, 0x2211, 0x2211,
94
+ 0x2215, 0x2215, 0x221A, 0x221A, 0x221D, 0x2220, 0x2223, 0x2223, 0x2225, 0x2225,
95
+ 0x2227, 0x222C, 0x222E, 0x222E, 0x2234, 0x2237, 0x223C, 0x223D, 0x2248, 0x2248,
96
+ 0x224C, 0x224C, 0x2252, 0x2252, 0x2260, 0x2261, 0x2264, 0x2267, 0x226A, 0x226B,
97
+ 0x226E, 0x226F, 0x2282, 0x2283, 0x2286, 0x2287, 0x2295, 0x2295, 0x2299, 0x2299,
98
+ 0x22A5, 0x22A5, 0x22BF, 0x22BF, 0x2312, 0x2312, 0x2460, 0x24E9, 0x24EB, 0x254B,
99
+ 0x2550, 0x2573, 0x2580, 0x258F, 0x2592, 0x2595, 0x25A0, 0x25A1, 0x25A3, 0x25A9,
100
+ 0x25B2, 0x25B3, 0x25B6, 0x25B7, 0x25BC, 0x25BD, 0x25C0, 0x25C1, 0x25C6, 0x25C8,
101
+ 0x25CB, 0x25CB, 0x25CE, 0x25D1, 0x25E2, 0x25E5, 0x25EF, 0x25EF, 0x2605, 0x2606,
102
+ 0x2609, 0x2609, 0x260E, 0x260F, 0x261C, 0x261C, 0x261E, 0x261E, 0x2640, 0x2640,
103
+ 0x2642, 0x2642, 0x2660, 0x2661, 0x2663, 0x2665, 0x2667, 0x266A, 0x266C, 0x266D,
104
+ 0x266F, 0x266F, 0x269E, 0x269F, 0x26BF, 0x26BF, 0x26C6, 0x26CD, 0x26CF, 0x26D3,
105
+ 0x26D5, 0x26E1, 0x26E3, 0x26E3, 0x26E8, 0x26E9, 0x26EB, 0x26F1, 0x26F4, 0x26F4,
106
+ 0x26F6, 0x26F9, 0x26FB, 0x26FC, 0x26FE, 0x26FF, 0x273D, 0x273D, 0x2776, 0x277F,
107
+ 0x2B56, 0x2B59, 0x3248, 0x324F, 0xE000, 0xF8FF, 0xFE00, 0xFE0F, 0xFFFD, 0xFFFD,
108
+ 0x1F100, 0x1F10A, 0x1F110, 0x1F12D, 0x1F130, 0x1F169, 0x1F170, 0x1F18D, 0x1F18F, 0x1F190,
109
+ 0x1F19B, 0x1F1AC, 0xE0100, 0xE01EF, 0xF0000, 0xFFFFD, 0x100000, 0x10FFFD,
110
+ ];
111
+ /** Binary search over a flat `[low, high]` table. Both tables are laid out for this. */
112
+ function inTable(table, codePoint) {
61
113
  let low = 0;
62
- let high = WIDE.length / PAIR - 1;
114
+ let high = table.length / PAIR - 1;
63
115
  while (low <= high) {
64
116
  const mid = (low + high) >> 1;
65
- const start = WIDE[mid * PAIR] ?? 0;
66
- const end = WIDE[mid * PAIR + 1] ?? 0;
117
+ const start = table[mid * PAIR] ?? 0;
118
+ const end = table[mid * PAIR + 1] ?? 0;
67
119
  if (codePoint < start)
68
120
  high = mid - 1;
69
121
  else if (codePoint > end)
@@ -73,47 +125,227 @@ function isWide(codePoint) {
73
125
  }
74
126
  return false;
75
127
  }
76
- // `v`-mode properties: the whole point of using them is that Node ships the tables.
77
- const ZERO_WIDTH_CLUSTER = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
78
- const LEADING_NON_PRINTING = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
128
+ function isWide(codePoint) {
129
+ return inTable(WIDE, codePoint);
130
+ }
131
+ function isAmbiguous(codePoint) {
132
+ return inTable(AMBIGUOUS, codePoint);
133
+ }
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;
79
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
+ */
159
+ const UNQUALIFIED_KEYCAP = /^[\d#*]\u20E3$/u;
160
+ const ZWJ = '\u200D';
161
+ /** Two pictographs is what separates an emoji ZWJ sequence from an Indic conjunct. */
162
+ 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
+ const EMOJI_SCAN_LIMIT = 50;
80
168
  /** The Halfwidth and Fullwidth Forms block, which a cluster can carry after its base. */
81
169
  const FORMS_FIRST = 0xff00;
82
170
  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
+ const JAMO_LEADING = [0x1100, 0x115f, 0xa960, 0xa97c];
176
+ const JAMO_VOWEL = [0x1160, 0x11a7, 0xd7b0, 0xd7c6];
177
+ const JAMO_TRAILING = [0x11a8, 0x11ff, 0xd7cb, 0xd7fb];
83
178
  const segmenter = new Intl.Segmenter();
84
- /** Columns a cluster's trailing fullwidth forms add — `ガ` is a base plus a wide mark. */
85
- function trailingForms(cluster) {
179
+ /** East Asian Width of one code point, in columns, under the caller's ambiguous policy. */
180
+ function columnsOf(codePoint, ambiguousIsWide) {
181
+ return isWide(codePoint) || (ambiguousIsWide && isAmbiguous(codePoint)) ? WIDE_COLUMNS : NARROW;
182
+ }
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
+ function trailingColumns(visible, ambiguousIsWide) {
86
193
  let extra = 0;
87
- for (const character of [...cluster].slice(1)) {
194
+ for (const character of [...visible].slice(1)) {
88
195
  const codePoint = character.codePointAt(0) ?? 0;
89
- if (codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST)
90
- extra += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
196
+ const isForm = codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST;
197
+ if (isForm || SPACING_MARK.test(character))
198
+ extra += columnsOf(codePoint, ambiguousIsWide);
91
199
  }
92
200
  return extra;
93
201
  }
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
+ function isUnqualifiedEmojiSequence(cluster) {
215
+ if (cluster.length > EMOJI_SCAN_LIMIT)
216
+ return false;
217
+ if (UNQUALIFIED_KEYCAP.test(cluster))
218
+ return true;
219
+ if (!cluster.includes(ZWJ))
220
+ return false;
221
+ let pictographs = 0;
222
+ for (const character of cluster) {
223
+ if (EXTENDED_PICTOGRAPHIC.test(character))
224
+ pictographs += 1;
225
+ if (pictographs >= EMOJI_ZWJ_PICTOGRAPHS)
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+ /** Whether `codePoint` falls in one of a flat `[low, high]` pair list. */
231
+ function inPairs(pairs, codePoint) {
232
+ for (let i = 0; i < pairs.length; i += PAIR) {
233
+ if (codePoint >= (pairs[i] ?? 0) && codePoint <= (pairs[i + 1] ?? 0))
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+ function isJamo(codePoint) {
239
+ return inPairs(JAMO_LEADING, codePoint) || inPairs(JAMO_VOWEL, codePoint) || inPairs(JAMO_TRAILING, codePoint);
240
+ }
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
+ function hangulColumns(visible, ambiguousIsWide) {
257
+ const codePoints = [];
258
+ for (const character of visible) {
259
+ if (ZERO_WIDTH_CLUSTER.test(character))
260
+ continue;
261
+ codePoints.push(character.codePointAt(0) ?? 0);
262
+ }
263
+ if (codePoints.length === 0 || !isJamo(codePoints[0] ?? 0))
264
+ return undefined;
265
+ let columns = 0;
266
+ for (let index = 0; index < codePoints.length; index += 1) {
267
+ const codePoint = codePoints[index] ?? 0;
268
+ if (!isJamo(codePoint)) {
269
+ for (let rest = index; rest < codePoints.length; rest += 1)
270
+ columns += columnsOf(codePoints[rest] ?? 0, ambiguousIsWide);
271
+ return columns;
272
+ }
273
+ if (inPairs(JAMO_LEADING, codePoint) && inPairs(JAMO_VOWEL, codePoints[index + 1] ?? -1)) {
274
+ columns += WIDE_COLUMNS;
275
+ index += inPairs(JAMO_TRAILING, codePoints[index + PAIR] ?? -1) ? PAIR : 1;
276
+ continue;
277
+ }
278
+ columns += columnsOf(codePoint, ambiguousIsWide);
279
+ }
280
+ return columns;
281
+ }
94
282
  /**
95
283
  * Columns a string of *plain* text occupies — no escape scan. The wrapper below has
96
284
  * already split its input into text runs and complete sequences, so rescanning would only
97
285
  * give a malformed sequence a second chance to be mistaken for one.
98
286
  */
99
- export function measure(text) {
287
+ export function measure(text, ambiguousIsWide = false) {
100
288
  let columns = 0;
101
289
  for (const { segment } of segmenter.segment(text)) {
102
290
  if (ZERO_WIDTH_CLUSTER.test(segment))
103
291
  continue;
104
- if (RGI_EMOJI.test(segment)) {
292
+ if (RGI_EMOJI.test(segment) || isUnqualifiedEmojiSequence(segment)) {
105
293
  columns += WIDE_COLUMNS;
106
294
  continue;
107
295
  }
108
- const codePoint = segment.replace(LEADING_NON_PRINTING, '').codePointAt(0) ?? 0;
109
- columns += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
110
- columns += trailingForms(segment);
296
+ const visible = segment.replace(LEADING_NON_PRINTING, '');
297
+ const hangul = hangulColumns(visible, ambiguousIsWide);
298
+ if (hangul !== undefined) {
299
+ columns += hangul;
300
+ continue;
301
+ }
302
+ columns += columnsOf(visible.codePointAt(0) ?? 0, ambiguousIsWide);
303
+ columns += trailingColumns(visible, ambiguousIsWide);
111
304
  }
112
305
  return columns;
113
306
  }
114
- /** How many terminal columns `input` occupies once its escape sequences are removed. */
115
- export function width(input) {
116
- return input === '' ? 0 : measure(stripVTControlCharacters(input));
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
+ function asciiColumns(text) {
318
+ 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
+ const code = text.codePointAt(i) ?? 0;
324
+ if (code < 0x20 || code > 0x7e)
325
+ return undefined;
326
+ }
327
+ return text.length;
328
+ }
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
+ export function width(input, options = {}) {
339
+ if (typeof input !== 'string' || input === '')
340
+ return 0;
341
+ const ascii = asciiColumns(input);
342
+ if (ascii !== undefined)
343
+ 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
+ return measure(options.countAnsiEscapeCodes === true ? input : strip(input), options.ambiguousIsNarrow === false);
117
349
  }
118
350
  /**
119
351
  * Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
@@ -121,7 +353,7 @@ export function width(input) {
121
353
  */
122
354
  export function lineCount(text, columns) {
123
355
  let count = 0;
124
- for (const line of stripVTControlCharacters(text).split('\n'))
356
+ for (const line of strip(text).split('\n'))
125
357
  count += Math.max(1, Math.ceil(width(line) / columns));
126
358
  return count;
127
359
  }
package/dist/wrap.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** The visible width of a string, escape sequences ignored. */
2
+ export declare function visibleWidth(string: string): number;
1
3
  export interface WrapOptions {
2
4
  /** Trim leading and trailing whitespace from each row. Default true. */
3
5
  trim?: boolean;
@@ -8,3 +10,13 @@ export interface WrapOptions {
8
10
  }
9
11
  /** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
10
12
  export declare function wrap(string: string, columns: number, options?: WrapOptions): string;
13
+ /**
14
+ * The default export, for the reason `strip.ts` gives at length: `wrap-ansi` 10's suite
15
+ * imports its entry point's **default**, and that suite now grades this file.
16
+ *
17
+ * Written without quoting the specifier, deliberately. `subpath-isolation.test.ts` scans the
18
+ * *emitted text* of `dist/wrap.js` for relative imports, comments included, so a doc comment
19
+ * that spells one out makes the entry look as though it reaches a sibling. Caught by that
20
+ * lock on 2026-09-14, which is the lock doing exactly its job on the wrong input.
21
+ */
22
+ export { wrap as default };
package/dist/wrap.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ASCII_PRINTABLE, ROW_BOUNDARY, TAB_SIZE, applyLeadingResets, applyParameters, closingSequence, forEachSegment, hyperlink, matchEscape, openingSequence, segmenter, sgr } from './style.js';
1
2
  /**
2
3
  * Wrapping text that carries ANSI, ported from wrap-ansi 10 — the third dependency the
3
4
  * render façades share, after the spinner corpus and the width function (R7, R10).
@@ -13,112 +14,8 @@
13
14
  * is graded against `string-width`: the incumbent is the specification.
14
15
  */
15
16
  import { measure } from './width.js';
16
- const ESC = '\u001B';
17
- const BELL = '\u0007';
18
- /** The single-byte C1 form of `ESC [`, which a terminal accepts and a suite will send. */
19
- const C1_CSI = '\u009B';
20
- const CSI = '[';
21
- const OSC = ']';
22
- const SGR_TERMINATOR = 'm';
23
- const SGR_RESET = 0;
24
- const SGR_RESET_FOREGROUND = 39;
25
- const SGR_RESET_BACKGROUND = 49;
26
- const SGR_RESET_UNDERLINE_COLOR = 59;
27
- const SGR_FOREGROUND_EXTENDED = 38;
28
- const SGR_BACKGROUND_EXTENDED = 48;
29
- const SGR_UNDERLINE_COLOR_EXTENDED = 58;
30
- const SGR_COLOR_MODE_RGB = 2;
31
- const SGR_COLOR_MODE_256 = 5;
32
- const FOREGROUND_FIRST = 30;
33
- const FOREGROUND_LAST = 37;
34
- const FOREGROUND_BRIGHT_FIRST = 90;
35
- const FOREGROUND_BRIGHT_LAST = 97;
36
- const BACKGROUND_FIRST = 40;
37
- const BACKGROUND_LAST = 47;
38
- const BACKGROUND_BRIGHT_FIRST = 100;
39
- const BACKGROUND_BRIGHT_LAST = 107;
40
- /** How many columns a tab advances to the next stop. */
41
- const TAB_SIZE = 8;
42
- /** `38;5;n` — the code, the mode, and one index. */
43
- const COLOR_256_PARTS = 3;
44
- /** `38;2;r;g;b` — the code, the mode, and three components. */
45
- const COLOR_RGB_PARTS = 3;
46
- /** `38:2::r:g:b` carries a colour space between the mode and the components. */
47
- const COLON_RGB_WITH_SPACE = 6;
48
- const ESCAPES = new Set([ESC, C1_CSI]);
49
- const ESCAPE_CHARACTERS = [...ESCAPES].join('');
50
- const CSI_INTRODUCER = `(?:${ESC}\\${CSI}|${C1_CSI})`;
51
- const CSI_PARAMETERS = '[0-?]*[ -/]*[@-~]';
52
- const SGR_PARAMETERS = `(?<sgr>[0-9;:]*)${SGR_TERMINATOR}`;
53
- const OSC_TERMINATOR = `(?:${BELL}|${ESC}\\\\)`;
54
- const OSC_PAYLOAD = String.raw `[^\u0000-\u001F\u007F-\u009F]*`;
55
- /** `OSC 8 ; params ; URI ST` — a hyperlink, whose URI is tracked so a row can reopen it. */
56
- const LINK_PARAMETERS = String.raw `8;(?<parameters>[^;\u0000-\u001F\u007F-\u009F]*);(?<uri>${OSC_PAYLOAD})${OSC_TERMINATOR}`;
57
- // Deliberately not a terminal emulator: semicolon-delimited SGR, colon-delimited extended
58
- // colour and OSC 8 links are understood; every other complete CSI or OSC command is carried
59
- // through as an opaque zero-width unit, and anything that only looks like an introducer
60
- // stays plain text. `y` (sticky), so a match is anchored where the scan asked.
61
- const ANSI_ESCAPE = new RegExp(`${CSI_INTRODUCER}(?:${SGR_PARAMETERS}|${CSI_PARAMETERS})|${ESC}\\${OSC}(?:${LINK_PARAMETERS}|${OSC_PAYLOAD}${OSC_TERMINATOR})`, 'y');
62
- const ESCAPE_INTRODUCER = new RegExp(`[${ESCAPE_CHARACTERS}]`, 'g');
63
- const ROW_BOUNDARY = new RegExp(`[\\n${ESCAPE_CHARACTERS}]`, 'g');
64
- /** Every printable ASCII character is its own cluster of width one — skip the segmenter. */
65
- const ASCII_PRINTABLE = /^[ -~]*$/;
66
- /**
67
- * Which SGR code closes which modifier. This is ECMA-48, not any library's table — bold
68
- * opens with 1 and closes with 22 wherever you read it — so it lives here rather than
69
- * being imported, which keeps `wrap()` free of `roundel/chalk` and takes 18 KB off every
70
- * subpath that wraps. The colour families close with 39, 49 and 59 and are handled by name
71
- * above, before this map is consulted.
72
- */
73
- const MODIFIER_CLOSE = new Map([
74
- [1, 22],
75
- [2, 22],
76
- [3, 23],
77
- [4, 24],
78
- [7, 27],
79
- [8, 28],
80
- [9, 29],
81
- [53, 55],
82
- ]);
83
- const MODIFIER_CLOSE_CODES = new Set(MODIFIER_CLOSE.values());
84
- const segmenter = new Intl.Segmenter();
85
- const sgr = (code) => `${ESC}${CSI}${code}${SGR_TERMINATOR}`;
86
- const hyperlink = (url, parameters = '') => `${ESC}${OSC}8;${parameters};${url}${BELL}`;
87
- /** The complete escape sequence starting at `index`, or nothing when none starts there. */
88
- function matchEscape(string, index) {
89
- if (!ESCAPES.has(string[index] ?? ''))
90
- return undefined;
91
- ANSI_ESCAPE.lastIndex = index;
92
- return ANSI_ESCAPE.exec(string) ?? undefined;
93
- }
94
- /**
95
- * Walk a string as alternating runs of plain text and complete escape sequences. A
96
- * character that looks like an introducer but starts no valid sequence stays plain text.
97
- */
98
- function forEachSegment(string, onPlainText, onEscape = () => undefined) {
99
- let plainStart = 0;
100
- let index = 0;
101
- while (index < string.length) {
102
- ESCAPE_INTRODUCER.lastIndex = index;
103
- const introducer = ESCAPE_INTRODUCER.exec(string);
104
- if (introducer === null)
105
- break;
106
- const escape = matchEscape(string, introducer.index);
107
- if (escape === undefined) {
108
- index = introducer.index + 1;
109
- continue;
110
- }
111
- if (introducer.index > plainStart)
112
- onPlainText(string.slice(plainStart, introducer.index));
113
- onEscape(escape[0]);
114
- index = introducer.index + escape[0].length;
115
- plainStart = index;
116
- }
117
- if (plainStart < string.length)
118
- onPlainText(string.slice(plainStart));
119
- }
120
17
  /** The visible width of a string, escape sequences ignored. */
121
- function visibleWidth(string) {
18
+ export function visibleWidth(string) {
122
19
  let plainText = '';
123
20
  forEachSegment(string, (part) => {
124
21
  plainText += part;
@@ -164,155 +61,6 @@ function splitWords(string) {
164
61
  word.width = measure(word.plainText);
165
62
  return words;
166
63
  }
167
- const isDigits = (value) => /^\d+$/.test(value);
168
- /** `38:5:9` and `38:2::r:g:b` — the colon form, which carries its arguments in one parameter. */
169
- function colonColorToken(parameter) {
170
- const parts = parameter.split(':');
171
- const code = Number.parseInt(parts[0] ?? '', 10);
172
- const mode = Number.parseInt(parts[1] ?? '', 10);
173
- if (![SGR_FOREGROUND_EXTENDED, SGR_BACKGROUND_EXTENDED, SGR_UNDERLINE_COLOR_EXTENDED].includes(code))
174
- return undefined;
175
- if (mode === SGR_COLOR_MODE_256 && parts.length === COLOR_256_PARTS && isDigits(parts[2] ?? '')) {
176
- return { code, open: parameter, hasArguments: true };
177
- }
178
- if (mode !== SGR_COLOR_MODE_RGB)
179
- return undefined;
180
- const withSpace = parts.length === COLON_RGB_WITH_SPACE;
181
- const components = withSpace ? parts.slice(3) : parts.slice(2);
182
- const colorSpace = withSpace ? parts[2] : undefined;
183
- if (components.length === COLOR_RGB_PARTS && components.every(isDigits) && (colorSpace === undefined || /^\d*$/.test(colorSpace))) {
184
- return { code, open: parameter, hasArguments: true };
185
- }
186
- return undefined;
187
- }
188
- /** One extended-colour parameter run, `38;5;n` or `38;2;r;g;b`, or nothing if malformed. */
189
- function extendedColorToken(code, parameters, index) {
190
- const mode = Number.parseInt(parameters[index + 1] ?? '', 10);
191
- const first = Number.parseInt(parameters[index + 2] ?? '', 10);
192
- if (mode === SGR_COLOR_MODE_256 && Number.isFinite(first)) {
193
- return { token: { code, open: [code, mode, first].join(';'), hasArguments: true }, consumed: 2 };
194
- }
195
- const green = Number.parseInt(parameters[index + 3] ?? '', 10);
196
- const blue = Number.parseInt(parameters[index + 4] ?? '', 10);
197
- if (mode === SGR_COLOR_MODE_RGB && Number.isFinite(first) && Number.isFinite(green) && Number.isFinite(blue)) {
198
- return { token: { code, open: [code, mode, first, green, blue].join(';'), hasArguments: true }, consumed: 4 };
199
- }
200
- return undefined;
201
- }
202
- const isExtendedColor = (code) => code === SGR_FOREGROUND_EXTENDED || code === SGR_BACKGROUND_EXTENDED || code === SGR_UNDERLINE_COLOR_EXTENDED;
203
- function sgrTokens(parameters) {
204
- const parts = parameters.split(';');
205
- const tokens = [];
206
- for (let index = 0; index < parts.length; index += 1) {
207
- const parameter = parts[index] ?? '';
208
- if (parameter.includes(':')) {
209
- const token = colonColorToken(parameter);
210
- if (token !== undefined)
211
- tokens.push(token);
212
- continue;
213
- }
214
- const code = parameter === '' ? SGR_RESET : Number.parseInt(parameter, 10);
215
- if (!Number.isFinite(code))
216
- continue;
217
- if (isExtendedColor(code)) {
218
- if (index + 1 >= parts.length)
219
- break;
220
- const extended = extendedColorToken(code, parts, index);
221
- if (extended === undefined)
222
- break;
223
- tokens.push(extended.token);
224
- index += extended.consumed;
225
- continue;
226
- }
227
- tokens.push({ code, open: String(code), hasArguments: false });
228
- }
229
- return tokens;
230
- }
231
- function removeFamily(active, family) {
232
- const at = active.findIndex((style) => style.family === family);
233
- if (at !== -1)
234
- active.splice(at, 1);
235
- }
236
- function colorStyle(token) {
237
- const { code, open, hasArguments } = token;
238
- if ((code >= FOREGROUND_FIRST && code <= FOREGROUND_LAST) || (code >= FOREGROUND_BRIGHT_FIRST && code <= FOREGROUND_BRIGHT_LAST) || (code === SGR_FOREGROUND_EXTENDED && hasArguments)) {
239
- return { family: 'foreground', open, close: SGR_RESET_FOREGROUND };
240
- }
241
- if ((code >= BACKGROUND_FIRST && code <= BACKGROUND_LAST) || (code >= BACKGROUND_BRIGHT_FIRST && code <= BACKGROUND_BRIGHT_LAST) || (code === SGR_BACKGROUND_EXTENDED && hasArguments)) {
242
- return { family: 'background', open, close: SGR_RESET_BACKGROUND };
243
- }
244
- if (code === SGR_UNDERLINE_COLOR_EXTENDED && hasArguments) {
245
- return { family: 'underlineColor', open, close: SGR_RESET_UNDERLINE_COLOR };
246
- }
247
- return undefined;
248
- }
249
- /** True when the code closed something rather than opening it. */
250
- function applyResetCode(code, active) {
251
- if (code === SGR_RESET) {
252
- active.length = 0;
253
- return true;
254
- }
255
- if (code === SGR_RESET_FOREGROUND) {
256
- removeFamily(active, 'foreground');
257
- return true;
258
- }
259
- if (code === SGR_RESET_BACKGROUND) {
260
- removeFamily(active, 'background');
261
- return true;
262
- }
263
- if (code === SGR_RESET_UNDERLINE_COLOR) {
264
- removeFamily(active, 'underlineColor');
265
- return true;
266
- }
267
- if (MODIFIER_CLOSE_CODES.has(code)) {
268
- // One close code can end several modifiers — `22` ends both bold and dim.
269
- for (let index = active.length - 1; index >= 0; index -= 1) {
270
- const style = active[index];
271
- if (style !== undefined && style.family.startsWith('modifier-') && style.close === code)
272
- active.splice(index, 1);
273
- }
274
- return true;
275
- }
276
- return false;
277
- }
278
- function applyToken(token, active) {
279
- if (applyResetCode(token.code, active))
280
- return;
281
- const color = colorStyle(token);
282
- if (color !== undefined) {
283
- removeFamily(active, color.family);
284
- active.push(color);
285
- return;
286
- }
287
- const close = MODIFIER_CLOSE.get(token.code);
288
- if (close !== undefined && close !== SGR_RESET) {
289
- const family = `modifier-${token.code}`;
290
- removeFamily(active, family);
291
- active.push({ family, open: token.open, close });
292
- }
293
- }
294
- const applyParameters = (parameters, active) => {
295
- for (const token of sgrTokens(parameters))
296
- applyToken(token, active);
297
- };
298
- const applyResets = (parameters, active) => {
299
- for (const { code } of sgrTokens(parameters))
300
- applyResetCode(code, active);
301
- };
302
- /** A row that opens with its own resets should not have them undone by the reopening. */
303
- function applyLeadingResets(string, startIndex, active) {
304
- let index = startIndex;
305
- while (index < string.length) {
306
- const match = matchEscape(string, index);
307
- if (match === undefined)
308
- break;
309
- if (match.groups?.['sgr'] !== undefined)
310
- applyResets(match.groups['sgr'], active);
311
- index += match[0].length;
312
- }
313
- }
314
- const closingSequence = (active) => [...active].reverse().map((style) => sgr(style.close)).join('');
315
- const openingSequence = (active) => active.map((style) => sgr(style.open)).join('');
316
64
  /**
317
65
  * Break one long word across rows. Takes the visible width of the row it starts on and
318
66
  * returns the width of the row it ends on, so the caller never measures a row itself.
@@ -520,4 +268,15 @@ export function wrap(string, columns, options = {}) {
520
268
  .map((line) => wrapLine(expandTabs(line), columns, options))
521
269
  .join('\n');
522
270
  }
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
+ export { wrap as default };
523
282
  //# sourceMappingURL=wrap.js.map