stand = $stand; } /** * The pages the last read_again() couldn't read only for now. * * @return int[] */ public function retry(): array { return $this->retry; } /** * Whether Stand turned a request away only for now. * * @param WP_Error $error Stand's answer. */ private static function for_now( WP_Error $error ): bool { return in_array( $error->get_error_code(), array( 'stand_reconnect_needed', 'stand_unreachable', 'stand_rate_limited', 'stand_server_error', 'stand_busy' ), true ); } /** * Post types whose posts can be picked. * * @return string[] */ public static function post_types(): array { $types = get_post_types( array( 'public' => true, 'publicly_queryable' => true, ) ); $types = array_merge( array( 'page' ), array_values( $types ) ); return array_values( array_unique( array_diff( $types, array( 'attachment' ) ) ) ); } /** * IDs of picked posts. * * @return int[] */ public static function selected_ids(): array { global $wpdb; // Of every type, also one that's no longer public or registered, as // while the plugin that adds it is off: its pages stay listed here, so // what Stand read from them can still be removed. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- WP_Query skips post types that aren't registered. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID WHERE m.meta_key = %s AND p.post_status IN ('publish', 'private', 'draft', 'pending', 'future', 'trash') ORDER BY p.menu_order ASC, p.post_title ASC, p.ID ASC", self::META_SOURCE ) ); return array_values( array_unique( array_map( 'intval', $ids ) ) ); } /** * Whether Stand can read this post: published, public and not behind a password. * * @param WP_Post $post Post. * @return true|WP_Error */ public function readable( WP_Post $post ) { if ( ! in_array( $post->post_type, self::post_types(), true ) ) { return new WP_Error( 'stand_chat_not_public', __( 'This kind of content isn\'t public on your site now, so Stand can\'t read it.', 'stand-chat' ) ); } if ( 'publish' !== $post->post_status ) { return new WP_Error( 'stand_chat_unpublished', __( 'Only published pages can be read.', 'stand-chat' ) ); } if ( post_password_required( $post ) || '' !== $post->post_password ) { return new WP_Error( 'stand_chat_protected', __( 'Pages behind a password or login can\'t be read.', 'stand-chat' ) ); } if ( false !== strpos( (string) get_permalink( $post ), '?' ) ) { return new WP_Error( 'stand_chat_plain_permalinks', __( 'Stand reads pages by their address, and this page\'s address is a query (?p=…). Choose a permalink structure other than Plain in Settings → Permalinks.', 'stand-chat' ) ); } return true; } /** * Picks pages: each becomes a source that reads exactly that page. * * @param int[] $ids Post IDs. * @param string $claimed The claim this request already holds on the one * page, while it picks the page anew. * @return array Errors by post ID. */ public function add( array $ids, string $claimed = '' ): array { // Pages are linked only for the connection this began with. $identity = $this->stand->connection()->identity(); $errors = array(); $limit = $this->page_limit(); $count = count( self::selected_ids() ); $known = null; foreach ( array_map( 'absint', $ids ) as $id ) { $post = get_post( $id ); if ( ! $post instanceof WP_Post || '' !== (string) get_post_meta( $id, self::META_SOURCE, true ) ) { continue; } $readable = $this->readable( $post ); if ( is_wp_error( $readable ) ) { $errors[ $id ] = $readable->get_error_message(); continue; } // Stand keeps a second source for an address it already reads, and // counts both: after reinstalling or connecting again, link the page // to the Site's own source instead. $known = $known ?? $this->sources_by_url(); if ( is_wp_error( $known ) ) { // Without the Site's sources, a new one could duplicate one Stand has. $errors[ $id ] = $known->get_error_message(); if ( self::for_now( $known ) ) { $this->retry[] = $id; } continue; } $url = (string) get_permalink( $post ); if ( isset( $known[ $url ] ) ) { $linked = $this->link( $id, $known[ $url ], $url, false, $identity ); if ( true === $linked ) { ++$count; } elseif ( is_wp_error( $linked ) ) { $errors[ $id ] = $linked->get_error_message(); } continue; } if ( null !== $limit && $count >= $limit ) { $errors[ $id ] = sprintf( /* translators: %d: page limit */ __( 'Your plan reads up to %d pages. Unpick a page or upgrade to Pro.', 'stand-chat' ), $limit ); continue; } // Two requests adding the page at once would each create a source. $own = '' === $claimed; $claim = $own ? self::claim( $id ) : $claimed; if ( '' === $claim ) { // Said, rather than taken as added: the other request may not // get a source either. $errors[ $id ] = self::busy(); $this->retry[] = $id; continue; } wp_cache_delete( $id, 'post_meta' ); if ( '' !== (string) get_post_meta( $id, self::META_SOURCE, true ) ) { // Another request linked it meanwhile. if ( $own ) { self::release( $id, $claim ); } continue; } $task = $this->stand->add_page( $url ); if ( is_wp_error( $task ) ) { if ( $own ) { self::release( $id, $claim ); } $errors[ $id ] = $task->get_error_message(); if ( self::for_now( $task ) ) { $this->retry[] = $id; } if ( in_array( $task->get_error_code(), array( 'stand_reconnect_needed', 'stand_unreachable', 'stand_rate_limited' ), true ) ) { break; } continue; } $linked = $this->link( $id, (string) ( $task['sourceId'] ?? '' ), $url, true, $identity ); if ( $own ) { self::release( $id, $claim ); } if ( true === $linked ) { ++$count; } elseif ( is_wp_error( $linked ) ) { $errors[ $id ] = $linked->get_error_message(); } } return $errors; } /** * Claims a page while Stand creates its source. A claim older than two * minutes was left by a request that ended on the way. * * @param int $id Post ID. * @return string The claim's token; '' while another request adds the page. */ private static function claim( int $id ): string { return Option_Queue::claim( self::OPTION_ADDING, (string) $id, 2 * MINUTE_IN_SECONDS ); } /** * Lets go of a page's claim. * * @param int $id Post ID. * @param string $token The claim's token. */ private static function release( int $id, string $token ): void { Option_Queue::release( self::OPTION_ADDING, (string) $id, $token ); } /** * Links a page to its source, then checks the page again: it may have * stopped being public while Stand was asked, before Knowledge_Sync could * know its source to remove it. Then the source goes instead, later if * Stand can't remove it now. Connected again meanwhile, maybe to another * account, the page stays unlinked: the source isn't that connection's. * * @param int $id Post ID. * @param string $source Source ID. * @param string $url The address Stand reads. * @param bool $requested Whether Stand was just asked to read it. * @param string $identity Connection::identity() when the change began. * @return true|WP_Error|null True when linked; why not, or null for a page deleted meanwhile. */ private function link( int $id, string $source, string $url, bool $requested, string $identity ) { $linked = $this->stand->connection()->as_connected( $identity, static function () use ( $id, $source, $url, $requested ): bool { update_post_meta( $id, self::META_SOURCE, $source ); update_post_meta( $id, self::META_URL, $url ); if ( $requested ) { update_post_meta( $id, self::META_REQUESTED, time() ); } return true; } ); if ( is_wp_error( $linked ) ) { return $linked; } clean_post_cache( $id ); $post = get_post( $id ); $readable = $post instanceof WP_Post ? $this->readable( $post ) : null; if ( true === $readable ) { if ( (string) get_permalink( $post ) !== $url ) { // Its address changed meanwhile: reading it again moves it to the // new one, and the source for the old one goes. wp_schedule_single_event( time() + MINUTE_IN_SECONDS, Knowledge_Sync::HOOK, array( $id ) ); } return true; } self::forget( $id ); $this->drop_source( $source ); return $readable; } /** * Removes a source from Stand, later if Stand can't remove it now, so a * page that's no longer public doesn't stay in what AI assistants read. * * @param string $source Source ID. */ private function drop_source( string $source ): void { $removed = $this->stand->remove_source( $source ); if ( is_wp_error( $removed ) && ! self::gone( $removed ) ) { self::queue_removal( $source ); } } /** * The one-page crawls in this Site's knowledge base by address, one that * was read first. * * @return array|WP_Error Source IDs by URL, or why Stand couldn't list them. */ private function sources_by_url() { // When Stand can't list them only for now, say so: adding a page then // could duplicate a source Stand has. Otherwise there are none to find. $kb = $this->stand->own_knowledge_base(); if ( is_wp_error( $kb ) ) { return self::for_now( $kb ) ? $kb : array(); } if ( ! is_array( $kb ) ) { return array(); } // A source queued for removal is going, maybe right now: a page picked // again gets a new one instead. Read before the sources, so one the // removal retry deletes meanwhile still counts as going. $going = Option_Queue::read( self::OPTION_REMOVALS ); $sources = $this->stand->sources(); if ( is_wp_error( $sources ) ) { return self::for_now( $sources ) ? $sources : array(); } $linked = array_flip( array_map( 'strval', array_filter( array_map( array( $this, 'source_of' ), self::selected_ids() ) ) ) ); $found = array(); foreach ( $sources as $source ) { $id = (string) ( $source['sourceId'] ?? '' ); $one = 'website' === ( $source['sourceType'] ?? '' ) && 0 === (int) ( $source['crawlMaxDepth'] ?? -1 ); $url = (string) ( $source['crawlUrlPrefix'] ?? '' ); if ( ! $one || '' === $id || '' === $url || isset( $linked[ $id ] ) || isset( $going[ $id ] ) ) { continue; } if ( ! isset( $found[ $url ] ) || ( 'completed' === ( $source['status'] ?? '' ) && 'completed' !== $found[ $url ]['status'] ) ) { $found[ $url ] = array( 'id' => $id, 'status' => (string) ( $source['status'] ?? '' ), ); } } return array_map( static function ( $source ) { return $source['id']; }, $found ); } /** * A picked post's source ID. * * @param int $id Post ID. */ private function source_of( int $id ): string { return (string) get_post_meta( $id, self::META_SOURCE, true ); } /** * Unpicks pages and removes what Stand read from them. * * @param int[] $ids Post IDs. * @return array Errors by post ID. */ public function remove( array $ids ): array { $errors = array(); foreach ( array_map( 'absint', $ids ) as $id ) { // Waits for Read again replacing the page's source, which clears // the link meanwhile and would link the page again once unpicked. $claim = self::claim( $id ); if ( '' === $claim ) { $errors[ $id ] = self::busy(); continue; } wp_cache_delete( $id, 'post_meta' ); $source = (string) get_post_meta( $id, self::META_SOURCE, true ); $result = '' === $source ? true : $this->stand->remove_source( $source ); if ( is_wp_error( $result ) && ! self::gone( $result ) ) { $errors[ $id ] = $result->get_error_message(); } else { self::forget( $id ); } self::release( $id, $claim ); } return $errors; } /** * Why a page waits: another request is changing what Stand reads from it. */ private static function busy(): string { return __( 'WordPress is reading this page in another request right now. Try again in a moment.', 'stand-chat' ); } /** * Whether a source is out of WordPress's reach: removed in Stand (404), or * another person's, which this connection can't change (403). * * @param mixed $result Stand's answer about the source. */ private static function gone( $result ): bool { return is_wp_error( $result ) && in_array( $result->get_error_code(), array( 'stand_not_found', 'stand_forbidden' ), true ); } /** * Reads pages again; the old version stays until the new one is ready. * * @param int[] $ids Post IDs. * @return array Errors by post ID. */ public function read_again( array $ids ): array { $errors = array(); $this->retry = array(); foreach ( array_map( 'absint', $ids ) as $id ) { $source = (string) get_post_meta( $id, self::META_SOURCE, true ); if ( '' === $source ) { continue; } $post = get_post( $id ); if ( $post instanceof WP_Post ) { $readable = $this->readable( $post ); if ( is_wp_error( $readable ) ) { $errors[ $id ] = $readable->get_error_message(); if ( in_array( $readable->get_error_code(), array( 'stand_chat_not_public', 'stand_chat_unpublished', 'stand_chat_protected' ), true ) ) { // Still read though it's not public: nothing removed its // source when it stopped being, as when no source was linked // for Knowledge_Sync then, or its kind of content changed. self::forget( $id ); $this->drop_source( $source ); } continue; } } $url = $post instanceof WP_Post ? (string) get_permalink( $post ) : ''; if ( '' !== $url && (string) get_post_meta( $id, self::META_URL, true ) !== $url ) { // The address changed: read the page at its new address. $errors += $this->pick_anew( $id, $source, true ); continue; } // One request at a time: another would start a second reading of the // page, and Unpick or a new source waits for this one. $claim = self::claim( $id ); if ( '' === $claim ) { // Tried again later, in case the other request doesn't get it read. $errors[ $id ] = self::busy(); $this->retry[] = $id; continue; } try { wp_cache_delete( $id, 'post_meta' ); if ( $this->source_of( $id ) !== $source ) { // Unpicked, or picked anew, meanwhile. continue; } $task = $this->stand->read_again( $source ); if ( ! is_wp_error( $task ) ) { update_post_meta( $id, self::META_REQUESTED, time() ); } } finally { self::release( $id, $claim ); } if ( self::gone( $task ) ) { // Removed in Stand meanwhile, or another Site's or person's: pick // the page anew. $errors += $this->pick_anew( $id, $source, false ); $linked = (string) get_post_meta( $id, self::META_SOURCE, true ); if ( '' !== $linked && ! isset( $errors[ $id ] ) && '' === (string) get_post_meta( $id, self::META_REQUESTED, true ) ) { // Linked to the source the Site already had for it: read that again. $again = $this->stand->read_again( $linked ); if ( is_wp_error( $again ) ) { $errors[ $id ] = $again->get_error_message(); if ( self::for_now( $again ) ) { $this->retry[] = $id; } } else { update_post_meta( $id, self::META_REQUESTED, time() ); } } continue; } if ( is_wp_error( $task ) ) { $errors[ $id ] = $task->get_error_message(); if ( self::for_now( $task ) ) { $this->retry[] = $id; } } } return $errors; } /** * Picks a page anew in place of its source. When Stand doesn't take it, * for now or for good, such as at the plan's page limit, which still counts * the old source, the page keeps its source, so trying again later finds * it, unless it stopped being readable. Once the page has moved on, or * stopped being readable, the old source goes, later if Stand can't remove * it now; only the request that replaces it decides that. * * @param int $id Post ID. * @param string $source Its source now. * @param bool $remove_old Whether the old source is still in Stand. * @return array Errors by post ID. */ private function pick_anew( int $id, string $source, bool $remove_old ): array { // One request at a time changes the page's source: another Read again // or Unpick waits for this one instead of undoing it, or being undone. $claim = self::claim( $id ); if ( '' === $claim ) { // The request replacing it decides about the old source. $this->retry[] = $id; return array( $id => self::busy() ); } try { wp_cache_delete( $id, 'post_meta' ); if ( $this->source_of( $id ) !== $source ) { // Unpicked, or picked anew, meanwhile. return array(); } $kept = array( self::META_URL => get_post_meta( $id, self::META_URL, true ), self::META_REQUESTED => get_post_meta( $id, self::META_REQUESTED, true ), ); $identity = $this->stand->connection()->identity(); self::forget( $id ); $errors = $this->add( array( $id ), $claim ); clean_post_cache( $id ); if ( '' === $this->source_of( $id ) && get_post( $id ) instanceof WP_Post ) { // Back to its source, for the connection this began with only. $restored = $this->stand->connection()->as_connected( $identity, static function () use ( $id, $source, $kept ): bool { update_post_meta( $id, self::META_SOURCE, $source ); foreach ( array_filter( $kept, 'strlen' ) as $key => $value ) { update_post_meta( $id, $key, $value ); } return true; } ); if ( is_wp_error( $restored ) ) { return array( $id => $restored->get_error_message() ); } // Checked once linked again, as link() does: made private or // deleted before this, while Knowledge_Sync found no source to // remove, it stays unlinked; after this, Knowledge_Sync removes it. clean_post_cache( $id ); $post = get_post( $id ); $readable = $post instanceof WP_Post ? $this->readable( $post ) : null; if ( true !== $readable ) { self::forget( $id ); $this->retry = array_values( array_diff( $this->retry, array( $id ) ) ); if ( is_wp_error( $readable ) ) { $errors[ $id ] = $readable->get_error_message(); } } } if ( $remove_old && $this->source_of( $id ) !== $source ) { // Moved on, deleted, or no longer readable. $this->drop_source( $source ); } return $errors; } finally { self::release( $id, $claim ); } } /** * Removes the link between a post and its source. * * @param int $id Post ID. */ public static function forget( int $id ): void { delete_post_meta( $id, self::META_SOURCE ); delete_post_meta( $id, self::META_URL ); delete_post_meta( $id, self::META_REQUESTED ); } /** * Removes every page's link to its source, when the sources are another * Site's: WordPress connected to a different Site. */ public static function forget_all(): void { foreach ( array( self::META_SOURCE, self::META_URL, self::META_REQUESTED ) as $key ) { delete_post_meta_by_key( $key ); } } /** * Remembers a source to remove from Stand when it can't be removed now, so * a page that's no longer public doesn't stay in what AI assistants read. * * @param string $source_id Source ID. */ public static function queue_removal( string $source_id ): void { if ( '' === $source_id ) { return; } Option_Queue::change( self::OPTION_REMOVALS, static function ( array $queue ) use ( $source_id ): array { if ( ! isset( $queue[ $source_id ] ) ) { $queue[ $source_id ] = time(); } return $queue; } ); self::schedule_removals(); } /** * Removes the queued sources from Stand. What still fails stays queued and * is tried again in an hour. * * @return int How many are still waiting. */ public function retry_removals(): int { $removed = array(); foreach ( array_keys( (array) get_option( self::OPTION_REMOVALS, array() ) ) as $source_id ) { $result = $this->stand->remove_source( (string) $source_id ); if ( ! is_wp_error( $result ) || self::gone( $result ) ) { $removed[ $source_id ] = true; } elseif ( 'stand_reconnect_needed' === $result->get_error_code() ) { break; } } // Only these leave the queue: another request may have queued more meanwhile. $queue = Option_Queue::change( self::OPTION_REMOVALS, static function ( array $queue ) use ( $removed ): array { return array_diff_key( $queue, $removed ); } ) ?? Option_Queue::read( self::OPTION_REMOVALS ); if ( ! empty( $queue ) ) { self::schedule_removals(); } return count( $queue ); } /** * Tries the queued removals again in an hour. */ public static function schedule_removals(): void { if ( ! wp_next_scheduled( self::HOOK_REMOVALS ) ) { wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::HOOK_REMOVALS ); } } /** * The plan's page limit, or null for no limit. */ public function page_limit(): ?int { $overview = $this->stand->knowledge_overview(); if ( is_wp_error( $overview ) ) { return null; } $limit = $overview['capabilities']['pageCountLimit'] ?? null; return null === $limit ? null : (int) $limit; } /** * Picked pages with their reading status. * * @return array{pages: array>, others: array>, sitemap: array|null, stale: bool} */ public function pages(): array { $sources = $this->stand->sources(); $stale = is_wp_error( $sources ); $by_id = array(); foreach ( $stale ? array() : $sources as $source ) { $by_id[ (string) ( $source['sourceId'] ?? '' ) ] = $source; } $tasks = $stale ? array() : $this->stand->tasks(); $tasks = is_wp_error( $tasks ) ? array() : $tasks; $by_source_task = array(); foreach ( $tasks as $task ) { $source_id = (string) ( $task['sourceId'] ?? '' ); if ( '' !== $source_id && ! isset( $by_source_task[ $source_id ] ) ) { $by_source_task[ $source_id ] = $task; // Newest first. } } $pages = array(); $used = array(); foreach ( self::selected_ids() as $id ) { $post = get_post( $id ); $source_id = (string) get_post_meta( $id, self::META_SOURCE, true ); if ( ! $post instanceof WP_Post ) { continue; } $used[ $source_id ] = true; $pages[] = $this->page_row( $post, $by_id[ $source_id ] ?? null, $by_source_task[ $source_id ] ?? null, $stale ); } $others = array(); $sitemap = null; foreach ( $by_id as $source_id => $source ) { if ( isset( $used[ $source_id ] ) ) { continue; } if ( 'sitemap' === ( $source['sourceType'] ?? '' ) ) { $sitemap = $source; continue; } $others[] = array( 'sourceId' => $source_id, 'displayName' => (string) ( $source['displayName'] ?? '' ), 'url' => (string) ( $source['originalUrl'] ?? $source['crawlUrlPrefix'] ?? '' ), 'status' => self::status_label( (string) ( $source['status'] ?? '' ) ), 'pageCount' => (int) ( $source['pageCount'] ?? 0 ), ); } return array( 'pages' => $pages, 'others' => $others, 'sitemap' => $sitemap, 'stale' => $stale, ); } /** * One picked page with its status. * * @param WP_Post $post Post. * @param array|null $source Stand source. * @param array|null $task Latest ingestion task. * @param bool $stale Whether Stand couldn't be reached. * @return array */ private function page_row( WP_Post $post, ?array $source, ?array $task, bool $stale ): array { $status = $stale ? 'unknown' : ( null === $source ? 'missing' : self::status_label( (string) ( $source['status'] ?? '' ) ) ); $read_at = null === $source ? 0 : (int) strtotime( (string) ( $source['updatedAt'] ?? '' ) ); // Tasks aren't cached, sources are for a few seconds: the newest task // tells first when reading starts or ends. $task_status = $task ? (string) ( $task['status'] ?? '' ) : ''; $task_failed = in_array( $task_status, array( 'failed', 'partially_failed' ), true ); if ( in_array( $task_status, array( 'queued', 'processing', 'queued_v2', 'processing_v2' ), true ) ) { $status = 'reading'; } elseif ( $task_failed ) { $status = 'failed'; } elseif ( 'completed' === $task_status && null !== $source && 'missing' !== $status && ! $stale ) { $status = 'read'; $read_at = max( $read_at, (int) strtotime( (string) ( $task['completedAt'] ?? '' ) ) ); } $modified = (int) strtotime( $post->post_modified_gmt . ' UTC' ); // Compare with when WordPress asked Stand to read the page: both times // come from this server's clock. Older links fall back to Stand's time. $requested = (int) get_post_meta( $post->ID, self::META_REQUESTED, true ); $baseline = $requested > 0 ? $requested : ( $read_at > 0 ? $read_at + 60 : 0 ); $changed = 'read' === $status && $baseline > 0 && $modified > $baseline; // A type that's no longer registered, as while the plugin that adds it // is off, has no address in WordPress: show the one Stand reads. $read_url = (string) get_post_meta( $post->ID, self::META_URL, true ); $url = post_type_exists( $post->post_type ) ? (string) get_permalink( $post ) : $read_url; return array( 'id' => $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES ), 'path' => wp_make_link_relative( $url ), 'url' => $url, 'type' => $post->post_type, 'postStatus' => $post->post_status, 'modified' => gmdate( 'c', $modified ), 'sourceId' => (string) get_post_meta( $post->ID, self::META_SOURCE, true ), 'status' => $status, 'readAt' => $read_at > 0 ? gmdate( 'c', $read_at ) : '', 'changed' => $changed, 'taskId' => $task ? (string) ( $task['taskId'] ?? '' ) : '', 'error' => $task_failed ? (string) ( $task['errorMessage'] ?? '' ) : '', 'addressMoved' => $url !== $read_url, ); } /** * Plugin status for a Stand source status. * * @param string $status Stand status. */ public static function status_label( string $status ): string { switch ( $status ) { case 'completed': return 'read'; case 'failed': case 'partially_failed': // Part of it wasn't read: not a success. return 'failed'; case 'cancelled': return 'cancelled'; case 'deleted': return 'missing'; case '': return 'unknown'; default: return 'reading'; } } /** * Picked pages that changed since Stand read them. * * @return array */ public function changed(): array { $out = array(); foreach ( $this->pages()['pages'] as $page ) { if ( ! empty( $page['changed'] ) ) { $out[] = array( 'id' => (int) $page['id'], 'title' => (string) $page['title'], ); } } return $out; } /** * The sitemap WordPress or an SEO plugin publishes. */ public static function sitemap_url(): string { if ( defined( 'WPSEO_VERSION' ) ) { return home_url( '/sitemap_index.xml' ); } if ( defined( 'RANK_MATH_VERSION' ) ) { return home_url( '/sitemap_index.xml' ); } if ( function_exists( 'wp_sitemaps_get_server' ) && wp_sitemaps_get_server()->sitemaps_enabled() ) { return get_sitemap_url( 'index' ); } return home_url( '/wp-sitemap.xml' ); } /** * Pages suggested first in the picker, with why. * * @return array Reason by post ID. */ public static function suggestions(): array { $reasons = array(); $front = (int) get_option( 'page_on_front' ); if ( 'page' === get_option( 'show_on_front' ) && $front ) { $reasons[ $front ] = 'front'; } foreach ( self::menu_post_ids() as $id ) { if ( ! isset( $reasons[ $id ] ) ) { $reasons[ $id ] = 'menu'; } } $slugs = array( 'about', 'about-us', 'services', 'products', 'pricing', 'prices', 'plans', 'faq', 'faqs', 'contact', 'contact-us', 'book-a-call', 'support', 'help' ); foreach ( $slugs as $slug ) { $page = get_page_by_path( $slug ); if ( $page instanceof WP_Post && 'publish' === $page->post_status && ! isset( $reasons[ $page->ID ] ) ) { $reasons[ $page->ID ] = 'common'; } } return $reasons; } /** * Pages linked from the site's menus: classic menu locations, or the * Navigation block's menus in block themes. * * @return int[] */ public static function menu_post_ids(): array { $ids = array(); $locations = get_nav_menu_locations(); foreach ( $locations as $menu_id ) { foreach ( (array) wp_get_nav_menu_items( $menu_id ) as $item ) { if ( isset( $item->object_id, $item->type ) && 'post_type' === $item->type ) { $ids[] = (int) $item->object_id; } } } if ( empty( $ids ) && wp_is_block_theme() ) { $navigations = get_posts( array( 'post_type' => 'wp_navigation', 'post_status' => 'publish', 'posts_per_page' => 3, ) ); foreach ( $navigations as $navigation ) { foreach ( self::navigation_link_ids( parse_blocks( $navigation->post_content ) ) as $id ) { $ids[] = $id; } } } return array_values( array_unique( array_filter( $ids ) ) ); } /** * Post IDs linked from Navigation link blocks. * * @param array> $blocks Parsed blocks. * @return int[] */ private static function navigation_link_ids( array $blocks ): array { $ids = array(); foreach ( $blocks as $block ) { if ( in_array( $block['blockName'] ?? '', array( 'core/navigation-link', 'core/navigation-submenu' ), true ) && ! empty( $block['attrs']['id'] ) ) { $ids[] = (int) $block['attrs']['id']; } if ( ! empty( $block['innerBlocks'] ) ) { $ids = array_merge( $ids, self::navigation_link_ids( $block['innerBlocks'] ) ); } } return $ids; } }