prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * LinkGraph * * Builds and queries the internal link graph stored in {prefix}clusterlane_links. */ class LinkGraph { /** * Extract all internal links from a post's content. * Returns each link keyed by "type:id" (e.g. "post:42", "term:7") so * a post-link and a term-link can never collide, since post IDs and * term IDs are independent numeric sequences in WordPress. * * @return array */ public function extract_links( int $post_id ): array { $post = get_post( $post_id ); if ( ! $post ) return []; preg_match_all( '/]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', $post->post_content, $matches, PREG_SET_ORDER ); return $this->resolve_links( array_map( fn( $m ) => [ $m[1], wp_strip_all_tags( $m[2] ) ], $matches ), $post_id ); } /** * Internal links among [url, anchor] pairs — from the article text, or from * its published page (PageReader, 0.98.177) — keyed "type:id" like extract_links(). * * @param array $pairs * @return array */ public function resolve_links( array $pairs, int $post_id ): array { $links = []; foreach ( $pairs as [ $url, $anchor ] ) { $url = (string) $url; $anchor = (string) $anchor; // Only internal URLs — on subdirectory multisite this also // excludes links to other sites on the same domain. if ( ! \Clusterlane\Core\Multisite::is_internal_url( $url ) ) continue; $target = $this->resolve_target( \Clusterlane\Core\Multisite::normalize_url( $url ), $post_id ); if ( ! $target ) continue; if ( $target['type'] === 'post' && $target['id'] === $post_id ) continue; $key = $target['type'] . ':' . $target['id']; $links[ $key ] = [ 'type' => $target['type'], 'id' => $target['id'], 'anchor' => substr( $anchor, 0, 255 ), ]; } return $links; } /** * Resolve an internal URL to either a post or a taxonomy term * (category/tag archive page). Posts are tried first since they're * the overwhelmingly common case and url_to_postid() is authoritative; * term archives are only checked once no post matches. * * @return array{type: string, id: int}|null */ private function resolve_target( string $url, int $source_id = 0 ): ?array { // Keyed by blog ID: static vars survive switch_to_blog(), so a // single request touching several sites (WP-CLI loops, network // tools) must not reuse another site's slug map. static $caches = []; $blog_id = get_current_blog_id(); if ( ! isset( $caches[ $blog_id ] ) ) { $caches[ $blog_id ] = [ $this->build_slug_cache(), $this->build_term_slug_cache() ]; } [ $post_cache, $term_cache ] = $caches[ $blog_id ]; // Plain permalinks (?p=123, ?page_id=, ?cat=, ?tag=) carry the target // in the query string — resolve them before it is stripped below. // Without this, sites using plain permalinks got an empty link graph. if ( strpos( $url, '?' ) !== false ) { $id = url_to_postid( \Clusterlane\Core\Multisite::absolutize( $url ) ); if ( $id ) return [ 'type' => 'post', 'id' => $id ]; $query = []; parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); if ( ! empty( $query['cat'] ) && is_numeric( $query['cat'] ) ) { $term = get_term( (int) $query['cat'], 'category' ); if ( $term && ! is_wp_error( $term ) ) return [ 'type' => 'term', 'id' => (int) $term->term_id ]; } if ( ! empty( $query['tag'] ) && is_string( $query['tag'] ) ) { $term = get_term_by( 'slug', $query['tag'], 'post_tag' ); if ( $term ) return [ 'type' => 'term', 'id' => (int) $term->term_id ]; } $cpt = self::query_var_id( $query ); if ( $cpt ) return [ 'type' => 'post', 'id' => $cpt ]; } // Strip query string and fragment. $clean = strtok( $url, '?#' ); $clean = rtrim( $clean, '/' ); // Strip .htm / .html extension. $clean = preg_replace( '/\.html?$/i', '', $clean ); // Try native url_to_postid first (handles edge cases). $id = url_to_postid( $clean ); if ( $id ) return [ 'type' => 'post', 'id' => $id ]; $path = wp_parse_url( $clean, PHP_URL_PATH ); if ( ! $path ) return null; $parts = explode( '/', trim( $path, '/' ) ); $slug = end( $parts ); if ( isset( $post_cache[ $slug ] ) ) { // Multilingual sites (0.98.168): the same slug can exist in several languages — // take the one in the linking article's language. $in_lang = $source_id ? self::slug_in_lang( (string) $slug, Lang::of( $source_id ) ) : 0; return [ 'type' => 'post', 'id' => $in_lang ?: $post_cache[ $slug ] ]; } if ( isset( $term_cache[ $slug ] ) ) { return [ 'type' => 'term', 'id' => $term_cache[ $slug ] ]; } // An article's old slug: WordPress redirects it to the article, so the link counts (0.98.167). $old = self::old_slug_id( (string) $slug ); if ( $old ) return [ 'type' => 'post', 'id' => $old ]; return null; } /** * Custom post types with plain permalinks: ?product= (WooCommerce) or any * ?= — url_to_postid() only knows ?p= and ?page_id= (0.98.172). * @param array|string $query parsed query array, or a URL */ public static function query_var_id( $query ): int { if ( is_string( $query ) ) { $q = []; parse_str( (string) wp_parse_url( $query, PHP_URL_QUERY ), $q ); $query = $q; } foreach ( Scope::post_types() as $pt ) { $obj = get_post_type_object( $pt ); $qv = $obj && is_string( $obj->query_var ) && $obj->query_var !== '' ? $obj->query_var : ''; if ( $qv === '' || empty( $query[ $qv ] ) || ! is_string( $query[ $qv ] ) ) continue; $hit = get_page_by_path( sanitize_title( $query[ $qv ] ), OBJECT, $pt ); if ( $hit ) return (int) $hit->ID; } return 0; } /** The article with this slug in $lang (0 when not multilingual, unknown or none). */ private static function slug_in_lang( string $slug, string $lang ): int { if ( $lang === '' || ! Lang::active() ) return 0; static $cache = []; $k = $lang . '|' . $slug; if ( ! array_key_exists( $k, $cache ) ) { global $wpdb; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND " . Scope::sql() . ' AND ' . Scope::type_sql( '', true ), $slug // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Scope fragments ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared $cache[ $k ] = count( $ids ) > 1 ? ( Lang::filter_ids( $ids, $lang )[0] ?? 0 ) : 0; } return $cache[ $k ]; } /** * A published article by its slug, as the link graph resolves it: the current * slug first, then an old one (0.98.167 — shared with Text Match, so both agree * on what a link points at). */ public static function slug_id( string $slug ): int { static $caches = []; $blog_id = get_current_blog_id(); if ( ! isset( $caches[ $blog_id ] ) ) $caches[ $blog_id ] = ( new self() )->build_slug_cache(); return (int) ( $caches[ $blog_id ][ $slug ] ?? self::old_slug_id( $slug ) ); } /** * The published article that used to have this slug (WordPress keeps old * slugs in _wp_old_slug and redirects to the article). 0 when none — or when * several articles once had it, so a link is never pinned on a guess. */ public static function old_slug_id( string $slug ): int { static $caches = []; $blog_id = get_current_blog_id(); if ( ! isset( $caches[ $blog_id ] ) ) { global $wpdb; $map = []; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). foreach ( (array) $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT pm.meta_value AS slug, pm.post_id AS id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = pm.post_id WHERE pm.meta_key = %s AND " . \Clusterlane\Core\Scope::sql() . ' AND ' . \Clusterlane\Core\Scope::type_sql( '', true ), '_wp_old_slug' ), ARRAY_A ) as $r ) { // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared $s = (string) $r['slug']; $map[ $s ] = isset( $map[ $s ] ) && $map[ $s ] !== (int) $r['id'] ? 0 : (int) $r['id']; } $caches[ $blog_id ] = $map; } return (int) ( $caches[ $blog_id ][ $slug ] ?? 0 ); } /** * Build a slug→ID map for all published posts. * Called once per request and cached statically. * * @return array */ private function build_slug_cache(): array { global $wpdb; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ID, post_name FROM %i WHERE ' . \Clusterlane\Core\Scope::sql() . ' AND ' . \Clusterlane\Core\Scope::type_sql( '', true ), // the scanned post types (+ pages, often linked) $wpdb->posts ), ARRAY_A ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared $cache = []; foreach ( $rows as $row ) { $cache[ $row['post_name'] ] = (int) $row['ID']; } return $cache; } /** * Build a slug→term_id map, but only for taxonomies that are actually * used as cluster types (clusterlane_clusters.cluster_type) — those are the * only term archives that matter as a potential "hub" node. Scanning * every taxonomy on the site would be wasted work and risks matching * an unrelated term archive that happens to share a slug. * * @return array */ private function build_term_slug_cache(): array { global $wpdb; $taxonomies = $wpdb->get_col( "SELECT DISTINCT cluster_type FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_type != 'custom'" ); $cache = []; foreach ( $taxonomies as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) continue; $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false ] ); if ( is_wp_error( $terms ) ) continue; foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( is_wp_error( $link ) ) continue; $path = wp_parse_url( $link, PHP_URL_PATH ); if ( ! $path ) continue; $parts = explode( '/', trim( $path, '/' ) ); $slug = end( $parts ); $cache[ $slug ] = (int) $term->term_id; } } return $cache; } /** * Persist links for a post to the DB. * Replaces all existing links for this source. * * @param array $links * As returned by extract_links(). */ public function save_links( int $source_id, array $links, ?array $template = null ): void { global $wpdb; $table = $wpdb->prefix . 'clusterlane_links'; $now = current_time( 'mysql', true ); // UTC, consistent with clusterlane_post_metrics.last_scanned // Links the theme adds on the published page (PageReader, 0.98.177). null = // keep this article's stored ones: an edit of the text (Apply, Undo, Text // Match) doesn't change what the templates show. if ( $template === null ) $template = $this->template_links_of( $source_id ); // Remove stale records. $wpdb->delete( $table, [ 'source_id' => $source_id ], [ '%d' ] ); /** * Links the article shows that are not in its text (Pro: the pillar * link box, 0.98.134). Same shape as $links; stored as link_type * 'auto' so metrics count them and tools can tell them apart. */ $auto = (array) apply_filters( 'clusterlane_auto_links', [], $source_id, $links ); foreach ( $auto as $key => $link ) { if ( isset( $links[ $key ] ) || empty( $link['id'] ) ) continue; $wpdb->replace( $table, [ 'source_id' => $source_id, 'source_type' => 'post', 'target_id' => (int) $link['id'], 'target_type' => $link['type'] ?? 'post', 'anchor_text' => substr( (string) ( $link['anchor'] ?? '' ), 0, 255 ), 'link_type' => 'auto', 'scanned_at' => $now, ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] ); } foreach ( $links as $link ) { $wpdb->replace( $table, [ 'source_id' => $source_id, 'source_type' => 'post', 'target_id' => $link['id'], 'target_type' => $link['type'], 'anchor_text' => $link['anchor'], 'link_type' => 'inline', 'scanned_at' => $now, ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] ); } // Page links that are neither in the text nor the pillar box. foreach ( $template as $key => $link ) { if ( isset( $links[ $key ] ) || isset( $auto[ $key ] ) || empty( $link['id'] ) ) continue; if ( ( $link['type'] ?? 'post' ) === 'post' && (int) $link['id'] === $source_id ) continue; $wpdb->replace( $table, [ 'source_id' => $source_id, 'source_type' => 'post', 'target_id' => (int) $link['id'], 'target_type' => $link['type'] ?? 'post', 'anchor_text' => substr( (string) ( $link['anchor'] ?? '' ), 0, 255 ), 'link_type' => 'template', 'scanned_at' => $now, ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] ); } } /** * The template links stored for an article (PageReader), keyed like extract_links(). * @return array */ public function template_links_of( int $source_id ): array { global $wpdb; $out = []; foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT target_id, target_type, anchor_text FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND link_type = 'template'", $source_id ) ) as $r ) { $out[ $r->target_type . ':' . (int) $r->target_id ] = [ 'type' => (string) $r->target_type, 'id' => (int) $r->target_id, 'anchor' => (string) $r->anchor_text ]; } return $out; } /** * Get all source IDs that link TO $target_id. * $type distinguishes a post target from a term (category hub) target, * since the two ID spaces can otherwise collide numerically. * @return int[] */ public function get_inbound( int $target_id, string $type = 'post' ): array { global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT source_id FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = %s", $target_id, $type ) ) ); } /** Get all post IDs that $source_id links TO. @return int[] */ public function get_outbound( int $source_id ): array { global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post'", $source_id ) ) ); } /** * Count inbound links for a target. $type defaults to 'post' so every * existing caller (post inbound counts) keeps working unchanged; pass * 'term' to count links into a category/tag archive hub instead. */ public function count_inbound( int $target_id, string $type = 'post' ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = %s", $target_id, $type ) ); } /** Count outbound links for a post. */ public function count_outbound( int $source_id ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d", $source_id ) ); } /** * Get all unique post IDs that appear in the link graph * (either as source or target). * @return int[] */ public function get_all_linked_ids(): array { global $wpdb; $table = $wpdb->prefix . 'clusterlane_links'; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT source_id FROM %i WHERE source_type = 'post' UNION SELECT DISTINCT target_id FROM %i WHERE target_type = 'post'", $table, $table ) ); return array_map( 'intval', $ids ); } }