settings = $settings; $this->story_post_type = $story_post_type; $this->context = $context; } /** * Initializes all hooks. * * @since 1.2.0 */ public function register(): void { add_filter( 'option_amp-options', [ $this, 'filter_amp_options' ] ); add_filter( 'amp_supportable_post_types', [ $this, 'filter_supportable_post_types' ] ); add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 10, 4 ); add_filter( 'amp_content_sanitizers', [ $this, 'add_amp_content_sanitizers' ] ); add_filter( 'amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 ); add_filter( 'amp_skip_post', [ $this, 'filter_amp_skip_post' ], 10, 2 ); // This filter is actually used in this plugin's `Sanitization` class. add_filter( 'web_stories_amp_validation_error_sanitized', [ $this, 'filter_amp_validation_error_sanitized' ], 10, 2 ); } /** * Get the list of service IDs required for this service to be registered. * * Needed because settings needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'settings' ]; } /** * Filter AMP options to force Standard mode (AMP-first) when a web story is being requested. * * @since 1.2.0 * * @param array|mixed $options Options. * @return array|mixed Filtered options. * * @phpstan-param AMPOptions $options * * @template T * * @phpstan-return ($options is array ? array : mixed) */ public function filter_amp_options( $options ) { if ( ! \is_array( $options ) ) { return $options; } if ( $this->get_request_post_type() === $this->story_post_type->get_slug() ) { $options['theme_support'] = 'standard'; $options['supported_post_types'][] = $this->story_post_type->get_slug(); $options['supported_templates'][] = 'is_singular'; } return $options; } /** * Filter the post types which are supportable. * * Remove web-stories from the list unless the currently requested post type is for a web-story. This is done in * order to hide stories from the list of supportable post types on the AMP Settings screen. * * @since 1.2.0 * * @param string[]|mixed $post_types Supportable post types. * @return array|mixed Supportable post types. * * @template T * * @phpstan-return ($post_types is array ? array : mixed) */ public function filter_supportable_post_types( $post_types ) { if ( ! \is_array( $post_types ) ) { return $post_types; } $story_post_type = $this->story_post_type->get_slug(); $post_types = array_diff( $post_types, [ $story_post_type ] ); if ( $this->get_request_post_type() === $story_post_type ) { $post_types = [ ...$post_types, $story_post_type ]; } return array_unique( array_values( $post_types ) ); } /** * Filters the AMP plugin's sanitizers. * * @since 1.2.0 * * @param array|mixed $sanitizers Sanitizers. * @return array|mixed Sanitizers. * * @phpstan-param AMPSanitizers|mixed $sanitizers * @phpstan-return AMPSanitizers|mixed */ public function add_amp_content_sanitizers( $sanitizers ) { if ( ! $this->context->is_web_story() ) { return $sanitizers; } $post = get_queried_object(); if ( ! ( $post instanceof WP_Post ) ) { return $sanitizers; } if ( ! \is_array( $sanitizers ) ) { return $sanitizers; } /** * AMP sanitizer configuration. * * @phpstan-var AMPSanitizers $sanitizers */ $video_cache_enabled = (bool) $this->settings->get_setting( $this->settings::SETTING_NAME_VIDEO_CACHE ); $story = new Story(); $story->load_from_post( $post ); $poster_images = [ 'poster-portrait-src' => esc_url_raw( $story->get_poster_portrait() ), ]; if ( isset( $sanitizers['AMP_Style_Sanitizer'] ) ) { if ( ! isset( $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] ) ) { $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'] = []; } $sanitizers['AMP_Style_Sanitizer']['dynamic_element_selectors'][] = 'amp-story-captions'; } $sanitizers[ AMP_Story_Sanitizer::class ] = [ 'publisher_logo' => (string) $story->get_publisher_logo_url(), 'publisher' => $story->get_publisher_name(), 'poster_images' => array_filter( $poster_images ), 'video_cache' => $video_cache_enabled, 'title_tag' => wp_get_document_title(), 'description' => wp_strip_all_tags( get_the_excerpt() ), ]; return $sanitizers; } /** * Filter amp_validation_error_sanitized to prevent invalid markup removal for Web Stories. * * Since the amp-story element requires the poster-portrait-src attribute to be valid, when this attribute is absent * the AMP plugin will try to remove the amp-story element altogether. This is not the preferred resolution! So * instead, this will force the invalid markup to be kept. When this is done, the AMP plugin in Standard mode * (which Web Stories enforces while serving singular web-story posts) will remove the amp attribute from the html * element so that the page will not be advertised as AMP. This prevents GSC from complaining about a validation * issue which we already know about. * * The same is done for elements, for example when they have missing poster images. * * @since 1.1.1 * * @link https://github.com/ampproject/amp-wp/blob/c6aed8f/includes/validation/class-amp-validation-manager.php#L1777-L1809 * * @param null|bool $sanitized Whether sanitized. Null means sanitization is not overridden. * @param array{node_type?: int, node_name?: string, parent_name?: string} $error Validation error being sanitized. * @return null|bool Whether sanitized. */ public function filter_amp_validation_error_sanitized( ?bool $sanitized, array $error ): ?bool { // Skip sanitization for missing publisher logos and poster portrait images. if ( isset( $error['node_type'], $error['node_name'], $error['parent_name'] ) && ( ( XML_ELEMENT_NODE === $error['node_type'] && 'amp-story' === $error['node_name'] && 'body' === $error['parent_name'] ) || ( XML_ATTRIBUTE_NODE === $error['node_type'] && 'poster-portrait-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] ) || ( XML_ATTRIBUTE_NODE === $error['node_type'] && 'publisher-logo-src' === $error['node_name'] && 'amp-story' === $error['parent_name'] ) ) ) { return false; } // Skip sanitization for missing video posters. if ( isset( $error['node_name'] ) && 'amp-video' === $error['node_name'] ) { return false; } // Skip sanitization for amp-video > source with invalid src. if ( isset( $error['parent_name'] ) && 'source' === $error['parent_name'] ) { return false; } return $sanitized; } /** * Filters whether AMP-to-AMP is excluded for an element. * * The element may be either a link (`a` or `area`) or a `form`. * * @since 1.2.0 * * @param bool|mixed $excluded Excluded. Default value is whether element already has a `noamphtml` link relation or the URL is among `excluded_urls`. * @param string $url URL considered for exclusion. * @param string[] $rel Link relations. * @param DOMElement|null $element The element considered for excluding from AMP-to-AMP linking. May be instance of `a`, `area`, or `form`. * @return bool|mixed Whether AMP-to-AMP is excluded. */ public function filter_amp_to_amp_linking_element_excluded( $excluded, string $url, array $rel, ?DOMElement $element ) { if ( $element instanceof DOMElement && $element->parentNode instanceof DOMElement && 'amp-story-player' === $element->parentNode->tagName ) { return true; } return $excluded; } /** * Filters whether to skip the post from AMP. * * Skips the post if the AMP plugin's version is lower than what is bundled in this plugin. * Prevents issues where this plugin uses newer features that the plugin doesn't know about yet, * causing false positives with validation. * * @since 1.6.0 * * @link https://github.com/googleforcreators/web-stories-wp/issues/7131 * * @param bool|mixed $skipped Whether the post should be skipped from AMP. * @param int $post Post ID. * @return bool|mixed Whether post should be skipped from AMP. */ public function filter_amp_skip_post( $skipped, int $post ) { // This is the opposite to the `AMP__VERSION >= WEBSTORIES_AMP_VERSION` check in the HTML renderer. if ( $this->story_post_type->get_slug() === get_post_type( $post ) && \defined( '\AMP__VERSION' ) && version_compare( WEBSTORIES_AMP_VERSION, AMP__VERSION, '>' ) ) { return true; } return $skipped; } /** * Get the post type for the current request. * * @SuppressWarnings(PHPMD.NPathComplexity) * * @since 1.2.0 */ protected function get_request_post_type(): ?string { // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( did_action( 'wp' ) && is_singular() ) { $post_type = get_post_type( get_queried_object_id() ); return $post_type ?: null; } if ( isset( $_GET['action'], $_GET['post'] ) && 'amp_validate' === $_GET['action'] && is_admin() ) { /** * Post ID. * * @var string|int $post_id */ $post_id = $_GET['post']; if ( get_post_type( (int) $post_id ) === self::AMP_VALIDATED_URL_POST_TYPE ) { return $this->get_validated_url_post_type( (int) $post_id ); } } $current_screen_post_type = $this->context->get_screen_post_type(); if ( $current_screen_post_type ) { $current_post = get_post(); if ( self::AMP_VALIDATED_URL_POST_TYPE === $current_screen_post_type && $current_post instanceof WP_Post && $current_post->post_type === $current_screen_post_type ) { $validated_url_post_type = $this->get_validated_url_post_type( $current_post->ID ); if ( $validated_url_post_type ) { return $validated_url_post_type; } } return $current_screen_post_type; } if ( isset( $_SERVER['REQUEST_URI'] ) ) { /** * Request URI. * * @var string $request_uri */ $request_uri = $_SERVER['REQUEST_URI']; if ( str_contains( (string) wp_unslash( $request_uri ), $this->story_post_type->get_rest_url() ) ) { return $this->story_post_type->get_slug(); } } // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return null; } /** * Get the singular post type which is the queried object for the given validated URL post. * * @since 1.0.0 * * @param int $post_id Post ID for Validated URL Post. * @return string|null Post type or null if validated URL is not for a singular post. */ protected function get_validated_url_post_type( int $post_id ): ?string { if ( empty( $post_id ) ) { return null; } $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return null; } if ( self::AMP_VALIDATED_URL_POST_TYPE !== $post->post_type ) { return null; } /** * AMP queried object. * * @var array{type?: string, id?: int|string}|string $queried_object */ $queried_object = get_post_meta( $post->ID, '_amp_queried_object', true ); if ( ! \is_array( $queried_object ) ) { return null; } if ( isset( $queried_object['id'], $queried_object['type'] ) && 'post' === $queried_object['type'] ) { /** * Post ID. * * @var int|string $post_id */ $post_id = $queried_object['id']; $post_type = get_post_type( (int) $post_id ); if ( $post_type ) { return $post_type; } } return null; } }