teemux 1.0.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.
@@ -0,0 +1,118 @@
1
+ import { stripHtmlTags } from './stripHtmlTags.js';
2
+ import { unescapeHtml } from './unescapeHtml.js';
3
+
4
+ /**
5
+ * Apply syntax highlighting to JSON text that uses HTML-escaped quotes (").
6
+ * Uses placeholder technique to avoid double-wrapping strings.
7
+ */
8
+ export const highlightJsonText = (text: string): string => {
9
+ // First, extract and mark all JSON strings with placeholders
10
+ const strings: string[] = [];
11
+ let result = text.replaceAll(
12
+ /"((?:(?!").)*)"/gu,
13
+ (_match, content) => {
14
+ strings.push(content as string);
15
+ return `\u0000STR${strings.length - 1}\u0000`;
16
+ },
17
+ );
18
+
19
+ // Booleans and null
20
+ result = result.replaceAll(
21
+ /\b(true|false|null)\b/gu,
22
+ '<span class="json-bool">$1</span>',
23
+ );
24
+
25
+ // Numbers
26
+ result = result.replaceAll(
27
+ /(?<!\w)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/gu,
28
+ '<span class="json-number">$1</span>',
29
+ );
30
+
31
+ // Restore strings with appropriate highlighting
32
+ result = result.replaceAll(
33
+ /\0STR(\d+)\0(\s*:)?/gu,
34
+ (_match, index, colon) => {
35
+ const content = strings[Number.parseInt(index as string, 10)];
36
+ if (colon) {
37
+ // This is a key
38
+ return `<span class="json-key">&quot;${content}&quot;</span>${colon}`;
39
+ }
40
+
41
+ // This is a value
42
+ return `<span class="json-string">&quot;${content}&quot;</span>`;
43
+ },
44
+ );
45
+
46
+ return result;
47
+ };
48
+
49
+ /**
50
+ * Process HTML text, applying JSON highlighting only to text outside of HTML tags.
51
+ */
52
+ export const syntaxHighlightJson = (html: string): string => {
53
+ let result = '';
54
+ let index = 0;
55
+
56
+ while (index < html.length) {
57
+ if (html[index] === '<') {
58
+ // Find end of tag
59
+ const tagEnd = html.indexOf('>', index);
60
+ if (tagEnd === -1) {
61
+ result += html.slice(index);
62
+ break;
63
+ }
64
+ result += html.slice(index, tagEnd + 1);
65
+ index = tagEnd + 1;
66
+ } else {
67
+ // Find next tag or end of string
68
+ const nextTag = html.indexOf('<', index);
69
+ const textEnd = nextTag === -1 ? html.length : nextTag;
70
+ const text = html.slice(index, textEnd);
71
+
72
+ // Highlight JSON syntax in this text segment
73
+ result += highlightJsonText(text);
74
+ index = textEnd;
75
+ }
76
+ }
77
+
78
+ return result;
79
+ };
80
+
81
+ /**
82
+ * Detect if the content (after prefix) is valid JSON and apply syntax highlighting.
83
+ * Returns the original HTML if not valid JSON.
84
+ */
85
+ export const highlightJson = (html: string): string => {
86
+ // Extract the text content (strip HTML tags) to check if it's JSON
87
+ const textContent = stripHtmlTags(html);
88
+
89
+ // Unescape HTML entities for JSON parsing
90
+ const unescaped = unescapeHtml(textContent);
91
+
92
+ // Find where the actual log content starts (after the prefix like [name])
93
+ const prefixMatch = /^(\[[\w-]+\]\s*)/u.exec(unescaped);
94
+ const prefix = prefixMatch?.[0] ?? '';
95
+ const content = unescaped.slice(prefix.length).trim();
96
+
97
+ // Check if the content is valid JSON
98
+ if (!content.startsWith('{') && !content.startsWith('[')) {
99
+ return html;
100
+ }
101
+
102
+ try {
103
+ JSON.parse(content);
104
+ } catch {
105
+ return html;
106
+ }
107
+
108
+ // It's valid JSON - now highlight it
109
+ // Find the position after the prefix span in the HTML
110
+ const prefixHtmlMatch = /^(<span[^>]*>\[[^\]]+\]<\/span>\s*)/u.exec(html);
111
+ const htmlPrefix = prefixHtmlMatch?.[0] ?? '';
112
+ const jsonHtml = html.slice(htmlPrefix.length);
113
+
114
+ // Apply syntax highlighting to the JSON portion
115
+ const highlighted = syntaxHighlightJson(jsonHtml);
116
+
117
+ return htmlPrefix + highlighted;
118
+ };
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { linkifyUrls } from './linkifyUrls.js';
3
+
4
+ describe('linkifyUrls', () => {
5
+ it('converts http URLs to links', () => {
6
+ const input = 'Visit http://example.com for more';
7
+ const result = linkifyUrls(input);
8
+
9
+ expect(result).toBe(
10
+ 'Visit <a href="http://example.com" target="_blank" rel="noopener">http://example.com</a> for more',
11
+ );
12
+ });
13
+
14
+ it('converts https URLs to links', () => {
15
+ const input = 'Secure: https://example.com/path';
16
+ const result = linkifyUrls(input);
17
+
18
+ expect(result).toBe(
19
+ 'Secure: <a href="https://example.com/path" target="_blank" rel="noopener">https://example.com/path</a>',
20
+ );
21
+ });
22
+
23
+ it('converts file:// URLs to links', () => {
24
+ const input = 'Open file:///Users/test/file.txt';
25
+ const result = linkifyUrls(input);
26
+
27
+ expect(result).toBe(
28
+ 'Open <a href="file:///Users/test/file.txt" target="_blank" rel="noopener">file:///Users/test/file.txt</a>',
29
+ );
30
+ });
31
+
32
+ it('handles URLs with query parameters', () => {
33
+ const input = 'Link: https://example.com/search?q=test';
34
+ const result = linkifyUrls(input);
35
+
36
+ expect(result).toContain('href="https://example.com/search?q=test"');
37
+ });
38
+
39
+ it('handles URLs with port numbers', () => {
40
+ const input = 'Server at http://localhost:3000/api';
41
+ const result = linkifyUrls(input);
42
+
43
+ expect(result).toContain('href="http://localhost:3000/api"');
44
+ });
45
+
46
+ it('strips trailing periods', () => {
47
+ const input = 'Visit http://example.com.';
48
+ const result = linkifyUrls(input);
49
+
50
+ expect(result).toBe(
51
+ 'Visit <a href="http://example.com" target="_blank" rel="noopener">http://example.com</a>.',
52
+ );
53
+ });
54
+
55
+ it('strips trailing commas', () => {
56
+ const input = 'URLs: http://a.com, http://b.com';
57
+ const result = linkifyUrls(input);
58
+
59
+ expect(result).toContain(
60
+ '<a href="http://a.com" target="_blank" rel="noopener">http://a.com</a>,',
61
+ );
62
+ expect(result).toContain(
63
+ '<a href="http://b.com" target="_blank" rel="noopener">http://b.com</a>',
64
+ );
65
+ });
66
+
67
+ it('strips trailing parentheses', () => {
68
+ const input = '(see http://example.com)';
69
+ const result = linkifyUrls(input);
70
+
71
+ expect(result).toBe(
72
+ '(see <a href="http://example.com" target="_blank" rel="noopener">http://example.com</a>)',
73
+ );
74
+ });
75
+
76
+ it('strips trailing brackets', () => {
77
+ const input = '[http://example.com]';
78
+ const result = linkifyUrls(input);
79
+
80
+ expect(result).toBe(
81
+ '[<a href="http://example.com" target="_blank" rel="noopener">http://example.com</a>]',
82
+ );
83
+ });
84
+
85
+ it('does not double-link existing href attributes', () => {
86
+ const input = '<a href="http://example.com">link</a>';
87
+ const result = linkifyUrls(input);
88
+
89
+ // Should not add another <a> tag
90
+ expect(result).toBe(input);
91
+ });
92
+
93
+ it('handles multiple URLs in one string', () => {
94
+ const input = 'Check http://a.com and http://b.com';
95
+ const result = linkifyUrls(input);
96
+
97
+ expect(result).toContain('href="http://a.com"');
98
+ expect(result).toContain('href="http://b.com"');
99
+ });
100
+
101
+ it('escapes ampersands in href', () => {
102
+ const input = 'http://example.com/path?a=1&b=2';
103
+ const result = linkifyUrls(input);
104
+
105
+ // Ampersand is NOT in the URL because regex excludes &
106
+ // This is intentional to avoid capturing HTML entities like &quot;
107
+ expect(result).toContain('href="http://example.com/path?a=1"');
108
+ });
109
+
110
+ it('does not match URLs inside HTML attributes', () => {
111
+ const input = '<img src="http://example.com/img.png">';
112
+ const result = linkifyUrls(input);
113
+
114
+ // The URL in src should not be linkified (it's in quotes after =)
115
+ // Note: current implementation only checks href=, so src= would be linkified
116
+ // This test documents current behavior
117
+ expect(result).toContain('<a href="http://example.com/img.png"');
118
+ });
119
+
120
+ it('handles URLs with hash fragments', () => {
121
+ const input = 'See http://example.com/page#section';
122
+ const result = linkifyUrls(input);
123
+
124
+ expect(result).toContain('href="http://example.com/page#section"');
125
+ });
126
+
127
+ it('handles file URLs with spaces encoded', () => {
128
+ const input = 'file:///Users/test/my%20file.txt';
129
+ const result = linkifyUrls(input);
130
+
131
+ expect(result).toContain('href="file:///Users/test/my%20file.txt"');
132
+ });
133
+
134
+ it('returns text unchanged if no URLs', () => {
135
+ const input = 'Just some regular text without URLs';
136
+ const result = linkifyUrls(input);
137
+
138
+ expect(result).toBe(input);
139
+ });
140
+
141
+ it('handles URLs at start of string', () => {
142
+ const input = 'http://example.com is the site';
143
+ const result = linkifyUrls(input);
144
+
145
+ expect(result).toContain('<a href="http://example.com"');
146
+ });
147
+
148
+ it('handles URLs at end of string', () => {
149
+ const input = 'The site is http://example.com';
150
+ const result = linkifyUrls(input);
151
+
152
+ expect(result).toContain('href="http://example.com"');
153
+ });
154
+
155
+ it('handles IPv4 addresses in URLs', () => {
156
+ const input = 'Server at http://192.168.1.1:8080/api';
157
+ const result = linkifyUrls(input);
158
+
159
+ expect(result).toContain('href="http://192.168.1.1:8080/api"');
160
+ });
161
+
162
+ it('handles localhost URLs', () => {
163
+ const input = 'Dev server: http://localhost:3000';
164
+ const result = linkifyUrls(input);
165
+
166
+ expect(result).toContain('href="http://localhost:3000"');
167
+ });
168
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Convert URLs in HTML text to clickable anchor tags.
3
+ * Supports http://, https://, and file:// URLs.
4
+ * Avoids double-linking URLs that are already in href attributes.
5
+ */
6
+ export const linkifyUrls = (html: string): string => {
7
+ // Match URLs that are not already inside href attributes
8
+ // Supports http://, https://, and file:// URLs
9
+ // Exclude common delimiters and HTML entities (&quot; &amp; etc)
10
+ const urlRegex = /(?<!href=["'])(?:https?|file):\/\/[^\s<>"'{}&]+/gu;
11
+
12
+ return html.replaceAll(urlRegex, (url) => {
13
+ // Remove trailing punctuation that's likely not part of the URL
14
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/u, '');
15
+ const trailing = url.slice(cleanUrl.length);
16
+
17
+ // Escape HTML entities in the URL for the href attribute
18
+ const escapedHref = cleanUrl
19
+ .replaceAll('&', '&amp;')
20
+ .replaceAll('"', '&quot;');
21
+
22
+ return `<a href="${escapedHref}" target="_blank" rel="noopener">${cleanUrl}</a>${trailing}`;
23
+ });
24
+ };
@@ -0,0 +1,203 @@
1
+ import { matchesFilters } from './matchesFilters.js';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ describe('matchesFilters', () => {
5
+ describe('with no filters', () => {
6
+ it('returns true for any line', () => {
7
+ expect(matchesFilters('any text', [], [])).toBe(true);
8
+ });
9
+
10
+ it('returns true for empty line', () => {
11
+ expect(matchesFilters('', [], [])).toBe(true);
12
+ });
13
+ });
14
+
15
+ describe('include filtering (OR logic)', () => {
16
+ it('matches when single include is present', () => {
17
+ expect(matchesFilters('hello world', ['hello'], [])).toBe(true);
18
+ });
19
+
20
+ it('does not match when single include is absent', () => {
21
+ expect(matchesFilters('hello world', ['foo'], [])).toBe(false);
22
+ });
23
+
24
+ it('matches when any include is present', () => {
25
+ expect(matchesFilters('hello world', ['hello', 'foo'], [])).toBe(true);
26
+ });
27
+
28
+ it('matches when all includes are present', () => {
29
+ expect(matchesFilters('hello world foo', ['hello', 'foo'], [])).toBe(
30
+ true,
31
+ );
32
+ });
33
+
34
+ it('does not match when no includes are present', () => {
35
+ expect(matchesFilters('hello world', ['foo', 'bar'], [])).toBe(false);
36
+ });
37
+
38
+ it('is case insensitive', () => {
39
+ expect(matchesFilters('Hello World', ['hello'], [])).toBe(true);
40
+ expect(matchesFilters('hello world', ['HELLO'], [])).toBe(true);
41
+ });
42
+
43
+ it('matches partial words', () => {
44
+ expect(matchesFilters('hello world', ['ell'], [])).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('exclude filtering (OR logic)', () => {
49
+ it('excludes when single pattern matches', () => {
50
+ expect(matchesFilters('error occurred', [], ['error'])).toBe(false);
51
+ });
52
+
53
+ it('includes when single pattern does not match', () => {
54
+ expect(matchesFilters('info message', [], ['error'])).toBe(true);
55
+ });
56
+
57
+ it('excludes when any pattern matches', () => {
58
+ expect(matchesFilters('warning message', [], ['error', 'warning'])).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ it('includes when no patterns match', () => {
64
+ expect(matchesFilters('info message', [], ['error', 'warning'])).toBe(
65
+ true,
66
+ );
67
+ });
68
+
69
+ it('is case insensitive', () => {
70
+ expect(matchesFilters('ERROR occurred', [], ['error'])).toBe(false);
71
+ expect(matchesFilters('error occurred', [], ['ERROR'])).toBe(false);
72
+ });
73
+
74
+ it('matches partial words', () => {
75
+ expect(matchesFilters('error123', [], ['err'])).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('combined include and exclude', () => {
80
+ it('includes when include matches and exclude does not', () => {
81
+ expect(matchesFilters('info: success', ['info'], ['error'])).toBe(true);
82
+ });
83
+
84
+ it('excludes when both include and exclude match', () => {
85
+ expect(matchesFilters('error: info', ['info'], ['error'])).toBe(false);
86
+ });
87
+
88
+ it('excludes when include does not match', () => {
89
+ expect(matchesFilters('hello world', ['foo'], ['bar'])).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('with ANSI codes', () => {
94
+ it('ignores ANSI codes when matching includes', () => {
95
+ const line = '\u001B[31merror\u001B[0m: something failed';
96
+ expect(matchesFilters(line, ['error'], [])).toBe(true);
97
+ });
98
+
99
+ it('ignores ANSI codes when matching excludes', () => {
100
+ const line = '\u001B[32minfo\u001B[0m: all good';
101
+ expect(matchesFilters(line, [], ['error'])).toBe(true);
102
+ });
103
+
104
+ it('matches text inside ANSI codes', () => {
105
+ const line = '\u001B[36m[server]\u001B[0m Starting...';
106
+ expect(matchesFilters(line, ['server'], [])).toBe(true);
107
+ });
108
+
109
+ it('handles complex colored output', () => {
110
+ const line =
111
+ '\u001B[90m[teemux]\u001B[0m \u001B[1;31mERROR\u001B[0m: Connection failed';
112
+ // OR logic: matches if error OR connection is present (both are)
113
+ expect(matchesFilters(line, ['error', 'connection'], [])).toBe(true);
114
+ // Also matches with just one term present
115
+ expect(matchesFilters(line, ['error', 'foobar'], [])).toBe(true);
116
+ expect(matchesFilters(line, [], ['error'])).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe('glob patterns with *', () => {
121
+ it('matches with * at the end', () => {
122
+ expect(matchesFilters('error: something failed', ['error*'], [])).toBe(
123
+ true,
124
+ );
125
+ expect(matchesFilters('info: all good', ['error*'], [])).toBe(false);
126
+ });
127
+
128
+ it('matches with * at the start', () => {
129
+ expect(matchesFilters('request failed', ['*failed'], [])).toBe(true);
130
+ expect(matchesFilters('request succeeded', ['*failed'], [])).toBe(false);
131
+ });
132
+
133
+ it('matches with * in the middle', () => {
134
+ expect(matchesFilters('[api] error occurred', ['[api]*error'], [])).toBe(
135
+ true,
136
+ );
137
+ expect(
138
+ matchesFilters('[worker] error occurred', ['[api]*error'], []),
139
+ ).toBe(false);
140
+ });
141
+
142
+ it('matches with multiple *', () => {
143
+ expect(matchesFilters('[api] GET /users 200', ['*GET*/users*'], [])).toBe(
144
+ true,
145
+ );
146
+ expect(
147
+ matchesFilters('[api] POST /users 201', ['*GET*/users*'], []),
148
+ ).toBe(false);
149
+ });
150
+
151
+ it('matches * as any characters (including empty)', () => {
152
+ expect(matchesFilters('error', ['error*'], [])).toBe(true);
153
+ expect(matchesFilters('error:', ['error*'], [])).toBe(true);
154
+ expect(matchesFilters('error: failed', ['error*'], [])).toBe(true);
155
+ });
156
+
157
+ it('is case insensitive with globs', () => {
158
+ expect(matchesFilters('ERROR: failed', ['error*'], [])).toBe(true);
159
+ expect(matchesFilters('error: FAILED', ['*FAILED'], [])).toBe(true);
160
+ });
161
+
162
+ it('works with excludes', () => {
163
+ expect(matchesFilters('healthcheck OK', [], ['health*'])).toBe(false);
164
+ expect(matchesFilters('error occurred', [], ['health*'])).toBe(true);
165
+ });
166
+
167
+ it('escapes regex special characters in glob patterns', () => {
168
+ // . is escaped (literal), so file.* matches "file." followed by anything
169
+ expect(matchesFilters('file.txt', ['file.*'], [])).toBe(true);
170
+ expect(matchesFilters('file_txt', ['file.*'], [])).toBe(false); // no literal . in text
171
+ expect(matchesFilters('price: $10.00', ['$10*'], [])).toBe(true);
172
+ expect(matchesFilters('[api] log', ['[api]*'], [])).toBe(true);
173
+ });
174
+
175
+ it('handles pattern with only *', () => {
176
+ expect(matchesFilters('anything', ['*'], [])).toBe(true);
177
+ expect(matchesFilters('', ['*'], [])).toBe(true);
178
+ });
179
+ });
180
+
181
+ describe('edge cases', () => {
182
+ it('handles empty includes array with excludes', () => {
183
+ expect(matchesFilters('hello', [], ['foo'])).toBe(true);
184
+ });
185
+
186
+ it('handles empty excludes array with includes', () => {
187
+ expect(matchesFilters('hello', ['hello'], [])).toBe(true);
188
+ });
189
+
190
+ it('handles whitespace in includes', () => {
191
+ expect(matchesFilters('hello world', ['hello world'], [])).toBe(true);
192
+ });
193
+
194
+ it('handles special regex characters in includes', () => {
195
+ expect(matchesFilters('price: $10.00', ['$10'], [])).toBe(true);
196
+ expect(matchesFilters('path/to/file', ['path/to'], [])).toBe(true);
197
+ });
198
+
199
+ it('handles unicode characters', () => {
200
+ expect(matchesFilters('Hello 世界', ['世界'], [])).toBe(true);
201
+ });
202
+ });
203
+ });
@@ -0,0 +1,65 @@
1
+ import { stripAnsi } from './stripAnsi.js';
2
+
3
+ /**
4
+ * Convert a glob pattern (with * wildcards) to a RegExp.
5
+ * - `*` matches any characters (zero or more)
6
+ * - All other characters are escaped for literal matching
7
+ */
8
+ const globToRegex = (pattern: string): RegExp => {
9
+ // Escape regex special characters except *
10
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
11
+ // Convert * to .*
12
+ const regexPattern = escaped.replace(/\*/g, '.*');
13
+ return new RegExp(regexPattern, 'i');
14
+ };
15
+
16
+ /**
17
+ * Check if text matches a pattern (supports * glob wildcards).
18
+ * If no wildcards, does a simple substring match for better performance.
19
+ */
20
+ const matchesPattern = (text: string, pattern: string): boolean => {
21
+ if (pattern.includes('*')) {
22
+ return globToRegex(pattern).test(text);
23
+ }
24
+ return text.includes(pattern.toLowerCase());
25
+ };
26
+
27
+ /**
28
+ * Check if a line matches the given filter criteria.
29
+ *
30
+ * @param line - The line to check (may contain ANSI codes)
31
+ * @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.
32
+ * @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.
33
+ * @returns true if the line should be included, false if filtered out
34
+ */
35
+ export const matchesFilters = (
36
+ line: string,
37
+ includes: string[],
38
+ excludes: string[],
39
+ ): boolean => {
40
+ const plainText = stripAnsi(line).toLowerCase();
41
+
42
+ // Any include must match (OR logic) - case insensitive
43
+ if (includes.length > 0) {
44
+ const anyIncludeMatches = includes.some((pattern) =>
45
+ matchesPattern(plainText, pattern),
46
+ );
47
+
48
+ if (!anyIncludeMatches) {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ // None of the excludes should match (OR logic for exclusion) - case insensitive
54
+ if (excludes.length > 0) {
55
+ const anyExcludeMatches = excludes.some((pattern) =>
56
+ matchesPattern(plainText, pattern),
57
+ );
58
+
59
+ if (anyExcludeMatches) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ return true;
65
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { stripAnsi } from './stripAnsi.js';
3
+
4
+ describe('stripAnsi', () => {
5
+ it('returns plain text unchanged', () => {
6
+ expect(stripAnsi('hello world')).toBe('hello world');
7
+ });
8
+
9
+ it('strips single color code', () => {
10
+ expect(stripAnsi('\u001B[31mred text\u001B[0m')).toBe('red text');
11
+ });
12
+
13
+ it('strips multiple color codes', () => {
14
+ const input = '\u001B[31mred\u001B[0m \u001B[32mgreen\u001B[0m';
15
+ expect(stripAnsi(input)).toBe('red green');
16
+ });
17
+
18
+ it('strips bold code', () => {
19
+ expect(stripAnsi('\u001B[1mbold\u001B[0m')).toBe('bold');
20
+ });
21
+
22
+ it('strips dim code', () => {
23
+ expect(stripAnsi('\u001B[2mdim\u001B[0m')).toBe('dim');
24
+ });
25
+
26
+ it('strips combined codes', () => {
27
+ // Bold + red
28
+ expect(stripAnsi('\u001B[1;31mbold red\u001B[0m')).toBe('bold red');
29
+ });
30
+
31
+ it('strips 256 color codes', () => {
32
+ expect(stripAnsi('\u001B[38;5;196mcolor\u001B[0m')).toBe('color');
33
+ });
34
+
35
+ it('strips bright color codes', () => {
36
+ expect(stripAnsi('\u001B[91mbright red\u001B[0m')).toBe('bright red');
37
+ });
38
+
39
+ it('handles empty string', () => {
40
+ expect(stripAnsi('')).toBe('');
41
+ });
42
+
43
+ it('handles string with only ANSI codes', () => {
44
+ expect(stripAnsi('\u001B[31m\u001B[0m')).toBe('');
45
+ });
46
+
47
+ it('preserves newlines', () => {
48
+ expect(stripAnsi('line1\nline2')).toBe('line1\nline2');
49
+ });
50
+
51
+ it('preserves tabs', () => {
52
+ expect(stripAnsi('col1\tcol2')).toBe('col1\tcol2');
53
+ });
54
+
55
+ it('handles realistic log prefix', () => {
56
+ const input = '\u001B[36m[server]\u001B[0m Starting...';
57
+ expect(stripAnsi(input)).toBe('[server] Starting...');
58
+ });
59
+
60
+ it('handles nested formatting', () => {
61
+ const input = '\u001B[1m\u001B[31mERROR\u001B[0m: something failed';
62
+ expect(stripAnsi(input)).toBe('ERROR: something failed');
63
+ });
64
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Strip ANSI escape codes from text.
3
+ * Removes color codes and other terminal formatting sequences.
4
+ */
5
+ export const stripAnsi = (text: string): string => {
6
+ // eslint-disable-next-line no-control-regex
7
+ return text.replaceAll(/\u001B\[[\d;]*m/gu, '');
8
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Strip HTML tags from a string, leaving only text content.
3
+ */
4
+ export const stripHtmlTags = (html: string): string => {
5
+ return html.replaceAll(/<[^>]*>/gu, '');
6
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Unescape HTML entities back to their original characters.
3
+ */
4
+ export const unescapeHtml = (text: string): string => {
5
+ return text
6
+ .replaceAll('&quot;', '"')
7
+ .replaceAll('&amp;', '&')
8
+ .replaceAll('&lt;', '<')
9
+ .replaceAll('&gt;', '>')
10
+ .replaceAll('&#x27;', "'")
11
+ .replaceAll('&#39;', "'");
12
+ };