chrome-devtools-frontend
Version:
Chrome DevTools UI
168 lines (150 loc) • 4.42 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {SelectorPart, type Selector} from './Selector.js';
const attributeSelector = (name: string, value: string): string => {
return `//*[@${name}=${JSON.stringify(value)}]`;
};
const getSelectorPart = (
node: Node,
optimized?: boolean,
attributes: string[] = [],
): SelectorPart|undefined => {
let value: string;
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (!(node instanceof Element)) {
return;
}
if (optimized) {
for (const attribute of attributes) {
value = node.getAttribute(attribute) ?? '';
if (value) {
return new SelectorPart(attributeSelector(attribute, value), true);
}
}
}
if (node.id) {
return new SelectorPart(attributeSelector('id', node.id), true);
}
value = node.localName;
break;
case Node.ATTRIBUTE_NODE:
value = '@' + node.nodeName;
break;
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
value = 'text()';
break;
case Node.PROCESSING_INSTRUCTION_NODE:
value = 'processing-instruction()';
break;
case Node.COMMENT_NODE:
value = 'comment()';
break;
case Node.DOCUMENT_NODE:
value = '';
break;
default:
value = '';
break;
}
const index = getXPathIndexInParent(node);
if (index > 0) {
value += `[${index}]`;
}
return new SelectorPart(value, node.nodeType === Node.DOCUMENT_NODE);
};
const getXPathIndexInParent = (node: Node): number => {
/**
* @returns -1 in case of error, 0 if no siblings matching the same expression,
* XPath index among the same expression-matching sibling nodes otherwise.
*/
function areNodesSimilar(left: Node, right: Node): boolean {
if (left === right) {
return true;
}
if (left instanceof Element && right instanceof Element) {
return left.localName === right.localName;
}
if (left.nodeType === right.nodeType) {
return true;
}
// XPath treats CDATA as text nodes.
const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
return leftType === rightType;
}
const children = node.parentNode ? node.parentNode.children : null;
if (!children) {
return 0;
}
let hasSameNamedElements;
for (let i = 0; i < children.length; ++i) {
if (areNodesSimilar(node, children[i]) && children[i] !== node) {
hasSameNamedElements = true;
break;
}
}
if (!hasSameNamedElements) {
return 0;
}
let ownIndex = 1; // XPath indices start with 1.
for (let i = 0; i < children.length; ++i) {
if (areNodesSimilar(node, children[i])) {
if (children[i] === node) {
return ownIndex;
}
++ownIndex;
}
}
throw new Error(
'This is impossible; a child must be the child of the parent',
);
};
/**
* Computes the XPath for a node.
*
* @param node - The node to compute.
* @param optimized - Whether to optimize the XPath for the node. Does not imply
* the XPath is shorter; implies the XPath will be highly-scoped to the node.
* @returns The computed XPath.
*
* @internal
*/
export const computeXPath = (
node: Node,
optimized?: boolean,
attributes?: string[],
): Selector|undefined => {
if (node.nodeType === Node.DOCUMENT_NODE) {
return '/';
}
const selectors = [];
const buffer = [];
let contextNode: Node|null = node;
while (contextNode !== document && contextNode) {
const part = getSelectorPart(contextNode, optimized, attributes);
if (!part) {
return;
}
buffer.unshift(part);
if (part.optimized) {
contextNode = contextNode.getRootNode();
} else {
contextNode = contextNode.parentNode;
}
if (contextNode instanceof ShadowRoot) {
selectors.unshift((buffer[0].optimized ? '' : '/') + buffer.join('/'));
buffer.splice(0, buffer.length);
contextNode = contextNode.host;
}
}
if (buffer.length) {
selectors.unshift((buffer[0].optimized ? '' : '/') + buffer.join('/'));
}
if (!selectors.length) {
return;
}
return selectors;
};