true ] ); foreach ( $post_types as $pt ) { add_meta_box( 'awb-seo-meta', __( 'CitedSEO', 'adworksbid-seo' ), [ __CLASS__, 'render_meta_box' ], $pt, 'normal', 'low' ); } add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_meta_box_assets' ] ); } /** * Enqueue the React meta box bundle on post edit screens. * Only loads if the compiled build exists (i.e. after `npm run build`). */ public static function enqueue_meta_box_assets( $hook ) { if ( ! in_array( $hook, [ 'post.php', 'post-new.php' ], true ) ) { return; } $asset_file = AWBSEO_PLUGIN_DIR . 'build/meta-box.asset.php'; if ( ! file_exists( $asset_file ) ) { return; // Build hasn't run yet - fall back to the legacy PHP meta box. } $asset = require $asset_file; wp_enqueue_script( 'awbseo-meta-box', AWBSEO_PLUGIN_URL . 'build/meta-box.js', $asset['dependencies'], $asset['version'], true // in footer ); if ( file_exists( AWBSEO_PLUGIN_DIR . 'build/meta-box.css' ) ) { wp_enqueue_style( 'awbseo-meta-box', AWBSEO_PLUGIN_URL . 'build/meta-box.css', [], $asset['version'] ); } wp_enqueue_media(); // needed for OG / Twitter image picker // Bolt-on control: adapts the *full* meta description to the chosen // Arabic dialect (the React bundle above only ever adapted the focus // keyword). Wired in from outside the React tree because the source // for that bundle (src/meta-box/*.jsx) isn't present in this working // copy to edit directly -- see assets/js/dialect-desc-adapter.js. wp_enqueue_script( 'awbseo-dialect-desc-adapter', AWBSEO_PLUGIN_URL . 'assets/js/dialect-desc-adapter.js', [ 'awbseo-meta-box' ], AWBSEO_VERSION, true ); // Bolt-on control: lets the user capture an Arabizi/transliterated // (or otherwise code-switched) variant of the focus keyword. Kept // outside the compiled React bundle for the same reason as the // dialect description adapter above - the React source isn't // present in this working copy to edit directly. See // assets/js/arabizi-keyword-capture.js. wp_enqueue_script( 'awbseo-arabizi-keyword-capture', AWBSEO_PLUGIN_URL . 'assets/js/arabizi-keyword-capture.js', [ 'awbseo-meta-box' ], AWBSEO_VERSION, true ); // Bolt-on control: flags whether this post's content is natively // written or translated (or a mix) - purely editorial metadata, kept // outside the compiled React bundle for the same reason as the two // controls above. See assets/js/content-origin-flag.js. wp_enqueue_script( 'awbseo-content-origin-flag', AWBSEO_PLUGIN_URL . 'assets/js/content-origin-flag.js', [ 'awbseo-meta-box' ], AWBSEO_VERSION, true ); // Bolt-on control: when Polylang or WPML is active and the post // being edited is in Arabic, translates the React meta box's own // static chrome (tab names, field labels, hints, buttons, static // placeholders) into Arabic. Kept outside the compiled bundle for // the same reason as the three controls above - the React source // isn't present in this working copy to edit directly, so this // walks the rendered DOM instead. It never touches the post's // actual title/description content (real user input, left exactly // as typed) or the dynamic on-page-analysis check messages (still // English - a separate, much larger PHP-side translation job). See // assets/js/meta-box-post-language-i18n.js. wp_enqueue_script( 'awbseo-meta-box-post-language-i18n', AWBSEO_PLUGIN_URL . 'assets/js/meta-box-post-language-i18n.js', [ 'awbseo-meta-box' ], AWBSEO_VERSION, true ); } public static function render_meta_box( $post ) { wp_nonce_field( 'awbseo_save_meta', 'awbseo_nonce' ); $meta = self::get_post_meta( $post->ID ); $analysis = AWBSEO_On_Page_Analyzer::analyze( $post->ID ); // Pass data to the React component - wp_localize_script() works here even // though the script is in_footer because render_meta_box() runs during the // page body, before wp_footer() outputs the script tag. if ( wp_script_is( 'awbseo-meta-box', 'enqueued' ) ) { $permalink = get_permalink( $post->ID ); // WordPress stores non-Latin slugs (e.g. Arabic) as raw percent-encoded // UTF-8 bytes (sanitize_title() default behavior), so the permalink comes // back from get_permalink() looking like %d8%a7%d9%84... - decode it for // display in the SERP Preview and Canonical URL placeholder, matching the // same urldecode() pattern already used for slug suggestions in // AWBSEO_On_Page_Analyzer. This never touches the stored slug itself. $permalink = $permalink ? urldecode( $permalink ) : $permalink; // Per-post language (Polylang/WPML), independent of the plugin's // own admin-interface language setting -- lets the meta box show // Arabic chrome for an Arabic post even when the admin UI itself // is set to English, which is the common agency setup. $is_post_arabic = class_exists( 'AWBSEO_White_Label' ) ? AWBSEO_White_Label::is_post_arabic( $post->ID ) : false; wp_localize_script( 'awbseo-meta-box', 'AWBSEOMeta', [ 'postId' => $post->ID, 'nonce' => wp_create_nonce( 'awbseo_admin' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'meta' => $meta, 'analysis' => $analysis, 'schemaTypes' => AWBSEO_Schema_Manager::get_schema_types(), 'autoTitle' => self::get_title( $post->ID ), 'autoDesc' => self::get_description( $post->ID ), 'permalink' => $permalink ?: '', 'acfInfo' => class_exists( 'AWBSEO_ACF_Optimization' ) ? AWBSEO_ACF_Optimization::get_source_info( $post->ID ) : null, 'focusKeywordArabizi' => self::get( $post->ID, 'focus_keyword_arabizi' ), 'contentOrigin' => self::get( $post->ID, 'content_origin' ), 'isPostArabic' => $is_post_arabic, ] ); } require AWBSEO_PLUGIN_DIR . 'admin/views/meta-box.php'; } public static function save_post_meta( $post_id, $post ) { if ( ! isset( $_POST['awbseo_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['awbseo_nonce'] ) ), 'awbseo_save_meta' ) || defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE || ! current_user_can( 'edit_post', $post_id ) ) { return; } $fields = [ 'title' => 'sanitize_text_field', 'description' => 'sanitize_textarea_field', 'focus_keyword' => 'sanitize_text_field', 'robots_index' => 'sanitize_text_field', 'robots_follow' => 'sanitize_text_field', 'canonical' => 'esc_url_raw', 'og_title' => 'sanitize_text_field', 'og_description' => 'sanitize_textarea_field', 'og_image' => 'esc_url_raw', 'twitter_title' => 'sanitize_text_field', 'twitter_desc' => 'sanitize_textarea_field', 'twitter_image' => 'esc_url_raw', 'schema_type' => 'sanitize_text_field', 'schema_data' => [ __CLASS__, 'sanitize_schema_data' ], 'schema_fields' => [ __CLASS__, 'sanitize_schema_data' ], 'focus_keyword_arabizi' => 'sanitize_text_field', 'content_origin' => [ __CLASS__, 'sanitize_content_origin' ], ]; $post_title = get_the_title( $post_id ); foreach ( $fields as $key => $sanitizer ) { $raw_key = 'awbseo_' . $key; if ( isset( $_POST[ $raw_key ] ) ) { $value = is_array( $sanitizer ) ? call_user_func( $sanitizer, wp_unslash( $_POST[ $raw_key ] ) ) : $sanitizer( wp_unslash( $_POST[ $raw_key ] ) ); $old_val = get_post_meta( $post_id, self::META_PREFIX . $key, true ); update_post_meta( $post_id, self::META_PREFIX . $key, $value ); // Log to activity log only when the value actually changed. if ( (string) $old_val !== (string) $value && '' !== $value ) { switch ( $key ) { case 'description': do_action( 'awbseo_activity', 'on-page', 'Updated meta description', $post_title ); break; case 'focus_keyword': do_action( 'awbseo_activity', 'on-page', 'Set focus keyword', $post_title . ': "' . $value . '"' ); break; case 'focus_keyword_arabizi': do_action( 'awbseo_activity', 'on-page', 'Set Arabizi/transliterated keyword', $post_title . ': "' . $value . '"' ); break; case 'content_origin': do_action( 'awbseo_activity', 'on-page', 'Flagged content origin', $post_title . ': ' . $value ); break; case 'title': do_action( 'awbseo_activity', 'on-page', 'Updated SEO title', $post_title ); break; case 'schema_type': case 'schema_data': case 'schema_fields': do_action( 'awbseo_activity', 'on-page', 'Updated schema markup', $post_title ); break; case 'robots_index': do_action( 'awbseo_activity', 'on-page', 'Changed indexing setting', $post_title . ': ' . $value ); break; case 'og_image': case 'og_title': case 'og_description': do_action( 'awbseo_activity', 'on-page', 'Updated social sharing data', $post_title ); break; } } } } } /** * Whitelists the native-vs-translated content flag to its three known * values (or empty/unset) - this is a dropdown, not free text, so unlike * the other sanitize_text_field() fields above it should never silently * store an unexpected value if something posts a stray string here. */ public static function sanitize_content_origin( $value ) { $value = sanitize_text_field( $value ); return in_array( $value, [ 'native', 'translated', 'mixed' ], true ) ? $value : ''; } public static function sanitize_schema_data( $data ) { if ( is_array( $data ) ) { $decoded = $data; } else { $data = wp_unslash( $data ); // Allow pasting a full // block copied straight from a schema generator/testing tool - strip // the wrapper tags so the JSON inside can still be decoded and saved. $data = trim( preg_replace( '#]*>#i', '', $data ) ); $decoded = json_decode( $data, true ); } // Not valid JSON (or empty) - nothing to store. if ( ! is_array( $decoded ) ) { return ''; } return wp_json_encode( self::sanitize_schema_value( $decoded ) ); } /** * Recursively sanitize a decoded schema_data structure. * Schema JSON is often nested (FAQ pairs, HowTo steps, Recipe ingredients), * so we walk every level instead of only sanitizing the top-level values - * otherwise nested arrays get cast to the literal string "Array" and the * real content is lost on save. */ private static function sanitize_schema_value( $value ) { if ( is_array( $value ) ) { return array_map( [ __CLASS__, 'sanitize_schema_value' ], $value ); } if ( is_string( $value ) ) { // textarea variant: strips tags/unsafe input but keeps line breaks, // which matters for multi-line answers/instructions/descriptions. return sanitize_textarea_field( $value ); } return $value; // numbers, booleans, null pass through untouched } // ── Getters ─────────────────────────────────────────────────────────────── public static function get( $post_id, $key, $default = '' ) { $value = get_post_meta( $post_id, self::META_PREFIX . $key, true ); return $value !== '' ? $value : $default; } public static function get_post_meta( $post_id ) { $keys = [ 'title', 'description', 'focus_keyword', 'robots_index', 'robots_follow', 'canonical', 'og_title', 'og_description', 'og_image', 'twitter_title', 'twitter_desc', 'twitter_image', 'schema_type', 'schema_data', ]; $meta = []; foreach ( $keys as $key ) { $meta[ $key ] = self::get( $post_id, $key ); } return $meta; } public static function get_title( $post_id ) { $custom = self::get( $post_id, 'title' ); if ( $custom ) { return $custom; } $settings = get_option( 'awbseo_settings', AWBSEO_Core::default_settings() ); $format = $settings['title_format'] ?? '%title% %sep% %sitename%'; $title_string = str_replace( [ '%title%', '%sep%', '%sitename%' ], [ get_the_title( $post_id ), $settings['separator'], get_bloginfo( 'name' ) ], $format ); return apply_filters( 'awbseo_meta_title', $title_string, $post_id ); } public static function get_description( $post_id ) { $custom = self::get( $post_id, 'description' ); if ( $custom ) { return apply_filters( 'awbseo_meta_description', $custom, $post_id ); } $post = get_post( $post_id ); if ( ! $post ) { return ''; } $desc_string = wp_trim_words( wp_strip_all_tags( $post->post_excerpt ?: $post->post_content ), 30 ); return apply_filters( 'awbseo_meta_description', $desc_string, $post_id ); } public static function get_robots( $post_id ) { $index = self::get( $post_id, 'robots_index', 'index' ); $follow = self::get( $post_id, 'robots_follow', 'follow' ); return implode( ', ', array_filter( [ $index, $follow ] ) ); } }