diff --git a/finder.js b/finder.js index 061c02d..9102f96 100644 --- a/finder.js +++ b/finder.js @@ -14,7 +14,7 @@ export function finder(input, options) { idName: wordLike, className: wordLike, tagName: (name) => true, - attr: (name, value) => wordLike(name) && wordLike(value), + attr: useAttr, seedMinLength: 2, optimizedMinLength: 2, timeoutMs: 1000, @@ -73,43 +73,30 @@ export function wordLike(name) { } return false; } +const acceptedAttrNames = new Set(['role', 'name', 'aria-label', 'rel', 'href']); +export function useAttr(name, value) { + let nameIsOk = acceptedAttrNames.has(name); + nameIsOk ||= name.startsWith('data-') && wordLike(name); + let valueIsOk = wordLike(value) && value.length < 100; + valueIsOk ||= value.startsWith('#') && wordLike(value.slice(1)); + return nameIsOk && valueIsOk; +} function search(paths, config, input, rootDocument, startTime) { paths.sort(byPenalty); for (const candidate of paths) { const elapsedTimeMs = new Date().getTime() - startTime.getTime(); if (elapsedTimeMs > config.timeoutMs) { - const path = fallbackToNthChild(input, rootDocument); + const path = fallback(input, rootDocument); if (path) { return path; } - throw new Error(`Timeout: Can't find a unique selector after ${elapsedTimeMs}ms`); + throw new Error(`Timeout: Can't find a unique selector after ${config.timeoutMs}ms`); } if (unique(candidate, rootDocument)) { return candidate; } } } -function fallbackToNthChild(input, rootDocument) { - let i = 0; - let current = input; - const path = []; - while (current && current !== rootDocument) { - const index = indexOf(current); - if (index === undefined) { - return; - } - path.push({ - name: `:nth-child(${index})`, - penalty: 0, - level: i, - }); - current = current.parentElement; - i++; - } - if (unique(path, rootDocument)) { - return path; - } -} function tie(element, config) { const level = []; const elementId = element.getAttribute('id'); @@ -121,6 +108,9 @@ function tie(element, config) { } for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; + if (attr.name === 'id' || attr.name === 'class') { + continue; + } if (config.attr(attr.name, attr.value)) { level.push({ name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, @@ -133,7 +123,7 @@ function tie(element, config) { if (config.className(name)) { level.push({ name: '.' + CSS.escape(name), - penalty: 2, + penalty: 1, }); } } @@ -141,21 +131,21 @@ function tie(element, config) { if (config.tagName(tagName)) { level.push({ name: tagName, - penalty: 3, + penalty: 5, }); const index = indexOf(element, tagName); if (index !== undefined) { level.push({ - name: `${tagName}:nth-of-type(${index})`, - penalty: 4, + name: nthOfType(tagName, index), + penalty: 10, }); } } const nth = indexOf(element); if (nth !== undefined) { level.push({ - name: `:nth-child(${nth})`, - penalty: 9, + name: nthChild(tagName, nth), + penalty: 50, }); } return level; @@ -204,6 +194,40 @@ function indexOf(input, tagName) { } return i; } +function fallback(input, rootDocument) { + let i = 0; + let current = input; + const path = []; + while (current && current !== rootDocument) { + const tagName = current.tagName.toLowerCase(); + const index = indexOf(current, tagName); + if (index === undefined) { + return; + } + path.push({ + name: nthOfType(tagName, index), + penalty: NaN, + level: i, + }); + current = current.parentElement; + i++; + } + if (unique(path, rootDocument)) { + return path; + } +} +function nthChild(tagName, index) { + if (tagName === 'html') { + return 'html'; + } + return `${tagName}:nth-child(${index})`; +} +function nthOfType(tagName, index) { + if (tagName === 'html') { + return 'html'; + } + return `${tagName}:nth-of-type(${index})`; +} function* combinations(stack, path = []) { if (stack.length > 0) { for (let node of stack[0]) { diff --git a/finder.ts b/finder.ts index 4762a7f..67a37c3 100644 --- a/finder.ts +++ b/finder.ts @@ -32,7 +32,7 @@ export function finder(input: Element, options?: Partial): string { idName: wordLike, className: wordLike, tagName: (name: string) => true, - attr: (name: string, value: string) => wordLike(name) && wordLike(value), + attr: useAttr, seedMinLength: 2, optimizedMinLength: 2, timeoutMs: 1000, @@ -100,6 +100,16 @@ export function wordLike(name: string): boolean { return false } +const acceptedAttrNames = new Set(['role', 'name', 'aria-label', 'rel', 'href']) + +export function useAttr(name: string, value: string) { + let nameIsOk = acceptedAttrNames.has(name) + nameIsOk ||= name.startsWith('data-') && wordLike(name) + let valueIsOk = wordLike(value) && value.length < 100 + valueIsOk ||= value.startsWith('#') && wordLike(value.slice(1)) + return nameIsOk && valueIsOk +} + function search( paths: Knot[][], config: Options, @@ -111,12 +121,12 @@ function search( for (const candidate of paths) { const elapsedTimeMs = new Date().getTime() - startTime.getTime() if (elapsedTimeMs > config.timeoutMs) { - const path = fallbackToNthChild(input, rootDocument) + const path = fallback(input, rootDocument) if (path) { return path } throw new Error( - `Timeout: Can't find a unique selector after ${elapsedTimeMs}ms`, + `Timeout: Can't find a unique selector after ${config.timeoutMs}ms`, ) } if (unique(candidate, rootDocument)) { @@ -125,28 +135,6 @@ function search( } } -function fallbackToNthChild(input: Element, rootDocument: Element | Document) { - let i = 0 - let current: Element | null = input - const path: Knot[] = [] - while (current && current !== rootDocument) { - const index = indexOf(current) - if (index === undefined) { - return - } - path.push({ - name: `:nth-child(${index})`, - penalty: 0, - level: i, - }) - current = current.parentElement - i++ - } - if (unique(path, rootDocument)) { - return path - } -} - function tie(element: Element, config: Options): Knot[] { const level: Knot[] = [] @@ -160,6 +148,9 @@ function tie(element: Element, config: Options): Knot[] { for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i] + if (attr.name === 'id' || attr.name === 'class') { + continue + } if (config.attr(attr.name, attr.value)) { level.push({ name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, @@ -173,7 +164,7 @@ function tie(element: Element, config: Options): Knot[] { if (config.className(name)) { level.push({ name: '.' + CSS.escape(name), - penalty: 2, + penalty: 1, }) } } @@ -182,14 +173,14 @@ function tie(element: Element, config: Options): Knot[] { if (config.tagName(tagName)) { level.push({ name: tagName, - penalty: 3, + penalty: 5, }) const index = indexOf(element, tagName) if (index !== undefined) { level.push({ - name: `${tagName}:nth-of-type(${index})`, - penalty: 4, + name: nthOfType(tagName, index), + penalty: 10, }) } } @@ -197,8 +188,8 @@ function tie(element: Element, config: Options): Knot[] { const nth = indexOf(element) if (nth !== undefined) { level.push({ - name: `:nth-child(${nth})`, - penalty: 9, + name: nthChild(tagName, nth), + penalty: 50, }) } @@ -254,6 +245,43 @@ function indexOf(input: Element, tagName?: string): number | undefined { return i } +function fallback(input: Element, rootDocument: Element | Document) { + let i = 0 + let current: Element | null = input + const path: Knot[] = [] + while (current && current !== rootDocument) { + const tagName = current.tagName.toLowerCase() + const index = indexOf(current, tagName) + if (index === undefined) { + return + } + path.push({ + name: nthOfType(tagName, index), + penalty: NaN, + level: i, + }) + current = current.parentElement + i++ + } + if (unique(path, rootDocument)) { + return path + } +} + +function nthChild(tagName: string, index: number) { + if (tagName === 'html') { + return 'html' + } + return `${tagName}:nth-child(${index})` +} + +function nthOfType(tagName: string, index: number) { + if (tagName === 'html') { + return 'html' + } + return `${tagName}:nth-of-type(${index})` +} + function* combinations(stack: Knot[][], path: Knot[] = []): Generator { if (stack.length > 0) { for (let node of stack[0]) { diff --git a/tests/finder.test.js b/tests/finder.test.js index 8aaca1f..173c43e 100644 --- a/tests/finder.test.js +++ b/tests/finder.test.js @@ -10,14 +10,14 @@ import 'css.escape' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -function check({ file, html, target }, config = {}) { +function check({ file, html, query }, config = {}) { const dom = file ? new JSDOM(readFileSync(path.join(__dirname, file), 'utf8')) : new JSDOM(html) globalThis.document = dom.window.document globalThis.Node = dom.window.Node const selectors = [] - for (let node of document.querySelectorAll(target ?? '*')) { + for (let node of document.querySelectorAll(query ?? '*')) { let css try { css = finder(node, config) @@ -61,7 +61,7 @@ test('tailwindcss', () => { test('google', () => { check({ file: 'pages/google.com.html', - target: '[href="https://github.com/antonmedv/finder"]', + query: '[href]', }) })