type. * * @return array */ public static function fields() { return array( 'title' => 'text', 'description' => 'text', 'canonical' => 'url', 'noindex' => 'bool', 'nofollow' => 'bool', 'schema' => 'schema', // Editor checklist only; never printed on the page. Commas separate synonyms. 'focus_keyword' => 'text', 'extra_keyphrases' => 'lines', // Facebook, LinkedIn, X and other share previews (og: and twitter: tags). 'social_title' => 'text', 'social_description' => 'text', 'social_image' => 'url', // FAQ, HowTo, Product, Recipe and other rich-result schema (JSON, see Schema\RichTypes). 'rich_schema' => 'rich', 'sitemap_exclude' => 'bool', ); } /** * Most extra keyphrases a post can have (plus the focus keyword: five in all). */ const MAX_EXTRA_KEYPHRASES = 4; /** * Full meta keys, used by uninstall and coverage queries. * * @return string[] */ public static function meta_keys() { $keys = array(); foreach ( array_keys( self::fields() ) as $field ) { $keys[] = self::PREFIX . $field; } return $keys; } /** * Meta query clause matching items without a value for a field: no row, * or an empty row left by older versions. * * @param string $field Field name. * @return array */ public static function missing_clause( $field ) { return array( 'relation' => 'OR', array( 'key' => self::PREFIX . $field, 'compare' => 'NOT EXISTS', ), array( 'key' => self::PREFIX . $field, 'value' => self::is_flag( $field ) ? array( '', '0' ) : array( '' ), 'compare' => 'IN', ), ); } /** * Fields that decide whether and where a page is indexed. A contributor * could otherwise set them on a draft and have them go live unnoticed when * an editor publishes, so they need the right to publish that content type. * * @return string[] */ public static function indexing_fields() { return array( 'canonical', 'noindex', 'nofollow', 'sitemap_exclude' ); } /** * Whether the current user may change a field of a post. * * @param int $post_id Post ID. * @param string $field Field name. * @return bool */ public static function can_edit_field( $post_id, $field ) { if ( ! current_user_can( 'edit_post', $post_id ) ) { return false; } if ( ! in_array( $field, self::indexing_fields(), true ) ) { return true; } $type = get_post_type_object( (string) get_post_type( $post_id ) ); return $type && current_user_can( $type->cap->publish_posts ); } /** * Whether a field is a yes/no flag. * * @param string $field Field name. * @return bool */ private static function is_flag( $field ) { $fields = self::fields(); return isset( $fields[ $field ] ) && 'bool' === $fields[ $field ]; } /** * Whether an object has a non-empty value for a field. * * @param string $object_type 'post' or 'term'. * @param int $object_id Object ID. * @param string $field Field name. * @return bool */ public static function has_value( $object_type, $object_id, $field ) { $value = get_metadata( $object_type, $object_id, self::PREFIX . $field, true ); if ( '' === $value || false === $value || null === $value ) { return false; } return ! ( self::is_flag( $field ) && '0' === (string) $value ); } /** * Short-circuits writes of an empty value to one of our keys into a delete, * whatever the source (block editor REST save, update_post_meta(), imports). * An empty row would otherwise count as "set" for sitemap, import and filters. * * @param null|bool $check Null to continue. * @param int $object_id Object ID. * @param string $meta_key Meta key. * @param mixed $meta_value Value. * @return null|bool */ public function delete_empty( $check, $object_id, $meta_key, $meta_value ) { if ( null !== $check || 0 !== strpos( (string) $meta_key, self::PREFIX ) ) { return $check; } $fields = self::fields(); $field = substr( $meta_key, strlen( self::PREFIX ) ); if ( ! isset( $fields[ $field ] ) ) { return $check; } $value = self::sanitize( $fields[ $field ], $meta_value ); if ( '' !== $value && false !== $value ) { return $check; } $type = 0 === strpos( current_filter(), 'update_term' ) || 0 === strpos( current_filter(), 'add_term' ) ? 'term' : 'post'; delete_metadata( $type, $object_id, $meta_key ); return true; } /** * Registers meta so core applies sanitization and capability checks. */ public function register() { foreach ( array( 'update_post_metadata', 'add_post_metadata', 'update_term_metadata', 'add_term_metadata' ) as $hook ) { add_filter( $hook, array( $this, 'delete_empty' ), 10, 4 ); } add_filter( 'rest_request_before_callbacks', array( $this, 'drop_unchanged_meta' ), 10, 3 ); $this->register_fields(); } /** * The block editor sends every registered field on save, changed or not. * Fields that would not change anything (an empty value where none is * stored, or the stored value itself) are removed from the request, so the * publish-rights check on canonical, noindex and nofollow only applies to * real changes. Without this a contributor could not save a post at all. * * @param mixed $response Response so far (passed through). * @param array $handler Route handler. * @param \WP_REST_Request $request Request. * @return mixed */ public function drop_unchanged_meta( $response, $handler, $request ) { if ( ! $request instanceof \WP_REST_Request || 'GET' === $request->get_method() || 0 !== strpos( $request->get_route(), '/wp/v2/' ) ) { return $response; } $meta = $request->get_param( 'meta' ); if ( ! is_array( $meta ) ) { return $response; } $id = (int) $request->get_param( 'id' ); $fields = self::fields(); $changed = false; foreach ( self::indexing_fields() as $field ) { $key = self::PREFIX . $field; if ( ! array_key_exists( $key, $meta ) ) { continue; } $new = self::sanitize( $fields[ $field ], $meta[ $key ] ); if ( '' === $new || false === $new ) { $noop = ! $id || ! self::has_value( 'post', $id, $field ); } else { $stored = $id ? get_post_meta( $id, $key, true ) : ''; $noop = 'bool' === $fields[ $field ] ? (bool) $stored === $new && self::has_value( 'post', $id, $field ) : (string) $stored === (string) $new; } if ( $noop ) { unset( $meta[ $key ] ); $changed = true; } } if ( $changed ) { $request->set_param( 'meta', $meta ); } return $response; } /** * Registers the SEO fields as post and term meta. */ private function register_fields() { foreach ( self::fields() as $field => $type ) { $args = array( 'type' => 'bool' === $type ? 'boolean' : 'string', 'single' => true, 'sanitize_callback' => function ( $value ) use ( $type ) { return self::sanitize( $type, $value ); }, ); // Posts: exposed to REST for the block-editor sidebar. These values are // printed publicly in the page head anyway; writes need edit_post. register_post_meta( '', self::PREFIX . $field, $args + array( 'show_in_rest' => in_array( $field, self::private_fields(), true ) ? array( 'prepare_callback' => array( __CLASS__, 'prepare_private' ) ) : ( 'rich_schema' === $field ? array( 'prepare_callback' => array( __CLASS__, 'prepare_protected' ) ) : true ), 'auth_callback' => function ( $allowed, $meta_key, $object_id ) use ( $field ) { return self::can_edit_field( (int) $object_id, $field ); }, ) ); register_term_meta( '', self::PREFIX . $field, $args + array( 'show_in_rest' => false, 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { return current_user_can( 'edit_term', $object_id ); }, ) ); } } /** * Fields that are never printed on the page, so visitors should not be able * to read them through the REST API either. * * @return string[] */ public static function private_fields() { return array( 'focus_keyword', 'extra_keyphrases' ); } /** * REST value of a private field: only for users who can edit the post (the * block editor sidebar). Everyone else, including logged-out visitors, gets * null. The posts controller sets the global post before it adds meta. * * @param mixed $value Stored value. * @return string|null */ public static function prepare_private( $value ) { $post = get_post(); if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { return null; } return is_scalar( $value ) ? (string) $value : ''; } /** * REST value of the rich-result field: it holds page content (FAQ answers, * steps), so on a password-protected post only users who can edit the post * see it, as the page itself only shows it after the password. * * @param mixed $value Stored value. * @return string|null */ public static function prepare_protected( $value ) { $post = get_post(); if ( $post && '' !== $post->post_password && ! current_user_can( 'edit_post', $post->ID ) ) { return null; } return is_scalar( $value ) ? (string) $value : ''; } /** * Sanitizes one value by field type. * * @param string $type Field type. * @param mixed $value Raw value. * @return string|bool */ public static function sanitize( $type, $value ) { switch ( $type ) { case 'bool': return (bool) $value; case 'url': return Settings::sanitize_absolute_url( $value ); case 'schema': return in_array( $value, Settings::schema_types(), true ) ? $value : ''; case 'rich': return \Orbitstem\SEO\Schema\RichTypes::sanitize( $value ); case 'lines': $lines = array(); foreach ( preg_split( '/[\r\n]+/', is_scalar( $value ) ? (string) $value : '' ) as $line ) { $line = Settings::sanitize_text( $line ); if ( '' !== $line && count( $lines ) < self::MAX_EXTRA_KEYPHRASES ) { $lines[] = $line; } } return implode( "\n", $lines ); default: return Settings::sanitize_text( $value ); } } /** * Reads one post field. * * @param int $post_id Post ID. * @param string $field Field name. * @return string|bool */ public function get_post( $post_id, $field ) { $value = get_post_meta( $post_id, self::PREFIX . $field, true ); return $this->cast( $field, $value ); } /** * Reads one term field. * * @param int $term_id Term ID. * @param string $field Field name. * @return string|bool */ public function get_term( $term_id, $field ) { $value = get_term_meta( $term_id, self::PREFIX . $field, true ); return $this->cast( $field, $value ); } /** * Saves post fields from an array. Empty values delete the meta row so * unused fields do not bloat the meta table. * * @param int $post_id Post ID. * @param array $values Raw values keyed by field name; missing bools mean false. */ public function save_post( $post_id, array $values ) { $this->save( 'post', $post_id, $values ); } /** * Saves term fields from an array. * * @param int $term_id Term ID. * @param array $values Raw values keyed by field name; missing bools mean false. */ public function save_term( $term_id, array $values ) { $this->save( 'term', $term_id, $values ); } /** * Updates only the given fields, leaving the others untouched. * * @param string $object_type 'post' or 'term'. * @param int $object_id Object ID. * @param array $values Raw values keyed by field name. */ public function update_fields( $object_type, $object_id, array $values ) { $this->save( $object_type, $object_id, $values, true ); } /** * Shared save routine. * * @param string $object_type 'post' or 'term'. * @param int $object_id Object ID. * @param array $values Raw values. * @param bool $partial Only touch fields present in $values. */ private function save( $object_type, $object_id, array $values, $partial = false ) { foreach ( self::fields() as $field => $type ) { if ( $partial && ! array_key_exists( $field, $values ) ) { continue; } $value = self::sanitize( $type, isset( $values[ $field ] ) ? $values[ $field ] : '' ); $key = self::PREFIX . $field; if ( '' === $value || false === $value ) { delete_metadata( $object_type, $object_id, $key ); } else { // update_metadata() unslashes; without wp_slash() a "\" in a title (or in JSON) would be lost. update_metadata( $object_type, $object_id, $key, wp_slash( $value ) ); } } delete_transient( 'orbitstem_seo_coverage' ); // Dashboard counts. } /** * Normalizes stored values to their field type. * * @param string $field Field name. * @param mixed $value Stored value. * @return string|bool */ private function cast( $field, $value ) { $fields = self::fields(); if ( isset( $fields[ $field ] ) && 'bool' === $fields[ $field ] ) { return (bool) $value; } return is_string( $value ) ? $value : ''; } }