'page',
'post_status' => array('publish', 'draft', 'private'),
'meta_key' => self::META_KEY,
'meta_value' => '1',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
));
if (!empty($pages) && is_array($pages)) {
return (int) $pages[0];
}
return 0;
}
/**
* Check whether the system page exists and is published.
*
* @return bool
*/
public function systemPageExists(): bool {
$pageId = $this->getSystemPageId();
if ($pageId <= 0) {
return false;
}
return get_post_status($pageId) === 'publish';
}
/**
* Ensure the system page exists and is published. If the page already
* exists and is published, return its ID. Otherwise, republish or create it.
*
* @return int The page ID, or 0 on failure.
*/
public function ensureSystemPage(): int {
$existingId = $this->getSystemPageId();
if ($existingId > 0 && get_post_status($existingId) === 'publish') {
return $existingId;
}
// If the page exists but is trashed/drafted, try to republish it
if ($existingId > 0) {
$result = wp_update_post(array(
'ID' => $existingId,
'post_status' => 'publish',
), true);
if (!is_wp_error($result)) {
return $existingId;
}
}
return $this->createSystemPage();
}
/**
* Legacy alias for callers that still use the old read-like name.
*
* @return int The page ID, or 0 on failure.
*/
public function getOrCreateSystemPage(): int {
return $this->ensureSystemPage();
}
/**
* Create a fresh system page with the shortcode.
*
* @return int The new page ID, or 0 on failure.
*/
public function createSystemPage(): int {
$title = __('Page Not Found', '404-solution');
$pageId = wp_insert_post(array(
'post_title' => $title,
'post_content' => '[abj404_solution_page_suggestions]',
'post_status' => 'publish',
'post_type' => 'page',
'post_author' => get_current_user_id() ?: 1,
'comment_status' => 'closed',
'ping_status' => 'closed',
), true);
if (is_wp_error($pageId)) {
return 0;
}
// Tag with system page meta
update_post_meta($pageId, self::META_KEY, '1');
// Exclude from sitemaps
update_post_meta($pageId, '_yoast_wpseo_meta-robots-noindex', '1');
update_post_meta($pageId, 'rank_math_robots', array('noindex'));
return (int) $pageId;
}
/**
* Delete the system page permanently.
*
* @return bool True if deleted, false otherwise.
*/
public function deleteSystemPage(): bool {
$pageId = $this->getSystemPageId();
if ($pageId <= 0) {
return false;
}
$result = wp_delete_post($pageId, true);
return $result !== false && $result !== null;
}
/**
* Check if a given post ID is the system page.
*
* @param int $postId
* @return bool
*/
public static function isSystemPage(int $postId): bool {
if ($postId <= 0) {
return false;
}
return get_post_meta($postId, self::META_KEY, true) === '1';
}
/**
* When the system page is deleted or trashed externally, flip the behavior
* setting to 'theme_default' and set a transient for the admin notice.
*
* @return void
*/
public function handleSystemPageDeleted(): void {
$logic = abj_service('plugin_logic');
$options = abj_service('options_repository')->getOptions(true);
if (isset($options['dest404_behavior']) && $options['dest404_behavior'] === 'suggest') {
$options['dest404_behavior'] = 'theme_default';
$options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
abj_service('options_repository')->updateOptions($options);
set_transient('abj404_system_page_deleted', '1', DAY_IN_SECONDS);
}
}
/**
* On each 404 hit, verify that the system page still exists when behavior is 'suggest'.
* This catches bulk deletes, DB restores, cleanup plugins, etc.
*
* @return void
*/
public function verifySystemPageOnRequest(): void {
$logic = abj_service('plugin_logic');
$options = abj_service('options_repository')->getOptions(true);
if (!isset($options['dest404_behavior']) || $options['dest404_behavior'] !== 'suggest') {
return;
}
if (!$this->systemPageExists()) {
$this->handleSystemPageDeleted();
}
}
/**
* Hook: before_delete_post / wp_trash_post - detect when system page is trashed/deleted.
*
* @param int $postId
* @return void
*/
public static function onPostDeleteOrTrash(int $postId): void {
if (!self::isSystemPage($postId)) {
return;
}
$instance = self::getInstance();
$instance->handleSystemPageDeleted();
}
/**
* Hook: admin_notices on plugin settings page - show notice if system page was deleted.
*
* @return void
*/
public static function maybeShowDeletedPageNotice(): void {
if (get_transient('abj404_system_page_deleted') !== '1') {
return;
}
// Only show on our plugin's settings page
$page = ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => 'page'));
if ($page !== ABJ404_PP) {
return;
}
$settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
$recreateUrl = wp_nonce_url(
add_query_arg('abj404_recreate_system_page', '1', $settingsUrl),
'abj404_recreate_system_page'
);
$tpl = ABJ_404_Solution_FileSystemService::readFileContents(
dirname(__DIR__) . '/html/systemPageDeletedAdminNotice.html'
);
$body = esc_html__('Your 404 suggestion page was deleted.', '404-solution')
. ' ' . esc_html__('Recreate it', '404-solution') . ''
. ' ' . esc_html__('or choose a different option below.', '404-solution');
echo strtr($tpl, ['{message_with_link}' => $body]);
delete_transient('abj404_system_page_deleted');
}
/**
* Hook: admin_init - handle the recreate system page action.
*
* @return void
*/
public static function handleRecreateAction(): void {
if (!isset($_GET['abj404_recreate_system_page'])) {
return;
}
if (!ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
return;
}
if (!wp_verify_nonce(
ABJ_404_Solution_RequestInputNormalizer::readText($_GET, array('name' => '_wpnonce')),
'abj404_recreate_system_page'
)) {
return;
}
$instance = self::getInstance();
$pageId = $instance->createSystemPage();
if ($pageId > 0) {
$logic = abj_service('plugin_logic');
$options = abj_service('options_repository')->getOptions(true);
$options['dest404_behavior'] = 'suggest';
$options['dest404page'] = $pageId . '|' . ABJ404_TYPE_POST;
abj_service('options_repository')->updateOptions($options);
}
// Redirect back to settings page (without the action param)
$settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
wp_safe_redirect($settingsUrl);
exit;
}
/**
* Hook: edit_form_after_title - show admin notice when editing the system page.
*
* @param \WP_Post $post
* @return void
*/
public static function showEditorNotice($post): void {
if (!self::isSystemPage($post->ID)) {
return;
}
$settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
$tpl = ABJ_404_Solution_FileSystemService::readFileContents(
dirname(__DIR__) . '/html/systemPageEditorNotice.html'
);
$body = esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution')
. ' ' . esc_html__('Learn more', '404-solution') . '';
echo strtr($tpl, ['{message_with_link}' => $body]);
}
/**
* Hook: wp_robots - add noindex to the system page.
*
* @param array $robots
* @return array
*/
public static function addNoindexToSystemPage($robots) {
if (!is_page()) {
return $robots;
}
$postId = get_the_ID();
if ($postId && self::isSystemPage($postId)) {
$robots['noindex'] = true;
}
return $robots;
}
/**
* Hook: wp_page_menu_args / wp_get_nav_menu_items - exclude system page from nav menus.
*
* @param array $args
* @return array
*/
public static function excludeFromPageMenu($args) {
$pageId = self::getInstance()->getSystemPageId();
if ($pageId > 0) {
$existing = isset($args['exclude']) ? $args['exclude'] : '';
$excludeList = $existing !== '' ? $existing . ',' . $pageId : (string) $pageId;
$args['exclude'] = $excludeList;
}
return $args;
}
/**
* Hook: template_redirect - show admin-only banner when system page is deleted
* and an admin visits a 404 page.
*
* @return void
*/
public static function maybeShowAdminFrontend404Banner(): void {
if (!is_404()) {
return;
}
if (!is_user_logged_in() || !ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin()) {
return;
}
// Check if behavior was 'suggest' but page was deleted
if (get_transient('abj404_system_page_deleted') !== '1') {
return;
}
$settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
add_action('wp_footer', function() use ($settingsUrl) {
/* allow-hardcoded-color: wp_footer front-end banner; admin theme vars are not loaded on the public site */
$tpl = ABJ_404_Solution_FileSystemService::readFileContents(
dirname(__DIR__) . '/html/systemPageAdminFrontend404Banner.html'
);
echo strtr($tpl, [
'{message}' => esc_html__('Your 404 Solution suggestion page was deleted. Visitors are seeing this default 404 page instead.', '404-solution'),
'{settings_url}' => esc_url($settingsUrl),
'{settings_label}' => esc_html__('Go to settings', '404-solution'),
]);
});
}
/**
* Hook: enqueue_block_editor_assets - show notice in the block editor when editing the system page.
*
* Uses the wp.data notices store to create an info notice at the top of the block editor.
*
* @return void
*/
public static function enqueueBlockEditorNotice(): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->base !== 'post') {
return;
}
// Check if the current post is the system page.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$postId = isset($_GET['post']) ? (int) $_GET['post'] : 0;
if (!$postId || !self::isSystemPage($postId)) {
return;
}
$settingsUrl = admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options');
$message = esc_html__('This page is used by 404 Solution to display suggested pages to visitors.', '404-solution');
$linkText = esc_html__('Learn more', '404-solution');
wp_add_inline_script('wp-edit-post', sprintf(
'wp.domReady(function(){' .
'wp.data.dispatch("core/notices").createNotice("info",%s+%s,{id:"abj404-system-page-notice",isDismissible:false,' .
'actions:[{label:%s,url:%s}]});' .
'});',
// Encoded through ABJ_404_Solution_JsonResponseEncoder rather than
// wp_json_encode(): $message and $linkText are __() results, so a
// co-plugin hooked on `gettext` can make wp_json_encode() return
// false. sprintf('%s') renders false as '', and the emitted line
// becomes createNotice("info",+,{...}) -- a syntax error that takes
// the whole block editor script with it.
ABJ_404_Solution_JsonResponseEncoder::encode($message . ' ')->json(),
ABJ_404_Solution_JsonResponseEncoder::encode('')->json(),
ABJ_404_Solution_JsonResponseEncoder::encode($linkText)->json(),
ABJ_404_Solution_JsonResponseEncoder::encode($settingsUrl)->json()
));
}
/**
* Register all WordPress hooks for system page management.
*
* @return void
*/
public static function registerHooks(): void {
// Deletion protection
add_action('before_delete_post', array(__CLASS__, 'onPostDeleteOrTrash'));
add_action('wp_trash_post', array(__CLASS__, 'onPostDeleteOrTrash'));
// Admin notices
add_action('admin_notices', array(__CLASS__, 'maybeShowDeletedPageNotice'));
// Recreate action
add_action('admin_init', array(__CLASS__, 'handleRecreateAction'));
// Editor notice (classic editor)
add_action('edit_form_after_title', array(__CLASS__, 'showEditorNotice'));
// Editor notice (block editor / Gutenberg)
add_action('enqueue_block_editor_assets', array(__CLASS__, 'enqueueBlockEditorNotice'));
// Exclude from sitemaps (WordPress 5.5+ native robots)
add_filter('wp_robots', array(__CLASS__, 'addNoindexToSystemPage'));
// Exclude from page menus
add_filter('wp_page_menu_args', array(__CLASS__, 'excludeFromPageMenu'));
// Admin-only frontend 404 banner
add_action('template_redirect', array(__CLASS__, 'maybeShowAdminFrontend404Banner'));
}
}