<?php
/**
 * Class KSES.
 *
 * @link      https://github.com/googleforcreators/web-stories-wp
 *
 * @copyright 2020 Google LLC
 * @license   https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
 */

/**
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

declare(strict_types = 1);

namespace Google\Web_Stories;

use Google\Web_Stories\Infrastructure\HasRequirements;

/**
 * KSES class.
 *
 * Provides KSES utility methods to override the ones from core.
 *
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
 *
 * @phpstan-type PostData array{
 *   post_parent: int|string|null,
 *   post_type: string,
 *   post_content?: string,
 *   post_content_filtered?: string
 * }
 */
class KSES extends Service_Base implements HasRequirements {

	/**
	 * Story_Post_Type instance.
	 *
	 * @var Story_Post_Type Story_Post_Type instance.
	 */
	private Story_Post_Type $story_post_type;

	/**
	 * Page_Template_Post_Type instance.
	 *
	 * @var Page_Template_Post_Type Page_Template_Post_Type instance.
	 */
	private Page_Template_Post_Type $page_template_post_type;

	/**
	 * KSES constructor.
	 *
	 * @since 1.12.0
	 *
	 * @param Story_Post_Type         $story_post_type         Story_Post_Type instance.
	 * @param Page_Template_Post_Type $page_template_post_type Page_Template_Post_Type instance.
	 */
	public function __construct(
		Story_Post_Type $story_post_type,
		Page_Template_Post_Type $page_template_post_type
	) {
		$this->story_post_type         = $story_post_type;
		$this->page_template_post_type = $page_template_post_type;
	}

	/**
	 * Initializes KSES filters for stories.
	 *
	 * @since 1.0.0
	 */
	public function register(): void {
		add_filter( 'wp_insert_post_data', [ $this, 'filter_insert_post_data' ], 10, 3 );
	}

	/**
	 * Get the list of service IDs required for this service to be registered.
	 *
	 * Needed because the story post type needs to be registered first.
	 *
	 * @since 1.13.0
	 *
	 * @return string[] List of required services.
	 */
	public static function get_requirements(): array {
		return [ 'story_post_type', 'page_template_post_type' ];
	}

	/**
	 * Filters slashed post data just before it is inserted into the database.
	 *
	 * Used to run story HTML markup through KSES on our own, but with some filters applied
	 * that should only affect the web-story post type.
	 *
	 * This allows storing full AMP HTML documents in post_content for stories, which require
	 * more allowed HTML tags and a patched version of {@see safecss_filter_attr}.
	 *
	 * @since 1.8.0
	 *
	 * @param mixed $data                An array of slashed, sanitized, and processed post data.
	 * @param mixed $postarr             An array of sanitized (and slashed) but otherwise unmodified post data.
	 * @param mixed $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as
	 *                                   originally passed to wp_insert_post().
	 * @return array<string,mixed>|mixed Filtered post data.
	 *
	 * @phpstan-param PostData $data
	 * @phpstan-param PostData $unsanitized_postarr
	 *
	 * @template T
	 *
	 * @phpstan-return ($data is array<T> ? array<T> : mixed)
	 */
	public function filter_insert_post_data( $data, $postarr, $unsanitized_postarr ) {
		if ( current_user_can( 'unfiltered_html' ) ) {
			return $data;
		}

		if ( ! \is_array( $data ) || ! \is_array( $postarr ) || ! \is_array( $unsanitized_postarr ) ) {
			return $data;
		}

		if ( ! $this->is_allowed_post_type( $data['post_type'], $data['post_parent'] ) ) {
			return $data;
		}

		if ( isset( $unsanitized_postarr['post_content_filtered'] ) ) {
			$data['post_content_filtered'] = $this->filter_story_data( $unsanitized_postarr['post_content_filtered'] );
		}

		if ( isset( $unsanitized_postarr['post_content'] ) ) {
			add_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
			add_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );

			$unsanitized_postarr['post_content'] = $this->filter_content_save_pre_before_kses( $unsanitized_postarr['post_content'] );

			$data['post_content'] = wp_filter_post_kses( $unsanitized_postarr['post_content'] );
			$data['post_content'] = $this->filter_content_save_pre_after_kses( $data['post_content'] );

			remove_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
			remove_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
		}

