<commit_before><?php
/**
 * Class AMP_Tag_And_Attribute_Sanitizer
 *
 * @package AMP
 */

/**
 * Strips the tags and attributes from the content that are not allowed by the AMP spec.
 *
 * Allowed tags array is generated from this protocol buffer:
 *
 *     https://github.com/ampproject/amphtml/blob/master/validator/validator-main.protoascii
 *     by the python script in amp-wp/bin/amp_wp_build.py. See the comment at the top
 *     of that file for instructions to generate class-amp-allowed-tags-generated.php.
 *
 * @todo Need to check the following items that are not yet checked by this sanitizer:
 *
 *     - `also_requires_attr` - if one attribute is present, this requires another.
 *     - `ChildTagSpec`       - Places restrictions on the number and type of child tags.
 *     - `if_value_regex`     - if one attribute value matches, this places a restriction
 *                              on another attribute/value.
 *     - `mandatory_oneof`    - Within the context of the tag, exactly one of the attributes
 *                              must be present.
 */
class AMP_Tag_And_Attribute_Sanitizer extends AMP_Base_Sanitizer {

	/**
	 * Allowed tags.
	 *
	 * @since 0.5
	 *
	 * @var string[]
	 */
	protected $allowed_tags;

	/**
	 * Globally-allowed attributes.
	 *
	 * @since 0.5
	 *
	 * @var array[][]
	 */
	protected $globally_allowed_attributes;

	/**
	 * Layout-allowed attributes.
	 *
	 * @since 0.5
	 *
	 * @var string[]
	 */
	protected $layout_allowed_attributes;

	/**
	 * Mapping of alternative names back to their primary names.
	 *
	 * @since 0.7
	 * @var array
	 */
	protected $rev_alternate_attr_name_lookup = [];

	/**
	 * Mapping of JSON-serialized tag spec to the number of instances encountered in the document.
	 *
	 * @var array
	 */
	protected $visited_unique_tag_specs = [];

	/**
	 * Default args.
	 *
	 * @since 0.5
	 *
	 * @var array
	 */
	protected $DEFAULT_ARGS = [];

	/**
	 * AMP script components that are discovered being required through sanitization.
	 *
	 * @var string[]
	 */
	protected $script_components = [];

	/**
	 * Keep track of nodes that should not be replaced to prevent duplicated validation errors since sanitization is rejected.
	 *
	 * @var array
	 */
	protected $should_not_replace_nodes = [];

	/**
	 * Keep track of the elements that are currently open.
	 *
	 * This is used to determine whether a node exists inside of a given element tree, speeding up has_ancestor checks
	 * as well as disabling attribute validation inside of templates.
	 *
	 * @see \AMP_Tag_And_Attribute_Sanitizer::has_ancestor()
	 * @since 1.3
	 * @var array
	 */
	protected $open_elements = [];

	/**
	 * AMP_Tag_And_Attribute_Sanitizer constructor.
	 *
	 * @since 0.5
	 *
	 * @param DOMDocument $dom  DOM.
	 * @param array       $args Args.
	 */
	public function __construct( $dom, $args = [] ) {
		// @todo It is pointless to have this DEFAULT_ARGS copying the array values. We should only get the data from AMP_Allowed_Tags_Generated.
		$this->DEFAULT_ARGS = [
			'amp_allowed_tags'                => AMP_Allowed_Tags_Generated::get_allowed_tags(),
			'amp_globally_allowed_attributes' => AMP_Allowed_Tags_Generated::get_allowed_attributes(),
			'amp_layout_allowed_attributes'   => AMP_Allowed_Tags_Generated::get_layout_attributes(),
		];

		parent::__construct( $dom, $args );

		// @todo AMP dev mode should eventually be used instead of allow_dirty_styles.
		if ( ! empty( $this->args['allow_dirty_styles'] ) ) {

			// Allow style attribute on all elements.
			$this->args['amp_globally_allowed_attributes']['style'] = [];

			// Remove restrictions on use of !important.
			foreach ( $this->args['amp_allowed_tags']['style'] as &$style ) {
				$style['cdata'] = [];
			}

			// Allow style elements.
			$this->args['amp_allowed_tags']['style'][] = [
				'attr_spec_list' => [
					'type' => [
						'value_casei' => 'text/css',
					],
				],
				'cdata'          => [],
				'tag_spec'       => [
					'spec_name' => 'style for Customizer preview',
				],
			];

			// Allow stylesheet links.
			$this->args['amp_allowed_tags']['link'][] = [
				'attr_spec_list' => [
					'async'       => [],
					'crossorigin' => [],
					'href'        => [
						'mandatory' => true,
					],
					'integrity'   => [],
					'media'       => [],
					'rel'         => [
						'dispatch_key' => 2,
						'mandatory'    => true,
						'value_casei'  => 'stylesheet',
					],
					'type'        => [
						'value_casei' => 'text/css',
					],
				],
				'tag_spec'       => [
					'spec_name' => 'link rel=stylesheet for Customizer preview', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
				],
			];
		}

		// @todo AMP dev mode should eventually be used instead of allow_dirty_scripts.
		// Allow scripts if requested.
		if ( ! empty( $this->args['allow_dirty_scripts'] ) ) {
			$this->args['amp_allowed_tags']['script'][] = [
				'attr_spec_list' => [
					'type'  => [],
					'src'   => [],
					'async' => [],
					'defer' => [],
				],
				'cdata'          => [],
				'tag_spec'       => [
					'spec_name' => 'scripts for Customizer preview',
				],
			];
		}

		// Prepare whitelists.
		$this->allowed_tags = $this->args['amp_allowed_tags'];
		foreach ( AMP_Rule_Spec::$additional_allowed_tags as $tag_name => $tag_rule_spec ) {
			$this->allowed_tags[ $tag_name ][] = $tag_rule_spec;
		}

		// @todo Do the same for body when !use_document_element?
		if ( ! empty( $this->args['use_document_element'] ) ) {
			foreach ( $this->allowed_tags['html'] as &$rule_spec ) {
				unset( $rule_spec[ AMP_Rule_Spec::TAG_SPEC ][ AMP_Rule_Spec::MANDATORY_PARENT ] );
			}

			unset( $rule_spec );
		}

		foreach ( $this->allowed_tags as &$tag_specs ) {
			foreach ( $tag_specs as &$tag_spec ) {
				if ( isset( $tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
					$tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] = $this->process_alternate_names( $tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] );
				}
			}

			unset( $tag_spec );
		}

