prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * Admin * * Registers admin menus and enqueues admin assets. */ class Admin { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register(): void { add_action( 'admin_menu', [ $this, 'register_menu' ] ); add_action( 'admin_menu', [ $this, 'group_menu' ], 999 ); add_action( 'admin_enqueue_scripts', [ $this, 'menu_assets' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); add_action( 'admin_init', [ $this, 'handle_settings_save' ], 1 ); } public function handle_settings_save(): void { if ( ! isset( $_POST['clusterlane_save_settings'] ) ) return; if ( ! check_admin_referer( 'clusterlane_settings_save' ) ) return; if ( ! \Clusterlane\Core\Capabilities::can_manage() ) return; require_once \CLUSTERLANE_DIR . 'intelligence/admin/class-settings-page.php'; $page = new \Clusterlane\Admin\SettingsPage( $this->plugin ); $page->handle_save(); $args = [ 'settings-updated' => '1' ]; if ( $page->recomputed ) $args['recomputed'] = '1'; $tab = sanitize_key( $_POST['clusterlane_tab'] ?? '' ); if ( in_array( $tab, [ 'general', 'content', 'styles', 'advanced' ], true ) ) $args['tab'] = $tab; // come back to the same tab wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php?page=clusterlane-settings' ) ) ); exit; } public function register_menu(): void { // The station mark (as on clusterlane.app), or the white-label logo — see Brand. $icon_url = \Clusterlane\Core\Brand::menu_icon(); add_menu_page( \Clusterlane\Core\Brand::name(), \Clusterlane\Core\Brand::name(), \Clusterlane\Core\Capabilities::USE, 'clusterlane-dashboard', [ $this, 'render_dashboard' ], $icon_url, 30 ); add_submenu_page( 'clusterlane-dashboard', __( 'Dashboard', 'clusterlane' ), __( 'Dashboard', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-dashboard', [ $this, 'render_dashboard' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Cluster Inspector', 'clusterlane' ), __( 'Cluster Inspector', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-cluster-inspector', [ $this, 'render_cluster_inspector' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Article Inspector', 'clusterlane' ), __( 'Article Inspector', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-article-inspector', [ $this, 'render_article_inspector' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Text Match', 'clusterlane' ), __( 'Text Match', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-text-match', [ $this, 'render_text_match' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Link Suggestions', 'clusterlane' ), __( 'Link Suggestions', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-link-suggest', [ $this, 'render_link_suggest' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'SEO Titles', 'clusterlane' ), __( 'SEO Titles', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-seo-titles', [ $this, 'render_seo_titles' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Report', 'clusterlane' ), __( 'Report', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-report', [ $this, 'render_report' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Cluster Admin', 'clusterlane' ), __( 'Cluster Admin', 'clusterlane' ), \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-cluster-settings', [ $this, 'render_cluster_settings' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Settings', 'clusterlane' ), __( 'Settings', 'clusterlane' ), \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-settings', [ $this, 'render_settings' ] ); add_submenu_page( 'clusterlane-dashboard', __( 'Help', 'clusterlane' ), __( 'Help', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-help', [ $this, 'render_help' ] ); } /** * Left menu in groups (0.98.146): the pages used most on top, then * TOOLS and ADMIN as small labels, the way WordPress plugins group a long * submenu (submenus have one level — no icons or indents). A label only * shows to users who can open a page under it; items other code added * keep their place at the end. */ public function group_menu(): void { global $submenu; if ( empty( $submenu['clusterlane-dashboard'] ) ) return; $items = []; foreach ( $submenu['clusterlane-dashboard'] as $it ) $items[ $it[2] ] = $it; $label = static fn( string $text, string $cap, string $id ) => [ '' . esc_html( $text ) . '', $cap, '#' . $id, '', 'clusterlane-menu-group' ]; $order = [ 'clusterlane-dashboard', 'clusterlane-cluster-inspector', 'clusterlane-article-inspector', 'clusterlane-report', 'clusterlane-help', $label( __( 'Tools', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-group-tools' ), 'clusterlane-text-match', 'clusterlane-link-suggest', 'clusterlane-seo-titles', $label( __( 'Admin', 'clusterlane' ), \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-group-admin' ), 'clusterlane-cluster-settings', 'clusterlane-settings', ]; $out = []; foreach ( $order as $o ) { if ( is_array( $o ) ) { $out[] = $o; continue; } if ( isset( $items[ $o ] ) ) { $out[] = $items[ $o ]; unset( $items[ $o ] ); } } $submenu['clusterlane-dashboard'] = array_merge( $out, array_values( $items ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- reordering our own submenu } /** * The group labels in the admin menu (Tools, Admin) — on every admin page, * where the menu is: their style goes with the core menu stylesheet, and * the script that takes them out of the tab order (no href, read as a * group heading) runs in the footer, after the menu. */ public function menu_assets(): void { wp_add_inline_style( 'admin-menu', '#adminmenu .clusterlane-menu-group a{pointer-events:none;cursor:default;padding-top:10px !important;padding-bottom:2px !important;box-shadow:none !important}' . '#adminmenu .clusterlane-menu-group-label{font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;opacity:.6}' . '#adminmenu .wp-submenu .clusterlane-menu-group a:hover,#adminmenu .wp-submenu .clusterlane-menu-group a:focus{color:inherit;background:none}' ); wp_register_script( 'clusterlane-menu', false, [], CLUSTERLANE_VERSION, true ); wp_enqueue_script( 'clusterlane-menu' ); wp_add_inline_script( 'clusterlane-menu', "document.querySelectorAll('#adminmenu .clusterlane-menu-group a').forEach(function(a){a.removeAttribute('href');a.setAttribute('role','heading');a.setAttribute('aria-level','3');a.tabIndex=-1;});" ); } /** * admin.min.css (built from admin.css with clean-css, level 1) unless * SCRIPT_DEBUG is on or the minified file is missing — then the source. */ private static function admin_css(): string { $min = ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) && is_readable( CLUSTERLANE_DIR . 'admin/css/admin.min.css' ); return $min ? 'admin/css/admin.min.css' : 'admin/css/admin.css'; } /** * Onboarding step 2 (0.98.177): post types with published pages (pre-ticked: * the scanned ones and any with 5+ pages, pages aside), whether their text has * links, and cluster candidates from categories and every other hierarchical * taxonomy of that content (2+ pages each). * * @return array{types: array, groups: array}>, hubs: array} */ private function onboarding_data( \Clusterlane\Core\Settings $settings ): array { $objs = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' ); unset( $objs['attachment'] ); $counts = \Clusterlane\Core\PageReader::text_link_density( array_keys( $objs ) ); $scanned = $settings->post_types(); $types = []; foreach ( $objs as $name => $o ) { $n = (int) ( $counts[ $name ]['posts'] ?? 0 ); if ( ! $n ) continue; $types[] = [ 'name' => $name, 'label' => html_entity_decode( (string) $o->labels->name, ENT_QUOTES, 'UTF-8' ), 'count' => $n, 'checked' => in_array( $name, $scanned, true ) || ( $name !== 'page' && $n >= 5 ), 'recommended' => \Clusterlane\Core\PageReader::is_recommended( $counts[ $name ] ), ]; } usort( $types, fn( $a, $b ) => ( $a['name'] === 'post' ? -1 : ( $b['name'] === 'post' ? 1 : 0 ) ) ?: $b['count'] <=> $a['count'] ); $groups = []; $taxes = [ 'category' => get_taxonomy( 'category' ) ] + array_filter( \Clusterlane\Core\ClusterTypes::extra_taxonomies( array_keys( $objs ) ), fn( $t ) => $t->hierarchical ); foreach ( $taxes as $tax => $obj ) { if ( ! $obj ) continue; $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 200 ] ); if ( is_wp_error( $terms ) ) continue; $list = []; foreach ( $terms as $t ) { if ( (int) $t->count < 2 || ( $tax === 'category' && (int) $t->term_id === (int) get_option( 'default_category' ) && (int) $t->count < 5 ) ) continue; // Pre-ticked (0.98.185): the 15 largest categories with 3+ pages, so // "Save & continue" never gives an empty map by accident. $pick = $tax === 'category' && (int) $t->count >= 3 && count( array_filter( $list, fn( $x ) => $x['checked'] ) ) < 15; $list[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 'count' => (int) $t->count, 'checked' => $pick ]; } if ( ! $list ) continue; usort( $list, fn( $a, $b ) => strcasecmp( $a['name'], $b['name'] ) ); $groups[] = [ 'taxonomy' => $tax, 'label' => html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ), 'types' => array_values( (array) $obj->object_type ), 'terms' => $list, ]; } // Two taxonomies both called "Categories" (posts and a custom type): // name the content they belong to, "Categories · Attractions". if ( count( $groups ) > 1 ) { $labels = []; foreach ( $types as $t ) $labels[ $t['name'] ] = $t['label']; foreach ( $groups as &$g ) { $of = array_values( array_filter( array_map( fn( $pt ) => $labels[ $pt ] ?? '', $g['types'] ) ) ); if ( $of ) $g['label'] .= ' · ' . implode( ', ', $of ); } unset( $g ); } // Pages as clusters (0.98.180): post types the others point to through custom fields. $labels = []; foreach ( $types as $t ) $labels[ $t['name'] ] = $t['label']; $hubs = []; $cands = \Clusterlane\Core\HubClusters::candidates( array_keys( $objs ) ); foreach ( $cands as $pt => $c ) { $from = []; foreach ( $c['paths'] as $p ) $from[ $p['from'] ] = true; // Ticked from the start: the top of the hierarchy (islands), not what points to it (areas). $top = true; foreach ( $cands as $other => $oc ) { foreach ( $oc['paths'] as $p ) if ( $other !== $pt && $p['from'] === $pt && ! isset( $p['via'] ) ) $top = false; } $names = array_values( array_filter( array_map( fn( $t ) => $labels[ $t ] ?? '', array_keys( $from ) ) ) ); $hubs[] = [ 'type' => $pt, 'singular' => $c['singular'], 'label' => $c['label'], 'pages' => $c['pages'], 'with' => $names, 'types' => array_merge( [ $pt ], array_keys( $from ) ), 'checked' => $top ]; } return [ 'types' => $types, 'groups' => $groups, 'hubs' => $hubs ]; } public function enqueue_assets( string $hook ): void { if ( strpos( $hook, 'clusterlane-' ) === false ) return; // Network Admin overview: styles only — the site scripts and their // clusterlaneData belong to a single site's admin, not the network. if ( is_network_admin() ) { wp_enqueue_style( 'clusterlane-admin', CLUSTERLANE_URL . self::admin_css(), [], CLUSTERLANE_VERSION ); return; } // "?" tooltips — loaded in so every page script can call // window.clusterlaneTip() while rendering. Definitions: clusterlane_tip_definitions(). wp_enqueue_script( 'clusterlane-tips', CLUSTERLANE_URL . 'admin/js/clusterlane-tips.js', [], CLUSTERLANE_VERSION, false ); // Edition (Free/Pro/Agency) — also in so page scripts can ask // clusterlaneHas( 'ai' ). Free gets the example data shown instead of AI output. require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php'; wp_enqueue_script( 'clusterlane-edition', CLUSTERLANE_URL . 'admin/js/clusterlane-edition.js', [], CLUSTERLANE_VERSION, false ); wp_localize_script( 'clusterlane-edition', 'clusterlaneBrand', \Clusterlane\Core\Brand::to_js() ); wp_localize_script( 'clusterlane-edition', 'clusterlaneClusterTypes', \Clusterlane\Core\ClusterTypes::for_js() ); // 0.98.177: labels for any taxonomy wp_localize_script( 'clusterlane-edition', 'clusterlaneEditionData', array_merge( \Clusterlane\Core\Edition::for_js(), [ 'labels' => \Clusterlane\Admin\ProExamples::labels(), 'can' => \Clusterlane\Core\Capabilities::for_js() + [ 'linksNote' => \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::LINKS ), 'seoNote' => \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::SEO ), ], 'examples' => \Clusterlane\Core\Edition::has( 'ai' ) ? new \stdClass() : \Clusterlane\Admin\ProExamples::data(), ] ) ); wp_localize_script( 'clusterlane-tips', 'clusterlaneTipsData', [ 'defs' => clusterlane_tip_definitions(), 'glyphs' => clusterlane_glyph_paths(), 'thumbs' => \Clusterlane\Core\Thumbs::enabled() ? 1 : 0, 'labels' => [ 'good' => __( 'Good:', 'clusterlane' ), 'fix' => __( 'Improve:', 'clusterlane' ), /* translators: %s: term */ 'what' => __( 'What is %s?', 'clusterlane' ), ], ] ); $settings = $this->plugin->get( 'settings' ); // Onboarding trigger — computed here so wp_localize_script can use it. $show_onboarding = false; $ob_categories = []; $is_dashboard = strpos( $hook, 'clusterlane-dashboard' ) !== false || strpos( $hook, 'toplevel_page_clusterlane' ) !== false; if ( $is_dashboard && $settings && \Clusterlane\Core\Capabilities::can_manage() && ! $settings->cluster_terms_configured() ) { $scanner = $this->plugin->get( 'scanner' ); $summary = $scanner ? $scanner->get_summary() : []; if ( ( (int) ( $summary['total_scanned'] ?? 0 ) ) === 0 ) { $show_onboarding = true; $terms = get_categories( [ 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC', 'number' => 200 ] ); if ( ! is_wp_error( $terms ) ) { foreach ( $terms as $t ) { if ( (int) ( $t->count ?? 0 ) >= 2 ) { $ob_categories[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES ), 'count' => (int) $t->count ]; } } } } } wp_enqueue_style( 'clusterlane-admin', CLUSTERLANE_URL . self::admin_css(), [], CLUSTERLANE_VERSION ); // Sticky bar — loaded on every Clusterlane page. wp_enqueue_script( 'clusterlane-sticky-bar', CLUSTERLANE_URL . 'admin/js/clusterlane-sticky-bar.js', [], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-sticky-bar', 'clusterlaneStickyBar', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'articleInspectorUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ), 'clusterInspectorUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ), 'batchSize' => (int) ( $settings ? $settings->get( 'batch_size', 25 ) : 25 ), 'siteName' => get_bloginfo( 'name' ), 'pages' => \Clusterlane\Core\PageReader::status(), // published pages still to read (0.98.178) ] ); // vis-network 9.1.2 (minified, bundled — WordPress.org doesn't allow // loading scripts from other servers, so there is no CDN fallback). // The file once shipped as 0 bytes after a zip round-trip, which left // the graphs stuck on "Loading graph…" — so a broken copy raises a // clear admin notice instead of failing silently. $vis_path = CLUSTERLANE_DIR . 'admin/js/clusterlane-graph-lib.js'; wp_register_script( 'clusterlane-vis', CLUSTERLANE_URL . 'admin/js/clusterlane-graph-lib.js', [], '9.1.2', true ); if ( ! is_readable( $vis_path ) || (int) filesize( $vis_path ) < 100 * 1024 ) { add_action( 'admin_notices', function () { if ( ! current_user_can( 'manage_options' ) ) return; echo '

' . esc_html( \Clusterlane\Core\Brand::name() ) . ': ' . esc_html__( 'the bundled graph library (admin/js/clusterlane-graph-lib.js) is missing or incomplete, so graphs cannot be drawn. Upload the plugin again to restore it.', 'clusterlane' ) . '

'; } ); } // dashboard.js only on non-inspector pages; inspector has its own // clusterlaneData and doesn't need the dashboard init code running. if ( strpos( $hook, 'clusterlane-article-inspector' ) === false && strpos( $hook, 'clusterlane-cluster-inspector' ) === false ) { wp_enqueue_script( 'clusterlane-admin', CLUSTERLANE_URL . 'admin/js/dashboard.js', ['clusterlane-vis'], CLUSTERLANE_VERSION, true ); } else { // On the inspector page we still need vis-network — enqueue it directly. wp_enqueue_script( 'clusterlane-vis' ); } // Cluster quick view (0.98.200) — the eye button in Report's cluster table and the Cluster Inspector list. if ( strpos( $hook, 'clusterlane-report' ) !== false || strpos( $hook, 'clusterlane-cluster-inspector' ) !== false ) { wp_enqueue_script( 'clusterlane-cluster-quick', CLUSTERLANE_URL . 'admin/js/clusterlane-cluster-quick.js', [ 'clusterlane-tips' ], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-cluster-quick', 'clusterlaneQuickData', [ 'rest' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'inspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ), ] ); } // Report — Print button, "Show all clusters" and (Pro) the AI summary. if ( strpos( $hook, 'clusterlane-report' ) !== false ) { wp_enqueue_script( 'clusterlane-report', CLUSTERLANE_URL . 'admin/js/report.js', [ 'clusterlane-edition' ], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-report', 'clusterlaneReport', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ), 'canManage' => (int) \Clusterlane\Core\Capabilities::can_manage(), 'labels' => [ 'less' => __( 'Show fewer clusters', 'clusterlane' ), 'write' => __( 'Write summary with AI', 'clusterlane' ), 'rewrite' => __( 'Write again', 'clusterlane' ), 'writing' => __( 'Writing…', 'clusterlane' ), 'failed' => __( 'The summary could not be written.', 'clusterlane' ), 'toSettings' => __( 'Go to Settings →', 'clusterlane' ), /* translators: 1: model name, 2: date and time */ 'meta' => __( 'Written by %1$s · %2$s', 'clusterlane' ), 'stale' => __( 'The figures have changed since — write it again for an up-to-date summary.', 'clusterlane' ), ], ] ); } // Settings — tabs + save bar. if ( strpos( $hook, 'clusterlane-settings' ) !== false ) { wp_enqueue_script( 'clusterlane-settings-page', CLUSTERLANE_URL . 'admin/js/settings-page.js', [], CLUSTERLANE_VERSION, true ); } // Cluster Admin — save bar + custom cluster editor. if ( strpos( $hook, 'clusterlane-cluster-settings' ) !== false ) { wp_enqueue_script( 'clusterlane-cluster-admin', CLUSTERLANE_URL . 'admin/js/cluster-admin.js', [ 'clusterlane-admin' ], CLUSTERLANE_VERSION, true ); wp_add_inline_script( 'clusterlane-cluster-admin', 'window.clusterlaneData = window.clusterlaneData || {}; clusterlaneData.adminUrl = ' . wp_json_encode( admin_url() ) . ';', 'before' ); wp_enqueue_script( 'clusterlane-cluster-review', CLUSTERLANE_URL . 'admin/js/cluster-review.js', [ 'clusterlane-edition' ], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-cluster-review', 'clusterlaneReview', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'ciUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ), ] ); } // Text Match Scanner — only load on TMS page. if ( strpos( $hook, 'clusterlane-text-match' ) !== false ) { wp_enqueue_script( 'clusterlane-tms', CLUSTERLANE_URL . 'admin/js/text-match.js', [], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-tms', 'clusterlaneData', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => CLUSTERLANE_VERSION, 'settingsUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ), 'canManage' => (int) \Clusterlane\Core\Capabilities::can_manage(), 'adminUrl' => esc_url_raw( admin_url() ), 'skipped' => (int) $GLOBALS['wpdb']->get_var( "SELECT COUNT(*) FROM {$GLOBALS['wpdb']->prefix}clusterlane_tms_skipped" ), // phpcs:ignore 'categories' => ( function() { global $wpdb; $rows = $wpdb->get_results( "SELECT t.term_id AS id, t.name, tt.count FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id WHERE tt.taxonomy = 'category' AND tt.count > 0 ORDER BY t.name ASC LIMIT 200" ); return array_map( fn( $r ) => [ 'id' => (int) $r->id, 'name' => html_entity_decode( $r->name, ENT_QUOTES, 'UTF-8' ), 'count' => (int) $r->count, ], $rows ); } )(), ] ); } // Cluster Inspector — only load on its own page. if ( strpos( $hook, 'clusterlane-cluster-inspector' ) !== false ) { wp_enqueue_script( 'clusterlane-vis' ); wp_enqueue_script( 'clusterlane-cluster-inspector', CLUSTERLANE_URL . 'admin/js/cluster-inspector.js', [ 'clusterlane-vis' ], CLUSTERLANE_VERSION, true ); wp_enqueue_script( 'clusterlane-cluster-split', CLUSTERLANE_URL . 'admin/js/cluster-split.js', [ 'clusterlane-cluster-inspector' ], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-cluster-inspector', 'clusterlaneData', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => CLUSTERLANE_VERSION, 'inspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ), 'titleTrunc' => $settings ? $settings->title_truncation() : 'none', 'titleTruncCustom' => $settings ? $settings->title_truncation_custom() : '', 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ), 'canManage' => (int) \Clusterlane\Core\Capabilities::can_manage(), 'linkTemplates' => array_map( fn( $t ) => [ 'id' => $t['id'], 'name' => $t['name'] ], ( $settings ? $settings->link_templates() : [] ) ), ] ); } // SEO Titles (0.98.154) — clusterlaneData comes from clusterlane-admin below (restUrl, nonce, titleTrunc …). if ( strpos( $hook, 'clusterlane-seo-titles' ) !== false ) { wp_enqueue_script( 'clusterlane-seo-titles', CLUSTERLANE_URL . 'admin/js/seo-titles.js', [ 'clusterlane-admin' ], CLUSTERLANE_VERSION, true ); } // Article Inspector — only load on its own page. if ( strpos( $hook, 'clusterlane-article-inspector' ) !== false ) { wp_enqueue_script( 'clusterlane-article-inspector', CLUSTERLANE_URL . 'admin/js/article-inspector.js', [ 'clusterlane-vis' ], CLUSTERLANE_VERSION, true ); wp_localize_script( 'clusterlane-article-inspector', 'clusterlaneData', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => CLUSTERLANE_VERSION, 'titleTrunc' => $settings ? $settings->title_truncation() : 'none', 'titleTruncCustom' => $settings ? $settings->title_truncation_custom() : '', 'clusterInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ), 'linkSuggestUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-link-suggest' ) ), 'clusterAdminUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-settings' ) ), 'canManage' => (int) \Clusterlane\Core\Capabilities::can_manage(), ] ); } wp_localize_script( 'clusterlane-admin', 'clusterlaneData', [ 'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => CLUSTERLANE_VERSION, 'clusterInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ), 'articleInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ), 'titleTrunc' => $settings ? $settings->title_truncation() : 'none', 'titleTruncCustom' => $settings ? $settings->title_truncation_custom() : '', 'onboarding' => $show_onboarding, 'canManage' => (int) \Clusterlane\Core\Capabilities::can_manage(), 'onboardingCategories'=> $ob_categories, 'categories' => ( function() { global $wpdb; $rows = $wpdb->get_results( "SELECT t.term_id AS id, t.name, tt.count FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id WHERE tt.taxonomy = 'category' AND tt.count > 0 ORDER BY t.name ASC LIMIT 200" ); return array_map( fn( $r ) => [ 'id' => (int) $r->id, 'name' => html_entity_decode( $r->name, ENT_QUOTES, 'UTF-8' ), 'count' => (int) $r->count, ], $rows ); } )(), 'themeStyleUrl'=> esc_url_raw( get_stylesheet_uri() ), 'linkTemplates'=> array_map( fn( $t ) => [ 'id' => $t['id'], 'name' => $t['name'] ], ( $this->plugin->get('settings') ? $this->plugin->get('settings')->link_templates() : [] ) ), 'batchSize' => (int) ( $this->plugin->get('settings') ? $this->plugin->get('settings')->get('batch_size', 25) : 25 ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ), 'graphSettings' => array_merge( [ 'avoid_overlap' => 1.1, 'spring_length' => 180, 'central_gravity' => 0.12, 'repulsion' => 320, 'iterations' => 220, 'graph_height' => 420, ], (array) ( $this->plugin->get('settings') ? $this->plugin->get('settings')->get('graph_display', []) : [] ) ), 'i18n' => [ 'scanning' => __( 'Scanning…', 'clusterlane' ), 'complete' => __( 'Scan complete', 'clusterlane' ), 'error' => __( 'Error — check console', 'clusterlane' ), ], ] ); // Premium scripts (AI Link Suggestions, link modal, Link Card Templates, AI UI handlers). do_action( 'clusterlane_enqueue_assets', $hook, $this->plugin ); } /* ------------------------------------------------------------------ Shared sub-navigation bar (shown on every page except dashboard) ------------------------------------------------------------------ */ private function render_subnav( string $active ): void { clusterlane_render_sticky_bar( $active ); } /* ------------------------------------------------------------------ Dashboard ------------------------------------------------------------------ */ public function render_dashboard(): void { $settings = $this->plugin->get('settings'); $scanner = $this->plugin->get('scanner'); $engine = $this->plugin->get('cluster_engine'); $summary = $scanner ? $scanner->get_summary() : []; $csumm = $engine ? $engine->get_summary() : []; $clusters = $engine ? $engine->get_clusters() : []; $th = $settings ? $settings->get( 'thresholds', [] ) : []; $health_green = (int) ( $th['health_green'] ?? 70 ); $health_orange = (int) ( $th['health_orange'] ?? 50 ); $total = $summary['total_posts'] ?? 0; $scanned = $summary['total_scanned'] ?? 0; $score = $summary['avg_score'] ?? 0; $inbound = $summary['avg_inbound'] ?? 0; $orphans = $summary['orphan_count'] ?? 0; $last = $summary['last_scan'] ?? null; $total_clusters = $csumm['total_clusters'] ?? 0; $avg_health = $csumm['avg_health'] ?? 0; // Archetype distribution + a member-weighted health average for // the two new overview panels below (a 43-article cluster should // count for more than a 5-article one, unlike the simple // per-cluster average already shown in the KPI cards above). $arch_colors = [ 'Authority Pyramid' => '#3b82f6', 'Balanced Mesh' => '#10b981', 'Developed Cluster' => '#0ea5e9', 'Over-Centralized Hub' => '#ef4444', 'Fragmented' => '#f59e0b', 'Weak Structure' => '#8b5cf6', 'Emerging Cluster' => '#64748b', 'Mixed' => '#94a3b8', 'Unclassified' => '#cbd5e1', // never computed ]; $arch_counts = array_fill_keys( array_keys( $arch_colors ), 0 ); foreach ( $clusters as $c ) { $arch = $c->archetype ?: 'Unclassified'; if ( isset( $arch_counts[ $arch ] ) ) $arch_counts[ $arch ]++; } $avg_cluster_health = \Clusterlane\Core\ClusterEngine::weighted_health( $clusters ); $ring_color = $avg_cluster_health >= 70 ? '#10b981' : ( $avg_cluster_health >= 50 ? '#f59e0b' : '#ef4444' ); // Big ring for the hero (separate radius from the old standalone // panel version, which has been folded into the hero below). $hero_r = 54; $hero_circumference = 2 * M_PI * $hero_r; $hero_dash_offset = $hero_circumference - ( $avg_cluster_health / 100 ) * $hero_circumference; // Stacked-bar segments for the archetype mix — proportional to // the TOTAL cluster count (so segments sum to 100%), unlike the // old per-archetype mini bars which were each scaled to the // largest single archetype instead. $arch_total = array_sum( $arch_counts ) ?: 1; $ci_url = admin_url( 'admin.php?page=clusterlane-cluster-inspector' ); $ai_url = admin_url( 'admin.php?page=clusterlane-article-inspector' ); // Focus next on the Dashboard too (0.98.185) — the same pick as in Cluster // Inspector: clusters under 70% with 2+ articles, low health weighted by // size, plus 4 points per orphan. Dashboard is the start page, so the // next step belongs here as well. $focus = []; if ( $clusters ) { global $wpdb; $orph = []; foreach ( (array) $wpdb->get_results( "SELECT cluster_key, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE content_class = 'orphan' GROUP BY cluster_key" ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $orph[ $r->cluster_key ] = (int) $r->n; } foreach ( $clusters as $c ) { $h = (int) $c->health_score; $m = (int) $c->total_members; $o = $orph[ $c->cluster_key ] ?? 0; if ( $h >= $health_green || $m < 2 || ! (int) ( $c->is_active ?? 1 ) ) continue; $focus[] = [ 'key' => $c->cluster_key, 'label' => $c->cluster_label, 'h' => $h, 'm' => $m, 'o' => $o, 'score' => ( $health_green - $h ) * log( $m + 1, 2 ) + $o * 4 ]; } usort( $focus, fn( $a, $b ) => $b['score'] <=> $a['score'] ?: $a['h'] <=> $b['h'] ); $focus = array_slice( $focus, 0, 3 ); } $tms_url = admin_url( 'admin.php?page=clusterlane-text-match' ); // Show first-run onboarding if never configured and nothing scanned yet. // Onboarding saves cluster config → admins (manage_clusterlane) only. $show_onboarding = \Clusterlane\Core\Capabilities::can_manage() && ! $settings->cluster_terms_configured() && $scanned === 0; $ob = $show_onboarding ? $this->onboarding_data( $settings ) : [ 'types' => [], 'groups' => [], 'hubs' => [] ]; // 0.98.177: scanned post types with almost no links in their text whose // published pages are not read — the theme probably links them. $low_link = []; if ( $scanned > 0 && \Clusterlane\Core\Capabilities::can_manage() ) { foreach ( array_diff( \Clusterlane\Core\PageReader::recommended( $settings->post_types() ), $settings->rendered_types() ) as $pt ) { $obj = get_post_type_object( $pt ); $low_link[] = $obj ? $obj->labels->name : $pt; } } ?>

v
= $total ? (string) $total : $scanned . ' / ' . $total ); ?> = $total ? esc_html_e( 'Articles', 'clusterlane' ) : esc_html_e( 'Scanned', 'clusterlane' ); ?>
%
= 70 ) esc_html_e( 'Clusters are well-structured.', 'clusterlane' ); elseif ( $avg_cluster_health >= 50 ) esc_html_e( 'Some clusters need attention — see Focus next below.', 'clusterlane' ); else esc_html_e( 'Significant structural gaps.', 'clusterlane' ); ?>
$count ): if ( $count === 0 ) continue; $color = $arch_colors[ $arch ] ?? '#777'; $pct = round( ( $count / $arch_total ) * 100, 2 ); ?>
$count ): if ( $count === 0 ) continue; $color = $arch_colors[ $arch ] ?? '#777'; ?>

