Newer
Older
vue-indexer / node_modules / puppeteer-core / src / injected / TextContent.ts
/**
 * @license
 * Copyright 2022 Google Inc.
 * SPDX-License-Identifier: Apache-2.0
 */

interface NonTrivialValueNode extends Node {
  value: string;
}

const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']);

/**
 * Determines if the node has a non-trivial value property.
 *
 * @internal
 */
const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => {
  if (node instanceof HTMLSelectElement) {
    return true;
  }
  if (node instanceof HTMLTextAreaElement) {
    return true;
  }
  if (
    node instanceof HTMLInputElement &&
    !TRIVIAL_VALUE_INPUT_TYPES.has(node.type)
  ) {
    return true;
  }
  return false;
};

const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']);

/**
 * Determines whether a given node is suitable for text matching.
 *
 * @internal
 */
export const isSuitableNodeForTextMatching = (node: Node): boolean => {
  return (
    !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node)
  );
};

/**
 * @internal
 */
export interface TextContent {
  // Contains the full text of the node.
  full: string;
  // Contains the text immediately beneath the node.
  immediate: string[];
}

/**
 * Maps {@link Node}s to their computed {@link TextContent}.
 */
const textContentCache = new WeakMap<Node, TextContent>();
const eraseFromCache = (node: Node | null) => {
  while (node) {
    textContentCache.delete(node);
    if (node instanceof ShadowRoot) {
      node = node.host;
    } else {
      node = node.parentNode;
    }
  }
};

/**
 * Erases the cache when the tree has mutated text.
 */
const observedNodes = new WeakSet<Node>();
const textChangeObserver = new MutationObserver(mutations => {
  for (const mutation of mutations) {
    eraseFromCache(mutation.target);
  }
});

/**
 * Builds the text content of a node using some custom logic.
 *
 * @remarks
 * The primary reason this function exists is due to {@link ShadowRoot}s not having
 * text content.
 *
 * @internal
 */
export const createTextContent = (root: Node): TextContent => {
  let value = textContentCache.get(root);
  if (value) {
    return value;
  }
  value = {full: '', immediate: []};
  if (!isSuitableNodeForTextMatching(root)) {
    return value;
  }

  let currentImmediate = '';
  if (isNonTrivialValueNode(root)) {
    value.full = root.value;
    value.immediate.push(root.value);

    root.addEventListener(
      'input',
      event => {
        eraseFromCache(event.target as HTMLInputElement);
      },
      {once: true, capture: true}
    );
  } else {
    for (let child = root.firstChild; child; child = child.nextSibling) {
      if (child.nodeType === Node.TEXT_NODE) {
        value.full += child.nodeValue ?? '';
        currentImmediate += child.nodeValue ?? '';
        continue;
      }
      if (currentImmediate) {
        value.immediate.push(currentImmediate);
      }
      currentImmediate = '';
      if (child.nodeType === Node.ELEMENT_NODE) {
        value.full += createTextContent(child).full;
      }
    }
    if (currentImmediate) {
      value.immediate.push(currentImmediate);
    }
    if (root instanceof Element && root.shadowRoot) {
      value.full += createTextContent(root.shadowRoot).full;
    }

    if (!observedNodes.has(root)) {
      textChangeObserver.observe(root, {
        childList: true,
        characterData: true,
        subtree: true,
      });
      observedNodes.add(root);
    }
  }
  textContentCache.set(root, value);
  return value;
};