Skip to content

Instantly share code, notes, and snippets.

@tw3
Last active July 16, 2020 13:57
Show Gist options
  • Save tw3/e09066bf361ac30aa404b223846182e4 to your computer and use it in GitHub Desktop.
Save tw3/e09066bf361ac30aa404b223846182e4 to your computer and use it in GitHub Desktop.
/**
* Example:
*
* const builder = new XPathBuilder();
* const xpathStr = builder
* .addDescendant({ nodeName: 'carbon-dropdown' })
* .addDescendant({ className: 'bx--label', innerText: 'AWS Region' })
* .addAncestor({ nodeName: 'carbon-dropdown' })
* .build();
*
* OR
*
* const xpathStr = $xp()
* .addDescendant({ nodeName: 'carbon-dropdown' })
* .addDescendant({ className: 'bx--label', innerText: 'AWS Region' })
* .addAncestor({ nodeName: 'carbon-dropdown' })
* .build();
*/
export const $xp = xpathBuilderFactory;
function xpathBuilderFactory(mode?: XPathStartMode): XPathBuilder {
return new XPathBuilder(mode);
}
export class XPathBuilder {
private readonly paths: XPathNodePath[];
private instanceNum: number;
private readonly pathTypeToStr: Record<XPathNodePathType, string> = {
'descendant': '//',
'child': '/',
'preceding-sibling': '/preceding-sibling::',
'following-sibling': '/following-sibling::',
'ancestor': '/ancestor::'
};
constructor(
public readonly mode?: XPathStartMode
) {
if (!this.mode) {
this.mode = 'relative';
}
this.paths = [];
}
addDescendant(input: XPathNodeInput): XPathBuilder {
const q: XPathNodeQuery = this.coerceQuery(input);
const p: XPathNodePath = { ...q, pathType: 'descendant' };
this.paths.push(p);
return this;
}
addChild(input: XPathNodeInput): XPathBuilder {
const q: XPathNodeQuery = this.coerceQuery(input);
const p: XPathNodePath = { ...q, pathType: 'child' };
this.paths.push(p);
return this;
}
addPrecedingSibling(input: XPathNodeInput): XPathBuilder {
const q: XPathNodeQuery = this.coerceQuery(input);
const p: XPathNodePath = { ...q, pathType: 'preceding-sibling' };
this.paths.push(p);
return this;
}
addFollowingSibling(input: XPathNodeInput): XPathBuilder {
const q: XPathNodeQuery = this.coerceQuery(input);
const p: XPathNodePath = { ...q, pathType: 'following-sibling' };
this.paths.push(p);
return this;
}
addAncestor(input: XPathNodeInput): XPathBuilder {
const q: XPathNodeQuery = this.coerceQuery(input);
const p: XPathNodePath = { ...q, pathType: 'ancestor' };
this.paths.push(p);
return this;
}
addInstanceNum(num: number): XPathBuilder {
this.instanceNum = num;
return this;
}
build(): string {
let pathStr = this.paths.reduce((previousStr: string, p: XPathNodePath) => {
return previousStr + this.buildPath(p);
}, '');
if (this.mode === 'relative') {
pathStr = `.${pathStr}`;
}
if (isPositiveInteger(this.instanceNum)) {
pathStr = `(${pathStr})[${this.instanceNum}]`;
}
return pathStr;
}
private buildPath(p: XPathNodePath): string {
return [
this.getPathTypeStr(p), // e.g. '//'
this.getXPathNodePath(p), // e.g. 'carbon-dropdown'
this.getXPathPredicate(p)
].join('');
}
private getPathTypeStr(p: XPathNodePath): string {
return this.pathTypeToStr[p.pathType];
}
private getXPathNodePath(p: XPathNodePath): string {
return p.nodeName ? p.nodeName : '*';
}
// e.g. [contains(concat(' ',@class,' '), ' bx--label ') and normalize-space(text()) = 'First']
private getXPathPredicate(p: XPathNodePath): string {
const predicateArr: string[] = [];
if (p.className) {
predicateArr.push(`contains(concat(' ',@class,' '), ' ${p.className} ')`);
}
if (p.name) {
predicateArr.push(`@name='${p.name}'`);
}
if (p.innerText) {
predicateArr.push(`normalize-space(.) = '${p.innerText}'`);
// TODO: Consider normalize-space(.) instead
}
if (Array.isArray(p.predicates)) {
for (const pr of p.predicates) {
const str: string = this.getCustomXPathPredicate(pr);
if (str) {
predicateArr.push(str);
}
}
}
if (predicateArr.length === 0) {
return '';
}
const joinedStr = `${predicateArr.join(' and ')}`;
return `[${joinedStr}]`;
}
private getCustomXPathPredicate(pr: XPathNodeAttributePredicate): string | undefined {
if (pr.attr && pr.endsWith) {
return `substring(@${pr.attr}, string-length(@${pr.attr}) -string-length('${pr.endsWith}') +1) = '${pr.endsWith}'`;
}
return undefined;
}
private coerceQuery(input: XPathNodeInput): XPathNodeQuery {
if (typeof input === 'string') {
return { nodeName: input } as XPathNodeQuery;
}
return input as XPathNodeQuery;
}
}
export type XPathNodeInput = XPathNodeQuery | string;
export interface XPathNodeQuery {
nodeName?: string;
className?: string;
name?: string;
innerText?: string;
predicates?: XPathNodePredicate[];
}
export type XPathNodePredicate = XPathNodeAttributePredicate;
export interface XPathNodeAttributePredicate {
attr: string;
endsWith?: string;
}
export type XPathStartMode = 'absolute' | 'relative';
type XPathNodePathType = 'descendant' | 'child' | 'preceding-sibling' | 'following-sibling' | 'ancestor';
interface XPathNodePath extends XPathNodeQuery {
pathType: XPathNodePathType;
}
function isPositiveInteger(num): boolean {
return (isInteger(num) && num > 0);
}
export function isInteger(num): boolean {
return (parseInt(num) === num);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment