Javascript Generators, Meet XPath

Using Generators to Modernize a Geriatric Javascript API for $CURRENT_YEAR

How do you find-and-replace text on an HTML page?

<div>Hello, <span>human</span>!</div>

If the text is neatly neatly isolated inside an HTML element, it's easy; this will do:

document.querySelector("span").textContent = "evolved ape";

But here's a puzzle: how do you you change text that isn't neatly isolated in an HTML element?

You could use innerHTML:

let elt = document.querySelector("div");
elt.innerHTML = elt.innerHTML.replace("Hello", "Greetings");

...but this will hose any event listeners registered on elt's children.

You could grapple onto the nearest selectable element:

let node = document.querySelector("div").childNodes[0];
node.textContent = node.textContent.replace("Hello", "Greetings");

Yuck; this sort of child-node indexing feels really brittle.

Why can't we just directly select the text nodes containing Hello?


We can! Enter: XPath, the excessively powerful language for querying XML documents. It's usable in web-browsers with the, uh, descriptively-named method document.evaluate.

It's a bit of a production to use:

let xpath = "//text()[contains(., 'Hello')]"; // find text nodes containing 'Hello'
let context = document.body; // look in the body element
let namespace_resolver = null; // some sorta xml voodoo

let results = document.evaluate(xpath, context, null, result_type);

for (let i = 0; i < results.snapshotLength; i++) {
  let node = results.snapshotItem(i);
  node.textContent = node.textContent.replace("Hello", "Greetings");

Yes, you really need to write all of that. The result_type argument is technically optional, but omit it at your own peril: without it, you must instead stream results via iterateNext, and this will crash with an exception if you dare modify the queried elements!

It's no wonder document.evaluate is seldom used. Can we improve on it?

Iterizing XPath Queries

Yes, with generators! We can exploit the implicit iterability of generators to modernize this unwieldy API:

Document.prototype.xpath = Element.prototype.xpath =
  function* xpath(xpath) {
    let context = this instanceof Document ? document.documentElement : this;
    let namespace_resolver = null;
    let result_type = XPathResult.ORDERED_NODE_SNAPSHOT_TYPE;
    let results = document.evaluate(xpath, context, null, result_type);

    for (let i = 0; i < results.snapshotLength; i++)
      yield results.snapshotItem(i);

And because the result of this function is iterable, we can use it with spread syntax:

[...document.xpath("//text()[contains(., 'Hello')]")].
  forEach(node => node.textContent = node.textContent.replace("Hello", "Greetings"));