function ngHtmlParser(input, parseOptions, options) {
  const {
    name,
    canSelfClose = true,
    normalizeTagName = false,
    normalizeAttributeName = false,
    allowHtmComponentClosingTags = false,
    isTagNameCaseSensitive = false,
    shouldParseAsRawText,
  } = parseOptions;

  let { rootNodes, errors } = parseHtml(input, {
    canSelfClose,
    allowHtmComponentClosingTags,
    isTagNameCaseSensitive,
    getTagContentType: shouldParseAsRawText
      ? (...args) =>
          shouldParseAsRawText(...args) ? TagContentType.RAW_TEXT : undefined
      : undefined,
    tokenizeAngularBlocks: name === "angular" ? true : undefined,
    tokenizeAngularLetDeclaration: name === "angular" ? true : undefined,
  });

  if (name === "vue") {
    const isHtml = rootNodes.some(
      (node) =>
        (node.type === "docType" && node.value === "html") ||
        (node.type === "element" && node.name.toLowerCase() === "html"),
    );

    // If not Vue SFC, treat as html
    if (isHtml) {
      return ngHtmlParser(input, HTML_PARSE_OPTIONS, options);
    }

    /** @type {ParseTreeResult | undefined} */
    let secondParseResult;
    const getHtmlParseResult = () =>
      (secondParseResult ??= parseHtml(input, {
        canSelfClose,
        allowHtmComponentClosingTags,
        isTagNameCaseSensitive,
      }));

    const getNodeWithSameLocation = (node) =>
      getHtmlParseResult().rootNodes.find(
        ({ startSourceSpan }) =>
          startSourceSpan &&
          startSourceSpan.start.offset === node.startSourceSpan.start.offset,
      ) ?? node;
    for (const [index, node] of rootNodes.entries()) {
      const { endSourceSpan, startSourceSpan } = node;
      const isVoidElement = endSourceSpan === null;
      if (isVoidElement) {
        errors = getHtmlParseResult().errors;
        rootNodes[index] = getNodeWithSameLocation(node);
      } else if (shouldParseVueRootNodeAsHtml(node, options)) {
        const error = getHtmlParseResult().errors.find(
          (error) =>
            error.span.start.offset > startSourceSpan.start.offset &&
            error.span.start.offset < endSourceSpan.end.offset,
        );
        if (error) {
          throwParseError(error);
        }
        rootNodes[index] = getNodeWithSameLocation(node);
      }
    }
  }

  if (errors.length > 0) {
    throwParseError(errors[0]);
  }

  /**
   * @param {Attribute | Element} node
   */
  const restoreName = (node) => {
    const namespace = node.name.startsWith(":")
      ? node.name.slice(1).split(":")[0]
      : null;
    const rawName = node.nameSpan.toString();
    const hasExplicitNamespace =
      namespace !== null && rawName.startsWith(`${namespace}:`);
    const name = hasExplicitNamespace
      ? rawName.slice(namespace.length + 1)
      : rawName;

    node.name = name;
    node.namespace = namespace;
    node.hasExplicitNamespace = hasExplicitNamespace;
  };

  /**
   * @param {AstNode} node
   */
  const restoreNameAndValue = (node) => {
    switch (node.type) {
      case "element":
        restoreName(node);
        for (const attr of node.attrs) {
          restoreName(attr);
          if (!attr.valueSpan) {
            attr.value = null;
          } else {
            attr.value = attr.valueSpan.toString();
            if (/["']/u.test(attr.value[0])) {
              attr.value = attr.value.slice(1, -1);
            }
          }
        }
        break;
      case "comment":
        node.value = node.sourceSpan
          .toString()
          .slice("<!--".length, -"-->".length);
        break;
      case "text":
        node.value = node.sourceSpan.toString();
        break;
      // No default
    }
  };

  const lowerCaseIfFn = (text, fn) => {
    const lowerCasedText = text.toLowerCase();
    return fn(lowerCasedText) ? lowerCasedText : text;
  };
  const normalizeName = (node) => {
    if (node.type === "element") {
      if (
        normalizeTagName &&
        (!node.namespace ||
          node.namespace === node.tagDefinition.implicitNamespacePrefix ||
          isUnknownNamespace(node))
      ) {
        node.name = lowerCaseIfFn(node.name, (lowerCasedName) =>
          HTML_TAGS.has(lowerCasedName),
        );
      }

      if (normalizeAttributeName) {
        for (const attr of node.attrs) {
          if (!attr.namespace) {
            attr.name = lowerCaseIfFn(
              attr.name,
              (lowerCasedAttrName) =>
                HTML_ELEMENT_ATTRIBUTES.has(node.name) &&
                (HTML_ELEMENT_ATTRIBUTES.get("*").has(lowerCasedAttrName) ||
                  HTML_ELEMENT_ATTRIBUTES.get(node.name).has(
                    lowerCasedAttrName,
                  )),
            );
          }
        }
      }
    }
  };

  const fixSourceSpan = (node) => {
    if (node.sourceSpan && node.endSourceSpan) {
      node.sourceSpan = new ParseSourceSpan(
        node.sourceSpan.start,
        node.endSourceSpan.end,
      );
    }
  };

  /**
   * @param {AstNode} node
   */
  const addTagDefinition = (node) => {
    if (node.type === "element") {
      const tagDefinition = getHtmlTagDefinition(
        isTagNameCaseSensitive ? node.name : node.name.toLowerCase(),
      );
      if (
        !node.namespace ||
        node.namespace === tagDefinition.implicitNamespacePrefix ||
        isUnknownNamespace(node)
      ) {
        node.tagDefinition = tagDefinition;
      } else {
        node.tagDefinition = getHtmlTagDefinition(""); // the default one
      }
    }
  };

  visitAll(
    new (class extends RecursiveVisitor {
      // Angular does not visit to the children of expansionCase
      // https://github.com/angular/angular/blob/e3a6bf9b6c3bef03df9bfc8f05b817bc875cbad6/packages/compiler/src/ml_parser/ast.ts#L161
      visitExpansionCase(ast, context) {
        if (name === "angular") {
          // @ts-expect-error
          this.visitChildren(context, (visit) => {
            visit(ast.expression);
          });
        }
      }
      visit(node) {
        restoreNameAndValue(node);
        addTagDefinition(node);
        normalizeName(node);
        fixSourceSpan(node);
      }
    })(),
    rootNodes,
  );

  return rootNodes;
}
@param {string} input
@param {ParseOptions} parseOptions
@param {Options} options