Commit 6afb5cca authored by Kyle Keating's avatar Kyle Keating Committed by Kyle Keating

Copy xpath.coffee & utils.coffee modules to js

Copy several util methods required to convert xpath.coffee, util.coffee and eventually range.coffee into javascirpt. This first step is to just convert the modules straight over and add basic unit tests for them which previously were missing.

A few changes have been made:

- findChild is exposed in part so it can be moved to this module and also be unit tested easily.
- simpleXPathJQuery- will eventually go away, but as a first pass, its being ported
- jquery is kept in this context, but eventually all methods that expect a jquery object will be converted over to pure js.
- typechecking is not complete
parent 1e7a72d4
import $ from 'jquery';
import {
findChild,
getTextNodes,
getLastTextNodeUpTo,
getFirstTextNodeNotBefore,
simpleXPathPure,
simpleXPathJQuery,
} from '../xpath-util';
describe('annotator/anchoring/xpath-util', () => {
describe('#findChild', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('throws an error when the parent has no children', () => {
assert.throws(() => {
findChild(container, 'fake', 0);
}, 'XPath error: node has no children!');
});
it('throws an error when the desired child is not found', () => {
container.innerHTML = `<div>text</div>`;
assert.throws(() => {
findChild(container, 'fake', 0);
}, 'XPath error: wanted child not found.');
});
describe('with children', () => {
beforeEach(() => {
container = document.createElement('div');
container.innerHTML = `text 1<span>text 2</span><span>text 3</span>text 4`;
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('finds the desired text() node at index 1', () => {
assert.equal(findChild(container, 'text()', 1).nodeValue, 'text 1');
document.body.removeChild(container);
});
it('finds the desired text() node at index 2', () => {
assert.equal(findChild(container, 'text()', 2).nodeValue, 'text 4');
});
it('finds the desired span node at index 1', () => {
assert.equal(findChild(container, 'span', 1).innerText, 'text 2');
});
it('finds the desired span node at index 2', () => {
assert.equal(findChild(container, 'span', 2).innerText, 'text 3');
});
});
});
describe('#getTextNodes', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('finds basic text nodes', () => {
container.innerHTML = `<span>text 1</span><span>text 2</span>`;
const result = getTextNodes($(container));
assert.equal(result.length, 2);
assert.equal(result[0].nodeValue, 'text 1');
assert.equal(result[1].nodeValue, 'text 2');
});
it('finds basic text nodes and whitespace', () => {
container.innerHTML = `<span>text 1</span>
<span>text 2</span>`;
const result = getTextNodes($(container));
assert.equal(result.length, 3);
});
it('finds basic text nodes and whitespace but ignores comments', () => {
container.innerHTML = `<span>text 1</span>
<!--span>text 2</span-->`;
const result = getTextNodes($(container));
assert.equal(result.length, 2);
assert.equal(result[0].nodeValue, 'text 1');
});
});
describe('#getLastTextNodeUpTo', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('gets the last text node', () => {
container.innerHTML = `<span>text</span>`;
const result = getLastTextNodeUpTo(container);
assert.equal(result.nodeValue, 'text');
});
it('gets the last text node nested', () => {
container.innerHTML = `<span>text first</span><span>text last</span>`;
const result = getLastTextNodeUpTo(container);
assert.equal(result.nodeValue, 'text last');
});
it('looks backwards to get the last text node if none are found', () => {
container.innerHTML = `<span>text first</span><span>text last</span><div id="too-far"></div>`;
const result = getLastTextNodeUpTo(container.querySelector('#too-far'));
assert.equal(result.nodeValue, 'text last');
});
it('returns null if no text node exists', () => {
const span = document.createElement('span');
container.appendChild(span);
assert.equal(getLastTextNodeUpTo(span), null);
});
});
describe('#getFirstTextNodeNotBefore', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('gets the first text node', () => {
container.innerHTML = `<span>text</span>`;
const result = getFirstTextNodeNotBefore(container);
assert.equal(result.nodeValue, 'text');
});
it('gets the last text node nested', () => {
container.innerHTML = `<span>text first</span><span>text last</span>`;
const result = getFirstTextNodeNotBefore(container);
assert.equal(result.nodeValue, 'text first');
});
it('looks forward to get the first text node if none are found', () => {
container.innerHTML = `<div id="too-far"></div><span>text first</span><span>text last</span>`;
const result = getFirstTextNodeNotBefore(
container.querySelector('#too-far')
);
assert.equal(result.nodeValue, 'text first');
});
it('returns null if no text node exists', () => {
const span = document.createElement('span');
container.appendChild(span);
assert.equal(getFirstTextNodeNotBefore(span), null);
});
});
describe('xpath', () => {
let container;
const html = `<div id="root">
<h1 id="h1-1">text</h1>
<p id="p-1">text<br/><br/><a id="a-1">text</a></p>
<p id="p-2">text<br/><em id="em-1"><br/>text</em>text</p>
<span>
<ul>
<li id="li-1">text1</li>
<li id="li-2">text</li>
<li id="li-3">text</li>
</ul>
</span>
</div>`;
beforeEach(() => {
container = document.createElement('div');
container.setAttribute('id', 'root');
container.innerHTML = html;
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
describe('#xpathFromNode', () => {
[
{
id: 'a-1',
xpath: '/div[1]/p[1]/a[1]',
},
{
id: 'h1-1',
xpath: '/div[1]/h1[1]',
},
{
id: 'p-1',
xpath: '/div[1]/p[1]',
},
{
id: 'a-1',
xpath: '/div[1]/p[1]/a[1]',
},
{
id: 'p-2',
xpath: '/div[1]/p[2]',
},
{
id: 'em-1',
xpath: '/div[1]/p[2]/em[1]',
},
{
id: 'li-3',
xpath: '/div[1]/span[1]/ul[1]/li[3]',
},
].forEach(test => {
it('produces the correct xpath for the provided nodes', () => {
let node = document.getElementById(test.id);
assert.equal(
simpleXPathJQuery($(node), document.querySelector('#root')),
test.xpath
);
});
});
});
describe('#simpleXPathPure', () => {
[
{
id: 'a-1',
xpaths: ['/div[1]/p[1]/a[1]', '/div[1]/p[1]/a[1]/text()[1]'],
},
{
id: 'h1-1',
xpaths: ['/div[1]/h1[1]', '/div[1]/h1[1]/text()[1]'],
},
{
id: 'p-1',
xpaths: ['/div[1]/p[1]', '/div[1]/p[1]/text()[1]'],
},
{
id: 'a-1',
xpaths: ['/div[1]/p[1]/a[1]', '/div[1]/p[1]/a[1]/text()[1]'],
},
{
id: 'p-2',
xpaths: [
'/div[1]/p[2]',
'/div[1]/p[2]/text()[1]',
'/div[1]/p[2]/text()[2]',
],
},
{
id: 'em-1',
xpaths: ['/div[1]/p[2]/em[1]', '/div[1]/p[2]/em[1]/text()[1]'],
},
{
id: 'li-3',
xpaths: [
'/div[1]/span[1]/ul[1]/li[3]',
'/div[1]/span[1]/ul[1]/li[3]/text()[1]',
],
},
].forEach(test => {
it('produces the correct xpath for the provided node', () => {
let node = document.getElementById(test.id);
assert.equal(
simpleXPathPure($(node), document.querySelector('#root')),
test.xpaths[0]
);
});
it('produces the correct xpath for the provided text node(s)', () => {
let node = document.getElementById(test.id).firstChild;
// collect all text nodes after the target queried node.
const textNodes = [];
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
textNodes.push(node);
}
node = node.nextSibling;
}
textNodes.forEach((node, index) => {
assert.equal(
simpleXPathPure($(node), document.querySelector('#root')),
test.xpaths[index + 1]
);
});
});
});
});
});
});
import $ from 'jquery';
/**
* Finds the child node associated with the provided index and
* type relative from a parent.
*/
export function findChild(parent, type, index) {
if (!parent.hasChildNodes()) {
throw new Error('XPath error: node has no children!');
}
const children = parent.childNodes;
let found = 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
let name = getNodeName(child);
if (name === type) {
found += 1;
if (found === index) {
return child;
}
}
}
throw new Error('XPath error: wanted child not found.');
}
/**
* Get the node name for use in generating an xpath expression.
*/
function getNodeName(node) {
const nodeName = node.nodeName.toLowerCase();
let result = nodeName;
if (nodeName === '#text') {
result = 'text()';
}
if (nodeName === '#comment') {
result = 'comment()';
}
if (nodeName === '#cdata-section') {
result = 'cdata-section()';
}
return result;
}
/**
* Get the index of the node as it appears in its parent's child list
*/
function getNodePosition(node) {
let pos = 0;
let tmp = node;
while (tmp) {
if (tmp.nodeName === node.nodeName) {
pos += 1;
}
tmp = tmp.previousSibling;
}
return pos;
}
/**
* A simple XPath evaluator using jQuery which can evaluate queries of
*
* @deprecated
*/
export function simpleXPathJQuery(nodes, relativeRoot) {
const paths = nodes.map((index, node) => {
let path = '';
let elem = node;
while (elem.nodeType === Node.ELEMENT_NODE && elem !== relativeRoot) {
let tagName = elem.tagName.replace(':', '\\:');
let idx = $(elem.parentNode).children(tagName).index(elem) + 1;
path = '/' + elem.tagName.toLowerCase() + `[${idx}]` + path;
elem = elem.parentNode;
}
return path;
});
return paths.get();
}
/**
* A simple XPath evaluator using only standard DOM methods which can
* evaluate queries of the form /tag[index]/tag[index].
*/
export function simpleXPathPure(nodes, relativeRoot) {
function getPathSegment(node) {
const name = getNodeName(node);
const pos = getNodePosition(node);
return `${name}[${pos}]`;
}
let rootNode = relativeRoot;
function getPathTo(node) {
let xpath = '';
let elem = node;
while (elem !== rootNode) {
if (!elem) {
throw new Error(
'Called getPathTo on a node which was not a descendant of @rootNode. ' +
rootNode
);
}
xpath = getPathSegment(elem) + '/' + xpath;
elem = elem.parentNode;
}
xpath = '/' + xpath;
xpath = xpath.replace(/\/$/, '');
return xpath;
}
const paths = nodes.map((index, node) => {
return getPathTo(node);
});
return paths.get();
}
/**
* Flatten a nested array structure.
* TODO: use Array.prototype.flat and polyfill
*/
function flatten(array) {
const flatten = ary => {
let flat = [];
ary.forEach(el => {
if (el && Array.isArray(el)) {
flat = flat.concat(flatten(el));
} else {
flat = flat.concat(el);
}
});
return flat;
};
return flatten(array);
}
/**
* Finds all text nodes within the elements in the current collection.
* Returns a new jQuery collection of text nodes.
*/
export function getTextNodes(jq) {
const getTextNodes = node => {
if (node && node.nodeType !== Node.TEXT_NODE) {
let nodes = [];
if (node.nodeType !== Node.COMMENT_NODE) {
[...node.childNodes].forEach(child => {
nodes.push(getTextNodes(child));
});
}
return nodes;
} else {
return node;
}
};
return jq.map((index, node) => {
return flatten(getTextNodes(node));
});
}
/**
* Determine the last text node inside or before the given node.
*/
export function getLastTextNodeUpTo(n) {
switch (n.nodeType) {
case Node.TEXT_NODE:
return n; // We have found our text node.
case Node.ELEMENT_NODE:
// This is an element, we need to dig in
if (n.lastChild) {
// Does it have children at all?
const result = getLastTextNodeUpTo(n.lastChild);
if (result) {
return result;
}
}
}
// Could not find a text node in current node, go backwards
const prev = n.previousSibling;
if (prev) {
// eslint-disable-next-line no-unused-vars
return getLastTextNodeUpTo(prev);
} else {
return null;
}
}
/**
* Determine the first text node in or after the given jQuery node.
*/
export function getFirstTextNodeNotBefore(n) {
switch (n.nodeType) {
case Node.TEXT_NODE:
return n; // We have found our text node.
case Node.ELEMENT_NODE:
// This is an element, we need to dig in
if (n.firstChild) {
// Does it have children at all?
const result = getFirstTextNodeNotBefore(n.firstChild);
if (result) {
return result;
}
}
}
// Could not find a text node in current node, go forward
const next = n.nextSibling;
if (next) {
// eslint-disable-next-line no-unused-vars
return getFirstTextNodeNotBefore(next);
} else {
return null;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment