Took inspiration from this Wes Bos tutorial: https://youtu.be/y4gZMJKAeWs?t=868 (I fast forwarded to relevant part)
However, he's doing a simple .replace
, which works for strings, but we want to return JSX!
So his solution doesn't work in our case (at least all the methods I've tried; I found this SO thread from createClass
days of React that addresses why this doesn't work and gives a possible solution: https://stackoverflow.com/questions/30474506/replace-part-of-string-with-tag-in-jsx).
To start, we need a function that constructs a regular expression from some string input. At first I naively thought I could do
const toRegExp = str => new RegExp(str, "gi")
This breaks though, for example when str
is [
you'll get an invalid regex.
We need to escape the special characters, and for this I copy-pasted from Stack Overflow 🤫
const toRegExp = str =>
new RegExp(str.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"), "gi")
Now I want a function that takes a string and splits it up into parts that match the regular expression and parts that don't.
As noted, .replace
almost works here but you can only return a string from it.
So
"Garage".replace(/g/ig, match => `<mark>${match}</mark>`)
will literally just give you the string "<mark>G</mark>ara<mark>g</mark>e"
, not the JSX that we want.
Playing around with String
and RegExp
methods, I found something that seemed to work: any time you run .split
and .match
on a string with the same regular expression, you'll get two arrays with which you can piece together the original string.
const toMissesAndMatches = str => query => {
const misses = str.split(query)
const matches = str.match(query)
return { misses, matches }
}
It seems like you can always reconstruct the original string by interweaving misses
and matches
toMissesAndMatches("Garage")(toRegExp("ga"))
//-> { misses: [ '', 'rage' ], matches: [ 'Ga' ] }
toMissesAndMatches("Elephant")(toRegExp("leph"))
//-> { misses: [ 'E', 'ant' ], matches: [ 'leph' ] }
toMissesAndMatches("Hello!")(toRegExp("hello!"))
//-> { misses: [ '', '' ], matches: [ 'Hello!' ] }
But this is still just conjecture at this point, there's no law that I know of that asserts this to be true, so how do we verify?
Let's make a reconstructFromMissesAndMatches
function and test it.
For that, I'm relying on a function that's common in Haskell for "zipping together" two collections, appropriately called zipWith
. Here's one way we could implement our own
const zipWith = fn => ([x, ...xs]) => ([y, ...ys], acc = []) =>
x === undefined ? acc : zipWith(fn)(xs)(ys, [...acc, fn(x)(y)])
zipWith(a => b => a + b)([0, 1, 2])([1, 2, 3])
//-> [1, 3, 5]
👆 That admittedly looks pretty weird, but luckily zipWith
is also implemented in utility libraries like lodash
, ramda
, etc.
import zipWith from "lodash/fp/zipWith"
const reconstructFromMissesAndMatches = str => query => {
const misses = str.split(query)
const matches = str.match(query)
return zipWith((miss, match) => [miss, match], misses, matches)
.flat()
.join("")
}
reconstructFromMissesAndMatches("Hello there how are you?")(toRegExp("h"))
//-> "Hello there how are you?"
Seems to work! But to be extra sure this doesn't fail on some edge case we're overlooking, let's really put it through the wringer using jsverify
.
import { reconstructFromMissesAndMatches, toRegExp } from "."
import { property, string } from "jsverify"
describe("reconstructFromMissesAndMatches", () => {
property(
"equality for arbitrary string values",
string,
string,
(str, q) => reconstructFromMissesAndMatches(str)(toRegExp(q)) === str
)
})
This generates 100 random tests, using arbitrary string values and testing edge cases first.
Then this is the final highlightMatches
function, which has the pseudo-Hindley-Milner signature String -> RegExp -> [JSX]
const highlightMatches = str => query => {
const misses = str.split(query)
const matches = str.match(query)
return zipWith((x, y) => [x, <mark>{y}</mark>], misses, matches).flat()
}
(Hopefully) working example found here: https://codesandbox.io/s/github/ptrfrncsmrph/React-Todo/tree/peter-murphy
(Array.prototype.flat()
is implemented in most browsers so hopefully this works out, but ideally should be poly-filled)