*/ private $memo = []; public static function init() { $self = new self(); // Late: after blocks, shortcodes and other content filters have added // whatever links they add. add_filter( 'the_content', [ $self, 'filter_content' ], 99 ); } public function filter_content( $content ) { if ( ! is_string( $content ) || false === stripos( $content, 'filter_html( $content, (int) get_the_ID() ); } /** * @param string $html Rendered HTML. * @param int $post_id Post the HTML belongs to, where the lookup map is * cached. 0 resolves without caching. * @return string */ public function filter_html( $html, $post_id = 0 ) { $urls = []; $processor = new \WP_HTML_Tag_Processor( $html ); while ( $processor->next_tag( 'a' ) ) { $url = Resolver::normalize( $processor->get_attribute( 'href' ) ); if ( '' !== $url ) { $urls[ $url ] = true; } } if ( ! $urls ) { return $html; } $ids = $this->lookup( array_keys( $urls ), $post_id ); _prime_post_caches( array_values( array_unique( array_filter( $ids ) ) ), false, false ); $hidden = []; foreach ( $ids as $url => $id ) { if ( $id && $id !== $post_id && ! self::is_linkable( $id ) ) { $hidden[ $url ] = $id; } } if ( ! $hidden ) { return $html; } $marked = false; $processor = new \WP_HTML_Tag_Processor( $html ); while ( $processor->next_tag( 'a' ) ) { $href = $processor->get_attribute( 'href' ); $url = Resolver::normalize( $href ); if ( ! isset( $hidden[ $url ] ) ) { continue; } /** * What to do with a link to a post visitors cannot see. * * - `unwrap` (default): drop the ``, keep its contents, so the * text still shows. Button-styled links included: their styling * lives on the ``, so the label shows as plain text. * - `remove`: drop the link and its contents. * - `keep`: leave the link alone. * * @param string $action unwrap|remove|keep. * @param int $target_id The unpublished post. * @param string $href The link as written. * @param int $post_id The post being rendered. */ $action = apply_filters( 'ablocks/link_guard/action', 'unwrap', $hidden[ $url ], $href, $post_id ); if ( in_array( $action, [ 'unwrap', 'remove' ], true ) ) { $processor->set_attribute( self::MARKER, $action ); $marked = true; } }//end while if ( ! $marked ) { return $html; } // The tag processor cannot delete a tag and keep its children, so it // marks the anchors and this removes them. Attribute values are matched // as whole quoted strings, so a `>` inside a title cannot end the tag // early. Anchors cannot nest, so the first `` closes the one opened. $attr = '(?:[^>"\']|"[^"]*"|\'[^\']*\')'; $pattern = '#(.*?)#is'; $result = preg_replace_callback( $pattern, static function ( $match ) { return 'remove' === $match[1] ? '' : $match[2]; }, $processor->get_updated_html() ); return null === $result ? $html : $result; } /** * Whether visitors can open a post. * * @param int $post_id Target post. * @return bool */ public static function is_linkable( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { $linkable = false; } elseif ( 'attachment' === $post->post_type ) { // Attachments inherit their status, and an attachment page is not // what a writer is waiting to publish. $linkable = true; } else { $linkable = is_post_publicly_viewable( $post ); } /** * @param bool $linkable Whether the link should be shown. * @param int $post_id Target post ID. * @param \WP_Post|null $post Target post, null when it no longer exists. */ return (bool) apply_filters( 'ablocks/link_guard/is_linkable', $linkable, (int) $post_id, $post ); } /** * Post ID for each URL, from the request memo, the post's stored map, or a * fresh resolve — in that order. Fresh answers are written back. * * @param string[] $urls Normalized URLs. * @param int $post_id Owner of the stored map, or 0. * @return array */ private function lookup( array $urls, $post_id ) { $generation = CacheBackend::generation( ABLOCKS_LINK_GUARD_GENERATION_OPTION ); $stored = $post_id ? get_post_meta( $post_id, self::META, true ) : []; $map = ( is_array( $stored ) && isset( $stored['map'] ) && is_array( $stored['map'] ) ) ? $stored['map'] : []; $fresh = is_array( $stored ) && isset( $stored['generation'] ) && (int) $stored['generation'] === $generation; $result = []; $dirty = false; foreach ( $urls as $url ) { if ( isset( $this->memo[ $url ] ) ) { $id = $this->memo[ $url ]; $dirty = $dirty || ! isset( $map[ $url ] ); } elseif ( isset( $map[ $url ] ) && ( (int) $map[ $url ] > 0 || $fresh ) ) { $id = (int) $map[ $url ]; } else { $id = Resolver::resolve( $url ); $dirty = true; } $this->memo[ $url ] = $id; $result[ $url ] = $id; } if ( $dirty && $post_id && ! wp_is_post_revision( $post_id ) ) { $this->store( $post_id, $map, $fresh, $result, $generation ); } return $result; } private function store( $post_id, array $map, $fresh, array $result, $generation ) { if ( ! $fresh ) { // Old zeros that were not re-checked in this render would otherwise // be stamped with the current generation and trusted again. $map = array_filter( $map ); } $map = array_merge( $map, $result ); if ( count( $map ) > self::MAX_ENTRIES ) { return; } update_post_meta( $post_id, self::META, [ 'generation' => $generation, 'map' => $map, ] ); $indexed = array_map( 'intval', get_post_meta( $post_id, self::TARGET_META ) ); foreach ( array_unique( array_filter( $result ) ) as $target_id ) { if ( $target_id !== $post_id && ! in_array( $target_id, $indexed, true ) ) { add_post_meta( $post_id, self::TARGET_META, $target_id ); } } } }