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/README.md +99 -23
- package/dist/index.d.ts +14 -5
- package/dist/index.js +13 -4
- package/dist/slice.d.ts +10 -0
- package/dist/slice.js +100 -0
- package/dist/strip.d.ts +26 -0
- package/dist/strip.js +62 -0
- package/dist/style.d.ts +71 -0
- package/dist/style.js +276 -0
- package/dist/truncate.d.ts +12 -0
- package/dist/truncate.js +42 -0
- package/dist/widest.d.ts +6 -0
- package/dist/widest.js +25 -0
- package/dist/width.d.ts +35 -3
- package/dist/width.js +110 -12
- package/dist/wrap.d.ts +2 -0
- package/dist/wrap.js +2 -254
- package/package.json +23 -4
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.
|
|
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
|
-
"
|
|
58
|
-
"vitest": "^4.0.0",
|
|
59
|
-
"wrap-ansi": "^10.0.1"
|
|
78
|
+
"vitest": "^5.0.0"
|
|
60
79
|
}
|
|
61
80
|
}
|