*/ public static function defaults(): array { return array( 'metabox_enabled' => true, 'metabox_position' => 'normal', // normal (below the editor) | side. 'metabox_types' => array(), ); } /** * @return array */ private function settings(): array { return \AiRankSuite\Kernel::service( Options::class )->get( 'editor', self::defaults() ); } public function register( Container $container ): void {} public function boot( Container $container ): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor' ) ); add_action( 'add_meta_boxes', array( $this, 'add_metabox' ) ); // Priority 20: after the post itself is in its final state, and after // anything that might still be rewriting post_content. add_action( 'save_post', array( $this, 'save_metabox' ), 20, 1 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_metabox' ) ); foreach ( get_post_types( array( 'public' => true ) ) as $type ) { if ( 'attachment' === $type ) { continue; } add_filter( "manage_{$type}_posts_columns", array( $this, 'columns' ) ); add_action( "manage_{$type}_posts_custom_column", array( $this, 'column' ), 10, 2 ); add_filter( "manage_edit-{$type}_sortable_columns", array( $this, 'sortable' ) ); } add_action( 'pre_get_posts', array( $this, 'sort_query' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'column_styles' ) ); // Category and tag archives are pages too: title, description, // canonical, robots and social, on the screen where the term is edited. // TermFields hooks what exists now and listens for taxonomies a theme // or WooCommerce registers later. ( new Services\TermFields() )->boot(); } /** * Block editor sidebar bundle (built separately against wp.* externals). */ public function enqueue_editor(): void { $file = AIRANKSUITE_DIR . 'assets/editor/build/editor.js'; if ( ! file_exists( $file ) ) { return; } wp_enqueue_script( 'airanksuite-editor', AIRANKSUITE_URL . 'assets/editor/build/editor.js', array( 'wp-plugins', 'wp-edit-post', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'wp-api-fetch' ), AIRANKSUITE_VERSION, true ); wp_set_script_translations( 'airanksuite-editor', 'ai-rank-suite', AIRANKSUITE_DIR . 'languages' ); wp_add_inline_script( 'airanksuite-editor', 'window.airanksuiteEditorData = ' . wp_json_encode( array( 'adminUrl' => admin_url(), // The editor toolbar has room for one score, and when two // plugins pin theirs it has none. With Rank Math active // ours takes the front of the pinned row and theirs step // back into the ⋮ menu, where their sidebar still opens. 'rankMath' => class_exists( 'RankMath' ) || defined( 'RANK_MATH_VERSION' ), // The sidebar's search preview resolves %tags% with these, // the same way the box below the editor and the front end do. 'siteName' => get_bloginfo( 'name' ), 'tagline' => get_bloginfo( 'description' ), 'separator' => (string) ( \AiRankSuite\Kernel::service( \AiRankSuite\Support\Options::class )->get( 'seo', \AiRankSuite\Modules\Seo\SeoModule::defaults() )['separator'] ?? '–' ), ) ) . ';', 'before' ); } /** * The always-visible SEO panel below the editor — no sidebar to discover, * no separate admin page to open. Works in Gutenberg's Meta Boxes area and * in the classic editor alike. */ public function add_metabox(): void { $settings = $this->settings(); if ( empty( $settings['metabox_enabled'] ) ) { return; } $position = in_array( (string) $settings['metabox_position'], array( 'normal', 'side' ), true ) ? (string) $settings['metabox_position'] : 'normal'; $chosen = array_map( 'strval', (array) $settings['metabox_types'] ); foreach ( get_post_types( array( 'public' => true ) ) as $type ) { if ( 'attachment' === $type ) { continue; } if ( ! empty( $chosen ) && ! in_array( $type, $chosen, true ) ) { continue; } add_meta_box( 'airanksuite-metabox', __( 'AI Rank Suite', 'ai-rank-suite' ), array( $this, 'render_metabox' ), $type, $position, 'high' ); } } /** * GET/PUT /settings/editor — the wizard's "SEO metabox" step and any * future settings screen share this one source of truth. */ public function routes(): void { register_rest_route( self::NAMESPACE, '/settings/editor', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'metabox_enabled' => array( 'type' => 'boolean' ), 'metabox_position' => array( 'type' => 'string', 'enum' => array( 'normal', 'side' ), ), 'metabox_types' => array( 'type' => 'array' ), ), ), ) ); } public function get_settings(): WP_REST_Response { $types = array(); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $type ) { if ( 'attachment' === $type->name ) { continue; } $types[] = array( 'name' => $type->name, 'label' => $type->labels->name, ); } return new WP_REST_Response( array( 'settings' => $this->settings(), 'post_types' => $types, ) ); } public function update_settings( WP_REST_Request $request ): WP_REST_Response { $values = array(); foreach ( array( 'metabox_enabled', 'metabox_position' ) as $key ) { if ( $request->has_param( $key ) ) { $values[ $key ] = $request->get_param( $key ); } } if ( $request->has_param( 'metabox_types' ) ) { $known = get_post_types( array( 'public' => true ) ); $values['metabox_types'] = array_values( array_intersect( array_map( 'sanitize_key', (array) $request->get_param( 'metabox_types' ) ), $known ) ); } \AiRankSuite\Kernel::service( Options::class )->set( 'editor', $values, self::defaults() ); return new WP_REST_Response( array( 'settings' => $this->settings() ) ); } public function render_metabox( \WP_Post $post ): void { // The panel is a JavaScript app talking to REST, which gave it its own // save button — and made WordPress's Update button a no-op for every // field in it. Typing a keyword and pressing Update, the one thing // every WordPress user does, threw the keyword away. // // So the panel now also contributes a real form field. Gutenberg // submits the meta-box form alongside its own save, and the classic // editor submits it natively, so Update persists exactly what is on // screen in both. The REST button stays for instant re-analysis. wp_nonce_field( 'airanksuite_save_meta_' . $post->ID, 'airanksuite_meta_nonce' ); echo ''; echo '
' . '

' . esc_html__( 'Loading…', 'ai-rank-suite' ) . '

' . '
'; } /** * Persist what the meta box was showing when the post was saved. * * Deliberately silent about everything it does not recognise: an empty or * missing payload means the panel never rendered (REST blocked, JS error, * quick edit, autosave), and in that case the correct action is to touch * nothing at all rather than to write blanks over real data. * * @param int $post_id Post being saved. */ public function save_metabox( int $post_id ): void { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing $raw = isset( $_POST['airanksuite_meta'] ) ? wp_unslash( $_POST['airanksuite_meta'] ) : ''; if ( ! is_string( $raw ) || '' === trim( $raw ) ) { return; } $nonce = isset( $_POST['airanksuite_meta_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['airanksuite_meta_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'airanksuite_save_meta_' . $post_id ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $payload = json_decode( $raw, true ); if ( ! is_array( $payload ) ) { return; } if ( array_key_exists( 'keywords', $payload ) && is_array( $payload['keywords'] ) ) { // KeywordAnalyzer::save() merges: a group missing from the payload // keeps whatever is stored. The meta box only ever edits short-tail, // so long-tail and GEO prompts entered in the full editor survive. KeywordAnalyzer::save( $post_id, $payload['keywords'] ); } if ( array_key_exists( 'seo_title', $payload ) ) { $title = Sanitize::template( (string) $payload['seo_title'] ); if ( '' === $title ) { delete_post_meta( $post_id, '_airanksuite_seo_title' ); } else { update_post_meta( $post_id, '_airanksuite_seo_title', $title ); } } if ( array_key_exists( 'seo_desc', $payload ) ) { $desc = Sanitize::template_multiline( (string) $payload['seo_desc'] ); if ( '' === $desc ) { delete_post_meta( $post_id, '_airanksuite_seo_desc' ); } else { update_post_meta( $post_id, '_airanksuite_seo_desc', $desc ); } } // The meta box grew Social and Advanced tabs, so WordPress' own Update // button has to persist those too — otherwise a canonical typed there // and saved with Update disappears, which is the exact failure the // hidden-payload field exists to prevent. if ( array_key_exists( 'robots', $payload ) ) { $allowed = array_values( array_intersect( array_map( 'sanitize_key', (array) $payload['robots'] ), \AiRankSuite\Modules\Robots\RobotsModule::DIRECTIVES ) ); if ( empty( $allowed ) ) { delete_post_meta( $post_id, '_airanksuite_robots' ); } else { update_post_meta( $post_id, '_airanksuite_robots', $allowed ); } } if ( array_key_exists( 'cornerstone', $payload ) ) { if ( empty( $payload['cornerstone'] ) ) { delete_post_meta( $post_id, '_airanksuite_cornerstone' ); } else { update_post_meta( $post_id, '_airanksuite_cornerstone', 1 ); } } if ( array_key_exists( 'canonical', $payload ) ) { $canonical = trim( (string) $payload['canonical'] ); if ( '' === $canonical ) { delete_post_meta( $post_id, \AiRankSuite\Support\PostSeo::CANONICAL ); } else { update_post_meta( $post_id, \AiRankSuite\Support\PostSeo::CANONICAL, esc_url_raw( $canonical ) ); } } if ( array_key_exists( 'social', $payload ) && is_array( $payload['social'] ) ) { \AiRankSuite\Support\PostSeo::save_social( $post_id, $payload['social'] ); } } public function enqueue_metabox( string $hook ): void { if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) { return; } $file = AIRANKSUITE_DIR . 'assets/metabox/metabox.js'; if ( ! file_exists( $file ) ) { return; } // wp-i18n so the panel can translate itself, and the catalogue so it // actually has something to translate with. Without both, the classic // editor's panel stays English on a translated site. wp_enqueue_script( 'airanksuite-metabox', AIRANKSUITE_URL . 'assets/metabox/metabox.js', array( 'wp-i18n' ), AIRANKSUITE_VERSION, true ); wp_set_script_translations( 'airanksuite-metabox', 'ai-rank-suite', AIRANKSUITE_DIR . 'languages' ); wp_add_inline_script( 'airanksuite-metabox', 'window.airanksuiteMetabox = ' . wp_json_encode( array( 'restUrl' => esc_url_raw( rest_url( 'airanksuite/v1' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // So the snippet preview can render %sitename%, %sep% and // %tagline% the way the front end will. 'siteName' => get_bloginfo( 'name' ), 'tagline' => get_bloginfo( 'description' ), 'separator' => (string) ( \AiRankSuite\Kernel::service( \AiRankSuite\Support\Options::class )->get( 'seo', \AiRankSuite\Modules\Seo\SeoModule::defaults() )['separator'] ?? '–' ), ) ) . ';', 'before' ); } /** * Add GEO/SEO columns after the title. * * @param array $columns Existing columns. * @return array */ public function columns( array $columns ): array { $out = array(); foreach ( $columns as $key => $label ) { $out[ $key ] = $label; if ( 'title' === $key ) { $out['airanksuite_geo'] = __( 'GEO', 'ai-rank-suite' ); $out['airanksuite_seo'] = __( 'SEO', 'ai-rank-suite' ); /* * The link counts come from the internal-link graph, which is * a Pro module. Without it the column is a dash on every row * of every page of the list — a column that has never once * held a value is worse than no column, because the reader * assumes it is broken rather than absent. */ if ( in_array( 'internal_links', \AiRankSuite\Kernel::instance()->moduleIds(), true ) ) { $out['airanksuite_links'] = __( 'Links', 'ai-rank-suite' ); } $out['airanksuite_details'] = __( 'SEO details', 'ai-rank-suite' ); } } return $out; } /** * Render a score cell. * * @param string $column Column key. * @param int $post_id Post ID. */ public function column( string $column, int $post_id ): void { if ( 'airanksuite_details' === $column ) { $this->details_cell( $post_id ); return; } if ( 'airanksuite_links' === $column ) { $this->links_cell( $post_id ); return; } if ( 'airanksuite_geo' !== $column && 'airanksuite_seo' !== $column ) { return; } $meta = 'airanksuite_geo' === $column ? '_airanksuite_geo_score' : '_airanksuite_seo_score'; $score = get_post_meta( $post_id, $meta, true ); if ( '' === $score || null === $score ) { echo '–'; return; } $score = (int) $score; $level = $score >= 80 ? 'good' : ( $score >= 50 ? 'warn' : 'bad' ); /* * "73 / 100", not "73". * * The column heading says GEO and SEO, and a heading is at the top of * a list somebody scrolls. Fifteen rows down, the pill was two bare * digits with no scale attached — 73 out of what? Rank Math writes the * denominator into every cell for exactly this reason, and it is the * one thing its column does better than ours did. The dot went with * it: the number now carries its own meaning, and the colour is the * judgement. */ // Two elements with a gap between them, not one string with a space in // it: a leading space inside a span collapses, and the pill read // "60/ 100" — the one place in the product where the spacing was // visibly wrong. printf( '%2$d/ 100', esc_attr( $level ), absint( $score ) ); } /** * How the site's own links treat this page. * * Two numbers and, when they are worth a word, the word: a page nothing * links to is an orphan, and a page marked as cornerstone that nothing * links to is the mistake this column exists to make visible. The counts * come from the last graph build, stored as post meta so the list can sort * on them without a join. */ private function links_cell( int $post_id ): void { $in = get_post_meta( $post_id, '_airanksuite_links_in', true ); $out = get_post_meta( $post_id, '_airanksuite_links_out', true ); $cornerstone = (bool) get_post_meta( $post_id, '_airanksuite_cornerstone', true ); if ( '' === $in && '' === $out ) { echo '–'; if ( $cornerstone ) { echo ' ' . esc_html__( 'Cornerstone', 'ai-rank-suite' ) . ''; } return; } $in = (int) $in; $out = (int) $out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $in/$out are cast to int two lines up and rendered with %d; every string goes through esc_attr()/esc_html__(). // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- $in/$out are cast to int above and rendered with %d; every string goes through esc_attr()/esc_html__(). printf( '%2$d %3$s · %4$d %5$s', esc_attr( sprintf( /* translators: 1: inbound link count, 2: outbound link count. */ __( '%1$d pages link to this one; it links out to %2$d.', 'ai-rank-suite' ), $in, $out ) ), $in, esc_html__( 'in', 'ai-rank-suite' ), $out, esc_html__( 'out', 'ai-rank-suite' ) ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped if ( 0 === $in ) { echo ' ' . esc_html__( 'Orphan', 'ai-rank-suite' ) . ''; } if ( $cornerstone ) { echo ' ' . esc_html__( 'Cornerstone', 'ai-rank-suite' ) . ''; } } /** * Focus keyword, keyword count, link counts and indexability at a glance — * everything a writer needs before opening the post. * * @param int $post_id Post ID. */ private function details_cell( int $post_id ): void { $summary = get_post_meta( $post_id, '_airanksuite_summary', true ); if ( ! is_array( $summary ) ) { echo '' . esc_html__( 'Not analyzed yet', 'ai-rank-suite' ) . ''; return; } $keyword = (string) ( $summary['keyword'] ?? '' ); $rows = array(); $rows[] = '' !== $keyword ? '' . esc_html__( 'Keyword:', 'ai-rank-suite' ) . ' ' . esc_html( $keyword ) : '' . esc_html__( 'No focus keyword', 'ai-rank-suite' ) . ''; $total = (int) ( $summary['keywords'] ?? 0 ); if ( $total > 1 ) { $rows[ count( $rows ) - 1 ] .= ' ' . sprintf( /* translators: %d: number of further keywords */ esc_html__( '+%d more', 'ai-rank-suite' ), $total - 1 ) . ''; } /* * The schema this page will carry, by name. Rank Math's column says * "Schema: Article" on every row and people read it as a checklist * item; ours said nothing, so a page with a FAQPage on it looked no * different from one without. The stored items come first; a page * with none gets the type the graph builder gives it by default. */ if ( class_exists( \AiRankSuite\Modules\Schema\Services\SchemaBuilder::class ) ) { $types = array(); foreach ( \AiRankSuite\Modules\Schema\Services\SchemaBuilder::items( $post_id ) as $item ) { $type = (string) ( $item['type'] ?? '' ); if ( '' !== $type && ! in_array( $type, $types, true ) ) { $types[] = $type; } } if ( empty( $types ) ) { $types[] = 'page' === get_post_type( $post_id ) ? 'WebPage' : 'Article'; } $rows[] = '' . esc_html__( 'Schema:', 'ai-rank-suite' ) . ' ' . esc_html( implode( ', ', $types ) ); } /* * Three link counts as three icons: links out to this site, links out * to other sites, and links in from this site. The third comes from the * link graph and is a dash until it has been built. */ $in = get_post_meta( $post_id, '_airanksuite_links_in', true ); $icons = array( array( 'dashicons-admin-links', (string) (int) ( $summary['internal'] ?? 0 ), __( 'Internal links on this page', 'ai-rank-suite' ) ), array( 'dashicons-external', (string) (int) ( $summary['external'] ?? 0 ), __( 'External links on this page', 'ai-rank-suite' ) ), array( 'dashicons-migrate', '' === $in ? '–' : (string) (int) $in, __( 'Pages on this site that link here', 'ai-rank-suite' ) ), ); $cells = array(); foreach ( $icons as $icon ) { $cells[] = '' . esc_html( $icon[1] ) . ''; } $rows[] = '' . esc_html__( 'Links:', 'ai-rank-suite' ) . ' ' . implode( '|', $cells ) . ' · ' . sprintf( /* translators: %d: word count */ esc_html( _n( '%d word', '%d words', (int) ( $summary['words'] ?? 0 ), 'ai-rank-suite' ) ), (int) ( $summary['words'] ?? 0 ) ) . ''; $robots = get_post_meta( $post_id, '_airanksuite_robots', true ); if ( is_array( $robots ) && in_array( 'noindex', $robots, true ) ) { $rows[] = '' . esc_html__( 'noindex', 'ai-rank-suite' ) . ''; } if ( '' !== (string) get_post_meta( $post_id, \AiRankSuite\Support\PostSeo::CANONICAL, true ) ) { $rows[] = '' . esc_html__( 'custom canonical', 'ai-rank-suite' ) . ''; } echo '
' . wp_kses_post( implode( '
', $rows ) ) . '
'; } /** * @param array $columns Sortable columns. * @return array */ public function sortable( array $columns ): array { $columns['airanksuite_geo'] = 'airanksuite_geo'; $columns['airanksuite_seo'] = 'airanksuite_seo'; $columns['airanksuite_links'] = 'airanksuite_links'; return $columns; } /** * Order by score meta when a score column is clicked. * * @param \WP_Query $query The query. */ public function sort_query( \WP_Query $query ): void { if ( ! is_admin() || ! $query->is_main_query() ) { return; } $orderby = $query->get( 'orderby' ); if ( 'airanksuite_links' === $orderby ) { // Sorting by "how well linked is this" means inbound links: the // number the site's own structure decides, not the one the author does. $query->set( 'meta_key', '_airanksuite_links_in' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $query->set( 'orderby', 'meta_value_num' ); return; } if ( 'airanksuite_geo' !== $orderby && 'airanksuite_seo' !== $orderby ) { return; } $query->set( 'meta_key', 'airanksuite_geo' === $orderby ? '_airanksuite_geo_score' : '_airanksuite_seo_score' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $query->set( 'orderby', 'meta_value_num' ); } public function column_styles( string $hook = '' ): void { // Only the post list needs these; every other admin screen used to // get nothing because the hook was admin_head-edit.php, and the same // narrowing is kept here so the rules do not travel further now that // they are enqueued. if ( 'edit.php' !== $hook ) { return; } // 62px was too narrow for the header: WordPress renders a sortable // column as "GEO" plus a stacked sort indicator, and at that width the // indicator wrapped onto its own line, pushing the header out of // alignment with its own cells. 74px fits both on one line, and // centring header and cell together keeps the column square whatever // the score is. $css = ' .column-airanksuite_geo, .column-airanksuite_seo { width: 92px; text-align: center; } th.column-airanksuite_geo a, th.column-airanksuite_seo a { justify-content: center; } th.column-airanksuite_geo, th.column-airanksuite_seo { white-space: nowrap; } /* Top-aligned like every other cell in the list. Centred vertically, the pill floated halfway down a row that another plugin column had made three hundred pixels tall, nowhere near the title it belongs to. */ td.column-airanksuite_geo, td.column-airanksuite_seo { text-align: center; vertical-align: top; } .airanksuite-score { display:inline-flex; align-items:baseline; justify-content:center; gap:4px; min-width:64px; padding:3px 9px; border-radius:4px; line-height:1.5; font-variant-numeric: tabular-nums; letter-spacing:-0.01em; } .airanksuite-score b { font-size:13px; font-weight:600; } /* The denominator is context, not data: same colour, lighter and a step smaller, so the eye lands on the number first. */ .airanksuite-score-of { font-weight:500; opacity:.6; font-size:11.5px; } .airanksuite-score--good { background:#e6f6ee; color:#0f7a4d; } .airanksuite-score--warn { background:#fff7d6; color:#9a5a0a; } .airanksuite-score--bad { background:#fdecea; color:#c02b21; } .airanksuite-score--none { background:#f0f0f1; color:#787c82; } .column-airanksuite_details { width: 240px; } .airanksuite-details { font-size:11.5px; line-height:1.7; color:#4d4f55; } .airanksuite-details .airanksuite-dim { color:#8a8d94; } .airanksuite-details .airanksuite-lk { display:inline-flex; align-items:center; gap:2px; font-variant-numeric:tabular-nums; } .airanksuite-details .airanksuite-lk .dashicons { font-size:14px; width:14px; height:14px; line-height:14px; color:#8a8d94; } .airanksuite-details .airanksuite-lk-sep { margin:0 6px; color:#c3c4c7; } .airanksuite-details .airanksuite-flag { display:inline-block; margin-top:2px; padding:1px 7px; border-radius:4px; background:#fff7d6; color:#9a5a0a; font-weight:600; } @media screen and (max-width: 782px) { .column-airanksuite_geo, .column-airanksuite_seo, .column-airanksuite_details { text-align: left; } } '; \AiRankSuite\Support\Inline::style( 'airanksuite-editor-columns', $css ); } }