		return $data;
	}

	/**
	 * Filters list of allowed CSS attributes.
	 *
	 * @since 1.0.0
	 *
	 * @param string[]|mixed $attr Array of allowed CSS attributes.
	 * @return string[]|mixed Filtered list of CSS attributes.
	 *
	 * @template T
	 *
	 * @phpstan-return ($attr is array<T> ? array<T> : mixed)
	 */
	public function filter_safe_style_css( $attr ) {
		if ( ! \is_array( $attr ) ) {
			return $attr;
		}

		$additional = [
			'display',
			'opacity',
			'position',
			'top',
			'left',
			'transform',
			'white-space',
			'clip-path',
			'-webkit-clip-path',
			'pointer-events',
			'will-change',
			'--initial-opacity',
			'--initial-transform',
		];

		array_push( $attr, ...$additional );

		return $attr;
	}

	/**
	 * Filters an inline style attribute and removes disallowed rules.
	 *
	 * This is equivalent to the WordPress core function of the same name,
	 * except that this does not remove CSS with parentheses in it.
	 *
	 * A few more allowed attributes are added via the safe_style_css filter.
	 *
	 * @SuppressWarnings(PHPMD)
	 *
	 * @since 1.0.0
	 *
	 * @see safecss_filter_attr()
	 *
	 * @param string $css A string of CSS rules.
	 * @return string Filtered string of CSS rules.
	 */
	public function safecss_filter_attr( string $css ): string { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
		$css = wp_kses_no_null( $css );
		$css = str_replace( [ "\n", "\r", "\t" ], '', $css );

		$allowed_protocols = wp_allowed_protocols();

		$css_array = explode( ';', trim( $css ) );

		/** This filter is documented in wp-includes/kses.php */
		$allowed_attr = apply_filters(
			'safe_style_css',
			[
				'background',
				'background-color',
				'background-image',
				'background-position',
				'background-size',
				'background-attachment',
				'background-blend-mode',

				'border',
				'border-radius',
				'border-width',
				'border-color',
				'border-style',
				'border-right',
				'border-right-color',
				'border-right-style',
				'border-right-width',
				'border-bottom',
				'border-bottom-color',
				'border-bottom-style',
				'border-bottom-width',
				'border-left',
				'border-left-color',
				'border-left-style',
				'border-left-width',
				'border-top',
				'border-top-color',
				'border-top-style',
				'border-top-width',

				'border-spacing',
				'border-collapse',
				'caption-side',

				'columns',
				'column-count',
				'column-fill',
				'column-gap',
				'column-rule',
				'column-span',
				'column-width',

				'color',
				'font',
				'font-family',
				'font-size',
				'font-style',
				'font-variant',
				'font-weight',
				'letter-spacing',
				'line-height',
				'text-align',
				'text-decoration',
				'text-indent',
				'text-transform',

				'height',
				'min-height',
				'max-height',

				'width',
				'min-width',
				'max-width',

				'margin',
				'margin-right',
				'margin-bottom',
				'margin-left',
				'margin-top',

				'padding',
				'padding-right',
				'padding-bottom',
				'padding-left',
				'padding-top',

				'flex',
				'flex-basis',
				'flex-direction',
				'flex-flow',
				'flex-grow',
				'flex-shrink',

				'grid-template-columns',
				'grid-auto-columns',
				'grid-column-start',
				'grid-column-end',
				'grid-column-gap',
				'grid-template-rows',
				'grid-auto-rows',
				'grid-row-start',
				'grid-row-end',
				'grid-row-gap',
				'grid-gap',

				'justify-content',
				'justify-items',
				'justify-self',
				'align-content',
				'align-items',
				'align-self',

				'clear',
				'cursor',
				'direction',
				'float',
				'overflow',
				'vertical-align',
				'list-style-type',

				'z-index',
			]
		);

		/*
		 * CSS attributes that accept URL data types.
		 *
		 * This is in accordance to the CSS spec and unrelated to
		 * the sub-set of supported attributes above.
		 *
		 * See: https://developer.mozilla.org/en-US/docs/Web/CSS/url
		 */
		$css_url_data_types = [
			'background',
			'background-image',

			'cursor',

			'list-style',
			'list-style-image',

			'clip-path',
			'-webkit-clip-path',
		];

		/*
		 * CSS attributes that accept gradient data types.
		 *
		 */
		$css_gradient_data_types = [
			'background',
			'background-image',
		];

		/*
		 * CSS attributes that accept color data types.
		 *
		 * This is in accordance to the CSS spec and unrelated to
		 * the sub-set of supported attributes above.
		 *
		 * See: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
		 */
		$css_color_data_types = [
			'color',
			'background',
			'background-color',
			'border-color',
			'box-shadow',
			'outline',
			'outline-color',
			'text-shadow',
		];

		if ( empty( $allowed_attr ) ) {
			return $css;
		}

		$css = '';
		foreach ( $css_array as $css_item ) {
			if ( '' === $css_item ) {
				continue;
			}

			$css_item        = trim( $css_item );
			$css_test_string = $css_item;
			$found           = false;
			$url_attr        = false;
			$gradient_attr   = false;
			$color_attr      = false;
			$transform_attr  = false;

			$parts = explode( ':', $css_item, 2 );

			if ( ! str_contains( $css_item, ':' ) ) {
				$found = true;
			} else {
				$css_selector = trim( $parts[0] );

				if ( \in_array( $css_selector, $allowed_attr, true ) ) {
					$found         = true;
					$url_attr      = \in_array( $css_selector, $css_url_data_types, true );
					$gradient_attr = \in_array( $css_selector, $css_gradient_data_types, true );
					$color_attr    = \in_array( $css_selector, $css_color_data_types, true );

					// --initial-transform is a special custom property used by the story editor.
					$transform_attr = 'transform' === $css_selector || '--initial-transform' === $css_selector;
				}
			}

			if ( $found && $url_attr ) {
				$url_matches = [];

				// Simplified: matches the sequence `url(*)`.
				preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches );

				foreach ( $url_matches[0] as $url_match ) {
					$url_pieces = [];

					// Clean up the URL from each of the matches above.
					preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces );

					if ( empty( $url_pieces[2] ) ) {
						$found = false;
						break;
					}

					$url = trim( $url_pieces[2] );

					if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) {
						$found = false;
						break;
					}

					// Remove the whole `url(*)` bit that was matched above from the CSS.
					$css_test_string = str_replace( $url_match, '', $css_test_string );
				}
			}

			if ( $found && $gradient_attr ) {
				$css_value = trim( $parts[1] );
				if ( preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(([^()]|rgb[a]?\([^()]*\))*\)$/', $css_value ) ) {
					// Remove the whole `gradient` bit that was matched above from the CSS.
					$css_test_string = str_replace( $css_value, '', $css_test_string );
				}
			}

			if ( $found && $color_attr ) {
				$color_matches = [];

				// Simplified: matches the sequence `rgb(*)` and `rgba(*)`.
				preg_match_all( '/rgba?\([^)]+\)/', $parts[1], $color_matches );

				foreach ( $color_matches[0] as $color_match ) {
					$color_pieces = [];

					// Clean up the color from each of the matches above.
					preg_match( '/^rgba?\([^)]*\)$/', $color_match, $color_pieces );

					// Remove the whole `rgb(*)` / `rgba(*) bit that was matched above from the CSS.
					$css_test_string = str_replace( $color_match, '', $css_test_string );
				}
			}

			if ( $found && $transform_attr ) {
				$css_value = trim( $parts[1] );
				if ( preg_match( '/^((matrix|matrix3d|perspective|rotate|rotate3d|rotateX|rotateY|rotateZ|translate|translate3d|translateX|translatY|translatZ|scale|scale3d|scalX|scaleY|scaleZ|skew|skewX|skeY)\(([^()])*\) ?)+$/', $css_value ) ) {
					// Remove the whole `gradient` bit that was matched above from the CSS.
					$css_test_string = str_replace( $css_value, '', $css_test_string );
				}
			}

			if ( $found ) {
				// Allow CSS calc().
				$css_test_string = (string) preg_replace( '/calc\(((?:\([^()]*\)?|[^()])*)\)/', '', $css_test_string );
				// Allow CSS var().
				$css_test_string = (string) preg_replace( '/\(?var\(--[a-zA-Z0-9_-]*\)/', '', $css_test_string );

				// Check for any CSS containing \ ( & } = or comments,
				// except for url(), calc(), or var() usage checked above.
				$allow_css = ! preg_match( '%[\\\(&=}]|/\*%', $css_test_string );

				/** This filter is documented in wp-includes/kses.php */
				$allow_css = apply_filters( 'safecss_filter_attr_allow_css', $allow_css, $css_test_string );

				// Only add the CSS part if it passes the regex check.
				if ( $allow_css ) {
					if ( '' !== $css ) {
						$css .= ';';
					}

					$css .= $css_item;
				}
			}
		}

		return $css;
	}

	/**
	 * Filter the allowed tags for KSES to allow for complete amp-story document markup.
	 *
	 * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string,bool>>|mixed $allowed_tags Allowed tags.
	 * @return array<string, array<string,bool>>|mixed Allowed tags.
	 *
	 * @template T
	 *
	 * @phpstan-return ($allowed_tags is array<T> ? array<T> : mixed)
	 */
	public function filter_kses_allowed_html( $allowed_tags ) {
		if ( ! \is_array( $allowed_tags ) ) {
			return $allowed_tags;
		}

		$story_components = [
			'html'                          => [
				'amp'  => true,
				'lang' => true,
			],
			'head'                          => [],
			'body'                          => [],
			'meta'                          => [
				'name'    => true,
				'content' => true,
				'charset' => true,
			],
			'script'                        => [
				'async'          => true,
				'src'            => true,
				'custom-element' => true,
				'type'           => true,
			],
			'noscript'                      => [],
			'link'                          => [
				'href' => true,
				'rel'  => true,
			],
			'style'                         => [
				'type'            => true,
				'amp-boilerplate' => true,
				'amp-custom'      => true,
			],
			'amp-story'                     => [
				'background-audio'     => true,
				'live-story'           => true,
				'live-story-disabled'  => true,
				'poster-landscape-src' => true,
				'poster-portrait-src'  => true,
				'poster-square-src'    => true,
				'publisher'            => true,
				'publisher-logo-src'   => true,
				'standalone'           => true,
				'supports-landscape'   => true,
				'title'                => true,
			],
			'amp-story-captions'            => [
				'height'       => true,
				'style-preset' => true,
			],
			'amp-story-shopping-attachment' => [
				'cta-text' => true,
				'theme'    => true,
				'src'      => true,
			],
			'amp-story-shopping-config'     => [
				'src' => true,
			],
			'amp-story-shopping-tag'        => [],
			'amp-story-page'                => [
				'auto-advance-after' => true,
				'background-audio'   => true,
				'id'                 => true,
			],
			'amp-story-page-attachment'     => [
				'href'  => true,
				'theme' => true,
			],
			'amp-story-page-outlink'        => [
				'cta-image'          => true,
				'theme'              => true,
				'cta-accent-color'   => true,
				'cta-accent-element' => true,
			],
			'amp-story-grid-layer'          => [
				'aspect-ratio' => true,
				'position'     => true,
				'template'     => true,
			],
			'amp-story-cta-layer'           => [],
			'amp-story-animation'           => [
				'trigger' => true,
			],
			'amp-img'                       => [
				'alt'                       => true,
				'attribution'               => true,
				'data-amp-bind-alt'         => true,
				'data-amp-bind-attribution' => true,
				'data-amp-bind-src'         => true,
				'data-amp-bind-srcset'      => true,
				'disable-inline-width'      => true,
				'lightbox'                  => true,
				'lightbox-thumbnail-id'     => true,
				'media'                     => true,
				'noloading'                 => true,
				'object-fit'                => true,
				'object-position'           => true,
				'placeholder'               => true,
				'sizes'                     => true,
				'src'                       => true,
				'srcset'                    => true,
			],
			'amp-video'                     => [
				'album'                      => true,
				'alt'                        => true,
				'artist'                     => true,
				'artwork'                    => true,
				'attribution'                => true,
				'autoplay'                   => true,
				'captions-id'                => true,
				'controls'                   => true,
				'controlslist'               => true,
				'crossorigin'                => true,
				'data-amp-bind-album'        => true,
				'data-amp-bind-alt'          => true,
				'data-amp-bind-artist'       => true,
				'data-amp-bind-artwork'      => true,
				'data-amp-bind-attribution'  => true,
				'data-amp-bind-controls'     => true,
				'data-amp-bind-controlslist' => true,
				'data-amp-bind-loop'         => true,
				'data-amp-bind-poster'       => true,
				'data-amp-bind-preload'      => true,
				'data-amp-bind-src'          => true,
				'data-amp-bind-title'        => true,
				'disableremoteplayback'      => true,
				'dock'                       => true,
				'lightbox'                   => true,
				'lightbox-thumbnail-id'      => true,
				'loop'                       => true,
				'media'                      => true,
				'muted'                      => true,
				'noaudio'                    => true,
				'noloading'                  => true,
				'object-fit'                 => true,
				'object-position'            => true,
				'placeholder'                => true,
				'poster'                     => true,
				'preload'                    => true,
				'rotate-to-fullscreen'       => true,
				'src'                        => true,
			],
			'source'                        => [
				'type' => true,
				'src'  => true,
			],
			'img'                           => [
				'alt'           => true,
				'attribution'   => true,
				'border'        => true,
				'decoding'      => true,
				'height'        => true,
				'importance'    => true,
				'intrinsicsize' => true,
				'ismap'         => true,
				'loading'       => true,
				'longdesc'      => true,
				'sizes'         => true,
				'src'           => true,
				'srcset'        => true,
				'srcwidth'      => true,
				'width'         => true,
			],
			'svg'                           => [
				'width'   => true,
				'height'  => true,
				'viewbox' => true,
				'fill'    => true,
				'xmlns'   => true,
			],
			'clippath'                      => [
				'transform'     => true,
				'clippathunits' => true,
				'path'          => true,
			],
			'defs'                          => [],
			'feblend'                       => [
				'in'     => true,
				'in2'    => true,
				'result' => true,
			],
			'fecolormatrix'                 => [
				'in'     => true,
				'values' => true,
			],
			'feflood'                       => [
				'flood-opacity' => true,
				'result'        => true,
			],
			'fegaussianblur'                => [
				'stddeviation' => true,
			],
			'feoffset'                      => [],
			'filter'                        => [
				'id'                          => true,
				'x'                           => true,
				'y'                           => true,
				'width'                       => true,
				'height'                      => true,
				'filterunits'                 => true,
				'color-interpolation-filters' => true,
			],
			'g'                             => [
				'filter'  => true,
				'opacity' => true,
			],
			'path'                          => [
				'd'         => true,
				'fill-rule' => true,
				'clip-rule' => true,
				'fill'      => true,
			],
		];

		$allowed_tags = $this->array_merge_recursive_distinct( $allowed_tags, $story_components );

		$allowed_tags = array_map( [ $this, 'add_global_attributes' ], $allowed_tags );

		return $allowed_tags;
	}

	/**
	 * Temporarily renames the style attribute to data-temp-style in full story markup.
	 *
	 * @since 1.0.0
	 *
	 * @param string $post_content Post content.
	 * @return string Filtered post content.
	 */
	public function filter_content_save_pre_before_kses( string $post_content ): string {
		return (string) preg_replace_callback(
			'|(?P<before><\w+(?:-\w+)*\s[^>]*?)style=\\\"(?P<styles>[^"]*)\\\"(?P<after>([^>]+?)*>)|', // Extra slashes appear here because $post_content is pre-slashed..
			static fn( $matches ) => $matches['before'] . sprintf( ' data-temp-style="%s" ', $matches['styles'] ) . $matches['after'],
			$post_content
		);
	}

	/**
	 * Renames data-temp-style back to style in full story markup.
	 *
	 * @since 1.0.0
	 *
	 * @param string $post_content Post content.
	 * @return string Filtered post content.
	 */
	public function filter_content_save_pre_after_kses( string $post_content ): string {
		return (string) preg_replace_callback(
			'/ data-temp-style=\\\"(?P<styles>[^"]*)\\\"/',
			function ( $matches ) {
				$styles = str_replace( '&quot;', '\"', $matches['styles'] );
				return sprintf( ' style="%s"', esc_attr( $this->safecss_filter_attr( wp_kses_stripslashes( $styles ) ) ) );
			},
			$post_content
		);
	}

	/**
	 * Checks whether the post type is correct and user has capability to edit it.
	 *
	 * @since 1.22.0
	 *
	 * @param string          $post_type   Post type slug.
	 * @param int|string|null $post_parent Parent post ID.
	 * @return bool Whether the user can edit the provided post type.
	 */
	private function is_allowed_post_type( string $post_type, $post_parent ): bool {
		if ( $this->story_post_type->get_slug() === $post_type && $this->story_post_type->has_cap( 'edit_posts' ) ) {
			return true;
		}

		if ( $this->page_template_post_type->get_slug() === $post_type && $this->page_template_post_type->has_cap( 'edit_posts' ) ) {
			return true;
		}

		// For story autosaves.
		if (
			(
				'revision' === $post_type &&
				! empty( $post_parent ) &&
				get_post_type( (int) $post_parent ) === $this->story_post_type->get_slug()
			) &&
			$this->story_post_type->has_cap( 'edit_posts' )
		) {
			return true;
		}

		return false;
	}

	/**
	 * Filters story data.
	 *
	 * Provides simple sanity check to ensure story data is valid JSON.
	 *
	 * @since 1.22.0
	 *
	 * @param string $story_data JSON-encoded story data.
	 * @return string Sanitized & slashed story data.
	 */
	private function filter_story_data( string $story_data ): string {
		$decoded = json_decode( (string) wp_unslash( $story_data ), true );
		return null === $decoded ? '' : wp_slash( (string) wp_json_encode( $decoded ) );
	}

	/**
	 * Recursively merge multiple arrays and ensure values are distinct.
	 *
	 * Based on information found in http://www.php.net/manual/en/function.array-merge-recursive.php
	 *
	 * @since 1.5.0
	 *
	 * @param array<int|string,mixed> ...$arrays [optional] Variable list of arrays to recursively merge.
	 * @return array<int|string,mixed> An array of values resulted from merging the arguments together.
	 */
	protected function array_merge_recursive_distinct( array ...$arrays ): array {
		if ( \count( $arrays ) < 2 ) {
			if ( [] === $arrays ) {
				return $arrays;
			}

			return array_shift( $arrays );
		}

		$merged = array_shift( $arrays );

		foreach ( $arrays as $array ) {
			foreach ( $array as $key => $value ) {
				if ( \is_array( $value ) && ( isset( $merged[ $key ] ) && \is_array( $merged[ $key ] ) ) ) {
					$merged[ $key ] = $this->array_merge_recursive_distinct( $merged[ $key ], $value );
				} else {
					$merged[ $key ] = $value;
				}
			}
		}

		return $merged;
	}

	/**
	 * Helper function to add global attributes to a tag in the allowed HTML list.
	 *
	 * @since 1.0.0
	 *
	 * @see _wp_add_global_attributes
	 *
	 * @param array<string,bool> $value An array of attributes.
	 * @return array<string,bool> The array of attributes with global attributes added.
	 */
	protected function add_global_attributes( array $value ): array {
		$global_attributes = [
			'aria-describedby'    => true,
			'aria-details'        => true,
			'aria-label'          => true,
			'aria-labelledby'     => true,
			'aria-hidden'         => true,
			'class'               => true,
			'id'                  => true,
			'style'               => true,
			'title'               => true,
			'role'                => true,
			'data-*'              => true,
			'animate-in'          => true,
			'animate-in-duration' => true,
			'animate-in-delay'    => true,
			'animate-in-after'    => true,
			'animate-in-layout'   => true,
			'layout'              => true,
		];

		return array_merge( $value, $global_attributes );
	}
}
