linegauge 0.1.0 → 0.2.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/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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linegauge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A printer's line gauge \u2014 the steel rule marked in picas and points. Measuring, wrapping, truncating and slicing styled terminal text without the edge fraying \u2014 grapheme-correct over Intl.Segmenter. Drop-in paths for string-width, wrap-ansi, strip-ansi and slice-ansi. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,6 +17,26 @@
17
17
  "types": "./dist/wrap.d.ts",
18
18
  "import": "./dist/wrap.js",
19
19
  "default": "./dist/wrap.js"
20
+ },
21
+ "./slice": {
22
+ "types": "./dist/slice.d.ts",
23
+ "import": "./dist/slice.js",
24
+ "default": "./dist/slice.js"
25
+ },
26
+ "./truncate": {
27
+ "types": "./dist/truncate.d.ts",
28
+ "import": "./dist/truncate.js",
29
+ "default": "./dist/truncate.js"
30
+ },
31
+ "./widest": {
32
+ "types": "./dist/widest.d.ts",
33
+ "import": "./dist/widest.js",
34
+ "default": "./dist/widest.js"
35
+ },
36
+ "./strip": {
37
+ "types": "./dist/strip.d.ts",
38
+ "import": "./dist/strip.js",
39
+ "default": "./dist/strip.js"
20
40
  }
21
41
  },
22
42
  "files": [
@@ -28,6 +48,7 @@
28
48
  "build": "tsc -p tsconfig.build.json",
29
49
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
50
  "test": "vitest run --passWithNoTests",
51
+ "coverage": "vitest run --coverage.enabled",
31
52
  "lint": "eslint src"
32
53
  },
33
54
  "repository": {
@@ -54,8 +75,6 @@
54
75
  "unicode"
55
76
  ],
56
77
  "devDependencies": {
57
- "string-width": "^8.1.0",
58
- "vitest": "^4.0.0",
59
- "wrap-ansi": "^10.0.1"
78
+ "vitest": "^5.0.0"
60
79
  }
61
80
  }