score (null = not yet analyzed), * populated in bulk by prime_score_cache() to avoid one query per list- * table row. * * @var array */ protected array $score_cache = array(); /** * Registers this class's hooks with the plugin's hook loader. * * @param ABDS_SEO_Loader $loader Hook loader. */ public function register_hooks( ABDS_SEO_Loader $loader ): void { $loader->add_action( 'add_meta_boxes', $this, 'maybe_add_classic_meta_box', 10, 2 ); // Priority 5, ahead of ABDS_SEO_Content_Score_Cache's save_post hook // (default priority 10): the keyword has to be written to post meta // BEFORE that hook re-runs analyze(), or the score it caches would // be one save behind whatever was just typed. $loader->add_action( 'save_post', $this, 'save_classic_meta_box', 5, 1 ); $loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_classic_meta_box_assets' ); $loader->add_filter( 'the_posts', $this, 'prime_score_cache', 10, 1 ); // Column hooks are per-post-type dynamic hook names, so they're // registered from an init callback (after custom post types exist), // the same pattern ABDS_SEO_Core::register_meta_fields() uses. $loader->add_action( 'init', $this, 'register_column_hooks' ); } /** * Registers the post-list score column for every public post type. */ public function register_column_hooks(): void { foreach ( get_post_types( array( 'public' => true ) ) as $post_type ) { add_filter( "manage_{$post_type}_posts_columns", array( $this, 'add_score_column' ) ); add_action( "manage_{$post_type}_posts_custom_column", array( $this, 'render_score_column' ), 10, 2 ); } } /** * Adds the classic-editor fallback meta box, but only when this screen * genuinely isn't using the block editor — on a Gutenberg screen the * sidebar panel already covers this, and showing both would just be * confusing duplication. * * @param string $post_type Post type of the current screen. * @param WP_Post $post Post being edited. */ public function maybe_add_classic_meta_box( string $post_type, $post ): void { if ( ! ( $post instanceof WP_Post ) || ! in_array( $post_type, get_post_types( array( 'public' => true ) ), true ) ) { return; } if ( ! current_user_can( ABDS_SEO_Activator::CONTENT_CAPABILITY ) || ! current_user_can( 'edit_post', $post->ID ) ) { return; } if ( use_block_editor_for_post( $post ) ) { return; } add_meta_box( 'abds-seo-classic-score', __( 'ABDS SEO', 'abds-seo' ), array( $this, 'render_classic_meta_box' ), $post_type, 'side', 'high' ); } /** * Renders the classic-editor fallback meta box: the SEO title / meta * description fields with the same 60/160-char gauges and pixel-accurate * Google snippet preview the Gutenberg sidebar has (src/sidebar.js, * src/snippet-width.js — see enqueue_classic_meta_box_assets(), which * loads a vanilla-JS port of the exact same truncation algorithm and * constants so both surfaces agree pixel-for-pixel), then the same score * and recommendations the sidebar shows, via the same analyzer call the * sidebar's REST endpoint uses. * * @param WP_Post $post Post being edited. */ public function render_classic_meta_box( WP_Post $post ): void { $keyword = (string) get_post_meta( $post->ID, '_abds_seo_focus_keyword', true ); $seo_title = (string) get_post_meta( $post->ID, '_abds_seo_title', true ); $seo_desc = (string) get_post_meta( $post->ID, '_abds_seo_description', true ); $permalink = (string) get_permalink( $post->ID ); wp_nonce_field( 'abds_seo_classic_meta_' . $post->ID, 'abds_seo_classic_meta_nonce' ); echo '
'; printf( '


', esc_html__( 'SEO title', 'abds-seo' ), esc_attr( $seo_title ), esc_attr( $post->post_title ) ); printf( '


', esc_html__( 'Meta description', 'abds-seo' ), esc_textarea( $seo_desc ) ); printf( '
%1$s
', esc_html( $permalink ), esc_html__( 'Enable JavaScript to see the live search-result preview.', 'abds-seo' ) ); echo '
'; // CROSS-PRODUCT CONTRACT (Priority 4.1, 2026-09-07): data-abds-seo-field="keyword" // on this specific input is read directly by ABDS SEO Pro's own classic-editor // panels bundle (abds-seo-pro/src/classic-panels.js) to track the live-typed // keyword and to write a chosen suggestion back in, since Classic Editor has no // wp.data/entity-record layer for Pro to hook into instead — the same "shared // symbol across independently-released products" hazard the 2026-09-07 // ABDS_Product_Updater_V2 incident was about, except the shared thing here is a // markup attribute, not a class name. Renaming, removing, or relocating this // attribute (or the element it's on) silently breaks Pro's classic panels on // every site running both, with no shared test to catch it, since the two // plugins are separate repos. Coordinate any change with abds-seo-pro's own // classic-panels.js before shipping it — do not treat this as a purely free- // plugin-internal rename. printf( '


', esc_html__( 'Focus keyword', 'abds-seo' ), esc_attr( $keyword ) ); echo '
'; if ( '' === $keyword ) { printf( '

%s

', esc_html__( 'Set a focus keyword to get a live SEO score and recommendations.', 'abds-seo' ) ); } else { $result = ( new ABDS_SEO_Content_Analyzer() )->analyze( $post->ID, $keyword ); $breakdown_is_open = (bool) ( new ABDS_SEO_UI_Preferences() )->get( get_current_user_id(), 'score_breakdown_open' ); $this->render_live_result_markup( $result, $breakdown_is_open ); } echo '
'; $this->render_classic_history_markup( $post->ID ); $this->print_dashboard_link(); } /** * Priority 4.2 (2026-09-07): the Classic Editor counterpart of * sidebar.js's ChangesLog panel — same three tracked fields * (ABDS_SEO_Changes_Log::TRACKED_META_KEYS), same GET/POST * /changes-log REST routes, same "Roll back" wording. The one real * difference is HOW a rollback lands in the visible field: Gutenberg * calls editEntityRecord() (an entity-record layer Classic Editor * doesn't have); classic-meta-box.js's own JS instead sets the target * [data-abds-seo-field] element's .value directly and dispatches a * real 'input' event, which is what its own debounced live-recalculation * listener (setupLiveRecalculation()) is already wired to react to — * a rollback re-triggers the same score refresh a manual edit would, * with no separate code path. * * Server-rendered as an empty, collapsed
shell — the list * itself is always JS-fetched (this meta box's live score/preview are * already JS-dependent the same way; a no-JS visitor already sees a * degraded meta box before this). * * @param int $post_id Post being edited. */ protected function render_classic_history_markup( int $post_id ): void { printf( '
%2$s
', (int) $post_id, esc_html__( 'Change history', 'abds-seo' ) ); } /** * Renders the score ring + recommendations list markup shared by the * meta box's initial server render and re-used (as the template JS * clones/updates) after each live /content-analysis-preview fetch — see * classic-meta-box.js's updateLiveResult(). Keeping this one PHP method * as the single source of the initial markup means the JS only ever has * to update text/classes/attributes inside it, never rebuild structure * that could drift from what PHP renders on first load. * * @param array $result analyze()/preview() result array. * @param bool $breakdown_is_open Whether the score-breakdown disclosure should render open. */ protected function render_live_result_markup( array $result, bool $breakdown_is_open ): void { // P3 (2026-09-04): content_score === null means the analysis // genuinely couldn't read the page's content (the content-boundary // fetch found nothing to trust) — casting null to (int) 0 here // would render a ring showing "0" and an empty, falsely-reassuring // "no issues found" list, the exact silent-failure this backlog // item exists to close. See sidebar.js's matching guard for the // Gutenberg side of the same fix. if ( null === $result['content_score'] ) { printf( '

%s

', esc_html( self::unavailable_message() ) ); return; } // P3 (2026-09-05): content_score is normalized 0-100 at the point // ABDS_SEO_Content_Analyzer::calculate_score() computes it — this // ring, categorize_score() below, and everything else reading // content_score never need to know that a post lacking images/links // scores against a smaller raw denominator internally. That raw // denominator only surfaces in the breakdown's own "N of M points" // header (render_score_breakdown_markup()), via // content_score_points/content_score_max. $score = (int) $result['content_score']; $status = ABDS_SEO_Content_Analyzer::categorize_score( $result['content_score'] ); // Unique per render so multiple score rings on one page (unlikely in // the meta box, but cheap insurance) never collide on the gradient id. $gradient_id = 'abds-seo-ring-grad-' . wp_generate_password( 8, false, false ); printf( '
%3$d %4$s
', esc_attr( $status ), (int) $score, (int) $score, esc_html__( 'SEO score', 'abds-seo' ), esc_attr( $gradient_id ) ); $this->render_score_breakdown_markup( $result['score_breakdown'] ?? array(), $breakdown_is_open, $result['content_score_points'] ?? null, $result['content_score_max'] ?? null ); echo ''; } /** * The one canonical "content_score is null" message — shared by this * class's own PHP render AND classic-meta-box.js's live-update render * (passed through enqueue_classic_meta_box_assets()'s wp_localize_script), * so the two never drift into slightly different wording and so * GlotPress only ever sees this string once, not two near-duplicates. * Same English source as sidebar.js's matching Notice, for the same * reason. * * @return string */ protected static function unavailable_message(): string { return __( "Could not analyze this page's content. The plugin could not read what a visitor actually sees — the page's template or builder may render content outside what a normal fetch can follow. No SEO score for now.", 'abds-seo' ); } /** * Renders the "what the score is made of" disclosure (Task A6, * ABDS-SEO-TZ-SCORE-WOW-093.md) — the Classic Editor counterpart of * sidebar.js's ScoreBreakdown component, built from the exact same * ABDS_SEO_Content_Analyzer::build_score_breakdown() rows so the two * surfaces can never show different explanations for the same score. * A plain
/, matching sidebar.js's choice not to pull * in a JS disclosure widget for this. * * Confirmed live measurement (SEO-TZ 1.2): closed by default, this reads * as an empty 17px line directly under the score ring in the 280px * column — indistinguishable from broken. Takes the caller's per-user * preference instead of always starting closed; see * ABDS_SEO_UI_Preferences for the default (open, key * 'score_breakdown_open') and how a user's own toggle is remembered. * * $points/$max (P3, 2026-09-05) are the RAW earned/possible totals these * rows sum to — analysis.content_score_points/content_score_max, NOT * the normalized 0-100 content_score the ring above shows. The two * deliberately no longer match when a check is excluded (zero images/ * links), so the summary spells out "N of M points" explicitly rather * than leaving a reader to reconcile a smaller raw total against the * ring's normalized number on their own. Null when the caller has no * totals to show (shouldn't happen for a non-empty $breakdown in * practice, but defensive rather than assuming). * * @param array $breakdown Rows from score_breakdown. * @param bool $is_open Whether to render the disclosure already open. * @param ?int $points Raw points earned (content_score_points). * @param ?int $max Raw points possible (content_score_max). */ protected function render_score_breakdown_markup( array $breakdown, bool $is_open, ?int $points = null, ?int $max = null ): void { if ( empty( $breakdown ) ) { return; } $summary_text = ( null !== $points && null !== $max ) ? sprintf( /* translators: 1: raw points earned, 2: raw points possible. */ __( 'What the score is made of (%1$d of %2$d points)', 'abds-seo' ), $points, $max ) : __( 'What the score is made of', 'abds-seo' ); echo '
'; printf( '%s', esc_html( $summary_text ) ); echo '
    '; foreach ( $breakdown as $item ) { $points = (int) $item['points']; $max_points = (int) $item['max_points']; $state = 'empty'; if ( $points >= $max_points ) { $state = 'full'; } elseif ( $points > 0 ) { $state = 'partial'; } $icon = array( 'full' => '✓', 'partial' => '±', 'empty' => '✕', )[ $state ]; printf( '
  • %3$s %4$d/%5$d
  • ', esc_attr( $state ), esc_html( $icon ), esc_html( (string) $item['label'] ), (int) $points, (int) $max_points ); } echo '
'; } /** * Persists the classic meta box's fields. Writes through * update_post_meta() to the exact same meta keys and sanitizers the * Gutenberg REST path already uses (ABDS_SEO_Core::register_meta_fields()), * so a value saved here is indistinguishable from one saved via the * block editor — the existing changes-log and score-cache save_post * hooks that already watch these meta keys keep working unchanged. Each * field is saved independently (isset() per field, not one all-or- * nothing check) so a request missing one field for any reason doesn't * wipe the others. * * @param int $post_id Post being saved. */ public function save_classic_meta_box( int $post_id ): void { if ( ! isset( $_POST['abds_seo_classic_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['abds_seo_classic_meta_nonce'] ) ), 'abds_seo_classic_meta_' . $post_id ) ) { return; } if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } if ( ! current_user_can( ABDS_SEO_Activator::CONTENT_CAPABILITY ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } if ( isset( $_POST['abds_seo_classic_focus_keyword'] ) ) { update_post_meta( $post_id, '_abds_seo_focus_keyword', sanitize_text_field( wp_unslash( $_POST['abds_seo_classic_focus_keyword'] ) ) ); } if ( isset( $_POST['abds_seo_classic_title'] ) ) { update_post_meta( $post_id, '_abds_seo_title', sanitize_text_field( wp_unslash( $_POST['abds_seo_classic_title'] ) ) ); } if ( isset( $_POST['abds_seo_classic_description'] ) ) { update_post_meta( $post_id, '_abds_seo_description', sanitize_textarea_field( wp_unslash( $_POST['abds_seo_classic_description'] ) ) ); } } /** * Enqueues the classic meta box's own vanilla-JS char-count gauges + * snippet preview, and its CSS — scoped to the exact same "genuinely not * using the block editor" check maybe_add_classic_meta_box() uses, so * this never loads on a Gutenberg screen (where the meta box itself * isn't even registered). Nothing else on post.php/post-new.php loads * this plugin's assets today — ABDS_SEO_Admin::enqueue_assets() only * fires on the plugin's own admin pages. * * @param string $hook_suffix Current admin page hook. */ public function enqueue_classic_meta_box_assets( string $hook_suffix ): void { if ( ! in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ) ) { return; } global $post; if ( ! ( $post instanceof WP_Post ) || use_block_editor_for_post( $post ) ) { return; } wp_enqueue_script( 'abds-seo-classic-meta-box', ABDS_SEO_PLUGIN_URL . 'admin/js/classic-meta-box.js', array(), ABDS_SEO_VERSION, true ); wp_localize_script( 'abds-seo-classic-meta-box', 'abdsSeoClassicMeta', array( 'noTitle' => __( '(No title)', 'abds-seo' ), 'noDescription' => __( '(No description)', 'abds-seo' ), 'noIssues' => __( 'No issues found for this focus keyword.', 'abds-seo' ), 'scoreLabel' => __( 'SEO score', 'abds-seo' ), 'breakdownLabel' => __( 'What the score is made of', 'abds-seo' ), // P3 (2026-09-05): used once content_score_points/_max are // known from a live fetch — see renderScoreBreakdown() in // classic-meta-box.js. Same string // render_score_breakdown_markup() builds server-side for // the initial render, so the two never drift. /* translators: 1: raw points earned, 2: raw points possible. */ 'breakdownLabelTemplate' => __( 'What the score is made of (%1$d of %2$d points)', 'abds-seo' ), /* translators: 1: recommendations fixed so far, 2: recommendations seen so far. */ 'fixCounterTemplate' => __( 'Fixed %1$d of %2$d', 'abds-seo' ), 'previewUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 . '/content-analysis-preview' ) ), 'restNonce' => wp_create_nonce( 'wp_rest' ), // SEO-TZ 1.2/1.5: the same per-user preference render_score_breakdown_markup() // uses for the initial server render, so ensureLiveResultSkeleton()'s // JS-built skeleton (built when no keyword was set on page load) // starts in the same state. uiPreferencesUrl is the shared store's // one route (1.5) — the JS sends {key, value}, not a bespoke shape. 'breakdownOpen' => (bool) ( new ABDS_SEO_UI_Preferences() )->get( get_current_user_id(), 'score_breakdown_open' ), 'uiPreferencesUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 . '/ui-preferences' ) ), // P3 (2026-09-04): shared with render_live_result_markup()'s // own PHP render of the same message — see unavailable_message(). 'unavailableMessage' => self::unavailable_message(), // Priority 4.2 (2026-09-07): changesLogUrl already carries this // post's id in the path (the REST route takes it positionally, // not as a query var); changesLogUndoBaseUrl is the route's own // base with no id, since undo's {id} identifies the LOG ROW, // picked at click time from whichever entry the list rendered, // not known ahead of a fetch. 'changesLogUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 . '/changes-log/' . $post->ID ) ), 'changesLogUndoBaseUrl' => esc_url_raw( rest_url( ABDS_SEO_REST_Base::NAMESPACE_V1 . '/changes-log' ) ), 'historyEmpty' => __( 'The change history is empty.', 'abds-seo' ), 'historyRollBack' => __( 'Roll back', 'abds-seo' ), 'historyEmptyValue' => __( '(empty)', 'abds-seo' ), // Same four keys/labels as sidebar.js's CHANGE_TYPE_LABELS — // same English source strings, so translators see one msgid // each, not a near-duplicate pair per surface. 'historyChangeTypeLabels' => array( 'seo_title' => __( 'SEO title', 'abds-seo' ), 'meta_description' => __( 'Meta description', 'abds-seo' ), 'focus_keyword' => __( 'Focus keyword', 'abds-seo' ), 'ai_applied_meta_description' => __( 'Meta description (AI)', 'abds-seo' ), ), ) ); // Design tokens first, so classic-meta-box.css can reference the same // CSS custom properties every other plugin screen uses (score status // colours, spacing) instead of its own hardcoded hex values. Same // handle the main admin screens register — wp_enqueue_style() is a // no-op if it's already loaded, so this is safe to call again here. wp_enqueue_style( 'abds-seo-tokens', ABDS_SEO_PLUGIN_URL . 'admin/css/tokens.css', array(), ABDS_SEO_VERSION ); wp_enqueue_style( 'abds-seo-classic-meta-box', ABDS_SEO_PLUGIN_URL . 'admin/css/classic-meta-box.css', array( 'abds-seo-tokens' ), ABDS_SEO_VERSION ); } /** * Prints the "open full Dashboard" link shared by both meta-box states. */ protected function print_dashboard_link(): void { printf( '

%s

', esc_url( admin_url( 'admin.php?page=' . ABDS_SEO_Admin::MENU_SLUG ) ), esc_html__( 'Open full SEO Dashboard', 'abds-seo' ) ); } /** * Adds the SEO-score column to a post-list screen. Labeled "ABDS SEO", * not just "SEO" — confirmed live (18Aug26) that a bare "SEO" label is * genuinely ambiguous next to Yoast's and Rank Math's own same-named * columns when more than one SEO plugin is active on a screen. * * @param array $columns Existing columns. * @return array */ public function add_score_column( array $columns ): array { $columns[ self::COLUMN_ID ] = __( 'ABDS SEO', 'abds-seo' ); return $columns; } /** * Renders one row's SEO-score column cell. * * @param string $column Column id being rendered. * @param int $post_id Post ID for this row. */ public function render_score_column( string $column, int $post_id ): void { if ( self::COLUMN_ID !== $column ) { return; } $score = $this->score_for( $post_id ); if ( null === $score ) { echo '—'; return; } $status = ABDS_SEO_Content_Analyzer::categorize_score( $score ); printf( '%2$d', esc_attr( $status ), (int) $score ); } /** * Bulk-primes the score cache for a set of posts (one query for the * whole list-table page instead of one per row) — the same anti-N+1 * discipline ABDS_SEO_Reports::query() already applies. * * @param WP_Post[] $posts Posts from the query this filters. * @return WP_Post[] Unmodified — this is a read-only side effect. */ public function prime_score_cache( array $posts ): array { if ( ! is_admin() || empty( $posts ) ) { return $posts; } global $wpdb; $post_ids = array_map( 'intval', wp_list_pluck( $posts, 'ID' ) ); if ( empty( $post_ids ) ) { return $posts; } $table = $wpdb->prefix . 'abds_seo_page_scores'; $placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix plus a hardcoded literal, never user input; every post id is passed through $wpdb->prepare(). Runs once per list-table page view (bulk prefetch), not per row. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_score FROM {$table} WHERE post_id IN ({$placeholders})", $post_ids ) ); // phpcs:enable foreach ( $rows as $row ) { $this->score_cache[ (int) $row->post_id ] = null !== $row->content_score ? (int) round( (float) $row->content_score ) : null; } return $posts; } /** * A post's cached score, falling back to a single-row query if * prime_score_cache() didn't already cover it (e.g. a Quick Edit AJAX * row refresh, which doesn't run through the main list-table query). * * @param int $post_id Post ID. * @return ?int Content score 0-100, or null if never analyzed. */ protected function score_for( int $post_id ): ?int { if ( array_key_exists( $post_id, $this->score_cache ) ) { return $this->score_cache[ $post_id ]; } global $wpdb; $table = $wpdb->prefix . 'abds_seo_page_scores'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix plus a hardcoded literal, never user input; $post_id is passed through $wpdb->prepare(). Fallback path only, not the common case. $score = $wpdb->get_var( $wpdb->prepare( "SELECT content_score FROM {$table} WHERE post_id = %d", $post_id ) ); // phpcs:enable $this->score_cache[ $post_id ] = null !== $score ? (int) round( (float) $score ) : null; return $this->score_cache[ $post_id ]; } }