0 ): ?>

$f ): $band = $f['h'] >= $health_green ? 'good' : ( $f['h'] >= $health_orange ? 'fair' : 'weak' ); /* translators: %d: number of articles */ $why = $f['o'] ? sprintf( _n( '%d article has no links pointing to it', '%d articles have no links pointing to them', $f['o'], 'clusterlane' ), $f['o'] ) : __( 'Its articles link to each other too little', 'clusterlane' ); ?> %

render_subnav( 'link-suggest' ); ?>

render_subnav( 'text-match' ); ?>

render(); } public function render_seo_titles(): void { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-seo-titles-page.php'; ( new \Clusterlane\Admin\SeoTitlesPage() )->render(); } public function render_article_inspector(): void { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-article-inspector-page.php'; ( new \Clusterlane\Admin\ArticleInspectorPage() )->render(); } /* ------------------------------------------------------------------ Settings Page ------------------------------------------------------------------ */ public function render_settings(): void { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-settings-page.php'; ( new \Clusterlane\Admin\SettingsPage( $this->plugin ) )->render(); } public function render_help(): void { $admin_section = null; if ( \Clusterlane\Core\Capabilities::can_manage() ) { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-support-panel.php'; $panel = new \Clusterlane\Admin\SupportPanel( $this->plugin ); $admin_section = [ $panel, 'render' ]; } clusterlane_render_help_page( $admin_section ); } public function render_report(): void { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php'; require_once CLUSTERLANE_DIR . 'intelligence/admin/class-report-page.php'; ( new \Clusterlane\Admin\ReportPage() )->render(); } public function render_cluster_settings(): void { require_once CLUSTERLANE_DIR . 'intelligence/admin/class-cluster-settings-page.php'; ( new \Clusterlane\Admin\ClusterSettingsPage( $this->plugin ) )->render(); } }