plugin that usually registers it. Only a hint; filter: nmrhtbl_sources. */ private const SOURCES = [ 'bp-email' => 'BuddyPress', 'buddypress' => 'BuddyPress', 'forum' => 'bbPress / BuddyPress', 'topic' => 'bbPress / BuddyPress', 'reply' => 'bbPress / BuddyPress', 'product' => 'WooCommerce', 'product_variation' => 'WooCommerce', 'shop_order' => 'WooCommerce', 'shop_order_refund' => 'WooCommerce', 'shop_coupon' => 'WooCommerce', 'shop_order_placehold' => 'WooCommerce', 'tribe_events' => 'The Events Calendar', 'tribe_venue' => 'The Events Calendar', 'tribe_organizer' => 'The Events Calendar', 'tribe-ea-record' => 'The Events Calendar', 'wpcf7_contact_form' => 'Contact Form 7', 'flamingo_contact' => 'Flamingo', 'flamingo_inbound' => 'Flamingo', 'flamingo_outbound' => 'Flamingo', 'elementor_library' => 'Elementor', 'acf-field-group' => 'Advanced Custom Fields', 'acf-field' => 'Advanced Custom Fields', 'acf-post-type' => 'Advanced Custom Fields', 'acf-taxonomy' => 'Advanced Custom Fields', 'tablepress_table' => 'TablePress', 'give_forms' => 'GiveWP', 'wpforms' => 'WPForms', 'nf_sub' => 'Ninja Forms', 'vc_grid_item' => 'WPBakery Page Builder', 'vc4_templates' => 'WPBakery Page Builder', 'essential_grid' => 'Essential Grid', 'sfwd-courses' => 'LearnDash', 'sfwd-lessons' => 'LearnDash', 'download' => 'Easy Digital Downloads', 'edd_payment' => 'Easy Digital Downloads', 'edd_discount' => 'Easy Digital Downloads', 'services' => 'a theme (typical for ThemeREX / RunCrew)', 'team' => 'a theme (typical for ThemeREX / RunCrew)', 'testimonial' => 'a theme (typical for ThemeREX / RunCrew)', 'clients' => 'a theme', 'portfolio' => 'a theme', ]; /** Phrases typical for placeholder text in theme demo content (lower case). Filter: nmrhtbl_demo_markers. */ private const DEMO_MARKERS = [ 'lorem ipsum', 'dolor sit amet', 'sed ut perspiciatis', 'consectetuer adipiscing', 'consectetur adipiscing', ]; /** Is the Pro add-on (nmr-html-blocks-pro) running? */ static function pro_active(): bool { return class_exists('NMRHTBL_Pro'); } static function init(): void { if (!is_admin()) return; // Late: every theme and plugin has had its chance to register its own types. add_action('init', [self::class, 'register_orphans'], 99); add_action('admin_menu', [self::class, 'admin_menu']); // After WordPress has added the per-type submenus: put the overview first. add_action('admin_menu', [self::class, 'order_menu'], 999); add_action('admin_post_nmrhtbl_export', [self::class, 'export']); add_action('admin_post_nmrhtbl_save_settings', [self::class, 'save_settings']); add_action('load-plugins.php', [self::class, 'plugins_screen']); add_filter('plugin_action_links_' . plugin_basename(__FILE__), [self::class, 'action_links']); } // ── Discovery ───────────────────────────────────────────────────────────── /** @return array}> keyed by post type */ static function find_orphans(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one grouped count over all post types; no WP API does this, and the result is used once per request $rows = $wpdb->get_results( "SELECT post_type, post_status, COUNT(*) AS n FROM {$wpdb->posts} GROUP BY post_type, post_status" ); $skip = apply_filters('nmrhtbl_exclude', self::INTERNAL); $out = []; foreach ((array) $rows as $r) { if (in_array($r->post_type, $skip, true) || post_type_exists($r->post_type)) continue; if ($r->post_status === 'auto-draft') continue; $out[$r->post_type]['statuses'][$r->post_status] = (int) $r->n; $out[$r->post_type]['total'] = ($out[$r->post_type]['total'] ?? 0) + (int) $r->n; } ksort($out); return $out; } /** Orphans found at registration time; after that they are "registered" by this plugin. */ private static ?array $found = null; static function register_orphans(): void { self::$found = self::find_orphans(); foreach (self::$found as $type => $info) { // register_post_type() accepts 1-20 chars: [a-z0-9_-] if (strlen($type) > 20 || $type !== sanitize_key($type)) continue; $label = ucwords(str_replace(['-', '_'], ' ', $type)); register_post_type($type, [ 'labels' => [ 'name' => $label . ' (orphan)', 'singular_name' => $label, 'all_items' => $label . ' (' . (int) $info['total'] . ')', ], // Admin only: no front end URL, no search, no archive, no REST (classic editor, // so old shortcodes are not wrapped in blocks on save). 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'show_in_rest' => false, 'show_ui' => true, 'show_in_menu' => self::SCREEN, // all types under one main menu entry 'show_in_admin_bar' => false, 'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'page-attributes', 'revisions'], 'capability_type' => 'post', 'capabilities' => self::capabilities(), 'map_meta_cap' => true, ]); } } /** Administrators only. */ private static function capabilities(): array { $keys = [ 'edit_posts', 'edit_others_posts', 'publish_posts', 'read_private_posts', 'create_posts', 'delete_posts', 'delete_private_posts', 'delete_published_posts', 'delete_others_posts', 'edit_private_posts', 'edit_published_posts', ]; return array_fill_keys($keys, 'manage_options'); } // ── Overview screen ─────────────────────────────────────────────────────── static function admin_menu(): void { add_menu_page('NMR HTML Blocks', 'NMR HTML Blocks', 'manage_options', self::SCREEN, [self::class, 'screen'], 'dashicons-archive', 80); add_submenu_page(self::SCREEN, 'NMR HTML Blocks', 'Overview', 'manage_options', self::SCREEN, [self::class, 'screen']); } static function order_menu(): void { global $submenu; if (empty($submenu[self::SCREEN])) return; $first = []; $rest = []; foreach ($submenu[self::SCREEN] as $item) { if ($item[2] === self::SCREEN) $first[] = $item; else $rest[] = $item; } $submenu[self::SCREEN] = array_merge(array_slice($first, 0, 1), $rest); } static function action_links(array $links): array { array_unshift($links, 'Open'); return $links; } static function screen(): void { if (!current_user_can('manage_options')) wp_die(esc_html__('Sorry, you are not allowed to access this page.', 'nmr-html-blocks')); // Pro (or any add-on) can add tabs: filter the list, then draw the tab on nmrhtbl_render_tab_{slug}. $tabs = apply_filters('nmrhtbl_tabs', ['impact' => 'Plugin impact', 'orphans' => 'Orphan post types']); $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'impact'; // phpcs:ignore WordPress.Security.NonceVerification if (!self::pro_active()) $tabs += ['fix' => 'Fix it (Pro)', 'retire' => 'Retire pages (Pro)', 'simulate' => 'Simulate (Pro)']; // teasers only, no functionality if (!isset($tabs[$tab])) $tab = 'impact'; $base = admin_url('admin.php?page=' . self::SCREEN); echo '

NMR HTML Blocks

'; if ($tab !== 'orphans') self::lost_data_banner($base . '&tab=orphans'); if ($tab === 'orphans') self::tab_orphans(); elseif ($tab === 'impact') self::tab_impact(); elseif (!self::pro_active() && in_array($tab, ['simulate', 'fix', 'retire'], true)) self::tab_pro_teaser($tab); else do_action('nmrhtbl_render_tab_' . $tab); echo '
'; } // ── Tab: orphan post types ──────────────────────────────────────────────── private static function tab_orphans(): void { $orphans = self::$found ?? self::find_orphans(); echo '

Post types that exist in the database but are not registered by the active theme or plugins. All of them are listed under this menu entry, with no public URL. Review and edit them there, export them, or delete them.

'; if (!$orphans) { echo '

No orphan post types found.

'; return; } $mostly_demo = []; echo ''; foreach ($orphans as $type => $info) { $parts = []; foreach ($info['statuses'] as $status => $n) $parts[] = esc_html($status) . ': ' . (int) $n; if (strlen($type) > 20 || $type !== sanitize_key($type)) { $link = 'cannot be registered (name longer than 20 characters or invalid)'; } else { $link = 'Open list'; } $demo = self::count_demo($type); if ($demo > 0 && $demo >= $info['total'] / 2) $mostly_demo[] = $type; $demo_cell = $demo ? (int) $demo . ' of ' . (int) $info['total'] : '–'; echo ''; } echo '
Post typeLikely sourcePostsBy statusPlaceholder textAdminExport
' . esc_html($type) . '' . (self::source_of($type) !== '' ? esc_html(self::source_of($type)) : '–') . '' . (int) $info['total'] . '' . wp_kses_post(implode(', ', $parts)) . '' . wp_kses_post($demo_cell) . '' . wp_kses_post($link) . '' . 'CSV · JSON
'; if ($mostly_demo) { echo '

Looks like theme demo content: ' . esc_html(implode(', ', $mostly_demo)) . ' (half or more of the posts contain placeholder text such as “lorem ipsum”). This is a guess from the text only. Open the list, check a few posts, export a copy, and delete them if they are not real content.

'; } self::shortcodes_section($orphans); echo '

CSV has one row per post (no content). JSON includes the content and the custom fields. Custom fields appear in the Custom Fields box (enable it in Screen Options). Old page builder shortcodes show as plain text in the editor. Back up the database before bulk deleting.

'; } // ── Plugins screen: warn before deactivating a plugin that live content uses ── const OPT_CONFIRM = 'nmrhtbl_confirm'; /** @var array|null plugin file => usage */ private static ?array $usage = null; /** Above this many published items a scan is not started automatically on the Plugins screen. */ const AUTO_SCAN_LIMIT = 20000; static function plugins_screen(): void { if (!current_user_can('activate_plugins')) return; self::$usage = self::plugin_usage(); if (self::$usage === null) { add_action('admin_notices', static function () { echo '

NMR HTML Blocks: this site is large, so usage warnings are off until you run the scan once.

'; }); return; } add_filter('plugin_row_meta', [self::class, 'row_meta'], 10, 2); add_filter('plugin_action_links', [self::class, 'deactivate_link'], 99, 2); add_action('admin_enqueue_scripts', [self::class, 'plugins_script']); } /** @return array|null null = not computed (large site, no cached scan) */ private static function plugin_usage(): ?array { global $wpdb; $scan = get_transient(self::LIVE_CACHE); if (!is_array($scan) || !isset($scan['sc'], $scan['bl'])) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plain count, run only when the Plugins screen opens $n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status IN ('publish','private')"); if ($n > self::AUTO_SCAN_LIMIT) return null; $scan = self::scan_live(); } [$groups] = self::build_impact($scan); if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; $by_slug = []; foreach (get_plugins() as $file => $data) { $by_slug[strpos($file, '/') === false ? $file : strstr($file, '/', true)] = [$file, $data['Name']]; } $out = []; foreach ($groups as $g) { if ($g['p']['kind'] !== 'plugin' || !isset($by_slug[$g['p']['slug']])) continue; [$file, $name] = $by_slug[$g['p']['slug']]; $ids = array_values(array_unique($g['ids'])); $public = array_values(array_filter($ids, [self::class, 'is_public'])); $items = []; foreach ($g['items'] as $it) $items[] = $it['label']; $out[$file] = [ 'name' => $name, 'items' => $items, 'ids' => $ids, 'pub' => count($public), 'titles' => array_map(static fn($id) => wp_strip_all_tags(get_the_title($id)) ?: '#' . $id, array_slice($public, 0, 5)), ]; } return $out; } static function row_meta(array $meta, string $file): array { $u = self::$usage[$file] ?? null; if (!$u) return $meta; $shown = array_slice($u['items'], 0, 4); $meta[] = 'Used in ' . count($u['ids']) . ' posts (' . (int) $u['pub'] . ' public): ' . esc_html(implode(' ', $shown)) . (count($u['items']) > 4 ? ' …' : '') . ' ' . '
Show' . self::post_links($u['ids']) . '
'; return $meta; } private static function confirm_text(string $file): string { $u = self::$usage[$file]; $text = sprintf("Deactivating %s will break %d post(s), %d of them public, that use its shortcodes or blocks:\n", $u['name'], count($u['ids']), $u['pub']); foreach ($u['titles'] as $t) $text .= "\n• " . $t; if ($u['pub'] > count($u['titles'])) $text .= "\n• and " . ($u['pub'] - count($u['titles'])) . ' more'; return $text . "\n\nBased on shortcodes and dynamic blocks in your content only. Continue?"; } private static function wants_confirm(string $file): bool { return get_option(self::OPT_CONFIRM, '1') === '1' && !empty(self::$usage[$file]['pub']); } static function deactivate_link(array $actions, string $file): array { if (isset($actions['deactivate']) && self::wants_confirm($file)) { $actions['deactivate'] = preg_replace('/^ true])), ['wp_block'])), ['attachment'])); $ph = implode(',', array_fill(0, count($types), '%s')); $sc = []; $bl = []; $count = 0; $last = 0; $cap = 5000; do { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ph is a list of %s placeholders; scanning content in batches cannot use a WP API $rows = $wpdb->get_results($wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_type IN ($ph) AND post_status IN ('publish','private') AND ID > %d ORDER BY ID LIMIT 200", array_merge($types, [$last]) )); // phpcs:enable foreach ((array) $rows as $r) { $last = (int) $r->ID; $count++; $txt = (string) $r->post_content; if (preg_match_all('/(? $n) { $sc[$tag]['n'] = ($sc[$tag]['n'] ?? 0) + $n; if (isset($closers[$tag]) || isset($withat[$tag])) $sc[$tag]['strong'] = ($sc[$tag]['strong'] ?? 0) + $n; if (count($sc[$tag]['ids'] ?? []) < $cap) $sc[$tag]['ids'][] = $last; } } if (preg_match_all('/