'POST', 'callback' => array( __CLASS__, 'pages' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/yoast/apply', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'apply' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/yoast/undo', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'undo' ), 'permission_callback' => $auth ) ); } public static function active() { return defined( 'WPSEO_VERSION' ); } /** Post types Yoast analyses: public ones, without attachments. */ private static function types() { $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); return array_values( $types ); } /** The page builder that holds the page's real content, if any: its text can't be changed through post_content. */ public static function builder( $post ) { $id = $post->ID; $content = (string) $post->post_content; if ( 'builder' === get_post_meta( $id, '_elementor_edit_mode', true ) ) { return 'Elementor'; } if ( 'on' === get_post_meta( $id, '_et_pb_use_builder', true ) || false !== strpos( $content, '[et_pb_' ) ) { return 'Divi'; } if ( false !== strpos( $content, '[vc_row' ) ) { return 'WPBakery'; } if ( get_post_meta( $id, '_fl_builder_enabled', true ) ) { return 'Beaver Builder'; } if ( get_post_meta( $id, '_bricks_page_content_2', true ) ) { return 'Bricks'; } if ( get_post_meta( $id, 'ct_builder_shortcodes', true ) || get_post_meta( $id, '_ct_builder_json', true ) ) { return 'Oxygen'; } return null; } /** The title and description Yoast prints for the post, with its %%variables%% filled in. */ private static function resolved( $id ) { $out = array( 'title' => '', 'description' => '' ); if ( function_exists( 'YoastSEO' ) ) { try { $meta = YoastSEO()->meta->for_post( $id ); if ( $meta ) { $out['title'] = (string) $meta->title; $out['description'] = (string) $meta->description; } } catch ( Throwable $e ) { unset( $e ); } } return $out; } /** One page as Gralus needs it for Yoast's analysis. */ public static function page( $post ) { $id = $post->ID; $resolved = self::resolved( $id ); return array( 'postId' => (int) $id, 'type' => $post->post_type, 'url' => get_permalink( $post ), 'title' => get_the_title( $post ), 'slug' => $post->post_name, 'content' => (string) $post->post_content, 'contentHash' => md5( (string) $post->post_content ), 'builder' => self::builder( $post ), 'keyphrase' => (string) get_post_meta( $id, '_yoast_wpseo_focuskw', true ), 'seoTitleTemplate' => (string) get_post_meta( $id, '_yoast_wpseo_title', true ), 'seoTitle' => $resolved['title'], 'metaDescription' => '' !== $resolved['description'] ? $resolved['description'] : (string) get_post_meta( $id, '_yoast_wpseo_metadesc', true ), 'cornerstone' => '1' === (string) get_post_meta( $id, '_yoast_wpseo_is_cornerstone', true ), 'noindex' => '1' === (string) get_post_meta( $id, '_yoast_wpseo_meta-robots-noindex', true ), 'yoastSeoScore' => (int) get_post_meta( $id, '_yoast_wpseo_linkdex', true ), 'yoastReadability' => (int) get_post_meta( $id, '_yoast_wpseo_content_score', true ), ); } /** Body: { offset?: int, limit?: int } or { ids: int[] }. Published pages of every public type, oldest first, with the site's language. */ public static function pages( WP_REST_Request $request ) { if ( ! self::active() ) { return new WP_Error( 'gralus_no_yoast', 'Yoast SEO is not active on this site.', array( 'status' => 409 ) ); } $p = $request->get_json_params(); $offset = max( 0, (int) ( $p['offset'] ?? 0 ) ); $limit = max( 1, min( 50, (int) ( $p['limit'] ?? 25 ) ) ); $ids = isset( $p['ids'] ) && is_array( $p['ids'] ) ? array_slice( array_filter( array_map( 'intval', $p['ids'] ) ), 0, 50 ) : array(); $args = array( 'post_type' => self::types(), 'post_status' => 'publish', 'posts_per_page' => $limit, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'has_password' => false, ); if ( $ids ) { // Specific pages (to fix them from what is on the site now). $args['post__in'] = $ids; $args['posts_per_page'] = count( $ids ); $args['offset'] = 0; } $q = new WP_Query( $args ); return array( 'yoastVersion' => WPSEO_VERSION, 'locale' => get_locale(), 'total' => (int) $q->found_posts, 'pages' => array_map( array( __CLASS__, 'page' ), $q->posts ), ); } /** * Body: { postId, contentHash, keyphrase?, seoTitle?, metaDescription?, content?, scores: { seo, readability } }. * Fields left out are not touched. New content is refused when the page changed since Gralus read it. */ public static function apply( WP_REST_Request $request ) { $p = $request->get_json_params(); $id = (int) ( $p['postId'] ?? 0 ); $post = $id ? get_post( $id ) : null; if ( ! $post || 'publish' !== $post->post_status ) { return new WP_Error( 'gralus_not_found', 'The page was not found.', array( 'status' => 404 ) ); } if ( isset( $p['content'] ) && md5( (string) $post->post_content ) !== (string) ( $p['contentHash'] ?? '' ) ) { return new WP_Error( 'gralus_changed', 'The page was edited since Gralus checked it. Check it again, then write the fix again.', array( 'status' => 409 ) ); } $previous = array( 'keyphrase' => (string) get_post_meta( $id, '_yoast_wpseo_focuskw', true ), 'seoTitle' => (string) get_post_meta( $id, '_yoast_wpseo_title', true ), 'metaDescription' => (string) get_post_meta( $id, '_yoast_wpseo_metadesc', true ), 'linkdex' => (string) get_post_meta( $id, '_yoast_wpseo_linkdex', true ), 'contentScore' => (string) get_post_meta( $id, '_yoast_wpseo_content_score', true ), ); if ( isset( $p['keyphrase'] ) ) { update_post_meta( $id, '_yoast_wpseo_focuskw', sanitize_text_field( (string) $p['keyphrase'] ) ); } if ( isset( $p['seoTitle'] ) || isset( $p['metaDescription'] ) ) { Gralus_Seo_Meta::set_exact( $id, isset( $p['seoTitle'] ) ? (string) $p['seoTitle'] : null, isset( $p['metaDescription'] ) ? (string) $p['metaDescription'] : null ); } if ( isset( $p['scores']['seo'] ) ) { update_post_meta( $id, '_yoast_wpseo_linkdex', (string) max( 0, min( 100, (int) $p['scores']['seo'] ) ) ); } if ( isset( $p['scores']['readability'] ) ) { update_post_meta( $id, '_yoast_wpseo_content_score', (string) max( 0, min( 100, (int) $p['scores']['readability'] ) ) ); } if ( isset( $p['content'] ) ) { $previous['content'] = (string) $post->post_content; if ( function_exists( 'wp_save_post_revision' ) ) { wp_save_post_revision( $id ); } // Gralus changes only text blocks (cleaned to plain tags) and sends every other block exactly as it was, // so the content is saved as-is: kses would strip the author's own embeds and scripts. kses_remove_filters(); $r = wp_update_post( wp_slash( array( 'ID' => $id, 'post_content' => (string) $p['content'] ) ), true ); kses_init_filters(); if ( is_wp_error( $r ) ) { return new WP_Error( 'gralus_save_failed', $r->get_error_message(), array( 'status' => 500 ) ); } } else { self::touch( $id ); } $all = get_post_meta( $id, self::PREVIOUS, true ); $all = is_array( $all ) ? $all : array(); $key = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) ( $p['fixId'] ?? 'last' ) ); $all[ $key ] = $previous; update_post_meta( $id, self::PREVIOUS, $all ); return array( 'ok' => true, 'url' => get_permalink( $id ), 'contentHash' => md5( (string) get_post( $id )->post_content ) ); } /** Save the post unchanged, so Yoast rebuilds its own copy of the fields (its indexables) from the new values. */ private static function touch( $id ) { clean_post_cache( $id ); // Saving re-runs the content filters; without kses removed, a signed request (no logged-in user) would strip embeds. kses_remove_filters(); wp_update_post( array( 'ID' => $id ) ); kses_init_filters(); } /** Body: { postId, fixId }. Puts back what the fix replaced. */ public static function undo( WP_REST_Request $request ) { $p = $request->get_json_params(); $id = (int) ( $p['postId'] ?? 0 ); $key = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) ( $p['fixId'] ?? 'last' ) ); $all = $id ? get_post_meta( $id, self::PREVIOUS, true ) : null; $prev = is_array( $all ) && isset( $all[ $key ] ) ? $all[ $key ] : null; if ( ! $prev ) { return new WP_Error( 'gralus_no_previous', 'The previous values were not found on the site.', array( 'status' => 404 ) ); } $meta = array( '_yoast_wpseo_focuskw' => $prev['keyphrase'], '_yoast_wpseo_title' => $prev['seoTitle'], '_yoast_wpseo_metadesc' => $prev['metaDescription'], '_yoast_wpseo_linkdex' => $prev['linkdex'], '_yoast_wpseo_content_score' => $prev['contentScore'], ); foreach ( $meta as $k => $v ) { if ( '' === (string) $v ) { delete_post_meta( $id, $k ); } else { update_post_meta( $id, $k, $v ); } } if ( isset( $prev['content'] ) ) { if ( function_exists( 'wp_save_post_revision' ) ) { wp_save_post_revision( $id ); } kses_remove_filters(); wp_update_post( wp_slash( array( 'ID' => $id, 'post_content' => (string) $prev['content'] ) ) ); kses_init_filters(); } else { self::touch( $id ); } unset( $all[ $key ] ); if ( $all ) { update_post_meta( $id, self::PREVIOUS, $all ); } else { delete_post_meta( $id, self::PREVIOUS ); } return array( 'ok' => true ); } }