add_action( 'wp_update_nav_menu_item', $this, 'reject_generated_page_item', 10, 3 ); // 2. Cleanup. $loader->add_action( 'save_post', $this, 'purge_after_save', 12, 2 ); $loader->add_action( 'save_post_academiccrew_member', $this, 'purge_linked_page', 30, 1 ); $loader->add_action( 'save_post_academiccrew_course', $this, 'purge_linked_page', 30, 1 ); $loader->add_action( 'save_post_acc_collaboration', $this, 'purge_linked_page', 30, 1 ); $loader->add_action( 'wp_loaded', $this, 'maybe_sweep_all' ); // 3. Render-time. Only the front-end nav walker (`wp_nav_menu`) and the // page-list fallbacks — NOT `wp_get_nav_menu_items`, which the menu // editor and core's own auto-add dedup rely on. $loader->add_filter( 'wp_nav_menu_objects', $this, 'strip_menu_objects', 10, 1 ); $loader->add_filter( 'wp_page_menu_args', $this, 'exclude_from_page_menu' ); $loader->add_filter( 'wp_list_pages_excludes', $this, 'add_to_excludes' ); // The Page List block (`core/page-list`), wp_list_pages(), wp_page_menu() // and Avada's page-hierarchy fallback all read get_pages() — strip the // generated pages there so they never show as menu sub-items. $loader->add_filter( 'get_pages', $this, 'strip_from_page_lists', 10, 2 ); $loader->add_action( 'academiccrew_generated_page_ids_flush', $this, 'flush' ); } // ── 1. Interception ─────────────────────────────────────────── /** * Fires after every nav-menu item is written. If it targets an * auto-generated member / course / project page, delete it immediately — * this is what stops a re-import (which regenerates the linked page and * re-triggers core's auto-add) from putting the page back in the menu. * * @param int $menu_id * @param int $menu_item_db_id * @param array $args processed menu-item args (menu-item-object, -object-id, …) */ public function reject_generated_page_item( $menu_id, $menu_item_db_id, $args = [] ): void { $object = is_array( $args ) ? ( $args['menu-item-object'] ?? '' ) : ''; $object_id = is_array( $args ) ? (int) ( $args['menu-item-object-id'] ?? 0 ) : 0; // Fall back to the stored meta when the args don't carry the object id. if ( ! $object_id && $menu_item_db_id ) { $object = (string) get_post_meta( (int) $menu_item_db_id, '_menu_item_object', true ); $object_id = (int) get_post_meta( (int) $menu_item_db_id, '_menu_item_object_id', true ); } if ( 'page' !== $object || ! $object_id ) { return; } if ( $this->has_flag( $object_id ) || in_array( $object_id, $this->generated_ids(), true ) ) { wp_delete_post( (int) $menu_item_db_id, true ); $this->flush(); } } // ── 2. Cleanup ───────────────────────────────────────────────── /** * Runs after core's auto-add (save_post priority 10): if a generated page * still gained a menu item, drop it. * * @param int $post_id * @param \WP_Post $post */ public function purge_after_save( int $post_id, $post ): void { if ( ! ( $post instanceof \WP_Post ) || 'page' !== $post->post_type ) { return; } if ( $this->has_flag( $post_id ) ) { $this->delete_menu_items_for( $post_id ); $this->flush(); } } /** Sweep the page linked to a member / course / project that was just saved. */ public function purge_linked_page( int $cpt_id ): void { $page_id = (int) get_post_meta( $cpt_id, '_academiccrew_linked_page_id', true ); if ( $page_id ) { $this->delete_menu_items_for( $page_id ); $this->flush(); } } /** One-time (per SWEEP_VERSION) removal of items added before this guard. */ public function maybe_sweep_all(): void { if ( get_option( 'academiccrew_navmenu_swept' ) === self::SWEEP_VERSION ) { return; } foreach ( $this->generated_ids() as $page_id ) { $this->delete_menu_items_for( $page_id ); } $this->flush(); update_option( 'academiccrew_navmenu_swept', self::SWEEP_VERSION, true ); } private function delete_menu_items_for( int $page_id ): void { $items = get_posts( [ 'post_type' => 'nav_menu_item', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => '_menu_item_object_id', 'meta_value' => (string) $page_id, ] ); foreach ( $items as $item_id ) { if ( 'nav_menu_item' === get_post_type( (int) $item_id ) ) { wp_delete_post( (int) $item_id, true ); } } } // ── 3. Render-time ───────────────────────────────────────────── /** * @param array $items array of menu-item objects (each with ->object, ->object_id) * @return array */ public function strip_menu_objects( $items ) { if ( ! is_array( $items ) ) { return $items; } $ids = $this->generated_ids(); if ( ! $ids ) { return $items; } return array_values( array_filter( $items, function ( $item ) use ( $ids ) { return ! $this->item_targets_generated_page( $item, $ids ); } ) ); } private function item_targets_generated_page( $item, array $ids ): bool { $type = $item->type ?? ( $item->menu_item_type ?? '' ); $object = $item->object ?? ( $item->menu_item_object ?? '' ); $obj_id = (int) ( $item->object_id ?? ( $item->menu_item_object_id ?? 0 ) ); return 'post_type' === $type && 'page' === $object && in_array( $obj_id, $ids, true ); } /** * Drop generated pages from every get_pages() result on the front end: * the Page List block (`core/page-list`), wp_list_pages(), wp_page_menu() * and Avada's page-hierarchy menu fallback all go through here. Left intact * in wp-admin so the "Parent page" dropdowns keep working. * * @param mixed $pages \WP_Post[] or int[] from get_pages() * @param array $args * @return mixed */ public function strip_from_page_lists( $pages, $args = [] ) { if ( is_admin() || ! is_array( $pages ) || ! $pages ) { return $pages; } $ids = $this->generated_ids(); if ( ! $ids ) { return $pages; } return array_values( array_filter( $pages, static function ( $page ) use ( $ids ) { $pid = (int) ( is_object( $page ) ? ( $page->ID ?? 0 ) : $page ); return ! in_array( $pid, $ids, true ); } ) ); } public function exclude_from_page_menu( array $args ): array { $ids = $this->generated_ids(); if ( $ids ) { $existing = array_filter( array_map( 'intval', explode( ',', (string) ( $args['exclude'] ?? '' ) ) ) ); $args['exclude'] = implode( ',', array_unique( array_merge( $existing, $ids ) ) ); } return $args; } /** @param int[] $excludes */ public function add_to_excludes( array $excludes ): array { return array_values( array_unique( array_merge( $excludes, $this->generated_ids() ) ) ); } // ── Shared ──────────────────────────────────────────────────── public function flush(): void { $this->ids = null; delete_transient( 'academiccrew_generated_page_ids' ); } private function has_flag( int $page_id ): bool { foreach ( self::FLAGS as $flag ) { if ( get_post_meta( $page_id, $flag, true ) ) { return true; } } return false; } /** @return int[] */ private function generated_ids(): array { if ( $this->ids !== null ) { return $this->ids; } $cached = get_transient( 'academiccrew_generated_page_ids' ); if ( is_array( $cached ) ) { return $this->ids = $cached; } $ids = get_posts( [ 'post_type' => 'page', // Include trash — a trashed generated page can still carry a stale // menu item that a re-import would otherwise leave behind. 'post_status' => [ 'publish', 'draft', 'pending', 'private', 'future', 'trash' ], 'posts_per_page' => -1, 'fields' => 'ids', 'meta_query' => [ 'relation' => 'OR', ...array_map( static fn( $f ) => [ 'key' => $f, 'compare' => 'EXISTS' ], self::FLAGS ), ], ] ); $ids = array_map( 'intval', $ids ); set_transient( 'academiccrew_generated_page_ids', $ids, HOUR_IN_SECONDS ); return $this->ids = $ids; } }