Skip to content

Instantly share code, notes, and snippets.

@mscolnick
Created March 2, 2023 20:25
Show Gist options
  • Save mscolnick/383e80b2649dbff780d0f1ebdece8113 to your computer and use it in GitHub Desktop.
Save mscolnick/383e80b2649dbff780d0f1ebdece8113 to your computer and use it in GitHub Desktop.
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