-
-
Notifications
You must be signed in to change notification settings - Fork 96
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
200 additions
and
499 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,119 +1,97 @@ | ||
// License: MIT | ||
// Author: Anton Medvedev <[email protected]> | ||
// Source: https://github.com/antonmedv/finder | ||
let config; | ||
let rootDocument; | ||
let start; | ||
export function finder(input, options) { | ||
start = new Date(); | ||
//const startTime = new Date() | ||
if (input.nodeType !== Node.ELEMENT_NODE) { | ||
throw new Error(`Can't generate CSS selector for non-element node type.`); | ||
} | ||
if ('html' === input.tagName.toLowerCase()) { | ||
if (input.tagName.toLowerCase() === 'html') { | ||
return 'html'; | ||
} | ||
const defaults = { | ||
root: document.body, | ||
idName: (name) => true, | ||
className: (name) => true, | ||
tagName: (name) => true, | ||
idName: (name) => false, | ||
attr: (name, value) => false, | ||
seedMinLength: 1, | ||
optimizedMinLength: 2, | ||
threshold: 1000, | ||
maxNumberOfTries: 10000, | ||
className: (name) => wordLike(name), | ||
tagName: (name) => true, | ||
timeoutMs: undefined, | ||
}; | ||
config = { ...defaults, ...options }; | ||
rootDocument = findRootDocument(config.root, defaults); | ||
let path = bottomUpSearch(input, 'all', () => bottomUpSearch(input, 'two', () => bottomUpSearch(input, 'one', () => bottomUpSearch(input, 'none')))); | ||
if (path) { | ||
const optimized = sort(optimize(path, input)); | ||
if (optimized.length > 0) { | ||
path = optimized[0]; | ||
} | ||
return selector(path); | ||
} | ||
else { | ||
throw new Error(`Selector was not found.`); | ||
} | ||
} | ||
function findRootDocument(rootNode, defaults) { | ||
if (rootNode.nodeType === Node.DOCUMENT_NODE) { | ||
return rootNode; | ||
} | ||
if (rootNode === defaults.root) { | ||
return rootNode.ownerDocument; | ||
} | ||
return rootNode; | ||
} | ||
function bottomUpSearch(input, limit, fallback) { | ||
let path = null; | ||
let stack = []; | ||
const config = { ...defaults, ...options }; | ||
const rootDocument = findRootDocument(config.root, defaults); | ||
const stack = []; | ||
let current = input; | ||
let i = 0; | ||
while (current) { | ||
checkTimeout(); | ||
let level = maybe(id(current)) || | ||
maybe(...attr(current)) || | ||
maybe(...classNames(current)) || | ||
maybe(tagName(current)) || [any()]; | ||
const nth = index(current); | ||
if (limit == 'all') { | ||
if (nth) { | ||
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); | ||
} | ||
} | ||
else if (limit == 'two') { | ||
level = level.slice(0, 1); | ||
if (nth) { | ||
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); | ||
} | ||
} | ||
else if (limit == 'one') { | ||
const [node] = (level = level.slice(0, 1)); | ||
if (nth && dispensableNth(node)) { | ||
level = [nthChild(node, nth)]; | ||
} | ||
} | ||
else if (limit == 'none') { | ||
level = [any()]; | ||
if (nth) { | ||
level = [nthChild(level[0], nth)]; | ||
} | ||
} | ||
const level = tie(current, config); | ||
for (let node of level) { | ||
node.level = i; | ||
} | ||
stack.push(level); | ||
if (stack.length >= config.seedMinLength) { | ||
path = findUniquePath(stack, fallback); | ||
if (path) { | ||
break; | ||
} | ||
} | ||
current = current.parentElement; | ||
i++; | ||
const paths = sort(combinations(stack)); | ||
for (const candidate of paths) { | ||
console.log(selector(candidate)); | ||
if (unique(candidate, rootDocument)) { | ||
return selector(candidate); | ||
} | ||
} | ||
} | ||
if (!path) { | ||
path = findUniquePath(stack, fallback); | ||
} | ||
if (!path && fallback) { | ||
return fallback(); | ||
} | ||
return path; | ||
throw new Error(`Selector was not found.`); | ||
} | ||
function findUniquePath(stack, fallback) { | ||
const paths = sort(combinations(stack)); | ||
if (paths.length > config.threshold) { | ||
return fallback ? fallback() : null; | ||
function tie(element, config) { | ||
const level = []; | ||
const elementId = element.getAttribute('id'); | ||
if (elementId && config.idName(elementId)) { | ||
level.push({ | ||
name: '#' + CSS.escape(elementId), | ||
penalty: 0, | ||
}); | ||
} | ||
for (let i = 0; i < element.attributes.length; i++) { | ||
const attr = element.attributes[i]; | ||
if (config.attr(attr.name, attr.value)) { | ||
level.push({ | ||
name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, | ||
penalty: 1, | ||
}); | ||
} | ||
} | ||
for (let i = 0; i < element.classList.length; i++) { | ||
const name = element.classList[i]; | ||
if (config.className(name)) { | ||
level.push({ | ||
name: '.' + CSS.escape(name), | ||
penalty: 2, | ||
}); | ||
} | ||
} | ||
for (let candidate of paths) { | ||
if (unique(candidate)) { | ||
return candidate; | ||
const tagName = element.tagName.toLowerCase(); | ||
if (config.tagName(tagName)) { | ||
level.push({ | ||
name: tagName, | ||
penalty: 3, | ||
}); | ||
const index = indexOf(element, tagName); | ||
if (index !== undefined) { | ||
level.push({ | ||
name: `${tagName}:nth-of-type(${index})`, | ||
penalty: 4, | ||
}); | ||
} | ||
} | ||
return null; | ||
const nth = indexOf(element); | ||
if (nth !== undefined) { | ||
level.push({ | ||
name: `*:nth-child(${nth})`, | ||
penalty: 5, | ||
}); | ||
} | ||
return level; | ||
} | ||
function wordLike(name) { | ||
return /^[a-zA-Z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name); | ||
} | ||
function selector(path) { | ||
let node = path[0]; | ||
|
@@ -133,69 +111,20 @@ function selector(path) { | |
function penalty(path) { | ||
return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0); | ||
} | ||
function unique(path) { | ||
const css = selector(path); | ||
switch (rootDocument.querySelectorAll(css).length) { | ||
case 0: | ||
throw new Error(`Can't select any node with this selector: ${css}`); | ||
case 1: | ||
return true; | ||
default: | ||
return false; | ||
} | ||
} | ||
function id(input) { | ||
const elementId = input.getAttribute('id'); | ||
if (elementId && config.idName(elementId)) { | ||
return { | ||
name: '#' + CSS.escape(elementId), | ||
penalty: 0, | ||
}; | ||
} | ||
return null; | ||
} | ||
function attr(input) { | ||
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value)); | ||
return attrs.map((attr) => ({ | ||
name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, | ||
penalty: 0.5, | ||
})); | ||
} | ||
function classNames(input) { | ||
const names = Array.from(input.classList).filter(config.className); | ||
return names.map((name) => ({ | ||
name: '.' + CSS.escape(name), | ||
penalty: 1, | ||
})); | ||
} | ||
function tagName(input) { | ||
const name = input.tagName.toLowerCase(); | ||
if (config.tagName(name)) { | ||
return { | ||
name, | ||
penalty: 2, | ||
}; | ||
} | ||
return null; | ||
} | ||
function any() { | ||
return { | ||
name: '*', | ||
penalty: 3, | ||
}; | ||
} | ||
function index(input) { | ||
function indexOf(input, tagName) { | ||
const parent = input.parentNode; | ||
if (!parent) { | ||
return null; | ||
return undefined; | ||
} | ||
let child = parent.firstChild; | ||
if (!child) { | ||
return null; | ||
return undefined; | ||
} | ||
let i = 0; | ||
while (child) { | ||
if (child.nodeType === Node.ELEMENT_NODE) { | ||
if (child.nodeType === Node.ELEMENT_NODE | ||
&& (tagName === undefined | ||
|| child.tagName.toLowerCase() === tagName)) { | ||
i++; | ||
} | ||
if (child === input) { | ||
|
@@ -205,25 +134,6 @@ function index(input) { | |
} | ||
return i; | ||
} | ||
function nthChild(node, i) { | ||
return { | ||
name: node.name + `:nth-child(${i})`, | ||
penalty: node.penalty + 1, | ||
}; | ||
} | ||
function dispensableNth(node) { | ||
return node.name !== 'html' && !node.name.startsWith('#'); | ||
} | ||
function maybe(...level) { | ||
const list = level.filter(notEmpty); | ||
if (list.length > 0) { | ||
return list; | ||
} | ||
return null; | ||
} | ||
function notEmpty(value) { | ||
return value !== null && value !== undefined; | ||
} | ||
function* combinations(stack, path = []) { | ||
if (stack.length > 0) { | ||
for (let node of stack[0]) { | ||
|
@@ -237,36 +147,23 @@ function* combinations(stack, path = []) { | |
function sort(paths) { | ||
return [...paths].sort((a, b) => penalty(a) - penalty(b)); | ||
} | ||
function* optimize(path, input, scope = { | ||
counter: 0, | ||
visited: new Map(), | ||
}) { | ||
if (path.length > 2 && path.length > config.optimizedMinLength) { | ||
for (let i = 1; i < path.length - 1; i++) { | ||
if (scope.counter > config.maxNumberOfTries) { | ||
return; // Okay At least I tried! | ||
} | ||
scope.counter += 1; | ||
const newPath = [...path]; | ||
newPath.splice(i, 1); | ||
const newPathKey = selector(newPath); | ||
if (scope.visited.has(newPathKey)) { | ||
return; | ||
} | ||
if (unique(newPath) && same(newPath, input)) { | ||
yield newPath; | ||
scope.visited.set(newPathKey, true); | ||
yield* optimize(newPath, input, scope); | ||
} | ||
} | ||
function findRootDocument(rootNode, defaults) { | ||
if (rootNode.nodeType === Node.DOCUMENT_NODE) { | ||
return rootNode; | ||
} | ||
if (rootNode === defaults.root) { | ||
return rootNode.ownerDocument; | ||
} | ||
return rootNode; | ||
} | ||
function same(path, input) { | ||
return rootDocument.querySelector(selector(path)) === input; | ||
} | ||
function checkTimeout() { | ||
const elapsedTime = new Date().getTime() - start.getTime(); | ||
if (config.timeoutMs !== undefined && elapsedTime > config.timeoutMs) { | ||
throw new Error(`Timeout: Can't find a unique selector after ${elapsedTime}ms`); | ||
function unique(path, rootDocument) { | ||
const css = selector(path); | ||
switch (rootDocument.querySelectorAll(css).length) { | ||
case 0: | ||
throw new Error(`Can't select any node with this selector: ${css}`); | ||
case 1: | ||
return true; | ||
default: | ||
return false; | ||
} | ||
} |
Oops, something went wrong.