text-highlight-js 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,368 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview Enterprise-grade text highlighting utility
4
+ * Provides safe, performant text highlighting with HTML preservation
5
+ * @module TextHighlightHelper
6
+ * @version 1.0.0
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.hasMatch = exports.countMatches = exports.stripHtmlTags = exports.highlightHtmlContent = exports.highlightText = void 0;
10
+ // ============================================================================
11
+ // Constants
12
+ // ============================================================================
13
+ // const DEFAULT_HIGHLIGHT_CLASS = 'text-highlight';
14
+ const MAX_SEARCH_TERMS = 50; // Prevent performance issues with too many terms
15
+ const MAX_TEXT_LENGTH = 1000000; // 1MB text limit
16
+ const MAX_SEARCH_TERM_LENGTH = 1000;
17
+ // ============================================================================
18
+ // Private Utilities
19
+ // ============================================================================
20
+ /**
21
+ * Validates and sanitizes input text
22
+ * @private
23
+ */
24
+ const validateText = (text) => {
25
+ if (text === null || text === undefined) {
26
+ return "";
27
+ }
28
+ if (typeof text !== "string") {
29
+ console.warn("[TextHighlight] Invalid text type:", typeof text);
30
+ return "";
31
+ }
32
+ if (text.length > MAX_TEXT_LENGTH) {
33
+ console.warn(`[TextHighlight] Text exceeds maximum length (${MAX_TEXT_LENGTH}). Truncating.`);
34
+ return text.substring(0, MAX_TEXT_LENGTH);
35
+ }
36
+ return text;
37
+ };
38
+ /**
39
+ * Normalizes search terms to array and validates
40
+ * @private
41
+ */
42
+ const normalizeSearchTerms = (searchTerm) => {
43
+ if (!searchTerm) {
44
+ return [];
45
+ }
46
+ const terms = Array.isArray(searchTerm) ? searchTerm : [searchTerm];
47
+ const validTerms = terms
48
+ .filter((term) => {
49
+ if (typeof term !== "string") {
50
+ console.warn("[TextHighlight] Invalid search term type:", typeof term);
51
+ return false;
52
+ }
53
+ if (term.length > MAX_SEARCH_TERM_LENGTH) {
54
+ console.warn(`[TextHighlight] Search term exceeds maximum length (${MAX_SEARCH_TERM_LENGTH})`);
55
+ return false;
56
+ }
57
+ return true;
58
+ })
59
+ .map((term) => term.trim())
60
+ .filter((term) => term.length > 0);
61
+ if (validTerms.length > MAX_SEARCH_TERMS) {
62
+ console.warn(`[TextHighlight] Too many search terms (${validTerms.length}). Using first ${MAX_SEARCH_TERMS}.`);
63
+ return validTerms.slice(0, MAX_SEARCH_TERMS);
64
+ }
65
+ return validTerms;
66
+ };
67
+ /**
68
+ * Escapes special regex characters in the search string
69
+ * @private
70
+ */
71
+ const escapeRegExp = (text) => {
72
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
+ };
74
+ /**
75
+ * Creates a regex pattern from search terms
76
+ * @private
77
+ */
78
+ const createSearchPattern = (terms, options = {}) => {
79
+ if (terms.length === 0) {
80
+ return null;
81
+ }
82
+ try {
83
+ const escapedTerms = [...terms]
84
+ .sort((a, b) => b.length - a.length)
85
+ .map(escapeRegExp);
86
+ const pattern = options.wholeWord
87
+ ? `\\b(${escapedTerms.join("|")})\\b`
88
+ : `(${escapedTerms.join("|")})`;
89
+ const flags = options.caseSensitive ? "g" : "gi";
90
+ return new RegExp(pattern, flags);
91
+ }
92
+ catch (error) {
93
+ console.error("[TextHighlight] Failed to create search pattern:", error);
94
+ return null;
95
+ }
96
+ };
97
+ /**
98
+ * Checks if a string matches any search term
99
+ * @private
100
+ */
101
+ const matchesSearchTerm = (text, normalizedTermSet, caseSensitive = false) => {
102
+ const compareText = caseSensitive ? text : text.toLowerCase();
103
+ return normalizedTermSet.has(compareText);
104
+ };
105
+ /**
106
+ * Creates a highlight mark element (styled via CSS class)
107
+ * @private
108
+ */
109
+ const createHighlightMark = (content, options = {}) => {
110
+ const mark = document.createElement("mark");
111
+ if (options.className) {
112
+ mark.className = options.className;
113
+ }
114
+ mark.textContent = content;
115
+ return mark;
116
+ };
117
+ // ============================================================================
118
+ // Public API
119
+ // ============================================================================
120
+ /**
121
+ * Highlights search terms in plain text content
122
+ * Returns structured segments indicating which parts should be highlighted.
123
+ * This function is framework-agnostic and does not return JSX.
124
+ *
125
+ * @param text - The text to highlight
126
+ * @param searchTerm - Single search term or array of terms
127
+ * @param options - Highlighting configuration options
128
+ * @returns Array of segments with highlight metadata
129
+ *
130
+ * @example
131
+ * highlightText('Hello World', 'world')
132
+ * // → [{ text: 'Hello ', highlighted: false }, { text: 'World', highlighted: true }]
133
+ */
134
+ const highlightText = (text, searchTerm, options = {}) => {
135
+ const validatedText = validateText(text);
136
+ if (!validatedText.trim()) {
137
+ return [{ text: validatedText, highlighted: false }];
138
+ }
139
+ const searchTerms = normalizeSearchTerms(searchTerm);
140
+ if (searchTerms.length === 0) {
141
+ return [{ text: validatedText, highlighted: false }];
142
+ }
143
+ const normalizedTermSet = new Set(options.caseSensitive
144
+ ? searchTerms
145
+ : searchTerms.map((t) => t.toLowerCase()));
146
+ const pattern = createSearchPattern(searchTerms, options);
147
+ if (!pattern) {
148
+ return [{ text: validatedText, highlighted: false }];
149
+ }
150
+ try {
151
+ const parts = validatedText.split(pattern);
152
+ let highlightCount = 0;
153
+ const maxHighlights = typeof options.maxHighlights === "number"
154
+ ? options.maxHighlights
155
+ : Infinity;
156
+ const segments = [];
157
+ parts.forEach((part) => {
158
+ if (!part) {
159
+ return;
160
+ }
161
+ const isMatch = matchesSearchTerm(part, normalizedTermSet, options.caseSensitive);
162
+ if (isMatch && highlightCount < maxHighlights) {
163
+ highlightCount++;
164
+ segments.push({ text: part, highlighted: true });
165
+ }
166
+ else {
167
+ segments.push({ text: part, highlighted: false });
168
+ }
169
+ });
170
+ return segments;
171
+ }
172
+ catch (error) {
173
+ console.error("[TextHighlight] Error highlighting text:", error);
174
+ return [{ text: validatedText, highlighted: false }];
175
+ }
176
+ };
177
+ exports.highlightText = highlightText;
178
+ /**
179
+ * Highlights search terms in HTML content while preserving HTML structure
180
+ * Only highlights text nodes, leaving HTML tags and attributes intact
181
+ *
182
+ * @param htmlContent - HTML string to highlight
183
+ * @param searchTerm - Single search term or array of terms
184
+ * @param options - Highlighting configuration options
185
+ * @returns HTML string with highlighted text
186
+ *
187
+ * @example
188
+ * ```tsx
189
+ * highlightHtmlContent('<p>Hello World</p>', 'world')
190
+ * highlightHtmlContent('<div>React <br/> TypeScript</div>', ['react', 'script'])
191
+ * ```
192
+ */
193
+ const highlightHtmlContent = (htmlContent, searchTerm, options = {}) => {
194
+ const validatedHtml = validateText(htmlContent);
195
+ if (!validatedHtml) {
196
+ return "";
197
+ }
198
+ if (typeof document === "undefined") {
199
+ console.warn("[TextHighlight] DOM not available (SSR mode)");
200
+ return validatedHtml;
201
+ }
202
+ const searchTerms = normalizeSearchTerms(searchTerm);
203
+ if (searchTerms.length === 0) {
204
+ return validatedHtml;
205
+ }
206
+ const normalizedTermSet = new Set(options.caseSensitive
207
+ ? searchTerms
208
+ : searchTerms.map((t) => t.toLowerCase()));
209
+ const pattern = createSearchPattern(searchTerms, options);
210
+ if (!pattern) {
211
+ return validatedHtml;
212
+ }
213
+ try {
214
+ // Create temporary DOM element to parse HTML safely
215
+ const tempDiv = document.createElement("div");
216
+ tempDiv.innerHTML = validatedHtml;
217
+ let highlightCount = 0;
218
+ const maxHighlights = typeof options.maxHighlights === "number"
219
+ ? options.maxHighlights
220
+ : Infinity;
221
+ /**
222
+ * Recursively highlights text nodes in DOM tree
223
+ * @private
224
+ */
225
+ const highlightTextNodes = (node) => {
226
+ var _a;
227
+ if (highlightCount >= maxHighlights) {
228
+ return;
229
+ }
230
+ if (node.nodeType === Node.TEXT_NODE) {
231
+ // Text node - apply highlighting
232
+ const textContent = node.textContent || "";
233
+ // Reset regex lastIndex for accurate testing
234
+ pattern.lastIndex = 0;
235
+ if (pattern.test(textContent)) {
236
+ // Reset again for split operation
237
+ pattern.lastIndex = 0;
238
+ const fragment = document.createDocumentFragment();
239
+ const parts = textContent.split(pattern);
240
+ parts.forEach((part) => {
241
+ if (!part) {
242
+ return;
243
+ }
244
+ const isMatch = matchesSearchTerm(part, normalizedTermSet, options.caseSensitive);
245
+ if (isMatch && highlightCount < maxHighlights) {
246
+ highlightCount++;
247
+ fragment.appendChild(createHighlightMark(part, options));
248
+ }
249
+ else {
250
+ fragment.appendChild(document.createTextNode(part));
251
+ }
252
+ });
253
+ // Replace original text node with highlighted fragment
254
+ if (node.parentNode) {
255
+ node.parentNode.replaceChild(fragment, node);
256
+ }
257
+ }
258
+ }
259
+ else if (node.nodeType === Node.ELEMENT_NODE) {
260
+ // Element node - recursively process child nodes
261
+ // Skip script, style, and mark elements
262
+ const element = node;
263
+ const tagName = (_a = element.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase();
264
+ if (tagName === "script" || tagName === "style" || tagName === "mark") {
265
+ return;
266
+ }
267
+ // Convert to array to avoid live collection issues during modification
268
+ const childNodes = Array.from(node.childNodes);
269
+ childNodes.forEach(highlightTextNodes);
270
+ }
271
+ };
272
+ highlightTextNodes(tempDiv);
273
+ return tempDiv.innerHTML;
274
+ }
275
+ catch (error) {
276
+ console.error("[TextHighlight] Error highlighting HTML content:", error);
277
+ return validatedHtml;
278
+ }
279
+ };
280
+ exports.highlightHtmlContent = highlightHtmlContent;
281
+ /**
282
+ * Strips HTML tags from content to get plain text
283
+ * Useful for extracting searchable text from HTML
284
+ *
285
+ * @param html - HTML string to strip
286
+ * @returns Plain text content
287
+ *
288
+ * @example
289
+ * ```tsx
290
+ * stripHtmlTags('<p>Hello <strong>World</strong></p>') // Returns: "Hello World"
291
+ * ```
292
+ */
293
+ const stripHtmlTags = (html) => {
294
+ const validatedHtml = validateText(html);
295
+ if (!validatedHtml) {
296
+ return "";
297
+ }
298
+ if (typeof document === "undefined") {
299
+ console.warn("[TextHighlight] DOM not available (SSR mode)");
300
+ return validatedHtml;
301
+ }
302
+ try {
303
+ const tempDiv = document.createElement("div");
304
+ tempDiv.innerHTML = validatedHtml;
305
+ return tempDiv.textContent || tempDiv.innerText || "";
306
+ }
307
+ catch (error) {
308
+ console.error("[TextHighlight] Error stripping HTML tags:", error);
309
+ return validatedHtml;
310
+ }
311
+ };
312
+ exports.stripHtmlTags = stripHtmlTags;
313
+ /**
314
+ * Counts the number of matches for search terms in text
315
+ * Useful for displaying search result counts
316
+ *
317
+ * @param text - Text to search
318
+ * @param searchTerm - Single search term or array of terms
319
+ * @param options - Search configuration options
320
+ * @returns Number of matches found
321
+ *
322
+ * @example
323
+ * ```tsx
324
+ * countMatches('Hello World Hello', 'hello') // Returns: 2
325
+ * countMatches('React TypeScript', ['react', 'script']) // Returns: 2
326
+ * ```
327
+ */
328
+ const countMatches = (text, searchTerm, options = {}) => {
329
+ const validatedText = validateText(text);
330
+ if (!validatedText) {
331
+ return 0;
332
+ }
333
+ const searchTerms = normalizeSearchTerms(searchTerm);
334
+ if (searchTerms.length === 0) {
335
+ return 0;
336
+ }
337
+ const pattern = createSearchPattern(searchTerms, options);
338
+ if (!pattern) {
339
+ return 0;
340
+ }
341
+ try {
342
+ const matches = validatedText.match(pattern);
343
+ return matches ? matches.length : 0;
344
+ }
345
+ catch (error) {
346
+ console.error("[TextHighlight] Error counting matches:", error);
347
+ return 0;
348
+ }
349
+ };
350
+ exports.countMatches = countMatches;
351
+ /**
352
+ * Checks if text contains any of the search terms
353
+ *
354
+ * @param text - Text to search
355
+ * @param searchTerm - Single search term or array of terms
356
+ * @param options - Search configuration options
357
+ * @returns True if any match found
358
+ *
359
+ * @example
360
+ * ```tsx
361
+ * hasMatch('Hello World', 'world') // Returns: true
362
+ * hasMatch('React', ['angular', 'vue']) // Returns: false
363
+ * ```
364
+ */
365
+ const hasMatch = (text, searchTerm, options = {}) => {
366
+ return countMatches(text, searchTerm, options) > 0;
367
+ };
368
+ exports.hasMatch = hasMatch;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @fileoverview Enterprise-grade text highlighting utility
3
+ * Provides safe, performant text highlighting with HTML preservation
4
+ * @module TextHighlightHelper
5
+ * @version 1.0.0
6
+ */
7
+ /**
8
+ * Configuration options for text highlighting
9
+ */
10
+ export interface HighlightOptions {
11
+ /** CSS class name for highlighted elements (style via your own CSS) */
12
+ className?: string;
13
+ /** Case-sensitive search */
14
+ caseSensitive?: boolean;
15
+ /** Match whole words only */
16
+ wholeWord?: boolean;
17
+ /** Maximum number of highlights to apply (for performance) */
18
+ maxHighlights?: number;
19
+ }
20
+ /**
21
+ * Search term type - can be string or array of strings
22
+ */
23
+ export type SearchTerm = string | string[];
24
+ /**
25
+ * Result segment for highlighted text.
26
+ * Framework-agnostic structure that callers can render in any UI library.
27
+ */
28
+ export interface HighlightSegment {
29
+ text: string;
30
+ highlighted: boolean;
31
+ }
32
+ /**
33
+ * Highlights search terms in plain text content
34
+ * Returns structured segments indicating which parts should be highlighted.
35
+ * This function is framework-agnostic and does not return JSX.
36
+ *
37
+ * @param text - The text to highlight
38
+ * @param searchTerm - Single search term or array of terms
39
+ * @param options - Highlighting configuration options
40
+ * @returns Array of segments with highlight metadata
41
+ *
42
+ * @example
43
+ * highlightText('Hello World', 'world')
44
+ * // → [{ text: 'Hello ', highlighted: false }, { text: 'World', highlighted: true }]
45
+ */
46
+ declare const highlightText: (text: string, searchTerm: SearchTerm, options?: HighlightOptions) => HighlightSegment[];
47
+ /**
48
+ * Highlights search terms in HTML content while preserving HTML structure
49
+ * Only highlights text nodes, leaving HTML tags and attributes intact
50
+ *
51
+ * @param htmlContent - HTML string to highlight
52
+ * @param searchTerm - Single search term or array of terms
53
+ * @param options - Highlighting configuration options
54
+ * @returns HTML string with highlighted text
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * highlightHtmlContent('<p>Hello World</p>', 'world')
59
+ * highlightHtmlContent('<div>React <br/> TypeScript</div>', ['react', 'script'])
60
+ * ```
61
+ */
62
+ declare const highlightHtmlContent: (htmlContent: string, searchTerm: SearchTerm, options?: HighlightOptions) => string;
63
+ /**
64
+ * Strips HTML tags from content to get plain text
65
+ * Useful for extracting searchable text from HTML
66
+ *
67
+ * @param html - HTML string to strip
68
+ * @returns Plain text content
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * stripHtmlTags('<p>Hello <strong>World</strong></p>') // Returns: "Hello World"
73
+ * ```
74
+ */
75
+ declare const stripHtmlTags: (html: string) => string;
76
+ /**
77
+ * Counts the number of matches for search terms in text
78
+ * Useful for displaying search result counts
79
+ *
80
+ * @param text - Text to search
81
+ * @param searchTerm - Single search term or array of terms
82
+ * @param options - Search configuration options
83
+ * @returns Number of matches found
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * countMatches('Hello World Hello', 'hello') // Returns: 2
88
+ * countMatches('React TypeScript', ['react', 'script']) // Returns: 2
89
+ * ```
90
+ */
91
+ declare const countMatches: (text: string, searchTerm: SearchTerm, options?: HighlightOptions) => number;
92
+ /**
93
+ * Checks if text contains any of the search terms
94
+ *
95
+ * @param text - Text to search
96
+ * @param searchTerm - Single search term or array of terms
97
+ * @param options - Search configuration options
98
+ * @returns True if any match found
99
+ *
100
+ * @example
101
+ * ```tsx
102
+ * hasMatch('Hello World', 'world') // Returns: true
103
+ * hasMatch('React', ['angular', 'vue']) // Returns: false
104
+ * ```
105
+ */
106
+ declare const hasMatch: (text: string, searchTerm: SearchTerm, options?: HighlightOptions) => boolean;
107
+ export { highlightText, highlightHtmlContent, stripHtmlTags, countMatches, hasMatch, };
108
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAgBH;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4BAA4B;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,8DAA8D;IAC9D,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC;AAE3C;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,OAAO,CAAC;CACtB;AA0ID;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,aAAa,GACjB,MAAM,MAAM,EACZ,YAAY,UAAU,EACtB,UAAS,gBAAqB,KAC7B,gBAAgB,EAyDlB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,QAAA,MAAM,oBAAoB,GACxB,aAAa,MAAM,EACnB,YAAY,UAAU,EACtB,UAAS,gBAAqB,KAC7B,MA6GF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,QAAA,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,MAmBrC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,QAAA,MAAM,YAAY,GAChB,MAAM,MAAM,EACZ,YAAY,UAAU,EACtB,UAAS,gBAAqB,KAC7B,MAuBF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,QAAA,MAAM,QAAQ,GACZ,MAAM,MAAM,EACZ,YAAY,UAAU,EACtB,UAAS,gBAAqB,KAC7B,OAEF,CAAC;AAMF,OAAO,EACL,aAAa,EACb,oBAAoB,EACpB,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,CAAC"}
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @fileoverview Enterprise-grade text highlighting utility
3
+ * Provides safe, performant text highlighting with HTML preservation
4
+ * @module TextHighlightHelper
5
+ * @version 1.0.0
6
+ */
7
+ // ============================================================================
8
+ // Constants
9
+ // ============================================================================
10
+ // const DEFAULT_HIGHLIGHT_CLASS = 'text-highlight';
11
+ const MAX_SEARCH_TERMS = 50; // Prevent performance issues with too many terms
12
+ const MAX_TEXT_LENGTH = 1000000; // 1MB text limit
13
+ const MAX_SEARCH_TERM_LENGTH = 1000;
14
+ // ============================================================================
15
+ // Private Utilities
16
+ // ============================================================================
17
+ /**
18
+ * Validates and sanitizes input text
19
+ * @private
20
+ */
21
+ const validateText = (text) => {
22
+ if (text === null || text === undefined) {
23
+ return "";
24
+ }
25
+ if (typeof text !== "string") {
26
+ console.warn("[TextHighlight] Invalid text type:", typeof text);
27
+ return "";
28
+ }
29
+ if (text.length > MAX_TEXT_LENGTH) {
30
+ console.warn(`[TextHighlight] Text exceeds maximum length (${MAX_TEXT_LENGTH}). Truncating.`);
31
+ return text.substring(0, MAX_TEXT_LENGTH);
32
+ }
33
+ return text;
34
+ };
35
+ /**
36
+ * Normalizes search terms to array and validates
37
+ * @private
38
+ */
39
+ const normalizeSearchTerms = (searchTerm) => {
40
+ if (!searchTerm) {
41
+ return [];
42
+ }
43
+ const terms = Array.isArray(searchTerm) ? searchTerm : [searchTerm];
44
+ const validTerms = terms
45
+ .filter((term) => {
46
+ if (typeof term !== "string") {
47
+ console.warn("[TextHighlight] Invalid search term type:", typeof term);
48
+ return false;
49
+ }
50
+ if (term.length > MAX_SEARCH_TERM_LENGTH) {
51
+ console.warn(`[TextHighlight] Search term exceeds maximum length (${MAX_SEARCH_TERM_LENGTH})`);
52
+ return false;
53
+ }
54
+ return true;
55
+ })
56
+ .map((term) => term.trim())
57
+ .filter((term) => term.length > 0);
58
+ if (validTerms.length > MAX_SEARCH_TERMS) {
59
+ console.warn(`[TextHighlight] Too many search terms (${validTerms.length}). Using first ${MAX_SEARCH_TERMS}.`);
60
+ return validTerms.slice(0, MAX_SEARCH_TERMS);
61
+ }
62
+ return validTerms;
63
+ };
64
+ /**
65
+ * Escapes special regex characters in the search string
66
+ * @private
67
+ */
68
+ const escapeRegExp = (text) => {
69
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ };
71
+ /**
72
+ * Creates a regex pattern from search terms
73
+ * @private
74
+ */
75
+ const createSearchPattern = (terms, options = {}) => {
76
+ if (terms.length === 0) {
77
+ return null;
78
+ }
79
+ try {
80
+ const escapedTerms = [...terms]
81
+ .sort((a, b) => b.length - a.length)
82
+ .map(escapeRegExp);
83
+ const pattern = options.wholeWord
84
+ ? `\\b(${escapedTerms.join("|")})\\b`
85
+ : `(${escapedTerms.join("|")})`;
86
+ const flags = options.caseSensitive ? "g" : "gi";
87
+ return new RegExp(pattern, flags);
88
+ }
89
+ catch (error) {
90
+ console.error("[TextHighlight] Failed to create search pattern:", error);
91
+ return null;
92
+ }
93
+ };
94
+ /**
95
+ * Checks if a string matches any search term
96
+ * @private
97
+ */
98
+ const matchesSearchTerm = (text, normalizedTermSet, caseSensitive = false) => {
99
+ const compareText = caseSensitive ? text : text.toLowerCase();
100
+ return normalizedTermSet.has(compareText);
101
+ };
102
+ /**
103
+ * Creates a highlight mark element (styled via CSS class)
104
+ * @private
105
+ */
106
+ const createHighlightMark = (content, options = {}) => {
107
+ const mark = document.createElement("mark");
108
+ if (options.className) {
109
+ mark.className = options.className;
110
+ }
111
+ mark.textContent = content;
112
+ return mark;
113
+ };
114
+ // ============================================================================
115
+ // Public API
116
+ // ============================================================================
117
+ /**
118
+ * Highlights search terms in plain text content
119
+ * Returns structured segments indicating which parts should be highlighted.
120
+ * This function is framework-agnostic and does not return JSX.
121
+ *
122
+ * @param text - The text to highlight
123
+ * @param searchTerm - Single search term or array of terms
124
+ * @param options - Highlighting configuration options
125
+ * @returns Array of segments with highlight metadata
126
+ *
127
+ * @example
128
+ * highlightText('Hello World', 'world')
129
+ * // → [{ text: 'Hello ', highlighted: false }, { text: 'World', highlighted: true }]
130
+ */
131
+ const highlightText = (text, searchTerm, options = {}) => {
132
+ const validatedText = validateText(text);
133
+ if (!validatedText.trim()) {
134
+ return [{ text: validatedText, highlighted: false }];
135
+ }
136
+ const searchTerms = normalizeSearchTerms(searchTerm);
137
+ if (searchTerms.length === 0) {
138
+ return [{ text: validatedText, highlighted: false }];
139
+ }
140
+ const normalizedTermSet = new Set(options.caseSensitive
141
+ ? searchTerms
142
+ : searchTerms.map((t) => t.toLowerCase()));
143
+ const pattern = createSearchPattern(searchTerms, options);
144
+ if (!pattern) {
145
+ return [{ text: validatedText, highlighted: false }];
146
+ }
147
+ try {
148
+ const parts = validatedText.split(pattern);
149
+ let highlightCount = 0;
150
+ const maxHighlights = typeof options.maxHighlights === "number"
151
+ ? options.maxHighlights
152
+ : Infinity;
153
+ const segments = [];
154
+ parts.forEach((part) => {
155
+ if (!part) {
156
+ return;
157
+ }
158
+ const isMatch = matchesSearchTerm(part, normalizedTermSet, options.caseSensitive);
159
+ if (isMatch && highlightCount < maxHighlights) {
160
+ highlightCount++;
161
+ segments.push({ text: part, highlighted: true });
162
+ }
163
+ else {
164
+ segments.push({ text: part, highlighted: false });
165
+ }
166
+ });
167
+ return segments;
168
+ }
169
+ catch (error) {
170
+ console.error("[TextHighlight] Error highlighting text:", error);
171
+ return [{ text: validatedText, highlighted: false }];
172
+ }
173
+ };
174
+ /**
175
+ * Highlights search terms in HTML content while preserving HTML structure
176
+ * Only highlights text nodes, leaving HTML tags and attributes intact
177
+ *
178
+ * @param htmlContent - HTML string to highlight
179
+ * @param searchTerm - Single search term or array of terms
180
+ * @param options - Highlighting configuration options
181
+ * @returns HTML string with highlighted text
182
+ *
183
+ * @example
184
+ * ```tsx
185
+ * highlightHtmlContent('<p>Hello World</p>', 'world')
186
+ * highlightHtmlContent('<div>React <br/> TypeScript</div>', ['react', 'script'])
187
+ * ```
188
+ */
189
+ const highlightHtmlContent = (htmlContent, searchTerm, options = {}) => {
190
+ const validatedHtml = validateText(htmlContent);
191
+ if (!validatedHtml) {
192
+ return "";
193
+ }
194
+ if (typeof document === "undefined") {
195
+ console.warn("[TextHighlight] DOM not available (SSR mode)");
196
+ return validatedHtml;
197
+ }
198
+ const searchTerms = normalizeSearchTerms(searchTerm);
199
+ if (searchTerms.length === 0) {
200
+ return validatedHtml;
201
+ }
202
+ const normalizedTermSet = new Set(options.caseSensitive
203
+ ? searchTerms
204
+ : searchTerms.map((t) => t.toLowerCase()));
205
+ const pattern = createSearchPattern(searchTerms, options);
206
+ if (!pattern) {
207
+ return validatedHtml;
208
+ }
209
+ try {
210
+ // Create temporary DOM element to parse HTML safely
211
+ const tempDiv = document.createElement("div");
212
+ tempDiv.innerHTML = validatedHtml;
213
+ let highlightCount = 0;
214
+ const maxHighlights = typeof options.maxHighlights === "number"
215
+ ? options.maxHighlights
216
+ : Infinity;
217
+ /**
218
+ * Recursively highlights text nodes in DOM tree
219
+ * @private
220
+ */
221
+ const highlightTextNodes = (node) => {
222
+ var _a;
223
+ if (highlightCount >= maxHighlights) {
224
+ return;
225
+ }
226
+ if (node.nodeType === Node.TEXT_NODE) {
227
+ // Text node - apply highlighting
228
+ const textContent = node.textContent || "";
229
+ // Reset regex lastIndex for accurate testing
230
+ pattern.lastIndex = 0;
231
+ if (pattern.test(textContent)) {
232
+ // Reset again for split operation
233
+ pattern.lastIndex = 0;
234
+ const fragment = document.createDocumentFragment();
235
+ const parts = textContent.split(pattern);
236
+ parts.forEach((part) => {
237
+ if (!part) {
238
+ return;
239
+ }
240
+ const isMatch = matchesSearchTerm(part, normalizedTermSet, options.caseSensitive);
241
+ if (isMatch && highlightCount < maxHighlights) {
242
+ highlightCount++;
243
+ fragment.appendChild(createHighlightMark(part, options));
244
+ }
245
+ else {
246
+ fragment.appendChild(document.createTextNode(part));
247
+ }
248
+ });
249
+ // Replace original text node with highlighted fragment
250
+ if (node.parentNode) {
251
+ node.parentNode.replaceChild(fragment, node);
252
+ }
253
+ }
254
+ }
255
+ else if (node.nodeType === Node.ELEMENT_NODE) {
256
+ // Element node - recursively process child nodes
257
+ // Skip script, style, and mark elements
258
+ const element = node;
259
+ const tagName = (_a = element.tagName) === null || _a === void 0 ? void 0 : _a.toLowerCase();
260
+ if (tagName === "script" || tagName === "style" || tagName === "mark") {
261
+ return;
262
+ }
263
+ // Convert to array to avoid live collection issues during modification
264
+ const childNodes = Array.from(node.childNodes);
265
+ childNodes.forEach(highlightTextNodes);
266
+ }
267
+ };
268
+ highlightTextNodes(tempDiv);
269
+ return tempDiv.innerHTML;
270
+ }
271
+ catch (error) {
272
+ console.error("[TextHighlight] Error highlighting HTML content:", error);
273
+ return validatedHtml;
274
+ }
275
+ };
276
+ /**
277
+ * Strips HTML tags from content to get plain text
278
+ * Useful for extracting searchable text from HTML
279
+ *
280
+ * @param html - HTML string to strip
281
+ * @returns Plain text content
282
+ *
283
+ * @example
284
+ * ```tsx
285
+ * stripHtmlTags('<p>Hello <strong>World</strong></p>') // Returns: "Hello World"
286
+ * ```
287
+ */
288
+ const stripHtmlTags = (html) => {
289
+ const validatedHtml = validateText(html);
290
+ if (!validatedHtml) {
291
+ return "";
292
+ }
293
+ if (typeof document === "undefined") {
294
+ console.warn("[TextHighlight] DOM not available (SSR mode)");
295
+ return validatedHtml;
296
+ }
297
+ try {
298
+ const tempDiv = document.createElement("div");
299
+ tempDiv.innerHTML = validatedHtml;
300
+ return tempDiv.textContent || tempDiv.innerText || "";
301
+ }
302
+ catch (error) {
303
+ console.error("[TextHighlight] Error stripping HTML tags:", error);
304
+ return validatedHtml;
305
+ }
306
+ };
307
+ /**
308
+ * Counts the number of matches for search terms in text
309
+ * Useful for displaying search result counts
310
+ *
311
+ * @param text - Text to search
312
+ * @param searchTerm - Single search term or array of terms
313
+ * @param options - Search configuration options
314
+ * @returns Number of matches found
315
+ *
316
+ * @example
317
+ * ```tsx
318
+ * countMatches('Hello World Hello', 'hello') // Returns: 2
319
+ * countMatches('React TypeScript', ['react', 'script']) // Returns: 2
320
+ * ```
321
+ */
322
+ const countMatches = (text, searchTerm, options = {}) => {
323
+ const validatedText = validateText(text);
324
+ if (!validatedText) {
325
+ return 0;
326
+ }
327
+ const searchTerms = normalizeSearchTerms(searchTerm);
328
+ if (searchTerms.length === 0) {
329
+ return 0;
330
+ }
331
+ const pattern = createSearchPattern(searchTerms, options);
332
+ if (!pattern) {
333
+ return 0;
334
+ }
335
+ try {
336
+ const matches = validatedText.match(pattern);
337
+ return matches ? matches.length : 0;
338
+ }
339
+ catch (error) {
340
+ console.error("[TextHighlight] Error counting matches:", error);
341
+ return 0;
342
+ }
343
+ };
344
+ /**
345
+ * Checks if text contains any of the search terms
346
+ *
347
+ * @param text - Text to search
348
+ * @param searchTerm - Single search term or array of terms
349
+ * @param options - Search configuration options
350
+ * @returns True if any match found
351
+ *
352
+ * @example
353
+ * ```tsx
354
+ * hasMatch('Hello World', 'world') // Returns: true
355
+ * hasMatch('React', ['angular', 'vue']) // Returns: false
356
+ * ```
357
+ */
358
+ const hasMatch = (text, searchTerm, options = {}) => {
359
+ return countMatches(text, searchTerm, options) > 0;
360
+ };
361
+ // ============================================================================
362
+ // Exports
363
+ // ============================================================================
364
+ export { highlightText, highlightHtmlContent, stripHtmlTags, countMatches, hasMatch, };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "text-highlight-js",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight text highlighting utility with ESM and CJS support",
5
+ "keywords": [
6
+ "text",
7
+ "highlight",
8
+ "search",
9
+ "html",
10
+ "utility"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Anuj Gupta",
14
+
15
+ "main": "./dist/cjs/index.js",
16
+ "module": "./dist/esm/index.js",
17
+ "types": "./dist/esm/index.d.ts",
18
+
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/esm/index.d.ts",
22
+ "import": "./dist/esm/index.js",
23
+ "require": "./dist/cjs/index.js"
24
+ }
25
+ },
26
+
27
+ "sideEffects": false,
28
+
29
+ "files": [
30
+ "dist"
31
+ ],
32
+
33
+ "scripts": {
34
+ "clean": "rimraf dist",
35
+ "build:esm": "tsc",
36
+ "build:cjs": "tsc -p tsconfig.cjs.json",
37
+ "build": "npm run clean && npm run build:esm && npm run build:cjs",
38
+ "prepublishOnly": "npm run build"
39
+ },
40
+
41
+ "devDependencies": {
42
+ "typescript": "^5.9.3",
43
+ "rimraf": "^5.0.5"
44
+ },
45
+
46
+ "engines": {
47
+ "node": ">=14"
48
+ }
49
+ }