Implement textSearch(text, term) — scan a string and wrap every occurrence of a single search term in <mark> … </mark> tags, the way a browser's find-on-page highlights the word you searched for. Matching is case-insensitive (the term react finds React), but the output keeps the text's original casing. The term is a plain string, so a character like + or . inside it must match literally — a.b should find the text a.b, not behave like a regular expression.
// text: string — the source text to search within.
// term: string — the single term to highlight (case-insensitive, matched literally).
// returns: string
// The same text with each occurrence of term wrapped in <mark>…</mark>.
// Original casing is preserved; nothing else changes.
function textSearch(text, term): string;
// The one occurrence of 'cat' is wrapped; everything else is untouched.
textSearch('the cat sat', 'cat');
// → 'the <mark>cat</mark> sat'
// Case-insensitive match, but the original 'React' casing is kept in the output.
textSearch('React is great', 'react');
// → '<mark>React</mark> is great'
<mark> … </mark> pair. Adjacent matches are wrapped separately: textSearch('aaa', 'a') is <mark>a</mark><mark>a</mark><mark>a</mark>.react matches React, REACT, or reAct, but the wrapped text is exactly what appeared in the source — you never lowercase the output.c++, a.b, or (x) must match those exact characters. Escape regex-special characters before searching so they lose their special meaning.term (or empty text) returns the text unchanged — don't wrap a zero-length match.cat matches inside category.You'll scan a string and wrap every occurrence of one search term in <mark> … </mark>, the way find-on-page highlights the word you typed.
You press Cmd-F, type react, and the browser highlights every React on the page — even though you typed it in lowercase. That highlighting is what textSearch does: given a piece of text and a single term, it returns the same text with each occurrence of the term wrapped in <mark> and </mark>. Two details make it more than a one-line String.replace. The match is case-insensitive — searching react should find React — but the wrapped text keeps its original casing. And the term is a plain string, so a term like a.b has to match those literal characters, not act as a pattern where . means "any character".
Think of one cursor sweeping the text from left to right. At each position it asks: does the term start here? If yes, it wraps that span and jumps past it; if no, it copies the character and moves on. You could write that loop by hand, but JavaScript already has a left-to-right scanner that does exactly this: a regular expression with the global flag, driven by String.prototype.replace. The whole job becomes "turn the term into one regex, then let replace wrap each match." The two tricky details — case-insensitivity and treating the term literally — are each solved by how you build that regex, not by the loop itself.
The instinct is to reach for String.prototype.split and stitch the pieces back together with the highlighted term in between:
function textSearch(text, term) {
// split on the term, then rejoin with the wrapped term between the pieces
return text.split(term).join(`<mark>${term}</mark>`);
}
This wraps a simple lowercase term, but it breaks on both of the details above. It is case-sensitive: 'React is great'.split('react') finds nothing — there's no lowercase react in the text — so it returns the text unchanged instead of highlighting React. And even when it does match, it inserts term (what you searched for) rather than what was actually in the text, so the casing would be wrong anyway. Reaching for new RegExp(term, 'gi') fixes the case problem but introduces a worse one: a term like c++ becomes the pattern /c++/, where + means "one or more", so it throws or matches the wrong thing instead of finding the literal text c++.
The fix is to build the search as one regex: escape the term so its characters are literal, give the regex the global and ignore-case flags, and replace with the matched text so the original casing survives.
function textSearch(text, term) {
// No text or no term to look for: nothing to wrap, return the text as-is.
if (!text || !term) return text;
// Escape every regex-special character so a term like 'a.b' or 'c++'
// matches those literal characters instead of acting as a pattern.
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 'g' wraps every occurrence; 'i' makes the match case-insensitive.
const pattern = new RegExp(escaped, 'gi');
// '$&' is the matched substring exactly as it appeared in the source,
// so the output keeps the original casing even though matching ignored case.
return text.replace(pattern, '<mark>$&</mark>');
}
module.exports = { textSearch };
Three shifts carry the fix. The guard if (!text || !term) returns early on empty text or an empty term — an empty term would otherwise compile into a regex that matches the zero-width gap at every position and wrap <mark></mark> between every character. Escaping with .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') turns each special character into a backslashed literal, so c++ matches the text c++. And $& in the replacement is the matched substring as it appeared in the source, which is how the casing is preserved even though the i flag made the match itself case-insensitive.
Trace textSearch('React and reACT', 'react').
First the guard passes — both the text and the term are non-empty. The term react has no regex-special characters, so escaping leaves it as react. Building new RegExp('react', 'gi') gives the pattern /react/gi.
Now replace scans left to right:
input: "React and reACT"
pos 0 → /react/i matches "React" (chars 0–4, case ignored).
→ wrap with $& (the matched "React") → "<mark>React</mark>".
" and " → no match here; copied through unchanged.
pos 10 → /react/i matches "reACT" (chars 10–14, case ignored).
→ wrap with $& (the matched "reACT") → "<mark>reACT</mark>".
result: "<mark>React</mark> and <mark>reACT</mark>"
The key moment is what goes inside each <mark>. The i flag let the lowercase react match both React and reACT, but $& is the text that actually matched, so the first wrap keeps React and the second keeps reACT. The g flag is why the scan continues past the first match to find the second.
split/join or a bare term. text.split(term) is case-sensitive, so searching react misses React entirely, and it re-inserts what you searched for rather than what was in the text — the casing comes out wrong. Use a regex with the i flag and $& instead.new RegExp. A term is data, not a pattern. new RegExp('a.b') matches axb, a-b, anything-in-the-middle, because . is "any character"; new RegExp('c++') throws "Nothing to repeat". Escape first: term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') backslashes every metacharacter so it matches itself.text.toLowerCase() before searching, but then your output is lowercased too — React comes back as react. Use the i flag on the regex and $& (the original matched text) in the replacement, so matching ignores case while the output keeps it.term compiles to a regex that matches the zero-width position between every character, wrapping <mark></mark> everywhere. Short-circuit with if (!term) return text; before building the regex.[ or \ in the term. The escape character class must itself include [, ], and the backslash, or a term like a[b or a stray \ produces an invalid regex and throws. The class /[.*+?^${}()|[\]\\]/ covers all of them — note \] and \\ inside it.cat lights up inside category. For a "whole word only" mode, wrap the escaped term in \b…\b: new RegExp('\\b' + escaped + '\\b', 'gi'). Be aware \b is defined by \w (letters, digits, underscore), so a term with punctuation like c++ needs a different boundary rule.| — is the related Text Search II problem.<, >, or & and the result is dropped into real HTML, you must HTML-escape the non-matched parts to avoid injection — the <mark> tags are safe, but the surrounding text isn't. The robust shape uses a replacer function that escapes each gap and each match separately.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement textSearch(text, term) — scan a string and wrap every occurrence of a single search term in <mark> … </mark> tags, the way a browser's find-on-page highlights the word you searched for. Matching is case-insensitive (the term react finds React), but the output keeps the text's original casing. The term is a plain string, so a character like + or . inside it must match literally — a.b should find the text a.b, not behave like a regular expression.
// text: string — the source text to search within.
// term: string — the single term to highlight (case-insensitive, matched literally).
// returns: string
// The same text with each occurrence of term wrapped in <mark>…</mark>.
// Original casing is preserved; nothing else changes.
function textSearch(text, term): string;
// The one occurrence of 'cat' is wrapped; everything else is untouched.
textSearch('the cat sat', 'cat');
// → 'the <mark>cat</mark> sat'
// Case-insensitive match, but the original 'React' casing is kept in the output.
textSearch('React is great', 'react');
// → '<mark>React</mark> is great'
<mark> … </mark> pair. Adjacent matches are wrapped separately: textSearch('aaa', 'a') is <mark>a</mark><mark>a</mark><mark>a</mark>.react matches React, REACT, or reAct, but the wrapped text is exactly what appeared in the source — you never lowercase the output.c++, a.b, or (x) must match those exact characters. Escape regex-special characters before searching so they lose their special meaning.term (or empty text) returns the text unchanged — don't wrap a zero-length match.cat matches inside category.You'll scan a string and wrap every occurrence of one search term in <mark> … </mark>, the way find-on-page highlights the word you typed.
You press Cmd-F, type react, and the browser highlights every React on the page — even though you typed it in lowercase. That highlighting is what textSearch does: given a piece of text and a single term, it returns the same text with each occurrence of the term wrapped in <mark> and </mark>. Two details make it more than a one-line String.replace. The match is case-insensitive — searching react should find React — but the wrapped text keeps its original casing. And the term is a plain string, so a term like a.b has to match those literal characters, not act as a pattern where . means "any character".
Think of one cursor sweeping the text from left to right. At each position it asks: does the term start here? If yes, it wraps that span and jumps past it; if no, it copies the character and moves on. You could write that loop by hand, but JavaScript already has a left-to-right scanner that does exactly this: a regular expression with the global flag, driven by String.prototype.replace. The whole job becomes "turn the term into one regex, then let replace wrap each match." The two tricky details — case-insensitivity and treating the term literally — are each solved by how you build that regex, not by the loop itself.
The instinct is to reach for String.prototype.split and stitch the pieces back together with the highlighted term in between:
function textSearch(text, term) {
// split on the term, then rejoin with the wrapped term between the pieces
return text.split(term).join(`<mark>${term}</mark>`);
}
This wraps a simple lowercase term, but it breaks on both of the details above. It is case-sensitive: 'React is great'.split('react') finds nothing — there's no lowercase react in the text — so it returns the text unchanged instead of highlighting React. And even when it does match, it inserts term (what you searched for) rather than what was actually in the text, so the casing would be wrong anyway. Reaching for new RegExp(term, 'gi') fixes the case problem but introduces a worse one: a term like c++ becomes the pattern /c++/, where + means "one or more", so it throws or matches the wrong thing instead of finding the literal text c++.
The fix is to build the search as one regex: escape the term so its characters are literal, give the regex the global and ignore-case flags, and replace with the matched text so the original casing survives.
function textSearch(text, term) {
// No text or no term to look for: nothing to wrap, return the text as-is.
if (!text || !term) return text;
// Escape every regex-special character so a term like 'a.b' or 'c++'
// matches those literal characters instead of acting as a pattern.
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 'g' wraps every occurrence; 'i' makes the match case-insensitive.
const pattern = new RegExp(escaped, 'gi');
// '$&' is the matched substring exactly as it appeared in the source,
// so the output keeps the original casing even though matching ignored case.
return text.replace(pattern, '<mark>$&</mark>');
}
module.exports = { textSearch };
Three shifts carry the fix. The guard if (!text || !term) returns early on empty text or an empty term — an empty term would otherwise compile into a regex that matches the zero-width gap at every position and wrap <mark></mark> between every character. Escaping with .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') turns each special character into a backslashed literal, so c++ matches the text c++. And $& in the replacement is the matched substring as it appeared in the source, which is how the casing is preserved even though the i flag made the match itself case-insensitive.
Trace textSearch('React and reACT', 'react').
First the guard passes — both the text and the term are non-empty. The term react has no regex-special characters, so escaping leaves it as react. Building new RegExp('react', 'gi') gives the pattern /react/gi.
Now replace scans left to right:
input: "React and reACT"
pos 0 → /react/i matches "React" (chars 0–4, case ignored).
→ wrap with $& (the matched "React") → "<mark>React</mark>".
" and " → no match here; copied through unchanged.
pos 10 → /react/i matches "reACT" (chars 10–14, case ignored).
→ wrap with $& (the matched "reACT") → "<mark>reACT</mark>".
result: "<mark>React</mark> and <mark>reACT</mark>"
The key moment is what goes inside each <mark>. The i flag let the lowercase react match both React and reACT, but $& is the text that actually matched, so the first wrap keeps React and the second keeps reACT. The g flag is why the scan continues past the first match to find the second.
split/join or a bare term. text.split(term) is case-sensitive, so searching react misses React entirely, and it re-inserts what you searched for rather than what was in the text — the casing comes out wrong. Use a regex with the i flag and $& instead.new RegExp. A term is data, not a pattern. new RegExp('a.b') matches axb, a-b, anything-in-the-middle, because . is "any character"; new RegExp('c++') throws "Nothing to repeat". Escape first: term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') backslashes every metacharacter so it matches itself.text.toLowerCase() before searching, but then your output is lowercased too — React comes back as react. Use the i flag on the regex and $& (the original matched text) in the replacement, so matching ignores case while the output keeps it.term compiles to a regex that matches the zero-width position between every character, wrapping <mark></mark> everywhere. Short-circuit with if (!term) return text; before building the regex.[ or \ in the term. The escape character class must itself include [, ], and the backslash, or a term like a[b or a stray \ produces an invalid regex and throws. The class /[.*+?^${}()|[\]\\]/ covers all of them — note \] and \\ inside it.cat lights up inside category. For a "whole word only" mode, wrap the escaped term in \b…\b: new RegExp('\\b' + escaped + '\\b', 'gi'). Be aware \b is defined by \w (letters, digits, underscore), so a term with punctuation like c++ needs a different boundary rule.| — is the related Text Search II problem.<, >, or & and the result is dropped into real HTML, you must HTML-escape the non-matched parts to avoid injection — the <mark> tags are safe, but the surrounding text isn't. The robust shape uses a replacer function that escapes each gap and each match separately.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.