Created
March 2, 2023 20:25
-
-
Save mscolnick/383e80b2649dbff780d0f1ebdece8113 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
| var Limit; | |
| (function (Limit) { | |
| Limit[Limit["All"] = 0] = "All"; | |
| Limit[Limit["Two"] = 1] = "Two"; | |
| Limit[Limit["One"] = 2] = "One"; | |
| })(Limit || (Limit = {})); | |
| let config; | |
| let rootDocument; | |
| export function finder(input, options) { | |
| if (input.nodeType !== Node.ELEMENT_NODE) { | |
| throw new Error(`Can't generate CSS selector for non-element node type.`); | |
| } | |
| if ("html" === input.tagName.toLowerCase()) { | |
| return "html"; | |
| } | |
| const defaults = { | |
| root: document.body, | |
| idName: (name) => true, | |
| className: (name) => true, | |
| tagName: (name) => true, | |
| attr: (name, value) => false, | |
| seedMinLength: 1, | |
| optimizedMinLength: 2, | |
| threshold: 1000, | |
| maxNumberOfTries: 10000, | |
| maxRecursionDepth: 10, | |
| }; | |
| config = Object.assign(Object.assign({}, defaults), options); | |
| rootDocument = findRootDocument(config.root, defaults); | |
| let path = bottomUpSearch(input, Limit.All, () => bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One))); | |
| 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; | |
| const stack = []; | |
| let current = input; | |
| let i = 0; | |
| while (current && current !== config.root.parentElement) { | |
| let level = maybe(id(current)) || | |
| maybe(...attr(current)) || | |
| maybe(...classNames(current)) || | |
| maybe(tagName(current)) || [any()]; | |
| const nth = index(current); | |
| switch (limit) { | |
| case Limit.All: | |
| if (nth) { | |
| level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); | |
| } | |
| break; | |
| case Limit.Two: | |
| level = level.slice(0, 1); | |
| if (nth) { | |
| level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); | |
| } | |
| break; | |
| case Limit.One: { | |
| const [node] = (level = level.slice(0, 1)); | |
| if (nth && dispensableNth(node)) { | |
| level = [nthChild(node, nth)]; | |
| } | |
| break; | |
| } | |
| // No default | |
| } | |
| for (const 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++; | |
| } | |
| if (!path) { | |
| path = findUniquePath(stack, fallback); | |
| } | |
| return path; | |
| } | |
| function findUniquePath(stack, fallback) { | |
| const paths = sort(combinations(stack)); | |
| if (paths.length > config.threshold) { | |
| return fallback ? fallback() : null; | |
| } | |
| for (const candidate of paths) { | |
| if (unique(candidate)) { | |
| return candidate; | |
| } | |
| } | |
| return null; | |
| } | |
| function selector(path) { | |
| let node = path[0]; | |
| let query = node.name; | |
| for (let i = 1; i < path.length; i++) { | |
| const level = path[i].level || 0; | |
| query = node.level === level - 1 ? `${path[i].name} > ${query}` : `${path[i].name} ${query}`; | |
| node = path[i]; | |
| } | |
| return query; | |
| } | |
| function penalty(path) { | |
| return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0); | |
| } | |
| function unique(path) { | |
| switch (rootDocument.querySelectorAll(selector(path)).length) { | |
| case 0: | |
| throw new Error(`Can't select any node with this selector: ${selector(path)}`); | |
| case 1: | |
| return true; | |
| default: | |
| return false; | |
| } | |
| } | |
| function id(input) { | |
| const elementId = input.getAttribute("id"); | |
| if (elementId && config.idName(elementId)) { | |
| return { | |
| name: `#${cssesc(elementId, { isIdentifier: true })}`, | |
| penalty: 0, | |
| }; | |
| } | |
| return null; | |
| } | |
| function attr(input) { | |
| const attrs = [...input.attributes].filter((attr) => config.attr(attr.name, attr.value)); | |
| return attrs.map((attr) => ({ | |
| name: `[${cssesc(attr.name, { isIdentifier: true })}="${cssesc(attr.value)}"]`, | |
| penalty: 0.5, | |
| })); | |
| } | |
| function classNames(input) { | |
| const names = [...input.classList].filter(config.className); | |
| return names.map((name) => ({ | |
| name: `.${cssesc(name, { isIdentifier: true })}`, | |
| 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) { | |
| const parent = input.parentNode; | |
| if (!parent) { | |
| return null; | |
| } | |
| let child = parent.firstChild; | |
| if (!child) { | |
| return null; | |
| } | |
| let i = 0; | |
| while (child) { | |
| if (child.nodeType === Node.ELEMENT_NODE) { | |
| i++; | |
| } | |
| if (child === input) { | |
| break; | |
| } | |
| child = child.nextSibling; | |
| } | |
| 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 = [], depth = 0) { | |
| if (stack.length > 0 && depth < config.maxRecursionDepth) { | |
| for (const node of stack[0]) { | |
| yield* combinations(stack.slice(1, stack.length), path.concat(node), depth + 1); | |
| } | |
| } | |
| else { | |
| yield 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 same(path, input) { | |
| return rootDocument.querySelector(selector(path)) === input; | |
| } | |
| const regexAnySingleEscape = /[ -,./:-@[-^`{-~]/; | |
| const regexSingleEscape = /[ -,./:-@[\]^`{-~]/; | |
| const regexExcessiveSpaces = /(^|\\+)?(\\[\dA-F]{1,6}) (?![\d A-Fa-f])/g; | |
| const defaultOptions = { | |
| escapeEverything: false, | |
| isIdentifier: false, | |
| quotes: "single", | |
| wrap: false, | |
| }; | |
| function cssesc(string, opt = {}) { | |
| const options = Object.assign(Object.assign({}, defaultOptions), opt); | |
| if (options.quotes != "single" && options.quotes != "double") { | |
| options.quotes = "single"; | |
| } | |
| const quote = options.quotes == "double" ? '"' : "'"; | |
| const isIdentifier = options.isIdentifier; | |
| const firstChar = string.charAt(0); | |
| let output = ""; | |
| let counter = 0; | |
| const length = string.length; | |
| while (counter < length) { | |
| const character = string.charAt(counter++); | |
| let codePoint = character.charCodeAt(0); | |
| let value = void 0; | |
| // If it’s not a printable ASCII character… | |
| if (codePoint < 0x20 || codePoint > 0x7e) { | |
| if (codePoint >= 55296 && codePoint <= 56319 && counter < length) { | |
| // It’s a high surrogate, and there is a next character. | |
| const extra = string.charCodeAt(counter++); | |
| if ((extra & 64512) == 56320) { | |
| // next character is low surrogate | |
| codePoint = ((codePoint & 1023) << 10) + (extra & 1023) + 65536; | |
| } | |
| else { | |
| // It’s an unmatched surrogate; only append this code unit, in case | |
| // the next code unit is the high surrogate of a surrogate pair. | |
| counter--; | |
| } | |
| } | |
| value = `\\${codePoint.toString(16).toUpperCase()} `; | |
| } | |
| else { | |
| if (options.escapeEverything) { | |
| value = regexAnySingleEscape.test(character) ? `\\${character}` : `\\${codePoint.toString(16).toUpperCase()} `; | |
| } | |
| else if (/[\t\n\u000B\f\r]/.test(character)) { | |
| value = `\\${codePoint.toString(16).toUpperCase()} `; | |
| } | |
| else if (character == "\\" || | |
| (!isIdentifier && ((character == '"' && quote == character) || (character == "'" && quote == character))) || | |
| (isIdentifier && regexSingleEscape.test(character))) { | |
| value = `\\${character}`; | |
| } | |
| else { | |
| value = character; | |
| } | |
| } | |
| output += value; | |
| } | |
| if (isIdentifier) { | |
| if (/^-[\d-]/.test(output)) { | |
| output = `\\-${output.slice(1)}`; | |
| } | |
| else if (/\d/.test(firstChar)) { | |
| output = `\\3${firstChar} ${output.slice(1)}`; | |
| } | |
| } | |
| // Remove spaces after `\HEX` escapes that are not followed by a hex digit, | |
| // since they’re redundant. Note that this is only possible if the escape | |
| // sequence isn’t preceded by an odd number of backslashes. | |
| output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) { | |
| if ($1 && $1.length % 2) { | |
| // It’s not safe to remove the space, so don’t. | |
| return $0; | |
| } | |
| // Strip the space. | |
| return ($1 || "") + $2; | |
| }); | |
| if (!isIdentifier && options.wrap) { | |
| return quote + output + quote; | |
| } | |
| return output; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment