post_name === $post_before->post_name ) return;` * A page moved to a different parent keeps its slug, so core records nothing — even though * its address changed completely. * * 2. `|| is_post_type_hierarchical( $post->post_type )` * Pages get nothing at all. Trac #4328, open for years. * * Non-hierarchical post types are therefore left entirely alone: core has them covered, and their * permalinks do not include the parent, so a parent change does not move them. * * @package Adminkeep */ namespace Adminkeep\Feature; use Adminkeep\Feature; use Adminkeep\Service\PathRedirectResolver; use Adminkeep\Service\PathRedirectStore; defined( 'ABSPATH' ) || exit; /** * Records the addresses a page used to answer on, and 301s them to where it lives now. */ class KeepUrl implements Feature { /** * Paths captured before an update, keyed by post id. * * @var array */ private $old_paths = array(); /** * Feature id. * * @return string */ public function id() { return 'keep_url'; } /** * Label. * * @return string */ public function label() { return __( 'Keep URL', 'adminkeep' ); } /** * Description. * * @return string */ public function description() { return __( 'Rename or move a page and its old address keeps working. WordPress already does this for posts but not for pages, and for nothing at all when you move a page under a different parent — which is when whole sections of a site break at once.', 'adminkeep' ); } /** * A word for what this feature is doing right now. * * @return string */ public function state_label() { return __( 'Watching', 'adminkeep' ); } /** * Settings schema. No translated strings — this runs before init. * * @return array */ public function settings_schema() { return array( 'recorded' => array( 'type' => 'array', 'default' => array(), 'ui' => 'status', ), ); } /** * Field labels, resolved at render time. * * @param string $key Field key. * @return string */ public function field_label( $key ) { if ( 'recorded' === $key ) { return __( 'Addresses being kept alive', 'adminkeep' ); } return ''; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Watches published pages for an address change — the page\'s own slug, a parent\'s slug, or being moved under a different parent.', 'adminkeep' ), __( 'Records the old address and sends it to the new one with a permanent redirect, so links and search rankings follow the page.', 'adminkeep' ), __( 'Renaming a parent page fixes every page underneath it, not just the parent.', 'adminkeep' ), __( 'Posts are left alone: WordPress already redirects those, and a second layer doing the same job is a bug waiting to happen.', 'adminkeep' ), __( 'If a page is later trashed, its old addresses stop redirecting rather than pointing at a missing page.', 'adminkeep' ), __( 'Switching this off stops new addresses being recorded. The ones already recorded keep working, because a redirect that disappeared would strand every link pointing at it. Remove them individually below, or delete the plugin with "Delete all Adminkeep data" switched on to drop them all.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. Unused — this feature has none. * @return void */ public function boot( array $settings ) { add_action( 'pre_post_update', array( $this, 'capture_paths' ), 10, 1 ); add_action( 'post_updated', array( $this, 'record_changes' ), 10, 1 ); add_action( 'before_delete_post', array( $this, 'forget_post' ), 10, 1 ); add_action( 'template_redirect', array( $this, 'maybe_redirect' ), 5 ); } /** * Whether this post type is one core leaves uncovered. * * @param \WP_Post $post Post to check. * @return bool */ private function is_covered_by_core( \WP_Post $post ) { return ! is_post_type_hierarchical( $post->post_type ); } /** * Capture the addresses of a page and everything under it, before the update lands. * * Runs on pre_post_update, so get_page_uri() still sees the old ancestor chain. Descendants * matter because renaming a parent moves every page beneath it and core records none of them. * * @param int $post_id Post about to be updated. * @return void */ public function capture_paths( $post_id ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || $this->is_covered_by_core( $post ) ) { return; } // Only a published page has an address worth keeping alive. if ( 'publish' !== $post->post_status ) { return; } foreach ( array_merge( array( (int) $post_id ), $this->descendants_of( $post ) ) as $id ) { $this->old_paths[ $id ] = get_page_uri( $id ); } } /** * Record every address that actually changed. * * @param int $post_id Post that was updated. * @return void */ public function record_changes( $post_id ) { if ( empty( $this->old_paths ) ) { return; } $captured = $this->old_paths; $this->old_paths = array(); foreach ( $captured as $id => $old_path ) { // The ancestor chain changed under these, so their cached objects can be stale. clean_post_cache( $id ); $post = get_post( $id ); if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { continue; } $new_path = get_page_uri( $id ); if ( '' === $old_path || $new_path === $old_path ) { continue; } PathRedirectStore::record( $old_path, $id ); // This page now occupies an address something used to redirect away from. Leaving // that row would send the visitor away from the page they correctly asked for. PathRedirectStore::forget( $new_path ); } } /** * Drop recorded addresses for a post being permanently deleted. * * Trashing deliberately does not do this — a trashed page can be restored, and its addresses * should come back with it. Until then the resolver refuses to redirect to it. * * @param int $post_id Post being deleted. * @return void */ public function forget_post( $post_id ) { PathRedirectStore::forget_post( $post_id ); } /** * On a 404, send the visitor where the page went. * * @return void */ public function maybe_redirect() { if ( ! is_404() ) { return; } $target = PathRedirectResolver::resolve( PathRedirectResolver::requested_path() ); if ( null === $target ) { return; } // Only leave if the redirect was actually sent. Something on the wp_redirect filter can // veto it, and exiting anyway would turn a vetoed redirect into a blank page. if ( wp_safe_redirect( $target, 301 ) ) { exit; } } /** * Published descendants of a page, depth-first. * * @param \WP_Post $post Ancestor. * @return int[] */ private function descendants_of( \WP_Post $post ) { global $wpdb; $found = array(); $queue = array( (int) $post->ID ); $seen = array( (int) $post->ID => true ); while ( ! empty( $queue ) ) { $parent = array_shift( $queue ); $children = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d AND post_type = %s AND post_status = 'publish'", $parent, $post->post_type ) ); foreach ( $children as $child_id ) { $child_id = (int) $child_id; // A malformed parent chain must not spin forever. if ( isset( $seen[ $child_id ] ) ) { continue; } $seen[ $child_id ] = true; $found[] = $child_id; $queue[] = $child_id; } } return $found; } /** * The recorded addresses, for the settings screen. * * @return array */ public function status() { $rows = array(); foreach ( PathRedirectStore::all( 50 ) as $row ) { $permalink = get_permalink( (int) $row['post_id'] ); $post = get_post( (int) $row['post_id'] ); $live = $post instanceof \WP_Post && 'publish' === $post->post_status; $rows[] = array( array( 'text' => '/' . $row['old_path'], 'tone' => 'mono', ), $live && $permalink ? PathRedirectResolver::path_of( $permalink ) : __( 'not redirecting — the page is not published', 'adminkeep' ), ); } return array( 'columns' => array( __( 'Old address', 'adminkeep' ), __( 'Now goes to', 'adminkeep' ), ), 'rows' => $rows, 'empty' => __( 'No addresses recorded yet. One appears here the first time you rename or move a published page.', 'adminkeep' ), ); } }