linegauge 0.0.1 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ofri Peretz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # linegauge
2
2
 
3
- **Not yet released, and not yet on the roadmap.** This version reserves the name. The layer,
4
- its incumbents and the measurements behind it are documented in
5
- [`.sdlc/research/candidate-layers.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/candidate-layers.md).
6
- An intent and a working release follow if and when the roadmap takes it up.
3
+ **Not yet released.** This version reserves the name. The layer is planned as wave **F1** of the
4
+ foundation tier: its intent and design are at
5
+ [`.sdlc/intents/linegauge/`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/linegauge/),
6
+ under the umbrella
7
+ [`cli-foundation-stack`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/intents/cli-foundation-stack/),
8
+ and the measurements behind it are in
9
+ [`candidate-layers.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/candidate-layers.md)
10
+ and
11
+ [`replacement-map.md`](https://github.com/ofri-peretz/burgee/blob/main/.sdlc/research/replacement-map.md).
12
+ Both artifacts are `draft`; the human gate has not run, and no working release ships before
13
+ burgee's compatibility scoreboard is public.
7
14
 
8
15
  A **line gauge** is the printer's ruler — the steel rule marked in picas and points that a compositor uses to measure a line of type and check it fits the measure it was set to.
9
16
 
@@ -11,7 +18,7 @@ That is this package's whole job. Width, wrap, truncate and slice are one proble
11
18
 
12
19
  ## What it will be
13
20
 
14
- - **One package, not eleven.** `width` · `wrap` · `truncate` · `slice` · `strip` · `widest`. The incumbents split this across `string-width`, `wrap-ansi`, `strip-ansi`, `slice-ansi`, `cli-truncate`, `widest-line`, `string-length`, `wcwidth`, `eastasianwidth` and `get-east-asian-width`.
21
+ - **One package, not twelve.** `width` · `wrap` · `truncate` · `slice` · `strip` · `widest`. The incumbents split this across `strip-ansi`, `string-width`, `ansi-regex`, `wrap-ansi`, `emoji-regex`, `slice-ansi`, `get-east-asian-width`, `eastasianwidth`, `string-length`, `wcwidth`, `cli-truncate` and `widest-line` — **2.16 B weekly downloads** between them.
15
22
  - **Grapheme-correct by construction.** ZWJ families, regional-indicator flags, skin-tone modifiers, keycaps, combining marks and East Asian wide characters, over the platform's own `Intl.Segmenter`.
16
23
  - **Fast path for ASCII.** A byte scan when the string has no non-ASCII code unit; the segmenter only when it earns its cost.
17
24
  - **Drop-in paths** for `string-width`, `wrap-ansi`, `strip-ansi` and `slice-ansi`, graded by their own suites.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,24 @@
1
1
  /**
2
- * linegauge — name reserved, not yet on the roadmap. The layer and the measurements
3
- * behind it are documented in .sdlc/research/candidate-layers.md in the burgee
4
- * repository. Until an intent is opened this entry exports only its own name, so
5
- * that importing it costs nothing and promises nothing.
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.
6
5
  */
7
- export declare const name: "linegauge";
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
+ * Not here yet, and each still at the Design→Build gate: `slice`, `truncate`, `widest`,
19
+ * the R2 ASCII fast path, and an exported `strip`. This release is the move, so that a
20
+ * green `flagstaff` across the deletion proves the consolidation is real before anything
21
+ * new is written on top of it.
22
+ */
23
+ export { lineCount, measure, width, width as default } from './width.js';
24
+ export { wrap, type WrapOptions } from './wrap.js';
package/dist/index.js CHANGED
@@ -1,8 +1,25 @@
1
1
  /**
2
- * linegauge — name reserved, not yet on the roadmap. The layer and the measurements
3
- * behind it are documented in .sdlc/research/candidate-layers.md in the burgee
4
- * repository. Until an intent is opened this entry exports only its own name, so
5
- * that importing it costs nothing and promises nothing.
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.
6
5
  */
7
- export const name = 'linegauge';
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
+ * Not here yet, and each still at the Design→Build gate: `slice`, `truncate`, `widest`,
19
+ * the R2 ASCII fast path, and an exported `strip`. This release is the move, so that a
20
+ * green `flagstaff` across the deletion proves the consolidation is real before anything
21
+ * new is written on top of it.
22
+ */
23
+ export { lineCount, measure, width, width as default } from './width.js';
24
+ export { wrap } from './wrap.js';
8
25
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Columns a string of *plain* text occupies — no escape scan. The wrapper below has
3
+ * already split its input into text runs and complete sequences, so rescanning would only
4
+ * give a malformed sequence a second chance to be mistaken for one.
5
+ */
6
+ export declare function measure(text: string): number;
7
+ /** How many terminal columns `input` occupies once its escape sequences are removed. */
8
+ export declare function width(input: string): number;
9
+ /**
10
+ * Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
11
+ * actually asks for. An empty line still occupies one.
12
+ */
13
+ export declare function lineCount(text: string, columns: number): number;
package/dist/width.js ADDED
@@ -0,0 +1,128 @@
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 — `stripVTControlCharacters`.
12
+ * 2. A grapheme cluster made only of ignorable, control, mark or surrogate code points
13
+ * occupies no column.
14
+ * 3. An RGI emoji sequence is two columns, however many code points it is made of.
15
+ * 4. Otherwise the East Asian Width of the cluster's first visible code point, plus the
16
+ * halfwidth-and-fullwidth forms trailing it in the same cluster (a dakuten).
17
+ *
18
+ * Ambiguous-width characters are counted narrow, which is what a terminal does unless it
19
+ * has been told it is rendering an East Asian locale. `string-width` makes that an option;
20
+ * nothing above this function has ever needed the other answer, so it is not one here.
21
+ */
22
+ import { stripVTControlCharacters } from 'node:util';
23
+ /**
24
+ * East Asian Wide and Fullwidth, as sorted `[low, high]` pairs flattened into one array —
25
+ * Unicode 17's W and F categories, merged where they touch. Generated from the same
26
+ * `EastAsianWidth.txt` derivation everyone uses; a binary search over 122 ranges is the
27
+ * whole lookup.
28
+ */
29
+ const WIDE = [
30
+ 0x1100, 0x115F, 0x231A, 0x231B, 0x2329, 0x232A, 0x23E9, 0x23EC, 0x23F0, 0x23F0,
31
+ 0x23F3, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615, 0x2630, 0x2637, 0x2648, 0x2653,
32
+ 0x267F, 0x267F, 0x268A, 0x268F, 0x2693, 0x2693, 0x26A1, 0x26A1, 0x26AA, 0x26AB,
33
+ 0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26CE, 0x26D4, 0x26D4, 0x26EA, 0x26EA,
34
+ 0x26F2, 0x26F3, 0x26F5, 0x26F5, 0x26FA, 0x26FA, 0x26FD, 0x26FD, 0x2705, 0x2705,
35
+ 0x270A, 0x270B, 0x2728, 0x2728, 0x274C, 0x274C, 0x274E, 0x274E, 0x2753, 0x2755,
36
+ 0x2757, 0x2757, 0x2795, 0x2797, 0x27B0, 0x27B0, 0x27BF, 0x27BF, 0x2B1B, 0x2B1C,
37
+ 0x2B50, 0x2B50, 0x2B55, 0x2B55, 0x2E80, 0x2E99, 0x2E9B, 0x2EF3, 0x2F00, 0x2FD5,
38
+ 0x2FF0, 0x303E, 0x3041, 0x3096, 0x3099, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E,
39
+ 0x3190, 0x31E5, 0x31EF, 0x321E, 0x3220, 0x3247, 0x3250, 0xA48C, 0xA490, 0xA4C6,
40
+ 0xA960, 0xA97C, 0xAC00, 0xD7A3, 0xF900, 0xFAFF, 0xFE10, 0xFE19, 0xFE30, 0xFE52,
41
+ 0xFE54, 0xFE66, 0xFE68, 0xFE6B, 0xFF01, 0xFF60, 0xFFE0, 0xFFE6, 0x16FE0, 0x16FE4,
42
+ 0x16FF0, 0x16FF1, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08, 0x1AFF0, 0x1AFF3,
43
+ 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
44
+ 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1D300, 0x1D356, 0x1D360, 0x1D376,
45
+ 0x1F004, 0x1F004, 0x1F0CF, 0x1F0CF, 0x1F18E, 0x1F18E, 0x1F191, 0x1F19A, 0x1F200, 0x1F202,
46
+ 0x1F210, 0x1F23B, 0x1F240, 0x1F248, 0x1F250, 0x1F251, 0x1F260, 0x1F265, 0x1F300, 0x1F320,
47
+ 0x1F32D, 0x1F335, 0x1F337, 0x1F37C, 0x1F37E, 0x1F393, 0x1F3A0, 0x1F3CA, 0x1F3CF, 0x1F3D3,
48
+ 0x1F3E0, 0x1F3F0, 0x1F3F4, 0x1F3F4, 0x1F3F8, 0x1F43E, 0x1F440, 0x1F440, 0x1F442, 0x1F4FC,
49
+ 0x1F4FF, 0x1F53D, 0x1F54B, 0x1F54E, 0x1F550, 0x1F567, 0x1F57A, 0x1F57A, 0x1F595, 0x1F596,
50
+ 0x1F5A4, 0x1F5A4, 0x1F5FB, 0x1F64F, 0x1F680, 0x1F6C5, 0x1F6CC, 0x1F6CC, 0x1F6D0, 0x1F6D2,
51
+ 0x1F6D5, 0x1F6D7, 0x1F6DC, 0x1F6DF, 0x1F6EB, 0x1F6EC, 0x1F6F4, 0x1F6FC, 0x1F7E0, 0x1F7EB,
52
+ 0x1F7F0, 0x1F7F0, 0x1F90C, 0x1F93A, 0x1F93C, 0x1F945, 0x1F947, 0x1F9FF, 0x1FA70, 0x1FA7C,
53
+ 0x1FA80, 0x1FA89, 0x1FA8F, 0x1FAC6, 0x1FACE, 0x1FADC, 0x1FADF, 0x1FAE9, 0x1FAF0, 0x1FAF8,
54
+ 0x20000, 0x2FFFD, 0x30000, 0x3FFFD,
55
+ ];
56
+ const NARROW = 1;
57
+ const WIDE_COLUMNS = 2;
58
+ /** Half the flat array is lows, so a step over pairs. */
59
+ const PAIR = 2;
60
+ function isWide(codePoint) {
61
+ let low = 0;
62
+ let high = WIDE.length / PAIR - 1;
63
+ while (low <= high) {
64
+ const mid = (low + high) >> 1;
65
+ const start = WIDE[mid * PAIR] ?? 0;
66
+ const end = WIDE[mid * PAIR + 1] ?? 0;
67
+ if (codePoint < start)
68
+ high = mid - 1;
69
+ else if (codePoint > end)
70
+ low = mid + 1;
71
+ else
72
+ return true;
73
+ }
74
+ return false;
75
+ }
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;
79
+ const RGI_EMOJI = /^\p{RGI_Emoji}$/v;
80
+ /** The Halfwidth and Fullwidth Forms block, which a cluster can carry after its base. */
81
+ const FORMS_FIRST = 0xff00;
82
+ const FORMS_LAST = 0xffef;
83
+ 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) {
86
+ let extra = 0;
87
+ for (const character of [...cluster].slice(1)) {
88
+ const codePoint = character.codePointAt(0) ?? 0;
89
+ if (codePoint >= FORMS_FIRST && codePoint <= FORMS_LAST)
90
+ extra += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
91
+ }
92
+ return extra;
93
+ }
94
+ /**
95
+ * Columns a string of *plain* text occupies — no escape scan. The wrapper below has
96
+ * already split its input into text runs and complete sequences, so rescanning would only
97
+ * give a malformed sequence a second chance to be mistaken for one.
98
+ */
99
+ export function measure(text) {
100
+ let columns = 0;
101
+ for (const { segment } of segmenter.segment(text)) {
102
+ if (ZERO_WIDTH_CLUSTER.test(segment))
103
+ continue;
104
+ if (RGI_EMOJI.test(segment)) {
105
+ columns += WIDE_COLUMNS;
106
+ continue;
107
+ }
108
+ const codePoint = segment.replace(LEADING_NON_PRINTING, '').codePointAt(0) ?? 0;
109
+ columns += isWide(codePoint) ? WIDE_COLUMNS : NARROW;
110
+ columns += trailingForms(segment);
111
+ }
112
+ return columns;
113
+ }
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));
117
+ }
118
+ /**
119
+ * Lines a string occupies in a terminal `columns` wide — the measurement the frame loop
120
+ * actually asks for. An empty line still occupies one.
121
+ */
122
+ export function lineCount(text, columns) {
123
+ let count = 0;
124
+ for (const line of stripVTControlCharacters(text).split('\n'))
125
+ count += Math.max(1, Math.ceil(width(line) / columns));
126
+ return count;
127
+ }
128
+ //# sourceMappingURL=width.js.map
package/dist/wrap.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface WrapOptions {
2
+ /** Trim leading and trailing whitespace from each row. Default true. */
3
+ trim?: boolean;
4
+ /** Break a word longer than `columns` rather than let it overflow. Default false. */
5
+ hard?: boolean;
6
+ /** Break on any character rather than at word boundaries. Default true. */
7
+ wordWrap?: boolean;
8
+ }
9
+ /** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
10
+ export declare function wrap(string: string, columns: number, options?: WrapOptions): string;
package/dist/wrap.js ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Wrapping text that carries ANSI, ported from wrap-ansi 10 — the third dependency the
3
+ * render façades share, after the spinner corpus and the width function (R7, R10).
4
+ *
5
+ * The hard part is not the arithmetic, it is that a style opened on one row must not leak
6
+ * into the next: a terminal that reflows, a pager, or an agent reading one line at a time
7
+ * all see rows independently. So every row closes the styles it inherited and the next row
8
+ * reopens them, which has a second use here — after wrapping, **each row is self-contained**,
9
+ * and dropping leading rows needs no ANSI state tracking at all. `flagstaff/log-update`
10
+ * relies on exactly that, which is why it carries no port of `slice-ansi`.
11
+ *
12
+ * Graded differentially against the real `wrap-ansi` in `wrap.test.ts`, the way `width.ts`
13
+ * is graded against `string-width`: the incumbent is the specification.
14
+ */
15
+ 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
+ /** The visible width of a string, escape sequences ignored. */
121
+ function visibleWidth(string) {
122
+ let plainText = '';
123
+ forEachSegment(string, (part) => {
124
+ plainText += part;
125
+ });
126
+ return measure(plainText);
127
+ }
128
+ /**
129
+ * Escape sequences, which are zero width and must never be split, and grapheme clusters.
130
+ * A sequence written *inside* a cluster splits it — the supported boundary is between
131
+ * clusters and sequences, not within one.
132
+ */
133
+ function tokenize(string) {
134
+ const tokens = [];
135
+ forEachSegment(string, (plainText) => {
136
+ if (ASCII_PRINTABLE.test(plainText)) {
137
+ for (const character of plainText)
138
+ tokens.push({ value: character, width: 1 });
139
+ return;
140
+ }
141
+ for (const { segment } of segmenter.segment(plainText))
142
+ tokens.push({ value: segment, width: measure(segment) });
143
+ }, (escape) => tokens.push({ value: escape, width: 0 }));
144
+ return tokens;
145
+ }
146
+ /** Split on spaces, ignoring spaces that appear inside a recognised sequence. */
147
+ function splitWords(string) {
148
+ let current = { value: '', plainText: '', width: 0 };
149
+ const words = [current];
150
+ forEachSegment(string, (plainText) => {
151
+ const parts = plainText.split(' ');
152
+ current.value += parts[0] ?? '';
153
+ current.plainText += parts[0] ?? '';
154
+ for (let index = 1; index < parts.length; index += 1) {
155
+ const part = parts[index] ?? '';
156
+ current = { value: part, plainText: part, width: 0 };
157
+ words.push(current);
158
+ }
159
+ }, (escape) => {
160
+ current.value += escape;
161
+ });
162
+ // Measured once per word rather than per run, so a cluster an escape splits counts once.
163
+ for (const word of words)
164
+ word.width = measure(word.plainText);
165
+ return words;
166
+ }
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
+ /**
317
+ * Break one long word across rows. Takes the visible width of the row it starts on and
318
+ * returns the width of the row it ends on, so the caller never measures a row itself.
319
+ */
320
+ function wrapWord(rows, word, columns, rowWidth) {
321
+ const tokens = tokenize(word);
322
+ let visible = rowWidth;
323
+ for (const [index, token] of tokens.entries()) {
324
+ // Sequences and combining marks are zero width, so they stay on the current row.
325
+ if (token.width > 0 && visible > 0 && visible + token.width > columns) {
326
+ rows.push('');
327
+ visible = 0;
328
+ }
329
+ rows[rows.length - 1] += token.value;
330
+ visible += token.width;
331
+ if (visible === columns && index < tokens.length - 1) {
332
+ rows.push('');
333
+ visible = 0;
334
+ }
335
+ }
336
+ // The last row copied over can be nothing but escape characters.
337
+ const last = rows.at(-1) ?? '';
338
+ if (!visible && last.length > 0 && rows.length > 1)
339
+ rows[rows.length - 2] += rows.pop() ?? '';
340
+ // Tokens are measured one at a time, so a cluster an escape splits counts once per part.
341
+ // Only the finished row gives the true width, and it is at most one row to measure.
342
+ return visibleWidth(rows.at(-1) ?? '');
343
+ }
344
+ /** Drop the spaces trailing the last visible character, keeping the sequences among them. */
345
+ function trimVisibleEnd(string) {
346
+ if (!string.includes(' '))
347
+ return string;
348
+ const segments = [];
349
+ forEachSegment(string, (plainText) => segments.push({ value: plainText, isEscape: false }), (escape) => segments.push({ value: escape, isEscape: true }));
350
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
351
+ const segment = segments[index];
352
+ if (segment === undefined || segment.isEscape)
353
+ continue;
354
+ // Scanned rather than matched: a trailing-space pattern backtracks quadratically.
355
+ let end = segment.value.length;
356
+ while (end > 0 && segment.value[end - 1] === ' ')
357
+ end -= 1;
358
+ segment.value = segment.value.slice(0, end);
359
+ if (measure(segment.value) > 0)
360
+ break;
361
+ }
362
+ return segments.map((segment) => segment.value).join('');
363
+ }
364
+ function expandTabs(line) {
365
+ if (!line.includes('\t'))
366
+ return line;
367
+ let visible = 0;
368
+ let expanded = '';
369
+ let sinceTab = '';
370
+ forEachSegment(line, (plainText) => {
371
+ const parts = plainText.split('\t');
372
+ for (const [index, part] of parts.entries()) {
373
+ expanded += part;
374
+ sinceTab += part;
375
+ if (index < parts.length - 1) {
376
+ visible += measure(sinceTab);
377
+ sinceTab = '';
378
+ const spaces = TAB_SIZE - (visible % TAB_SIZE);
379
+ expanded += ' '.repeat(spaces);
380
+ visible += spaces;
381
+ }
382
+ }
383
+ }, (escape) => {
384
+ expanded += escape;
385
+ });
386
+ return expanded;
387
+ }
388
+ /**
389
+ * Close the active styles and hyperlink before every row break and reopen them after, so
390
+ * each row stands on its own. Only sequences and newlines matter, so the string is scanned
391
+ * directly rather than segmented.
392
+ */
393
+ function restoreStylesAcrossRows(preString) {
394
+ let out = '';
395
+ let activeHyperlink;
396
+ const active = [];
397
+ let index = 0;
398
+ let copied = 0;
399
+ while (index < preString.length) {
400
+ ROW_BOUNDARY.lastIndex = index;
401
+ const boundary = ROW_BOUNDARY.exec(preString);
402
+ if (boundary === null)
403
+ break;
404
+ index = boundary.index;
405
+ if (boundary[0] !== '\n') {
406
+ const escape = matchEscape(preString, index);
407
+ if (escape === undefined) {
408
+ index += 1;
409
+ continue;
410
+ }
411
+ const groups = escape.groups ?? {};
412
+ if (groups['sgr'] !== undefined)
413
+ applyParameters(groups['sgr'], active);
414
+ else if (groups['uri'] !== undefined)
415
+ activeHyperlink = groups['uri'].length === 0 ? undefined : { parameters: groups['parameters'] ?? '', uri: groups['uri'] };
416
+ index += escape[0].length;
417
+ continue;
418
+ }
419
+ // Everything up to the row break is copied verbatim, sequences included.
420
+ out += preString.slice(copied, index);
421
+ // An empty row never reopened anything, so there is nothing to close.
422
+ if (index > copied) {
423
+ if (activeHyperlink !== undefined)
424
+ out += hyperlink('');
425
+ out += closingSequence(active);
426
+ }
427
+ out += '\n';
428
+ index += 1;
429
+ copied = index;
430
+ // An empty row has nothing to style, so the styles stay closed until the next row with
431
+ // content; a trailing row break leaves no row at all.
432
+ if (index < preString.length && preString[index] !== '\n') {
433
+ const opening = [...active];
434
+ applyLeadingResets(preString, index, opening);
435
+ out += openingSequence(opening);
436
+ if (activeHyperlink !== undefined)
437
+ out += hyperlink(activeHyperlink.uri, activeHyperlink.parameters);
438
+ }
439
+ }
440
+ return out + preString.slice(copied);
441
+ }
442
+ /** Whether a word longer than the row should start on the next row instead of this one. */
443
+ function shouldStartLongWordOnNextRow(wordWidth, columns, rowLength) {
444
+ const remaining = columns - rowLength;
445
+ const breaksStartingThisRow = 1 + Math.floor((wordWidth - remaining - 1) / columns);
446
+ const breaksStartingNextRow = Math.floor((wordWidth - 1) / columns);
447
+ return breaksStartingNextRow < breaksStartingThisRow;
448
+ }
449
+ /** One line of input — the caller has already split on newlines. */
450
+ function wrapLine(string, columns, options) {
451
+ const trim = options.trim !== false;
452
+ if (trim && string.trim() === '')
453
+ return '';
454
+ const words = splitWords(string);
455
+ let rows = [''];
456
+ // Tracked as rows are built: remeasuring per word makes wrapping quadratic in the line.
457
+ let rowLength = 0;
458
+ // A row that already starts with content can never become trimmable again.
459
+ let trimmedRowIndex = -1;
460
+ let isFirstWord = true;
461
+ for (const word of words) {
462
+ const rowIndex = rows.length - 1;
463
+ if (trim && trimmedRowIndex !== rowIndex) {
464
+ const row = rows[rowIndex] ?? '';
465
+ const trimmedRow = row.trimStart();
466
+ if (trimmedRow.length !== row.length) {
467
+ rows[rowIndex] = trimmedRow;
468
+ rowLength = visibleWidth(trimmedRow);
469
+ }
470
+ if (trimmedRow.length > 0)
471
+ trimmedRowIndex = rowIndex;
472
+ }
473
+ if (isFirstWord) {
474
+ isFirstWord = false;
475
+ }
476
+ else {
477
+ if (rowLength >= columns && (options.wordWrap === false || !trim)) {
478
+ rows.push('');
479
+ rowLength = 0;
480
+ }
481
+ if (rowLength > 0 || !trim) {
482
+ rows[rows.length - 1] += ' ';
483
+ rowLength += 1;
484
+ }
485
+ }
486
+ // 'hard': a row is never allowed to extend past `columns`.
487
+ if (options.hard === true && options.wordWrap !== false && word.width > columns) {
488
+ if (shouldStartLongWordOnNextRow(word.width, columns, rowLength)) {
489
+ rows.push('');
490
+ rowLength = 0;
491
+ }
492
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
493
+ continue;
494
+ }
495
+ if (rowLength + word.width > columns && rowLength > 0 && word.width > 0) {
496
+ if (options.wordWrap === false && rowLength < columns) {
497
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
498
+ continue;
499
+ }
500
+ rows.push('');
501
+ rowLength = 0;
502
+ }
503
+ if (rowLength + word.width > columns && options.wordWrap === false) {
504
+ rowLength = wrapWord(rows, word.value, columns, rowLength);
505
+ continue;
506
+ }
507
+ rows[rows.length - 1] += word.value;
508
+ rowLength += word.width;
509
+ }
510
+ if (trim)
511
+ rows = rows.map((row) => trimVisibleEnd(row));
512
+ return restoreStylesAcrossRows(rows.join('\n'));
513
+ }
514
+ /** Wrap `string` to `columns`, keeping its ANSI intact and each row self-contained. */
515
+ export function wrap(string, columns, options = {}) {
516
+ return String(string)
517
+ .normalize()
518
+ .replaceAll('\r\n', '\n')
519
+ .split('\n')
520
+ .map((line) => wrapLine(expandTabs(line), columns, options))
521
+ .join('\n');
522
+ }
523
+ //# sourceMappingURL=wrap.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "linegauge",
3
- "version": "0.0.1",
4
- "description": "The width of material a saw removes in a cut. 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.",
3
+ "version": "0.1.0",
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",
7
7
  "engines": {
@@ -12,6 +12,11 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js",
14
14
  "default": "./dist/index.js"
15
+ },
16
+ "./wrap": {
17
+ "types": "./dist/wrap.d.ts",
18
+ "import": "./dist/wrap.js",
19
+ "default": "./dist/wrap.js"
15
20
  }
16
21
  },
17
22
  "files": [
@@ -49,6 +54,8 @@
49
54
  "unicode"
50
55
  ],
51
56
  "devDependencies": {
52
- "vitest": "^4.0.0"
57
+ "string-width": "^8.1.0",
58
+ "vitest": "^4.0.0",
59
+ "wrap-ansi": "^10.0.1"
53
60
  }
54
61
  }