Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
loader.lazyRequireGetter(
this,
"isWhitespaceTextNode",
true
);
/**
* The walker-search module provides a simple API to index and search strings
* and elements inside a given document.
* It indexes tag names, attribute names and values, and text contents.
* It provides a simple search function that returns a list of nodes that
* matched.
*/
class WalkerIndex {
/**
* The WalkerIndex class indexes the document (and all subdocs) from
* a given walker.
*
* It is only indexed the first time the data is accessed and will be
* re-indexed if a mutation happens between requests.
*
* @param {Walker} walker The walker to be indexed
*/
constructor(walker) {
this.walker = walker;
this.clearIndex = this.clearIndex.bind(this);
// Kill the index when mutations occur, the next data get will re-index.
this.walker.on("any-mutation", this.clearIndex);
}
/**
* Destroy this instance, releasing all data and references
*/
destroy() {
this.walker.off("any-mutation", this.clearIndex);
}
clearIndex() {
if (!this.currentlyIndexing) {
this._data = null;
}
}
get doc() {
return this.walker.rootDoc;
}
/**
* Get the indexed data
* This getter also indexes if it hasn't been done yet or if the state is
* dirty
*
* @returns Map<String, Array<{type:String, node:DOMNode}>>
* A Map keyed on the searchable value, containing an array with
* objects containing the 'type' (one of ALL_RESULTS_TYPES), and
* the DOM Node.
*/
get data() {
if (!this._data) {
this._data = new Map();
this.index();
}
return this._data;
}
_addToIndex(type, node, value) {
// Add an entry for this value if there isn't one
const entry = this._data.get(value);
if (!entry) {
this._data.set(value, []);
}
// Add the type/node to the list
this._data.get(value).push({
type,
node,
});
}
index() {
// Handle case where iterating nextNode() with the deepTreeWalker triggers
// a mutation (Bug 1222558)
this.currentlyIndexing = true;
const documentWalker = this.walker.getDocumentWalker(this.doc);
while (documentWalker.nextNode()) {
const node = documentWalker.currentNode;
if (
this.walker.targetActor.ignoreSubFrames &&
node.ownerDocument !== this.doc
) {
continue;
}
if (node.nodeType === 1) {
// For each element node, we get the tagname and all attributes names
// and values
const localName = node.localName;
if (localName === "_moz_generated_content_marker") {
this._addToIndex("tag", node, "::marker");
this._addToIndex("text", node, node.textContent.trim());
} else if (localName === "_moz_generated_content_before") {
this._addToIndex("tag", node, "::before");
this._addToIndex("text", node, node.textContent.trim());
} else if (localName === "_moz_generated_content_after") {
this._addToIndex("tag", node, "::after");
this._addToIndex("text", node, node.textContent.trim());
} else {
this._addToIndex("tag", node, node.localName);
}
for (const { name, value } of node.attributes) {
this._addToIndex("attributeName", node, name);
this._addToIndex("attributeValue", node, value);
}
} else if (node.textContent && node.textContent.trim().length) {
// For comments and text nodes, we get the text
this._addToIndex("text", node, node.textContent.trim());
}
}
this.currentlyIndexing = false;
}
}
exports.WalkerIndex = WalkerIndex;
class WalkerSearch {
/**
* The WalkerSearch class provides a way to search an indexed document as well
* as find elements that match a given css selector.
*
* Usage example:
* let s = new WalkerSearch(doc);
* let res = s.search("lang", index);
* for (let {matched, results} of res) {
* for (let {node, type} of results) {
* console.log("The query matched a node's " + type);
* console.log("Node that matched", node);
* }
* }
* s.destroy();
*
* @param {Walker} the walker to be searched
*/
constructor(walker) {
this.walker = walker;
this.index = new WalkerIndex(this.walker);
}
destroy() {
this.index.destroy();
this.walker = null;
}
_addResult(node, type, results) {
if (!results.has(node)) {
results.set(node, []);
}
const matches = results.get(node);
// Do not add if the exact same result is already in the list
let isKnown = false;
for (const match of matches) {
if (match.type === type) {
isKnown = true;
break;
}
}
if (!isKnown) {
matches.push({ type });
}
}
_searchIndex(query, options, results) {
for (const [matched, res] of this.index.data) {
if (!options.searchMethod(query, matched)) {
continue;
}
// Add any relevant results (skipping non-requested options).
res
.filter(entry => {
return options.types.includes(entry.type);
})
.forEach(({ node, type }) => {
this._addResult(node, type, results);
});
}
}
_searchSelectors(query, options, results) {
// If the query is just one "word", no need to search because _searchIndex
// will lead the same results since it has access to tagnames anyway
const isSelector = query && query.match(/[ >~.#\[\]]/);
if (!options.types.includes("selector") || !isSelector) {
return;
}
const nodes = this.walker._multiFrameQuerySelectorAll(query);
for (const node of nodes) {
this._addResult(node, "selector", results);
}
}
_searchXPath(query, options, results) {
if (!options.types.includes("xpath")) {
return;
}
const nodes = this.walker._multiFrameXPath(query);
for (const node of nodes) {
// Exclude text nodes that only contain whitespace
// because they are not displayed in the Inspector.
if (!isWhitespaceTextNode(node)) {
this._addResult(node, "xpath", results);
}
}
}
/**
* Search the document
* @param {String} query What to search for
* @param {Object} options The following options are accepted:
* - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_*
* defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to
* selector and XPath search types)
* - types {Array} a list of things to search for (tag, text, attributes, etc)
* defaults to WalkerSearch.ALL_RESULTS_TYPES
* @return {Array} An array is returned with each item being an object like:
* {
* node: <the dom node that matched>,
* type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES>
* }
*/
search(query, options = {}) {
options.searchMethod =
options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS;
options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES;
// Empty strings will return no results, as will non-string input
if (typeof query !== "string") {
query = "";
}
// Store results in a map indexed by nodes to avoid duplicate results
const results = new Map();
// Search through the indexed data
this._searchIndex(query, options, results);
// Search with querySelectorAll
this._searchSelectors(query, options, results);
// Search with XPath
this._searchXPath(query, options, results);
// Concatenate all results into an Array to return
const resultList = [];
for (const [node, matches] of results) {
for (const { type } of matches) {
resultList.push({
node,
type,
});
// For now, just do one result per node since the frontend
// doesn't have a way to highlight each result individually
// yet.
break;
}
}
const documents = this.walker.targetActor.windows.map(win => win.document);
// Sort the resulting nodes by order of appearance in the DOM
resultList.sort((a, b) => {
// Disconnected nodes won't get good results from compareDocumentPosition
// so check the order of their document instead.
if (a.node.ownerDocument != b.node.ownerDocument) {
const indA = documents.indexOf(a.node.ownerDocument);
const indB = documents.indexOf(b.node.ownerDocument);
return indA - indB;
}
// If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4)
// which means B is after A.
return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1;
});
return resultList;
}
}
WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => {
return query && candidate.toLowerCase().includes(query.toLowerCase());
};
WalkerSearch.ALL_RESULTS_TYPES = [
"tag",
"text",
"attributeName",
"attributeValue",
"selector",
"xpath",
];
exports.WalkerSearch = WalkerSearch;