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 uninstall the plugin 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' ), ); } }