		unset( $tag_specs );

		$this->globally_allowed_attributes = $this->process_alternate_names( $this->args['amp_globally_allowed_attributes'] );
		$this->layout_allowed_attributes   = $this->process_alternate_names( $this->args['amp_layout_allowed_attributes'] );
	}

	/**
	 * Return array of values that would be valid as an HTML `script` element.
	 *
	 * Array keys are AMP element names and array values are their respective
	 * Javascript URLs from https://cdn.ampproject.org
	 *
	 * @since 0.7
	 * @see amp_register_default_scripts()
	 *
	 * @return array() Returns component name as array key and true as value (or JavaScript URL string),
	 *                  respectively. When true then the default component script URL will be used.
	 *                  Will return an empty array if sanitization has yet to be run
	 *                  or if it did not find any HTML elements to convert to AMP equivalents.
	 */
	public function get_scripts() {
		return array_fill_keys( array_unique( $this->script_components ), true );
	}

	/**
	 * Process alternative names in attribute spec list.
	 *
	 * @since 0.7
	 *
	 * @param array $attr_spec_list Attribute spec list.
	 * @return array Modified attribute spec list.
	 */
	private function process_alternate_names( $attr_spec_list ) {
		foreach ( $attr_spec_list as $attr_name => &$attr_spec ) {
			// Save all alternative names in lookup to improve performance.
			if ( isset( $attr_spec[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] ) ) {
				foreach ( $attr_spec[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] as $alternative_name ) {
					$this->rev_alternate_attr_name_lookup[ $alternative_name ] = $attr_name;
				}
			}
		}
		return $attr_spec_list;
	}

	/**
	 * Sanitize the elements from the HTML contained in this instance's DOMDocument.
	 *
	 * @since 0.5
	 */
	public function sanitize() {
		$result = $this->sanitize_element( $this->root_element );
		if ( is_array( $result ) ) {
			$this->script_components = $result;
		}
	}

	/**
	 * Sanitize element.
	 *
	 * Walk the DOM tree with depth first search (DFS) with post order traversal (LRN).
	 *
	 * @param DOMElement $element Element.
	 * @return string[]|null Required component scripts from sanitizing an element tree, or null if the element was removed.
	 */
	private function sanitize_element( DOMElement $element ) {
		if ( ! isset( $this->open_elements[ $element->nodeName ] ) ) {
			$this->open_elements[ $element->nodeName ] = 0;
		}
		$this->open_elements[ $element->nodeName ]++;

		$script_components = [];

		// First recurse into children to sanitize descendants.
		// The check for $element->parentNode at each iteration is to make sure an invalid child didn't bubble up removed
		// ancestor nodes in AMP_Tag_And_Attribute_Sanitizer::remove_node().
		$this_child = $element->firstChild;
		while ( $this_child && $element->parentNode ) {
			$next_child = $this_child->nextSibling;
			if ( $this_child instanceof DOMElement ) {
				$result = $this->sanitize_element( $this_child );
				if ( is_array( $result ) ) {
					$script_components = array_merge(
						$script_components,
						$result
					);
				}
			} elseif ( $this_child instanceof DOMProcessingInstruction ) {
				$this->remove_invalid_child( $this_child, [ 'code' => 'invalid_processing_instruction' ] );
			}
			$this_child = $next_child;
		}

		// If the element is still in the tree, process it.
		// The element can currently be removed from the tree when processing children via AMP_Tag_And_Attribute_Sanitizer::remove_node().
		$was_removed = false;
		if ( $element->parentNode ) {
			$result = $this->process_node( $element );
			if ( is_array( $result ) ) {
				$script_components = array_merge( $script_components, $result );
			} else {
				$was_removed = true;
			}
		}

		$this->open_elements[ $element->nodeName ]--;

		if ( $was_removed ) {
			return null;
		}

		return $script_components;
	}

	/**
	 * Augment rule spec for validation.
	 *
	 * @since 1.0
	 *
	 * @param DOMElement $node      Node.
	 * @param array      $rule_spec Rule spec.
	 * @return array Augmented rule spec.
	 */
	private function get_rule_spec_list_to_validate( DOMElement $node, $rule_spec ) {

		// Expand extension_spec into a set of attr_spec_list.
		if ( isset( $rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['extension_spec'] ) ) {
			$extension_spec = $rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['extension_spec'];

			// This could also be derived from the extension_type in the extension_spec.
			$custom_attr = 'amp-mustache' === $extension_spec['name'] ? 'custom-template' : 'custom-element';

			$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ][ $custom_attr ] = [
				AMP_Rule_Spec::VALUE     => $extension_spec['name'],
				AMP_Rule_Spec::MANDATORY => true,
			];

			$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ]['src'] = [
				AMP_Rule_Spec::VALUE_REGEX => implode(
					'',
					[
						'^',
						preg_quote( 'https://cdn.ampproject.org/v0/' . $extension_spec['name'] . '-' ), // phpcs:ignore WordPress.PHP.PregQuoteDelimiter.Missing
						'(' . implode( '|', array_merge( $extension_spec['version'], [ 'latest' ] ) ) . ')',
						'\.js$',
					]
				),
			];
		}

		// Augment the attribute list according to the parent's reference points, if it has them.
		if ( ! empty( $node->parentNode ) && isset( $this->allowed_tags[ $node->parentNode->nodeName ] ) ) {
			foreach ( $this->allowed_tags[ $node->parentNode->nodeName ] as $parent_rule_spec ) {
				if ( empty( $parent_rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['reference_points'] ) ) {
					continue;
				}
				foreach ( $parent_rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['reference_points'] as $reference_point_spec_name => $reference_point_spec_instance_attrs ) {
					$reference_point = AMP_Allowed_Tags_Generated::get_reference_point_spec( $reference_point_spec_name );
					if ( empty( $reference_point[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
						/*
						 * See special case for amp-selector in AMP_Tag_And_Attribute_Sanitizer::is_amp_allowed_attribute()
						 * where its reference point applies to any descendant elements, not just direct children.
						 */
						continue;
					}
					foreach ( $reference_point[ AMP_Rule_Spec::ATTR_SPEC_LIST ] as $attr_name => $reference_point_spec_attr ) {
						$reference_point_spec_attr = array_merge(
							$reference_point_spec_attr,
							$reference_point_spec_instance_attrs
						);

						/*
						 * Ignore mandatory constraint for now since this would end up causing other sibling children
						 * getting removed due to missing a mandatory attribute. To sanitize this it would require
						 * higher-level processing to look at an element's surrounding context, similar to how the
						 * sanitizer does not yet handle the mandatory_oneof constraint.
						 */
						unset( $reference_point_spec_attr['mandatory'] );

						$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ][ $attr_name ] = $reference_point_spec_attr;
					}
				}
			}
		}
		return $rule_spec;
	}

	/**
	 * Process a node by checking if an element and its attributes are valid, and removing them when invalid.
	 *
	 * Attributes which are not valid are removed. Elements which are not allowed are also removed,
	 * including elements which miss mandatory attributes.
	 *
	 * @param DOMElement $node Node.
	 * @return string[]|null Required scripts, or null if the element was removed.
	 */
	private function process_node( DOMElement $node ) {

		// Remove nodes with tags that have not been whitelisted.
		if ( ! $this->is_amp_allowed_tag( $node ) ) {

			// If it's not an allowed tag, replace the node with it's children.
			$this->replace_node_with_children( $node );

			// Return early since this node no longer exists.
			return null;
		}

		/*
		 * Compile a list of rule_specs to validate for this node
		 * based on tag name of the node.
		 */
		$rule_spec_list_to_validate = [];
		$rule_spec_list             = [];
		if ( isset( $this->allowed_tags[ $node->nodeName ] ) ) {
			$rule_spec_list = $this->allowed_tags[ $node->nodeName ];
		}
		foreach ( $rule_spec_list as $id => $rule_spec ) {
			if ( $this->validate_tag_spec_for_node( $node, $rule_spec[ AMP_Rule_Spec::TAG_SPEC ] ) ) {
				$rule_spec_list_to_validate[ $id ] = $this->get_rule_spec_list_to_validate( $node, $rule_spec );
			}
		}

		// If no valid rule_specs exist, then remove this node and return.
		if ( empty( $rule_spec_list_to_validate ) ) {
			$this->remove_node( $node );
			return null;
		}

		// The remaining validations all have to do with attributes.
		$attr_spec_list = [];
		$tag_spec       = [];
		$cdata          = [];

		/*
		 * If we have exactly one rule_spec, use it's attr_spec_list
		 * to validate the node's attributes.
		 */
		if ( 1 === count( $rule_spec_list_to_validate ) ) {
			$rule_spec      = array_pop( $rule_spec_list_to_validate );
			$attr_spec_list = $rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ];
			$tag_spec       = $rule_spec[ AMP_Rule_Spec::TAG_SPEC ];
			if ( isset( $rule_spec[ AMP_Rule_Spec::CDATA ] ) ) {
				$cdata = $rule_spec[ AMP_Rule_Spec::CDATA ];
			}
		} else {
			/*
			 * If there is more than one valid rule_spec for this node,
			 * then try to deduce which one is intended by inspecting
			 * the node's attributes.
			 */

			/*
			 * Get a score from each attr_spec_list by seeing how many
			 * attributes and values match the node.
			 */
			$attr_spec_scores = [];
			foreach ( $rule_spec_list_to_validate as $spec_id => $rule_spec ) {
				$attr_spec_scores[ $spec_id ] = $this->validate_attr_spec_list_for_node( $node, $rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] );
			}

			// Remove all spec lists that didn't match.
			$attr_spec_scores = array_filter( $attr_spec_scores );

			// If no attribute spec lists match, then the element must be removed.
			if ( empty( $attr_spec_scores ) ) {
				// @todo How can we tell the reason for removal if none of the tag specs matched? Ignore attribute values?
				$this->remove_node( $node );
				return null;
			}

			// Get the key(s) to the highest score(s).
			$spec_ids_sorted = array_keys( $attr_spec_scores, max( $attr_spec_scores ), true );

			// If there is exactly one attr_spec with a max score, use that one.
			if ( 1 === count( $spec_ids_sorted ) ) {
				$attr_spec_list = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::ATTR_SPEC_LIST ];
				$tag_spec       = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::TAG_SPEC ];
				if ( isset( $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::CDATA ] ) ) {
					$cdata = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::CDATA ];
				}
			} else {
				// This should not happen very often, but...
				// If we're here, then we're not sure which spec should
				// be used. Let's use the top scoring ones.
				foreach ( $spec_ids_sorted as $id ) {
					$attr_spec_list = array_merge(
						$attr_spec_list,
						$rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::ATTR_SPEC_LIST ]
					);
					$tag_spec       = array_merge(
						$tag_spec,
						$rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::TAG_SPEC ]
					);
					if ( isset( $rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::CDATA ] ) ) {
						$cdata = array_merge( $cdata, $rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::CDATA ] );
					}
				}
				$first_spec = reset( $rule_spec_list_to_validate );
				if ( empty( $attr_spec_list ) && isset( $first_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
					$attr_spec_list = $first_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ];
				}
			}
		}

		// Remove element if it has illegal CDATA.
		if ( ! empty( $cdata ) && $node instanceof DOMElement ) {
			$validity = $this->validate_cdata_for_node( $node, $cdata );
			if ( is_wp_error( $validity ) ) {
				$this->remove_invalid_child( $node, [ 'code' => 'illegal_cdata' ] );
				return null;
			}
		}

		$merged_attr_spec_list = array_merge(
			$this->globally_allowed_attributes,
			$attr_spec_list
		);

		// Amend spec list with layout.
		if ( isset( $tag_spec['amp_layout'] ) ) {
			$merged_attr_spec_list = array_merge( $merged_attr_spec_list, $this->layout_allowed_attributes );

			if ( isset( $tag_spec['amp_layout']['supported_layouts'] ) ) {
				$layouts = wp_array_slice_assoc( AMP_Rule_Spec::$layout_enum, $tag_spec['amp_layout']['supported_layouts'] );

				$merged_attr_spec_list['layout'][ AMP_Rule_Spec::VALUE_REGEX_CASEI ] = '(' . implode( '|', $layouts ) . ')';
			}
		}

		// Enforce unique constraint.
		if ( ! empty( $tag_spec['unique'] ) ) {
			$removed      = false;
			$tag_spec_key = wp_json_encode( $tag_spec );
			if ( ! empty( $this->visited_unique_tag_specs[ $node->nodeName ][ $tag_spec_key ] ) ) {
				$removed = $this->remove_invalid_child(
					$node,
					[ 'code' => 'duplicate_element' ]
				);
			}
			$this->visited_unique_tag_specs[ $node->nodeName ][ $tag_spec_key ] = true;
			if ( $removed ) {
				return null;
			}
		}

		// Identify any remaining disallowed attributes.
		$disallowed_attributes = $this->get_disallowed_attributes_in_node( $node, $merged_attr_spec_list );

		// Identify attribute values that don't conform to the attr_spec.
		$disallowed_attributes = $this->sanitize_disallowed_attribute_values_in_node( $node, $merged_attr_spec_list, $disallowed_attributes );

		// Remove all invalid attributes.
		if ( ! empty( $disallowed_attributes ) ) {
			/*
			 * Capture all element attributes up front so that differing validation errors result when
			 * one invalid attribute is accepted but the others are still rejected.
			 */
			$validation_error = [
				'element_attributes' => [],
			];
			foreach ( $node->attributes as $attribute ) {
				$validation_error['element_attributes'][ $attribute->nodeName ] = $attribute->nodeValue;
			}
			$removed_attributes = [];
			foreach ( $disallowed_attributes as $disallowed_attribute ) {
				if ( $this->remove_invalid_attribute( $node, $disallowed_attribute, $validation_error ) ) {
					$removed_attributes[] = $disallowed_attribute;
				}
			}

			/*
			 * Only run cleanup after the fact to prevent a scenario where invalid markup is kept and so the attribute
			 * is actually not removed. This prevents a "DOMException: Not Found Error" from happening when calling
			 * remove_invalid_attribute() since clean_up_after_attribute_removal() can end up removing invalid link
			 * attributes (like 'target') when there is an invalid 'href' attribute, but if the 'target' attribute is
			 * itself invalid, then if clean_up_after_attribute_removal() is called inside of remove_invalid_attribute()
			 * it can cause a subsequent invocation of remove_invalid_attribute() to try to remove an invalid
			 * attribute that has already been removed from the DOM.
			 */
			foreach ( $removed_attributes as $removed_attribute ) {
				$this->clean_up_after_attribute_removal( $node, $removed_attribute );
			}
		}

		// @todo Need to pass the $tag_spec['spec_name'] into the validation errors for each.
		// After attributes have been sanitized (and potentially removed), if mandatory attribute(s) are missing, remove the element.
		if ( $this->is_missing_mandatory_attribute( $merged_attr_spec_list, $node ) ) {
			$this->remove_invalid_child( $node, [ 'code' => 'missing_mandatory_attribute' ] );
			return null;
		}

		// Add required AMP component scripts.
		$script_components = [];
		if ( ! empty( $tag_spec['requires_extension'] ) ) {
			$script_components = array_merge( $script_components, $tag_spec['requires_extension'] );
		}

		// Add required AMP components for attributes.
		foreach ( $node->attributes as $attribute ) {
			if ( isset( $merged_attr_spec_list[ $attribute->nodeName ]['requires_extension'] ) ) {
				$script_components = array_merge(
					$script_components,
					$merged_attr_spec_list[ $attribute->nodeName ]['requires_extension']
				);
			}
		}

		// Manually add components for attributes; this is hard-coded because attributes do not have requires_extension like tags do. See <https://github.com/ampproject/amp-wp/issues/1808>.
		if ( $node->hasAttribute( 'lightbox' ) ) {
			$script_components[] = 'amp-lightbox-gallery';
		}

		// Check if element needs amp-bind component.
		if ( $node instanceof DOMElement && ! in_array( 'amp-bind', $this->script_components, true ) ) {
			foreach ( $node->attributes as $name => $value ) {
				if ( AMP_DOM_Utils::AMP_BIND_DATA_ATTR_PREFIX === substr( $name, 0, 14 ) ) {
					$script_components[] = 'amp-bind';
					break;
				}
			}
		}

		return $script_components;
	}

	/**
	 * Whether a node is missing a mandatory attribute.
	 *
	 * @param array      $attr_spec The attribute specification.
	 * @param DOMElement $node      The DOMElement of the node to check.
	 * @return boolean $is_missing boolean Whether a required attribute is missing.
	 */
	public function is_missing_mandatory_attribute( $attr_spec, DOMElement $node ) {
		foreach ( $attr_spec as $attr_name => $attr_spec_rule_value ) {
			if ( '\u' === substr( $attr_name, 0, 2 ) ) {
				$attr_name = html_entity_decode( '&#x' . substr( $attr_name, 2 ) . ';' ); // Probably ⚡.
			}
			$is_mandatory     = isset( $attr_spec_rule_value[ AMP_Rule_Spec::MANDATORY ] ) ? ( true === $attr_spec_rule_value[ AMP_Rule_Spec::MANDATORY ] ) : false;
			$attribute_exists = false;
			if ( method_exists( $node, 'hasAttribute' ) ) {
				$attribute_exists = $node->hasAttribute( $attr_name );
				if ( ! $attribute_exists && ! empty( $attr_spec_rule_value[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] ) ) {
					foreach ( $attr_spec_rule_value[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] as $alternative_attr_name ) {
						if ( $node->hasAttribute( $alternative_attr_name ) ) {
							$attribute_exists = true;
							break;
						}
					}
				}
			}
			if ( $is_mandatory && ! $attribute_exists ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Validate element for its CDATA.<commit_msg>Reuse check_attr_spec_rule_mandatory in is_missing_mandatory_attribute<commit_after><?php
/**
 * Class AMP_Tag_And_Attribute_Sanitizer
 *
 * @package AMP
 */

/**
 * Strips the tags and attributes from the content that are not allowed by the AMP spec.
 *
 * Allowed tags array is generated from this protocol buffer:
 *
 *     https://github.com/ampproject/amphtml/blob/master/validator/validator-main.protoascii
 *     by the python script in amp-wp/bin/amp_wp_build.py. See the comment at the top
 *     of that file for instructions to generate class-amp-allowed-tags-generated.php.
 *
 * @todo Need to check the following items that are not yet checked by this sanitizer:
 *
 *     - `also_requires_attr` - if one attribute is present, this requires another.
 *     - `ChildTagSpec`       - Places restrictions on the number and type of child tags.
 *     - `if_value_regex`     - if one attribute value matches, this places a restriction
 *                              on another attribute/value.
 *     - `mandatory_oneof`    - Within the context of the tag, exactly one of the attributes
 *                              must be present.
 */
class AMP_Tag_And_Attribute_Sanitizer extends AMP_Base_Sanitizer {

	/**
	 * Allowed tags.
	 *
	 * @since 0.5
	 *
	 * @var string[]
	 */
	protected $allowed_tags;

	/**
	 * Globally-allowed attributes.
	 *
	 * @since 0.5
	 *
	 * @var array[][]
	 */
	protected $globally_allowed_attributes;

	/**
	 * Layout-allowed attributes.
	 *
	 * @since 0.5
	 *
	 * @var string[]
	 */
	protected $layout_allowed_attributes;

	/**
	 * Mapping of alternative names back to their primary names.
	 *
	 * @since 0.7
	 * @var array
	 */
	protected $rev_alternate_attr_name_lookup = [];

	/**
	 * Mapping of JSON-serialized tag spec to the number of instances encountered in the document.
	 *
	 * @var array
	 */
	protected $visited_unique_tag_specs = [];

	/**
	 * Default args.
	 *
	 * @since 0.5
	 *
	 * @var array
	 */
	protected $DEFAULT_ARGS = [];

	/**
	 * AMP script components that are discovered being required through sanitization.
	 *
	 * @var string[]
	 */
	protected $script_components = [];

	/**
	 * Keep track of nodes that should not be replaced to prevent duplicated validation errors since sanitization is rejected.
	 *
	 * @var array
	 */
	protected $should_not_replace_nodes = [];

	/**
	 * Keep track of the elements that are currently open.
	 *
	 * This is used to determine whether a node exists inside of a given element tree, speeding up has_ancestor checks
	 * as well as disabling attribute validation inside of templates.
	 *
	 * @see \AMP_Tag_And_Attribute_Sanitizer::has_ancestor()
	 * @since 1.3
	 * @var array
	 */
	protected $open_elements = [];

	/**
	 * AMP_Tag_And_Attribute_Sanitizer constructor.
	 *
	 * @since 0.5
	 *
	 * @param DOMDocument $dom  DOM.
	 * @param array       $args Args.
	 */
	public function __construct( $dom, $args = [] ) {
		// @todo It is pointless to have this DEFAULT_ARGS copying the array values. We should only get the data from AMP_Allowed_Tags_Generated.
		$this->DEFAULT_ARGS = [
			'amp_allowed_tags'                => AMP_Allowed_Tags_Generated::get_allowed_tags(),
			'amp_globally_allowed_attributes' => AMP_Allowed_Tags_Generated::get_allowed_attributes(),
			'amp_layout_allowed_attributes'   => AMP_Allowed_Tags_Generated::get_layout_attributes(),
		];

		parent::__construct( $dom, $args );

		// @todo AMP dev mode should eventually be used instead of allow_dirty_styles.
		if ( ! empty( $this->args['allow_dirty_styles'] ) ) {

			// Allow style attribute on all elements.
			$this->args['amp_globally_allowed_attributes']['style'] = [];

			// Remove restrictions on use of !important.
			foreach ( $this->args['amp_allowed_tags']['style'] as &$style ) {
				$style['cdata'] = [];
			}

			// Allow style elements.
			$this->args['amp_allowed_tags']['style'][] = [
				'attr_spec_list' => [
					'type' => [
						'value_casei' => 'text/css',
					],
				],
				'cdata'          => [],
				'tag_spec'       => [
					'spec_name' => 'style for Customizer preview',
				],
			];

			// Allow stylesheet links.
			$this->args['amp_allowed_tags']['link'][] = [
				'attr_spec_list' => [
					'async'       => [],
					'crossorigin' => [],
					'href'        => [
						'mandatory' => true,
					],
					'integrity'   => [],
					'media'       => [],
					'rel'         => [
						'dispatch_key' => 2,
						'mandatory'    => true,
						'value_casei'  => 'stylesheet',
					],
					'type'        => [
						'value_casei' => 'text/css',
					],
				],
				'tag_spec'       => [
					'spec_name' => 'link rel=stylesheet for Customizer preview', // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
				],
			];
		}

		// @todo AMP dev mode should eventually be used instead of allow_dirty_scripts.
		// Allow scripts if requested.
		if ( ! empty( $this->args['allow_dirty_scripts'] ) ) {
			$this->args['amp_allowed_tags']['script'][] = [
				'attr_spec_list' => [
					'type'  => [],
					'src'   => [],
					'async' => [],
					'defer' => [],
				],
				'cdata'          => [],
				'tag_spec'       => [
					'spec_name' => 'scripts for Customizer preview',
				],
			];
		}

		// Prepare whitelists.
		$this->allowed_tags = $this->args['amp_allowed_tags'];
		foreach ( AMP_Rule_Spec::$additional_allowed_tags as $tag_name => $tag_rule_spec ) {
			$this->allowed_tags[ $tag_name ][] = $tag_rule_spec;
		}

		// @todo Do the same for body when !use_document_element?
		if ( ! empty( $this->args['use_document_element'] ) ) {
			foreach ( $this->allowed_tags['html'] as &$rule_spec ) {
				unset( $rule_spec[ AMP_Rule_Spec::TAG_SPEC ][ AMP_Rule_Spec::MANDATORY_PARENT ] );
			}

			unset( $rule_spec );
		}

		foreach ( $this->allowed_tags as &$tag_specs ) {
			foreach ( $tag_specs as &$tag_spec ) {
				if ( isset( $tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
					$tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] = $this->process_alternate_names( $tag_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] );
				}
			}

			unset( $tag_spec );
		}

		unset( $tag_specs );

		$this->globally_allowed_attributes = $this->process_alternate_names( $this->args['amp_globally_allowed_attributes'] );
		$this->layout_allowed_attributes   = $this->process_alternate_names( $this->args['amp_layout_allowed_attributes'] );
	}

	/**
	 * Return array of values that would be valid as an HTML `script` element.
	 *
	 * Array keys are AMP element names and array values are their respective
	 * Javascript URLs from https://cdn.ampproject.org
	 *
	 * @since 0.7
	 * @see amp_register_default_scripts()
	 *
	 * @return array() Returns component name as array key and true as value (or JavaScript URL string),
	 *                  respectively. When true then the default component script URL will be used.
	 *                  Will return an empty array if sanitization has yet to be run
	 *                  or if it did not find any HTML elements to convert to AMP equivalents.
	 */
	public function get_scripts() {
		return array_fill_keys( array_unique( $this->script_components ), true );
	}

	/**
	 * Process alternative names in attribute spec list.
	 *
	 * @since 0.7
	 *
	 * @param array $attr_spec_list Attribute spec list.
	 * @return array Modified attribute spec list.
	 */
	private function process_alternate_names( $attr_spec_list ) {
		foreach ( $attr_spec_list as $attr_name => &$attr_spec ) {
			// Save all alternative names in lookup to improve performance.
			if ( isset( $attr_spec[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] ) ) {
				foreach ( $attr_spec[ AMP_Rule_Spec::ALTERNATIVE_NAMES ] as $alternative_name ) {
					$this->rev_alternate_attr_name_lookup[ $alternative_name ] = $attr_name;
				}
			}
		}
		return $attr_spec_list;
	}

	/**
	 * Sanitize the elements from the HTML contained in this instance's DOMDocument.
	 *
	 * @since 0.5
	 */
	public function sanitize() {
		$result = $this->sanitize_element( $this->root_element );
		if ( is_array( $result ) ) {
			$this->script_components = $result;
		}
	}

	/**
	 * Sanitize element.
	 *
	 * Walk the DOM tree with depth first search (DFS) with post order traversal (LRN).
	 *
	 * @param DOMElement $element Element.
	 * @return string[]|null Required component scripts from sanitizing an element tree, or null if the element was removed.
	 */
	private function sanitize_element( DOMElement $element ) {
		if ( ! isset( $this->open_elements[ $element->nodeName ] ) ) {
			$this->open_elements[ $element->nodeName ] = 0;
		}
		$this->open_elements[ $element->nodeName ]++;

		$script_components = [];

		// First recurse into children to sanitize descendants.
		// The check for $element->parentNode at each iteration is to make sure an invalid child didn't bubble up removed
		// ancestor nodes in AMP_Tag_And_Attribute_Sanitizer::remove_node().
		$this_child = $element->firstChild;
		while ( $this_child && $element->parentNode ) {
			$next_child = $this_child->nextSibling;
			if ( $this_child instanceof DOMElement ) {
				$result = $this->sanitize_element( $this_child );
				if ( is_array( $result ) ) {
					$script_components = array_merge(
						$script_components,
						$result
					);
				}
			} elseif ( $this_child instanceof DOMProcessingInstruction ) {
				$this->remove_invalid_child( $this_child, [ 'code' => 'invalid_processing_instruction' ] );
			}
			$this_child = $next_child;
		}

		// If the element is still in the tree, process it.
		// The element can currently be removed from the tree when processing children via AMP_Tag_And_Attribute_Sanitizer::remove_node().
		$was_removed = false;
		if ( $element->parentNode ) {
			$result = $this->process_node( $element );
			if ( is_array( $result ) ) {
				$script_components = array_merge( $script_components, $result );
			} else {
				$was_removed = true;
			}
		}

		$this->open_elements[ $element->nodeName ]--;

		if ( $was_removed ) {
			return null;
		}

		return $script_components;
	}

	/**
	 * Augment rule spec for validation.
	 *
	 * @since 1.0
	 *
	 * @param DOMElement $node      Node.
	 * @param array      $rule_spec Rule spec.
	 * @return array Augmented rule spec.
	 */
	private function get_rule_spec_list_to_validate( DOMElement $node, $rule_spec ) {

		// Expand extension_spec into a set of attr_spec_list.
		if ( isset( $rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['extension_spec'] ) ) {
			$extension_spec = $rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['extension_spec'];

			// This could also be derived from the extension_type in the extension_spec.
			$custom_attr = 'amp-mustache' === $extension_spec['name'] ? 'custom-template' : 'custom-element';

			$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ][ $custom_attr ] = [
				AMP_Rule_Spec::VALUE     => $extension_spec['name'],
				AMP_Rule_Spec::MANDATORY => true,
			];

			$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ]['src'] = [
				AMP_Rule_Spec::VALUE_REGEX => implode(
					'',
					[
						'^',
						preg_quote( 'https://cdn.ampproject.org/v0/' . $extension_spec['name'] . '-' ), // phpcs:ignore WordPress.PHP.PregQuoteDelimiter.Missing
						'(' . implode( '|', array_merge( $extension_spec['version'], [ 'latest' ] ) ) . ')',
						'\.js$',
					]
				),
			];
		}

		// Augment the attribute list according to the parent's reference points, if it has them.
		if ( ! empty( $node->parentNode ) && isset( $this->allowed_tags[ $node->parentNode->nodeName ] ) ) {
			foreach ( $this->allowed_tags[ $node->parentNode->nodeName ] as $parent_rule_spec ) {
				if ( empty( $parent_rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['reference_points'] ) ) {
					continue;
				}
				foreach ( $parent_rule_spec[ AMP_Rule_Spec::TAG_SPEC ]['reference_points'] as $reference_point_spec_name => $reference_point_spec_instance_attrs ) {
					$reference_point = AMP_Allowed_Tags_Generated::get_reference_point_spec( $reference_point_spec_name );
					if ( empty( $reference_point[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
						/*
						 * See special case for amp-selector in AMP_Tag_And_Attribute_Sanitizer::is_amp_allowed_attribute()
						 * where its reference point applies to any descendant elements, not just direct children.
						 */
						continue;
					}
					foreach ( $reference_point[ AMP_Rule_Spec::ATTR_SPEC_LIST ] as $attr_name => $reference_point_spec_attr ) {
						$reference_point_spec_attr = array_merge(
							$reference_point_spec_attr,
							$reference_point_spec_instance_attrs
						);

						/*
						 * Ignore mandatory constraint for now since this would end up causing other sibling children
						 * getting removed due to missing a mandatory attribute. To sanitize this it would require
						 * higher-level processing to look at an element's surrounding context, similar to how the
						 * sanitizer does not yet handle the mandatory_oneof constraint.
						 */
						unset( $reference_point_spec_attr['mandatory'] );

						$rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ][ $attr_name ] = $reference_point_spec_attr;
					}
				}
			}
		}
		return $rule_spec;
	}

	/**
	 * Process a node by checking if an element and its attributes are valid, and removing them when invalid.
	 *
	 * Attributes which are not valid are removed. Elements which are not allowed are also removed,
	 * including elements which miss mandatory attributes.
	 *
	 * @param DOMElement $node Node.
	 * @return string[]|null Required scripts, or null if the element was removed.
	 */
	private function process_node( DOMElement $node ) {

		// Remove nodes with tags that have not been whitelisted.
		if ( ! $this->is_amp_allowed_tag( $node ) ) {

			// If it's not an allowed tag, replace the node with it's children.
			$this->replace_node_with_children( $node );

			// Return early since this node no longer exists.
			return null;
		}

		/*
		 * Compile a list of rule_specs to validate for this node
		 * based on tag name of the node.
		 */
		$rule_spec_list_to_validate = [];
		$rule_spec_list             = [];
		if ( isset( $this->allowed_tags[ $node->nodeName ] ) ) {
			$rule_spec_list = $this->allowed_tags[ $node->nodeName ];
		}
		foreach ( $rule_spec_list as $id => $rule_spec ) {
			if ( $this->validate_tag_spec_for_node( $node, $rule_spec[ AMP_Rule_Spec::TAG_SPEC ] ) ) {
				$rule_spec_list_to_validate[ $id ] = $this->get_rule_spec_list_to_validate( $node, $rule_spec );
			}
		}

		// If no valid rule_specs exist, then remove this node and return.
		if ( empty( $rule_spec_list_to_validate ) ) {
			$this->remove_node( $node );
			return null;
		}

		// The remaining validations all have to do with attributes.
		$attr_spec_list = [];
		$tag_spec       = [];
		$cdata          = [];

		/*
		 * If we have exactly one rule_spec, use it's attr_spec_list
		 * to validate the node's attributes.
		 */
		if ( 1 === count( $rule_spec_list_to_validate ) ) {
			$rule_spec      = array_pop( $rule_spec_list_to_validate );
			$attr_spec_list = $rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ];
			$tag_spec       = $rule_spec[ AMP_Rule_Spec::TAG_SPEC ];
			if ( isset( $rule_spec[ AMP_Rule_Spec::CDATA ] ) ) {
				$cdata = $rule_spec[ AMP_Rule_Spec::CDATA ];
			}
		} else {
			/*
			 * If there is more than one valid rule_spec for this node,
			 * then try to deduce which one is intended by inspecting
			 * the node's attributes.
			 */

			/*
			 * Get a score from each attr_spec_list by seeing how many
			 * attributes and values match the node.
			 */
			$attr_spec_scores = [];
			foreach ( $rule_spec_list_to_validate as $spec_id => $rule_spec ) {
				$attr_spec_scores[ $spec_id ] = $this->validate_attr_spec_list_for_node( $node, $rule_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] );
			}

			// Remove all spec lists that didn't match.
			$attr_spec_scores = array_filter( $attr_spec_scores );

			// If no attribute spec lists match, then the element must be removed.
			if ( empty( $attr_spec_scores ) ) {
				// @todo How can we tell the reason for removal if none of the tag specs matched? Ignore attribute values?
				$this->remove_node( $node );
				return null;
			}

			// Get the key(s) to the highest score(s).
			$spec_ids_sorted = array_keys( $attr_spec_scores, max( $attr_spec_scores ), true );

			// If there is exactly one attr_spec with a max score, use that one.
			if ( 1 === count( $spec_ids_sorted ) ) {
				$attr_spec_list = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::ATTR_SPEC_LIST ];
				$tag_spec       = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::TAG_SPEC ];
				if ( isset( $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::CDATA ] ) ) {
					$cdata = $rule_spec_list_to_validate[ $spec_ids_sorted[0] ][ AMP_Rule_Spec::CDATA ];
				}
			} else {
				// This should not happen very often, but...
				// If we're here, then we're not sure which spec should
				// be used. Let's use the top scoring ones.
				foreach ( $spec_ids_sorted as $id ) {
					$attr_spec_list = array_merge(
						$attr_spec_list,
						$rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::ATTR_SPEC_LIST ]
					);
					$tag_spec       = array_merge(
						$tag_spec,
						$rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::TAG_SPEC ]
					);
					if ( isset( $rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::CDATA ] ) ) {
						$cdata = array_merge( $cdata, $rule_spec_list_to_validate[ $id ][ AMP_Rule_Spec::CDATA ] );
					}
				}
				$first_spec = reset( $rule_spec_list_to_validate );
				if ( empty( $attr_spec_list ) && isset( $first_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ] ) ) {
					$attr_spec_list = $first_spec[ AMP_Rule_Spec::ATTR_SPEC_LIST ];
				}
			}
		}

		// Remove element if it has illegal CDATA.
		if ( ! empty( $cdata ) && $node instanceof DOMElement ) {
			$validity = $this->validate_cdata_for_node( $node, $cdata );
			if ( is_wp_error( $validity ) ) {
				$this->remove_invalid_child( $node, [ 'code' => 'illegal_cdata' ] );
				return null;
			}
		}

		$merged_attr_spec_list = array_merge(
			$this->globally_allowed_attributes,
			$attr_spec_list
		);

		// Amend spec list with layout.
		if ( isset( $tag_spec['amp_layout'] ) ) {
			$merged_attr_spec_list = array_merge( $merged_attr_spec_list, $this->layout_allowed_attributes );

			if ( isset( $tag_spec['amp_layout']['supported_layouts'] ) ) {
				$layouts = wp_array_slice_assoc( AMP_Rule_Spec::$layout_enum, $tag_spec['amp_layout']['supported_layouts'] );

				$merged_attr_spec_list['layout'][ AMP_Rule_Spec::VALUE_REGEX_CASEI ] = '(' . implode( '|', $layouts ) . ')';
			}
		}

		// Enforce unique constraint.
		if ( ! empty( $tag_spec['unique'] ) ) {
			$removed      = false;
			$tag_spec_key = wp_json_encode( $tag_spec );
			if ( ! empty( $this->visited_unique_tag_specs[ $node->nodeName ][ $tag_spec_key ] ) ) {
				$removed = $this->remove_invalid_child(
					$node,
					[ 'code' => 'duplicate_element' ]
				);
			}
			$this->visited_unique_tag_specs[ $node->nodeName ][ $tag_spec_key ] = true;
			if ( $removed ) {
				return null;
			}
		}

		// Identify any remaining disallowed attributes.
		$disallowed_attributes = $this->get_disallowed_attributes_in_node( $node, $merged_attr_spec_list );

		// Identify attribute values that don't conform to the attr_spec.
		$disallowed_attributes = $this->sanitize_disallowed_attribute_values_in_node( $node, $merged_attr_spec_list, $disallowed_attributes );

		// Remove all invalid attributes.
		if ( ! empty( $disallowed_attributes ) ) {
			/*
			 * Capture all element attributes up front so that differing validation errors result when
			 * one invalid attribute is accepted but the others are still rejected.
			 */
			$validation_error = [
				'element_attributes' => [],
			];
			foreach ( $node->attributes as $attribute ) {
				$validation_error['element_attributes'][ $attribute->nodeName ] = $attribute->nodeValue;
			}
			$removed_attributes = [];
			foreach ( $disallowed_attributes as $disallowed_attribute ) {
				if ( $this->remove_invalid_attribute( $node, $disallowed_attribute, $validation_error ) ) {
					$removed_attributes[] = $disallowed_attribute;
				}
			}

			/*
			 * Only run cleanup after the fact to prevent a scenario where invalid markup is kept and so the attribute
			 * is actually not removed. This prevents a "DOMException: Not Found Error" from happening when calling
			 * remove_invalid_attribute() since clean_up_after_attribute_removal() can end up removing invalid link
			 * attributes (like 'target') when there is an invalid 'href' attribute, but if the 'target' attribute is
			 * itself invalid, then if clean_up_after_attribute_removal() is called inside of remove_invalid_attribute()
			 * it can cause a subsequent invocation of remove_invalid_attribute() to try to remove an invalid
			 * attribute that has already been removed from the DOM.
			 */
			foreach ( $removed_attributes as $removed_attribute ) {
				$this->clean_up_after_attribute_removal( $node, $removed_attribute );
			}
		}

		// @todo Need to pass the $tag_spec['spec_name'] into the validation errors for each.
		// After attributes have been sanitized (and potentially removed), if mandatory attribute(s) are missing, remove the element.
		if ( $this->is_missing_mandatory_attribute( $merged_attr_spec_list, $node ) ) {
			$this->remove_invalid_child( $node, [ 'code' => 'missing_mandatory_attribute' ] );
			return null;
		}

		// Add required AMP component scripts.
		$script_components = [];
		if ( ! empty( $tag_spec['requires_extension'] ) ) {
			$script_components = array_merge( $script_components, $tag_spec['requires_extension'] );
		}

		// Add required AMP components for attributes.
		foreach ( $node->attributes as $attribute ) {
			if ( isset( $merged_attr_spec_list[ $attribute->nodeName ]['requires_extension'] ) ) {
				$script_components = array_merge(
					$script_components,
					$merged_attr_spec_list[ $attribute->nodeName ]['requires_extension']
				);
			}
		}

		// Manually add components for attributes; this is hard-coded because attributes do not have requires_extension like tags do. See <https://github.com/ampproject/amp-wp/issues/1808>.
		if ( $node->hasAttribute( 'lightbox' ) ) {
			$script_components[] = 'amp-lightbox-gallery';
		}

		// Check if element needs amp-bind component.
		if ( $node instanceof DOMElement && ! in_array( 'amp-bind', $this->script_components, true ) ) {
			foreach ( $node->attributes as $name => $value ) {
				if ( AMP_DOM_Utils::AMP_BIND_DATA_ATTR_PREFIX === substr( $name, 0, 14 ) ) {
					$script_components[] = 'amp-bind';
					break;
				}
			}
		}

		return $script_components;
	}

	/**
	 * Whether a node is missing a mandatory attribute.
	 *
	 * @param array      $attr_spec The attribute specification.
	 * @param DOMElement $node      The DOMElement of the node to check.
	 * @return boolean $is_missing boolean Whether a required attribute is missing.
	 */
	public function is_missing_mandatory_attribute( $attr_spec, DOMElement $node ) {
		foreach ( $attr_spec as $attr_name => $attr_spec_rule_value ) {
			if ( '\u' === substr( $attr_name, 0, 2 ) ) {
				$attr_name = html_entity_decode( '&#x' . substr( $attr_name, 2 ) . ';' ); // Probably ⚡.
			}
			if ( ! $node->hasAttribute( $attr_name ) && AMP_Rule_Spec::FAIL === $this->check_attr_spec_rule_mandatory( $node, $attr_name, $attr_spec_rule_value ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Validate element for its CDATA.
{'fraction_non_alphanumeric': 0.12276752532438574, 'fraction_numerical': 0.0017413833379345495, 'mean_word_length': 3.3432945950931563, 'pattern_counts': {'":': 0, '<': 7, '<?xml version=': 0, '>': 273, 'https://': 8, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2013138', 'n_tokens_mistral': 17557, 'n_tokens_neox': 14853, 'n_words': 3826}