diff --git a/includes/abilities/class-redirect-abilities.php b/includes/abilities/class-redirect-abilities.php index a2eb673..9297b56 100644 --- a/includes/abilities/class-redirect-abilities.php +++ b/includes/abilities/class-redirect-abilities.php @@ -341,11 +341,166 @@ class EMCP_Tools_Redirect_Abilities { } // --------------------------------------------------------------------- - // find-broken-links (implemented in Task 5) + // find-broken-links // --------------------------------------------------------------------- private function register_find_broken_links(): void { - // Registered in Task 5. + $this->ability_names[] = 'emcp-tools/find-broken-links'; + emcp_tools_register_ability( + 'emcp-tools/find-broken-links', + array( + 'label' => __( 'Find Broken Links', 'emcp-tools' ), + 'description' => __( 'Scans published content for internal links that point at trashed/missing pages (dead), or at a path that already has a redirect (should link straight to the target). Read-only — proposes fixes, changes nothing. Bounded by max_posts/max_seconds.', 'emcp-tools' ), + 'category' => 'emcp-tools', + 'execute_callback' => array( $this, 'execute_find_broken_links' ), + 'permission_callback' => array( $this, 'check_manage_permission' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'max_posts' => array( 'type' => 'integer', 'description' => __( '1-2000. Default 200.', 'emcp-tools' ) ), + 'max_seconds' => array( 'type' => 'integer', 'description' => __( '1-60. Default 10.', 'emcp-tools' ) ), + ), + ), + 'output_schema' => array( 'type' => 'object', 'properties' => array( + 'scanned' => array( 'type' => 'integer' ), + 'findings' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), + 'partial' => array( 'type' => 'boolean' ), + ) ), + 'meta' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * @param array $input Tool input. + * @return array|WP_Error + */ + public function execute_find_broken_links( $input ) { + return $this->scan_broken_links( is_array( $input ) ? $input : array() ); + } + + /** + * Walk published content and report internal links that are dead or already + * redirected. Bounded by max_posts + max_seconds (partial flag when a cap + * trips). Read-only. + * + * @param array $opts { max_posts:int, max_seconds:int }. + * @return array { scanned, findings, partial }. + */ + public function scan_broken_links( array $opts ): array { + $max_posts = max( 1, min( 2000, (int) ( $opts['max_posts'] ?? 200 ) ) ); + $max_seconds = max( 1, min( 60, (int) ( $opts['max_seconds'] ?? 10 ) ) ); + $start = microtime( true ); + + $sources = array(); + if ( class_exists( 'EMCP_Tools_Redirect_Store' ) ) { + foreach ( EMCP_Tools_Redirect_Store::all( array( 'enabled' => true, 'limit' => 500 ) ) as $r ) { + $sources[] = (string) $r['source_path']; + } + } + + $types = array_values( get_post_types( array( 'public' => true ), 'names' ) ); + $query = new WP_Query( array( + 'post_type' => $types, + 'post_status' => 'publish', + 'posts_per_page' => $max_posts, + 'no_found_rows' => true, + 'fields' => 'ids', + ) ); + + $findings = array(); + $scanned = 0; + $partial = false; + foreach ( (array) $query->posts as $pid ) { + if ( microtime( true ) - $start > $max_seconds ) { + $partial = true; + break; + } + ++$scanned; + $content = (string) get_post_field( 'post_content', (int) $pid ); + foreach ( $this->extract_hrefs( $content ) as $href ) { + $c = $this->classify_href( $href, $sources ); + if ( in_array( $c['kind'], array( 'dead', 'redirected' ), true ) ) { + $findings[] = array( + 'post_id' => (int) $pid, + 'post_title' => get_the_title( (int) $pid ), + 'href' => $href, + 'kind' => $c['kind'], + 'suggestion' => $c['suggestion'] ?? '', + ); + } + } + } + return array( 'scanned' => $scanned, 'findings' => $findings, 'partial' => $partial ); + } + + /** + * Extract href values from HTML/block content. + * + * @param string $content Post content. + * @return string[] + */ + private function extract_hrefs( string $content ): array { + if ( ! preg_match_all( '#href=["\']([^"\']+)["\']#i', $content, $m ) ) { + return array(); + } + return array_values( array_unique( $m[1] ) ); + } + + /** + * Classify a single href. `$redirect_sources` is the set of enabled, + * normalized redirect source paths. Returns { kind, suggestion? } where kind + * is external | ok | dead | redirected. + * + * @param string $href Raw href. + * @param string[] $redirect_sources Enabled normalized source paths. + * @return array + */ + public function classify_href( string $href, array $redirect_sources ): array { + $path = $this->internal_path( $href ); + if ( '' === $path ) { + return array( 'kind' => 'external' ); + } + if ( in_array( $path, $redirect_sources, true ) ) { + return array( 'kind' => 'redirected', 'suggestion' => __( 'This path already has a redirect — link straight to the target instead.', 'emcp-tools' ) ); + } + $pid = function_exists( 'url_to_postid' ) ? (int) url_to_postid( home_url( $path ) ) : 0; + if ( ! $pid ) { + return array( 'kind' => 'dead', 'suggestion' => __( 'Points at a URL with no published content — fix the link or add a redirect.', 'emcp-tools' ) ); + } + $status = function_exists( 'get_post_status' ) ? get_post_status( $pid ) : 'publish'; + if ( ! in_array( $status, array( 'publish', 'inherit' ), true ) ) { + return array( 'kind' => 'dead', 'suggestion' => __( 'Points at trashed/unpublished content — fix the link or add a redirect.', 'emcp-tools' ) ); + } + return array( 'kind' => 'ok' ); + } + + /** + * Return the normalized internal path of an href, or '' when it is external, + * an anchor, or a non-http scheme. + * + * @param string $href Raw href. + * @return string + */ + private function internal_path( string $href ): string { + $href = trim( $href ); + if ( '' === $href || 0 === strpos( $href, '#' ) || 0 === strpos( $href, 'mailto:' ) || 0 === strpos( $href, 'tel:' ) ) { + return ''; + } + $parts = wp_parse_url( $href ); + $home = wp_parse_url( home_url( '/' ) ); + if ( ! empty( $parts['host'] ) ) { + if ( empty( $home['host'] ) || strtolower( $parts['host'] ) !== strtolower( (string) $home['host'] ) ) { + return ''; // External host. + } + } + if ( ! class_exists( 'EMCP_Tools_Redirect_Store' ) ) { + return ''; + } + return EMCP_Tools_Redirect_Store::normalize_path( $href ); } // --------------------------------------------------------------------- diff --git a/tests/RedirectAbilitiesTest.php b/tests/RedirectAbilitiesTest.php index 8873b2f..01ce9da 100644 --- a/tests/RedirectAbilitiesTest.php +++ b/tests/RedirectAbilitiesTest.php @@ -22,6 +22,7 @@ class RedirectAbilitiesTest extends \PHPUnit\Framework\TestCase { $this->assertContains( 'emcp-tools/create-redirect', $names ); $this->assertContains( 'emcp-tools/update-redirect', $names ); $this->assertContains( 'emcp-tools/delete-redirect', $names ); + $this->assertContains( 'emcp-tools/find-broken-links', $names ); // Every registered ability MUST carry category => emcp-tools. foreach ( $names as $n ) { $this->assertSame( 'emcp-tools', $GLOBALS['emcp_test']['abilities'][ $n ]['category'] ?? null, "$n missing category" ); @@ -52,4 +53,29 @@ class RedirectAbilitiesTest extends \PHPUnit\Framework\TestCase { EMCP_Tools_Redirect_Abilities::push_suggestion( '', 'post-deleted', 2 ); $this->assertSame( array(), get_option( 'emcp_tools_redirect_suggestions', array() ) ); } + + public function test_classify_href_dead_redirected_ok_external() { + $g = new EMCP_Tools_Redirect_Abilities(); + + // External host → external (ignored). + $this->assertSame( 'external', $g->classify_href( 'https://other.example/x', array() )['kind'] ); + // Anchor / mailto → external. + $this->assertSame( 'external', $g->classify_href( '#top', array() )['kind'] ); + + // Path that matches an enabled redirect source → redirected. + $this->assertSame( 'redirected', $g->classify_href( '/go-here', array( '/go-here' ) )['kind'] ); + + // Internal path with no post → dead. + $this->assertSame( 'dead', $g->classify_href( '/missing', array() )['kind'] ); + + // Internal path → trashed post → dead. + $GLOBALS['emcp_test']['url_to_postid']['/gone'] = 77; + $GLOBALS['emcp_test']['post_status'][77] = 'trash'; + $this->assertSame( 'dead', $g->classify_href( '/gone', array() )['kind'] ); + + // Internal path → published post → ok. + $GLOBALS['emcp_test']['url_to_postid']['/live'] = 88; + $GLOBALS['emcp_test']['post_status'][88] = 'publish'; + $this->assertSame( 'ok', $g->classify_href( 'http://example.test/live', array() )['kind'] ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 71cead4..f194d03 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -145,6 +145,26 @@ function admin_url( $path = '' ): string { return 'http://example.test/wp-admin/' . $path; } +function home_url( $path = '' ): string { + return 'http://example.test' . ( '' === $path ? '' : '/' . ltrim( (string) $path, '/' ) ); +} + +function wp_parse_url( $url, $component = -1 ) { + return parse_url( (string) $url, $component ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url +} + +// Fixture-driven: $GLOBALS['emcp_test']['url_to_postid'][ normalized-path ] => post id. +function url_to_postid( $url ) { + $path = parse_url( (string) $url, PHP_URL_PATH ); // phpcs:ignore + $path = '/' . trim( strtolower( (string) $path ), '/' ); + return (int) ( $GLOBALS['emcp_test']['url_to_postid'][ $path ] ?? 0 ); +} + +// Fixture-driven: $GLOBALS['emcp_test']['post_status'][ id ] => 'publish'|'trash'|... +function get_post_status( $id ) { + return $GLOBALS['emcp_test']['post_status'][ (int) $id ] ?? false; +} + // --------------------------------------------------------------------------- // ACF stubs (fixture-driven) // ---------------------------------------------------------------------------