/// Extracts meaningful portions of text from DOM, yielding the top matched nodes
///
/// e.g.
///
/// ```html
///
/// my hair are NOT
///
/// blue!
///
///
/// ```
///
/// Shall yield a reference to the `div` with text "my hair are NOT blue!"
const IS_DEV = import.meta.env.MODE == "development";
const TRAVERSE_STACK_BANS: Set = new Set([
"IMG",
"FIGURE",
"SVG",
"VIDEO",
"META",
]);
const TRAVERSE_STACK_ROOTS: Set = new Set([
"DIV",
"ARTICLE",
"SECTION",
"MAIN",
"HEADER",
"BLOCKQUOTE",
]);
const TRAVERSE_ALLOWED_ELEMENT_TAGS: Set = new Set([
...TRAVERSE_STACK_ROOTS,
"SPAN",
"P",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"H7",
"H8",
"UL",
"OL",
"LI",
"DEL",
"B",
"I",
"STRONG",
"EM",
]);
export function* traverse_text(soft_max_words: number, hard_min_words: number) {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// sanity check
if (!node.isConnected) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!IS_CONNECTED");
}
return NodeFilter.FILTER_REJECT;
}
if (node.parentNode?.nodeType != Node.ELEMENT_NODE) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!ELEMENT_NODE");
}
return NodeFilter.FILTER_REJECT;
}
// filter elements
if (!TRAVERSE_ALLOWED_ELEMENT_TAGS.has(node.parentElement?.tagName)) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!ALLOWED_TAG");
}
return NodeFilter.FILTER_REJECT;
}
// TODO: figure out more performant way to check this.
// Ignore anything inside of banned elements.
//
// For now I'll just limit the depth to 16
let checking = node.parentElement;
for (let i = 0; i < 16; i++) {
if (!checking || checking.tagName == "BODY") {
break;
}
if (TRAVERSE_STACK_BANS.has(checking.tagName)) {
if (IS_DEV) {
node.parentElement?.setAttribute(
"data-firaid-moz-traverse",
"REJECT-BANNED_ANCESTOR",
);
}
return NodeFilter.FILTER_REJECT;
}
checking = checking.parentElement;
}
// drop with nonsense content
if ((node.textContent?.trim()?.length || 0) < 8) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-SHORT_TEXT");
}
return NodeFilter.FILTER_REJECT;
}
// finally good ones
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-traverse", "ACCEPT");
}
return NodeFilter.FILTER_ACCEPT;
},
});
let stack: { root: Node; first_node: Node; last_node: Node; text: string; words: number } | null = null;
while (walker.nextNode()) {
const node = walker.currentNode;
let rootable_parent = node.parentElement;
while (
rootable_parent &&
rootable_parent.tagName != "HTML" &&
!TRAVERSE_STACK_ROOTS.has(rootable_parent.tagName)
) {
rootable_parent = rootable_parent.parentElement;
}
if (!rootable_parent) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-stack", "REJECTED-!ROOTABLE_PARENT");
}
continue;
}
let nodeText = node.textContent?.trim();
if (!nodeText) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-stack", "REJECTED-!TEXT");
}
continue;
}
let nodeTextWords = count_words_naive(nodeText);
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-stack", "VISITED");
}
if (!stack) {
stack = {
root: rootable_parent,
first_node: node,
last_node: node,
text: nodeText,
words: nodeTextWords,
};
} else if (stack.root.contains(rootable_parent)) {
if (IS_DEV) {
node.parentElement?.setAttribute("data-firaid-moz-stack", "MEMBER");
}
stack.text += " " + nodeText;
stack.words += nodeTextWords;
stack.last_node = node;
} else {
if (stack.words >= hard_min_words) {
yield stack;
if (IS_DEV) {
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "ROOT");
}
} else if (IS_DEV) {
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "SKIP-SHORT_TEXT");
}
stack = {
root: rootable_parent,
first_node: node,
last_node: node,
text: nodeText,
words: nodeTextWords,
};
continue;
}
if (stack.words > soft_max_words) {
if (stack.words >= hard_min_words) {
if (IS_DEV) {
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "ROOT");
}
} else if (IS_DEV) {
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "SKIP-SHORT_TEXT");
}
stack = null;
}
}
}
function count_words_naive(text: string): number {
const is_space = /\s/;
let count = 0;
let word_marker = false;
for (let i = 0; i < text.length; i++) {
if (is_space.test(text[i])) {
word_marker = false;
} else if (!word_marker) {
count += 1;
word_marker = true;
}
}
return count;
}